diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index c16db30..2da5a80 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -199,7 +199,10 @@ pull. except that accepted game-directory changes can force a UI snapshot for the new path without changing peer availability. - Active operation mutations emit `ActiveOperationsChanged` from the mutation - path instead of riding on local library scans. + path instead of riding on local library scans. Admission withdraws network + availability under the operation and library locks, then emits the active + operation before the withdrawn `LocalLibraryChanged` UI snapshot. This keeps + the game visible as busy while peers can no longer request its changing files. - The remote UI projection is a wholesale replacement derived from all current authenticated per-peer slices; two peers offering the same game remain distinct exact-content sources. diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 052957a..cd96491 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -1940,6 +1940,11 @@ async fn begin_operation_with_drain_timeout( }; active_operations.insert(target.game_id.clone(), operation); + // The network projection is already withdrawn under both guards. + // Tell the UI that the game is busy before its local snapshot loses + // availability, so it can keep the operation visible in the list. + events::send_active_operations_snapshot(tx_notify_ui, &active_operations); + if let Some(revision) = withdrawn_revision { let game_db = GameDB::from( library @@ -1966,11 +1971,9 @@ async fn begin_operation_with_drain_timeout( } // Once admitted, a directory change observes the active operation and is - // rejected. Release the admission barrier before emitting or draining. + // rejected. Release the admission barrier before draining transfers. drop(admission); - events::emit_active_operations(&ctx.active_operations, tx_notify_ui).await; - if operation_requires_outbound_drain(operation) && !cancel_and_wait_for_outbound_transfers( ctx, @@ -4113,7 +4116,7 @@ mod tests { } #[tokio::test] - async fn begin_operation_withdraws_fresh_library_snapshot_before_mutation() { + async fn begin_operation_announces_busy_before_withdrawn_ui_snapshot() { let temp = TempDir::new("lanspread-handler-active-withdrawal"); let root = temp.game_root(); write_file(&root.join("version.ini"), b"20250101"); @@ -4142,14 +4145,14 @@ mod tests { BeginOperationResult::Started ); - let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { - panic!("availability withdrawal must precede the active-operation event"); - }; - assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Installing), ); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("withdrawn UI snapshot must follow the active-operation event"); + }; + assert!(games.is_empty()); let snapshot = { let library = ctx.local_library.read().await; @@ -4207,10 +4210,6 @@ mod tests { ); assert!(token.is_cancelled()); - let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { - panic!("operation admission should withdraw availability first"); - }; - assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &[ActiveOperation { @@ -4218,6 +4217,10 @@ mod tests { operation: ActiveOperationKind::Updating, }], ); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("withdrawn UI snapshot must follow the active-operation event"); + }; + assert!(games.is_empty()); assert_local_update(recv_event(&mut rx).await, false, true); assert_active_update(recv_event(&mut rx).await, &[]); assert!( @@ -4354,14 +4357,14 @@ mod tests { run_install_operation(&ctx, &tx, operation_target(temp.path())).await; - let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { - panic!("operation admission should withdraw the ready game"); - }; - assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Updating), ); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("operation admission should withdraw the ready game"); + }; + assert!(games.is_empty()); assert_local_update(recv_event(&mut rx).await, true, true); assert_active_update(recv_event(&mut rx).await, &[]); assert!(matches!( @@ -4639,14 +4642,14 @@ mod tests { write_file(&root.join("game.eti"), b"new archive"); run_install_operation(&ctx, &tx, operation_target(temp.path())).await; - let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { - panic!("update admission should withdraw the ready game"); - }; - assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Updating), ); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("update admission should withdraw the ready game"); + }; + assert!(games.is_empty()); let game = local_update_game(recv_event(&mut rx).await, true, true); assert_eq!(game.local_version.as_deref(), Some("20250101")); assert_active_update(recv_event(&mut rx).await, &[]); @@ -4656,14 +4659,14 @@ mod tests { )); run_uninstall_operation(&ctx, &tx, operation_target(temp.path())).await; - let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { - panic!("uninstall admission should withdraw the ready game"); - }; - assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::Uninstalling), ); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("uninstall admission should withdraw the ready game"); + }; + assert!(games.is_empty()); let game = local_update_game(recv_event(&mut rx).await, false, true); assert_eq!(game.local_version.as_deref(), Some("20250101")); assert_active_update(recv_event(&mut rx).await, &[]); @@ -4725,14 +4728,14 @@ mod tests { run_remove_downloaded_operation(&ctx, &tx, operation_target(temp.path())).await; - let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { - panic!("download removal admission should withdraw the ready game"); - }; - assert!(games.is_empty()); assert_active_update( recv_event(&mut rx).await, &active_update("game", ActiveOperationKind::RemovingDownload), ); + let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else { + panic!("download removal admission should withdraw the ready game"); + }; + assert!(games.is_empty()); assert_active_update(recv_event(&mut rx).await, &[]); assert!(matches!( recv_event(&mut rx).await, diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts index d3551eb..6e555ab 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -177,6 +177,10 @@ export const mergeGameUpdate = ( ...incoming, availability: incoming.availability, install_status: installStatus, + installed_before_operation: activeStatus !== undefined + ? previous?.installed_before_operation ?? previous?.installed ?? + incoming.installed + : undefined, status_message: clearStatus ? undefined : previous?.status_message, status_level: clearStatus ? undefined : previous?.status_level, transfer_status: transferStatus, @@ -212,7 +216,7 @@ export const stateChipLabel = (game: Game): string => { case "downloading": return "Downloading"; case "busy": - return "Working"; + return (inProgressLabel(game) ?? "Working…").replace(/…$/, ""); case "none": return ""; } @@ -231,7 +235,7 @@ export const gameStatusLabel = (game: Game): string => { case "downloading": return "Downloading"; case "busy": - return "Working…"; + return inProgressLabel(game) ?? "Working…"; case "none": return "Not downloaded"; } @@ -394,26 +398,30 @@ export interface FilterCounts { installed: number; } -const isDownloading = (game: Game): boolean => - game.install_status === InstallStatus.Downloading; +const isLocalGame = (game: Game): boolean => + game.installed || game.downloaded || isInProgress(game.install_status); + +const matchesInstalledFilter = (game: Game): boolean => + game.installed || + (isInProgress(game.install_status) && + game.installed_before_operation === true); const isNetworkGame = (game: Game): boolean => - game.installed || game.downloaded || isDownloading(game) || + isLocalGame(game) || game.peer_count > 0 || game.transfer_status === GameTransferStatus.Exhausted; export const countByFilter = (games: Game[]): FilterCounts => ({ all: games.filter(isNetworkGame).length, - local: - games.filter((g) => g.installed || g.downloaded || isDownloading(g)).length, - installed: games.filter((g) => g.installed).length, + local: games.filter(isLocalGame).length, + installed: games.filter(matchesInstalledFilter).length, }); const matchesFilter = (game: Game, filter: GameFilter): boolean => { switch (filter) { case "local": - return game.installed || game.downloaded || isDownloading(game); + return isLocalGame(game); case "installed": - return game.installed; + return matchesInstalledFilter(game); case "all": return isNetworkGame(game); } diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts index 8dece8a..7ee789f 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/types.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -58,6 +58,8 @@ export interface Game { installed: boolean; availability: GameAvailability; install_status: InstallStatus; + /** UI-only filter membership retained until the active operation settles. */ + installed_before_operation?: boolean; eti_game_version?: string; local_version?: string; /** Optional richer metadata surfaced by the backend. */ diff --git a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts index 2005986..2890fe4 100644 --- a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts @@ -14,12 +14,14 @@ import { formatDownloadSpeedShort, gameStatusLabel, mergeGameUpdate, + primaryActionFor, stateChipLabel, } from "../src/lib/gameState.ts"; import { ActiveOperationKind, type Game, GameAvailability, + type GameFilter, GameTransferStatus, InstallStatus, } from "../src/lib/types.ts"; @@ -97,6 +99,158 @@ Deno.test("active operation snapshot is the source of busy status", () => { ); }); +const assertFilterMembership = ( + current: Game, + expected: Record, +) => { + const counts = countByFilter([current]); + for (const filter of ["all", "local", "installed"] as const) { + assertEquals( + applyFilterAndSort([current], filter, "az", "").length, + expected[filter], + `${current.install_status} visibility in ${filter}`, + ); + assertEquals(counts[filter], expected[filter], `${filter} count`); + } +}; + +Deno.test("offline local install stays visible through withdrawal and settlement", () => { + const local = game({ downloaded: true, peer_count: 0 }); + const active = mergeGameUpdate(local, local, InstallStatus.Installing); + // The peer withdraws shareable local state while staging the installation. + const withdrawn = mergeGameUpdate( + game({ peer_count: 0 }), + active, + InstallStatus.Installing, + ); + for (const current of [local, active, withdrawn]) { + assertFilterMembership(current, { all: 1, local: 1, installed: 0 }); + } + assertEquals( + withdrawn.downloaded, + false, + "keep backend availability truthful", + ); + assertEquals(stateChipLabel(withdrawn), "Installing", "card status chip"); + assertEquals(gameStatusLabel(withdrawn), "Installing…", "detail status"); + assertEquals(actionLabel(withdrawn), "Installing…", "action label"); + assertEquals( + primaryActionFor(withdrawn), + "busy", + "disable duplicate installs", + ); + assertEquals( + applyFilterAndSort([withdrawn], "all", "az", "unrelated").length, + 0, + "busy cards still obey search", + ); + + const settled = mergeGameUpdate( + game({ downloaded: true, installed: true, peer_count: 0 }), + withdrawn, + ); + assertFilterMembership(settled, { all: 1, local: 1, installed: 1 }); + assertEquals( + actionLabel(settled), + "Play", + "successful install becomes playable", + ); + + const failed = mergeGameUpdate(local, withdrawn); + assertFilterMembership(failed, { all: 1, local: 1, installed: 0 }); + assertEquals(actionLabel(failed), "Install", "failed install can be retried"); +}); + +Deno.test("stream install remains local after its last source leaves", () => { + const active = mergeGameUpdate(game(), game(), InstallStatus.Installing); + const withdrawn = mergeGameUpdate( + game({ peer_count: 0 }), + active, + InstallStatus.Installing, + ); + assertFilterMembership(withdrawn, { all: 1, local: 1, installed: 0 }); + + const settled = mergeGameUpdate(game({ peer_count: 0 }), withdrawn); + assertFilterMembership(settled, { all: 0, local: 0, installed: 0 }); +}); + +Deno.test("installed filter retains busy games until authoritative settlement", () => { + for ( + const operation of [ + ActiveOperationKind.Downloading, + ActiveOperationKind.Updating, + ActiveOperationKind.Uninstalling, + ] + ) { + const installed = game({ + downloaded: true, + installed: true, + peer_count: 0, + }); + const activeStatus = activeStatusById([{ id: "game", operation }]).get( + "game", + ); + const active = mergeGameUpdate(installed, installed, activeStatus); + const withdrawn = mergeGameUpdate( + game({ peer_count: 0 }), + active, + activeStatus, + ); + const repeated = mergeGameUpdate( + game({ peer_count: 0 }), + withdrawn, + activeStatus, + ); + for (const current of [active, withdrawn, repeated]) { + assertFilterMembership(current, { all: 1, local: 1, installed: 1 }); + } + assertEquals( + repeated.installed, + false, + "filter pin must not imply playable state", + ); + assertEquals( + primaryActionFor(repeated), + "busy", + "operation stays disabled", + ); + + const settled = mergeGameUpdate( + game({ downloaded: true, peer_count: 0 }), + repeated, + ); + assertFilterMembership(settled, { all: 1, local: 1, installed: 0 }); + assertEquals( + settled.installed_before_operation, + undefined, + "clear prior membership", + ); + const nextInstall = mergeGameUpdate( + settled, + settled, + InstallStatus.Installing, + ); + assertFilterMembership(nextInstall, { all: 1, local: 1, installed: 0 }); + } +}); + +Deno.test("local removal stays visible with its operation label until settled", () => { + const local = game({ downloaded: true, peer_count: 0 }); + const active = mergeGameUpdate(local, local, InstallStatus.Removing); + const withdrawn = mergeGameUpdate( + game({ peer_count: 0 }), + active, + InstallStatus.Removing, + ); + assertFilterMembership(withdrawn, { all: 1, local: 1, installed: 0 }); + assertEquals(stateChipLabel(withdrawn), "Removing", "card status chip"); + assertEquals(gameStatusLabel(withdrawn), "Removing…", "detail status"); + assertFilterMembership( + mergeGameUpdate(game({ peer_count: 0 }), withdrawn), + { all: 0, local: 0, installed: 0 }, + ); +}); + Deno.test("download progress is preserved only while actively downloading", () => { const downloading = game({ install_status: InstallStatus.Downloading, diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md index f6a65ba..e632e3b 100644 --- a/design/launcher/SPEC.md +++ b/design/launcher/SPEC.md @@ -599,6 +599,24 @@ Hover: `filter: brightness(1.12)`. Active: `transform: scale(0.98)`. **Uninstall / Delete-from-disk** are NOT on the card — only in the detail overlay (as ghost-danger buttons). +### Installation and other local operations + +Starting an install keeps the game card in the grid. While the backend reports +an active installation (including an update or Stream Install), show +**Installing** in the cover's state chip and **Installing…** in the disabled +primary-action slot, with an accent spinner. The detail overlay uses the same +action and **Installing…** status. Installation has no percentage or ETA until +the backend supplies measured progress. Uninstall and downloaded-file removal +use **Uninstalling** / **Uninstalling…** and **Removing** / **Removing…** with +the same busy styling. + +All active operations count as local activity and remain in **All Games** and +**Local**, even while their files are temporarily unavailable for sharing or +their last source leaves. A game already in **Installed** stays there while its +operation runs; a first installation enters **Installed** when it completes. +Filter counts follow the same rules, and search still applies. On settlement, +use the resulting local state to determine membership and the next action. + --- ## Download progress (state === 'downloading')