Compare commits

...

3 Commits

Author SHA1 Message Date
ddidderr 32f8f4c08e docs(peer-cli): record snapshot status matrix pass
Record the post-fix peer-cli validation run against the rebuilt Docker image.
The run covered S1-S36 after the backend snapshot-ordering fix and frontend
snapshot-status reducer cleanup, including auto-install, exact transfer,
failure, propagation, concurrency, mutation, and latest-version scenarios.

Test Plan:
- git diff --check
- just peer-cli-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py

Refs: local install/download status snapshot cleanup
2026-05-19 23:52:06 +02:00
ddidderr 6651f026b6 fix(ui): derive operation status from snapshots
The launcher was mixing lifecycle event handlers with the games-list snapshot
when deciding the card status. That left multiple writers for the same
install_status field and made event ordering visible in React.

Make games-list-updated active_operations the authoritative source for busy
status. Lifecycle events no longer mutate the card status; they only keep their
non-status side effects such as rescans and error messages. The only remaining
optimistic status is CheckingPeers before the backend emits its next snapshot.

Add a frontend reducer test that proves an install stays in Installing while an
active install snapshot exists, then settles to Installed only after the active
operation clears with installed local state.

Test Plan:
- git diff --check
- just fmt
- just frontend-test
- just build

Refs: local install/download status snapshot cleanup
2026-05-19 23:48:34 +02:00
ddidderr 5c4976d476 fix(peer): settle local state before clearing operations
Install, update, uninstall, and downloaded-file removal used to clear the
active operation before publishing the settled local-library snapshot. That
allowed the UI bridge to emit a snapshot with no active operation but stale
local state, which could briefly make an installing game look not installed.

Refresh the ending game while its operation is still active, but exempt only
that game from the active-operation freeze. Other active games keep the
existing scan-preservation behavior. Lifecycle finished/failed events are now
emitted after the local snapshot and active-operation clear, so the status
snapshot remains the source of truth.

Test Plan:
- git diff --check
- just fmt
- just test

Refs: local install/download status snapshot cleanup
2026-05-19 23:44:30 +02:00
8 changed files with 218 additions and 171 deletions
+2 -1
View File
@@ -26,11 +26,12 @@ Never use normal cargo ... commands, use the just ... commands instead.
- `just fmt` — format the workspace. - `just fmt` — format the workspace.
- `just clippy` — lint the workspace. - `just clippy` — lint the workspace.
- `just test` — run the workspace unit tests. - `just test` — run the workspace unit tests.
- `just frontend-test` — run frontend reducer/unit tests.
- `just fix` — auto-apply cargo/clippy fixes, then format. - `just fix` — auto-apply cargo/clippy fixes, then format.
- `just clean` — wipe the build cache. - `just clean` — wipe the build cache.
- `just peer-cli-build` — build the scripted peer harness. - `just peer-cli-build` — build the scripted peer harness.
- `just peer-cli-image` — build the peer harness Docker image. - `just peer-cli-image` — build the peer harness Docker image.
- `just peer-cli-run NAME` — run one named harness container with persistent state under `target/peer-cli/NAME/`. - `just peer-cli-run NAME` — run one named harness container with persistent state under `.lanspread-peer-cli/NAME/`.
## Protocol policy ## Protocol policy
+23
View File
@@ -92,6 +92,29 @@ until a dedicated fixture or temporary games root is prepared.
## Run Log ## Run Log
### 2026-05-19 - Snapshot Status Fix Docker Matrix Pass
- Code under test included `5c4976d` (`fix(peer): settle local state before
clearing operations`) and `6651f02` (`fix(ui): derive operation status from
snapshots`).
- Gates before the matrix: `just fmt`, `just test`, `just frontend-test`, and
`just build` passed. The peer harness image was rebuilt with
`just peer-cli-image`.
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
passed S1-S36 against the rebuilt `lanspread-peer-cli:dev` image.
- Auto-install coverage remained good: S5 downloaded and installed `cnctw`, saw
the fixture payload under `local/`, and the downloaded root diffed cleanly
against `fixture-bravo/cnctw` excluding local metadata.
- Large/exact transfer coverage remained good: S13 small and large downloads
diffed cleanly; S14 split `alienswarm` between two sources with chunk totals
`67,108,864` and `58,721,049` bytes and the final root diffed cleanly.
- Failure and mutation coverage remained good: S17 latest-version conflict,
S19 sole-source drop, S20 write failure, S26 duplicate operation, and S35
unknown catalog filtering all failed safely without advertising bad local
state; S21-S23 propagation, S24-S25 concurrency, S29-S31 bootstrapping, S32
reinstall, S33 mutation install, S34 many-small-files, and S36 latest
singleton all passed.
### 2026-05-18 - Full Automated Docker Matrix Pass ### 2026-05-18 - Full Automated Docker Matrix Pass
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` - Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
+90 -40
View File
@@ -508,28 +508,33 @@ async fn run_started_install_operation(
} }
} }
}; };
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
match result { match result {
Ok(()) => { Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after install: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send( events::send(
tx_notify_ui, tx_notify_ui,
PeerEvent::InstallGameFinished { id: id.clone() }, PeerEvent::InstallGameFinished { id: id.clone() },
); );
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after install: {err}");
}
} }
Err(err) => { Err(err) => {
log::error!("Install operation failed for {id}: {err}"); log::error!("Install operation failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after install failure: {refresh_err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send( events::send(
tx_notify_ui, tx_notify_ui,
PeerEvent::InstallGameFailed { id: id.clone() }, PeerEvent::InstallGameFailed { id: id.clone() },
); );
if let Err(refresh_err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after install failure: {refresh_err}");
}
} }
} }
} }
@@ -559,11 +564,15 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
install::uninstall(&game_root, &id).await install::uninstall(&game_root, &id).await
}; };
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
match result { match result {
Ok(()) => { Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after uninstall: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send( events::send(
tx_notify_ui, tx_notify_ui,
PeerEvent::UninstallGameFinished { id: id.clone() }, PeerEvent::UninstallGameFinished { id: id.clone() },
@@ -571,16 +580,21 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
} }
Err(err) => { Err(err) => {
log::error!("Uninstall operation failed for {id}: {err}"); log::error!("Uninstall operation failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!(
"Failed to refresh local library after uninstall failure: {refresh_err}"
);
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send( events::send(
tx_notify_ui, tx_notify_ui,
PeerEvent::UninstallGameFailed { id: id.clone() }, PeerEvent::UninstallGameFailed { id: id.clone() },
); );
} }
} }
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after uninstall: {err}");
}
} }
async fn run_remove_downloaded_operation( async fn run_remove_downloaded_operation(
@@ -612,11 +626,15 @@ async fn run_remove_downloaded_operation(
install::remove_downloaded(&game_dir, &id).await install::remove_downloaded(&game_dir, &id).await
}; };
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
match result { match result {
Ok(()) => { Ok(()) => {
if let Err(err) = refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!("Failed to refresh local library after downloaded-file removal: {err}");
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send( events::send(
tx_notify_ui, tx_notify_ui,
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() }, PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
@@ -624,16 +642,21 @@ async fn run_remove_downloaded_operation(
} }
Err(err) => { Err(err) => {
log::error!("Downloaded-file removal failed for {id}: {err}"); log::error!("Downloaded-file removal failed for {id}: {err}");
if let Err(refresh_err) =
refresh_local_game_for_ending_operation(ctx, tx_notify_ui, &id).await
{
log::error!(
"Failed to refresh local library after downloaded-file removal failure: {refresh_err}"
);
}
end_operation(ctx, tx_notify_ui, &id).await;
operation_guard.disarm();
events::send( events::send(
tx_notify_ui, tx_notify_ui,
PeerEvent::RemoveDownloadedGameFailed { id: id.clone() }, PeerEvent::RemoveDownloadedGameFailed { id: id.clone() },
); );
} }
} }
if let Err(err) = refresh_local_game(ctx, tx_notify_ui, &id).await {
log::error!("Failed to refresh local library after downloaded-file removal: {err}");
}
} }
async fn begin_operation( async fn begin_operation(
@@ -809,7 +832,7 @@ async fn scan_and_announce_local_library(
) -> eyre::Result<()> { ) -> eyre::Result<()> {
let catalog = ctx.catalog.read().await.clone(); let catalog = ctx.catalog.read().await.clone();
let scan = scan_local_library(game_dir, &catalog).await?; let scan = scan_local_library(game_dir, &catalog).await?;
update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy).await; update_and_announce_games_with_policy(ctx, tx_notify_ui, scan, event_policy, None).await;
Ok(()) Ok(())
} }
@@ -826,6 +849,28 @@ async fn refresh_local_game(
tx_notify_ui, tx_notify_ui,
scan, scan,
LocalLibraryEventPolicy::OnChange, LocalLibraryEventPolicy::OnChange,
None,
)
.await;
Ok(())
}
/// Refreshes the game whose operation has completed before clearing its
/// active-operation snapshot, while preserving freeze behavior for other games.
async fn refresh_local_game_for_ending_operation(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: &str,
) -> eyre::Result<()> {
let game_dir = { ctx.game_dir.read().await.clone() };
let catalog = ctx.catalog.read().await.clone();
let scan = rescan_local_game(&game_dir, &catalog, id).await?;
update_and_announce_games_with_policy(
ctx,
tx_notify_ui,
scan,
LocalLibraryEventPolicy::OnChange,
Some(id),
) )
.await; .await;
Ok(()) Ok(())
@@ -878,6 +923,7 @@ pub async fn update_and_announce_games(
tx_notify_ui, tx_notify_ui,
scan, scan,
LocalLibraryEventPolicy::OnChange, LocalLibraryEventPolicy::OnChange,
None,
) )
.await; .await;
} }
@@ -887,6 +933,7 @@ async fn update_and_announce_games_with_policy(
tx_notify_ui: &UnboundedSender<PeerEvent>, tx_notify_ui: &UnboundedSender<PeerEvent>,
scan: LocalLibraryScan, scan: LocalLibraryScan,
event_policy: LocalLibraryEventPolicy, event_policy: LocalLibraryEventPolicy,
ending_operation_id: Option<&str>,
) { ) {
let LocalLibraryScan { let LocalLibraryScan {
mut game_db, mut game_db,
@@ -894,7 +941,10 @@ async fn update_and_announce_games_with_policy(
revision, revision,
} = scan; } = scan;
let active_operation_ids = active_operation_ids(ctx).await; let mut active_operation_ids = active_operation_ids(ctx).await;
if let Some(id) = ending_operation_id {
active_operation_ids.remove(id);
}
if !active_operation_ids.is_empty() { if !active_operation_ids.is_empty() {
let previous = ctx.local_library.read().await.games.clone(); let previous = ctx.local_library.read().await.games.clone();
for id in &active_operation_ids { for id in &active_operation_ids {
@@ -1361,7 +1411,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn install_refreshes_settled_state_after_guard_release() { async fn install_refreshes_settled_state_before_operation_clear() {
let temp = TempDir::new("lanspread-handler-install"); let temp = TempDir::new("lanspread-handler-install");
let root = temp.game_root(); let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("version.ini"), b"20250101");
@@ -1383,13 +1433,13 @@ mod tests {
} }
_ => panic!("expected InstallGameBegin"), _ => panic!("expected InstallGameBegin"),
} }
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game" PeerEvent::InstallGameFinished { id } if id == "game"
)); ));
assert!(ctx.active_operations.read().await.is_empty()); assert!(ctx.active_operations.read().await.is_empty());
assert_local_update(recv_event(&mut rx).await, true, true);
} }
#[tokio::test] #[tokio::test]
@@ -1444,6 +1494,7 @@ mod tests {
} }
_ => panic!("expected InstallGameBegin"), _ => panic!("expected InstallGameBegin"),
} }
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
@@ -1451,11 +1502,10 @@ mod tests {
)); ));
assert!(ctx.active_operations.read().await.is_empty()); assert!(ctx.active_operations.read().await.is_empty());
assert!(ctx.active_downloads.read().await.is_empty()); assert!(ctx.active_downloads.read().await.is_empty());
assert_local_update(recv_event(&mut rx).await, true, true);
} }
#[tokio::test] #[tokio::test]
async fn update_refreshes_settled_state_after_guard_release() { async fn update_refreshes_settled_state_before_operation_clear() {
let temp = TempDir::new("lanspread-handler-update"); let temp = TempDir::new("lanspread-handler-update");
let root = temp.game_root(); let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("version.ini"), b"20250101");
@@ -1478,13 +1528,13 @@ mod tests {
} }
_ => panic!("expected InstallGameBegin"), _ => panic!("expected InstallGameBegin"),
} }
assert_local_update(recv_event(&mut rx).await, true, true);
assert_active_update(recv_event(&mut rx).await, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game" PeerEvent::InstallGameFinished { id } if id == "game"
)); ));
assert!(ctx.active_operations.read().await.is_empty()); assert!(ctx.active_operations.read().await.is_empty());
assert_local_update(recv_event(&mut rx).await, true, true);
} }
#[tokio::test] #[tokio::test]
@@ -1509,13 +1559,13 @@ mod tests {
operation: InstallOperation::Installing operation: InstallOperation::Installing
} if id == "game" } if id == "game"
)); ));
let game = local_update_game(recv_event(&mut rx).await, true, true);
assert_eq!(game.local_version.as_deref(), Some("20240101"));
assert_active_update(recv_event(&mut rx).await, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game" PeerEvent::InstallGameFinished { id } if id == "game"
)); ));
let game = local_update_game(recv_event(&mut rx).await, true, true);
assert_eq!(game.local_version.as_deref(), Some("20240101"));
write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"new archive"); write_file(&root.join("game.eti"), b"new archive");
@@ -1532,13 +1582,13 @@ mod tests {
operation: InstallOperation::Updating operation: InstallOperation::Updating
} if id == "game" } if id == "game"
)); ));
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, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::InstallGameFinished { id } if id == "game" PeerEvent::InstallGameFinished { id } if id == "game"
)); ));
let game = local_update_game(recv_event(&mut rx).await, true, true);
assert_eq!(game.local_version.as_deref(), Some("20250101"));
run_uninstall_operation(&ctx, &tx, "game".to_string()).await; run_uninstall_operation(&ctx, &tx, "game".to_string()).await;
assert_active_update( assert_active_update(
@@ -1549,18 +1599,18 @@ mod tests {
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game" PeerEvent::UninstallGameBegin { id } if id == "game"
)); ));
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, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game" PeerEvent::UninstallGameFinished { id } if id == "game"
)); ));
let game = local_update_game(recv_event(&mut rx).await, false, true);
assert_eq!(game.local_version.as_deref(), Some("20250101"));
assert!(ctx.active_operations.read().await.is_empty()); assert!(ctx.active_operations.read().await.is_empty());
} }
#[tokio::test] #[tokio::test]
async fn uninstall_refreshes_settled_state_after_guard_release() { async fn uninstall_refreshes_settled_state_before_operation_clear() {
let temp = TempDir::new("lanspread-handler-uninstall"); let temp = TempDir::new("lanspread-handler-uninstall");
let root = temp.game_root(); let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("version.ini"), b"20250101");
@@ -1580,17 +1630,17 @@ mod tests {
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::UninstallGameBegin { id } if id == "game" PeerEvent::UninstallGameBegin { id } if id == "game"
)); ));
assert_local_update(recv_event(&mut rx).await, false, true);
assert_active_update(recv_event(&mut rx).await, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::UninstallGameFinished { id } if id == "game" PeerEvent::UninstallGameFinished { id } if id == "game"
)); ));
assert!(ctx.active_operations.read().await.is_empty()); assert!(ctx.active_operations.read().await.is_empty());
assert_local_update(recv_event(&mut rx).await, false, true);
} }
#[tokio::test] #[tokio::test]
async fn remove_downloaded_refreshes_settled_state_after_guard_release() { async fn remove_downloaded_refreshes_settled_state_before_operation_clear() {
let temp = TempDir::new("lanspread-handler-remove-downloaded"); let temp = TempDir::new("lanspread-handler-remove-downloaded");
let root = temp.game_root(); let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join("version.ini"), b"20250101");
@@ -1615,15 +1665,15 @@ mod tests {
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::RemoveDownloadedGameBegin { id } if id == "game" PeerEvent::RemoveDownloadedGameBegin { id } if id == "game"
)); ));
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert!(games.is_empty());
assert_active_update(recv_event(&mut rx).await, Vec::new()); assert_active_update(recv_event(&mut rx).await, Vec::new());
assert!(matches!( assert!(matches!(
recv_event(&mut rx).await, recv_event(&mut rx).await,
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game" PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
)); ));
let PeerEvent::LocalLibraryChanged { games } = recv_event(&mut rx).await else {
panic!("expected LocalLibraryChanged");
};
assert!(games.is_empty());
assert!(!root.exists()); assert!(!root.exists());
assert!(ctx.active_operations.read().await.is_empty()); assert!(ctx.active_operations.read().await.is_empty());
} }
@@ -13,9 +13,9 @@ export interface GameActions {
/** /**
* Thin wrappers over the backend `run_game` / `install_game` / `update_game` * Thin wrappers over the backend `run_game` / `install_game` / `update_game`
* / `uninstall_game` / `remove_downloaded_game` commands. We mark peer-backed * / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads
* downloads as "checking peers" and already-downloaded installs as "installing" * are marked as "checking peers" until the backend emits an authoritative
* up-front so the UI doesn't have to wait for the first backend event. * operation snapshot.
*/ */
export const useGameActions = (games: UseGamesResult): GameActions => { export const useGameActions = (games: UseGamesResult): GameActions => {
const play = useCallback(async (id: string) => { const play = useCallback(async (id: string) => {
@@ -32,9 +32,7 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
if (!success) return; if (!success) return;
const game = games.games.find(item => item.id === id); const game = games.games.find(item => item.id === id);
if (game?.downloaded && !game.installed) { if (!game?.downloaded) {
games.markInstalling(id);
} else {
games.markChecking(id); games.markChecking(id);
} }
} catch (err) { } catch (err) {
@@ -6,7 +6,6 @@ import {
Game, Game,
GamesListPayload, GamesListPayload,
InstallStatus, InstallStatus,
StatusLevel,
} from '../lib/types'; } from '../lib/types';
import { import {
activeStatusById, activeStatusById,
@@ -17,36 +16,23 @@ import {
interface PendingPatch { interface PendingPatch {
install_status?: InstallStatus; install_status?: InstallStatus;
downloaded?: boolean;
installed?: boolean;
local_version?: string | null;
status_message?: string;
status_level?: StatusLevel | undefined;
clearStatus?: boolean; clearStatus?: boolean;
} }
const applyPatch = (game: Game, patch: PendingPatch): Game => { const applyPatch = (game: Game, patch: PendingPatch): Game => {
let next: Game = { ...game }; let next: Game = { ...game };
if (patch.install_status !== undefined) next.install_status = patch.install_status; if (patch.install_status !== undefined) next.install_status = patch.install_status;
if (patch.downloaded !== undefined) next.downloaded = patch.downloaded;
if (patch.installed !== undefined) next.installed = patch.installed;
if (patch.local_version !== undefined) next.local_version = patch.local_version ?? undefined;
if (patch.clearStatus) { if (patch.clearStatus) {
next.status_message = undefined; next.status_message = undefined;
next.status_level = undefined; next.status_level = undefined;
} }
if (patch.status_message !== undefined) {
next.status_message = patch.status_message;
next.status_level = patch.status_level;
}
return next; return next;
}; };
/** /**
* Owns the games list and reflects every backend event (download/install/ * Owns the games list and derives card status from backend snapshots. Returns
* uninstall/remove lifecycle, peer count) into local React state. Returns a * a fire-and-forget `markChecking` helper so action calls can immediately show
* fire-and-forget `markChecking` helper so action calls can immediately show a * a "Checking peers…" state until the next backend snapshot arrives.
* "Checking peers…" state until the backend emits the authoritative outcome.
*/ */
export interface UseGamesResult { export interface UseGamesResult {
games: Game[]; games: Game[];
@@ -54,7 +40,6 @@ export interface UseGamesResult {
totalPeerCount: number; totalPeerCount: number;
requestGames: () => Promise<void>; requestGames: () => Promise<void>;
markChecking: (id: string) => void; markChecking: (id: string) => void;
markInstalling: (id: string) => void;
} }
export const useGames = (rescanGameDir: () => void): UseGamesResult => { export const useGames = (rescanGameDir: () => void): UseGamesResult => {
@@ -71,14 +56,6 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
)); ));
}, []); }, []);
const markInstalling = useCallback((id: string) => {
setGames(prev => prev.map(item =>
item.id === id
? applyPatch(item, { install_status: InstallStatus.Installing, clearStatus: true })
: item,
));
}, []);
const requestGames = useCallback(async () => { const requestGames = useCallback(async () => {
try { try {
await invoke('request_games'); await invoke('request_games');
@@ -91,10 +68,6 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
const unlisteners: UnlistenFn[] = []; const unlisteners: UnlistenFn[] = [];
let cancelled = false; let cancelled = false;
const updateById = (id: string, patch: PendingPatch) => {
setGames(prev => prev.map(item => item.id === id ? applyPatch(item, patch) : item));
};
const handleErrorEvent = ( const handleErrorEvent = (
id: string, id: string,
message: string, message: string,
@@ -120,37 +93,16 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
event.payload as GamesListPayload | Game[], event.payload as GamesListPayload | Game[],
); );
const activeStatuses = activeStatusById(payload.active_operations); const activeStatuses = activeStatusById(payload.active_operations);
const hasAuthoritative = payload.active_operations !== undefined;
setGames(prev => { setGames(prev => {
const previousById = new Map(prev.map(item => [item.id, item])); const previousById = new Map(prev.map(item => [item.id, item]));
return payload.games.map(game => mergeGameUpdate( return payload.games.map(game => mergeGameUpdate(
game, game,
previousById.get(game.id), previousById.get(game.id),
activeStatuses.get(game.id), activeStatuses.get(game.id),
hasAuthoritative,
)); ));
}); });
})); }));
// 'game-download-pre' confirms peer metadata was found. The backend may still
// reject the download during majority validation (which now emits a terminal fail event),
// so keep showing CheckingPeers until 'game-download-begin' reports that transfer started.
unlisteners.push(await listen('game-download-pre', (e) => {
const id = e.payload as string;
updateById(id, { install_status: InstallStatus.CheckingPeers, clearStatus: true });
}));
// 'game-download-begin' signals consensus size validation has completed and file transfer has started.
unlisteners.push(await listen('game-download-begin', (e) => {
const id = e.payload as string;
updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true });
}));
unlisteners.push(await listen('game-download-finished', (e) => {
const id = e.payload as string;
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
}));
unlisteners.push(await listen('game-download-failed', (e) => { unlisteners.push(await listen('game-download-failed', (e) => {
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', { handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
triggerRescan: true, triggerRescan: true,
@@ -167,18 +119,7 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
handleErrorEvent(e.payload as string, 'No peers currently have this game.'); handleErrorEvent(e.payload as string, 'No peers currently have this game.');
})); }));
unlisteners.push(await listen('game-install-begin', (e) => { unlisteners.push(await listen('game-install-finished', () => {
const id = e.payload as string;
updateById(id, { install_status: InstallStatus.Installing, clearStatus: true });
}));
unlisteners.push(await listen('game-install-finished', (e) => {
const id = e.payload as string;
updateById(id, {
install_status: InstallStatus.Installed,
installed: true,
clearStatus: true,
});
rescanRef.current(); rescanRef.current();
})); }));
@@ -186,40 +127,11 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
handleErrorEvent(e.payload as string, 'Install failed. Please try again.'); handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
})); }));
unlisteners.push(await listen('game-uninstall-begin', (e) => {
updateById(e.payload as string, {
install_status: InstallStatus.Uninstalling,
clearStatus: true,
});
}));
unlisteners.push(await listen('game-uninstall-finished', (e) => {
updateById(e.payload as string, {
install_status: InstallStatus.NotInstalled,
installed: false,
clearStatus: true,
});
}));
unlisteners.push(await listen('game-uninstall-failed', (e) => { unlisteners.push(await listen('game-uninstall-failed', (e) => {
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.'); handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
})); }));
unlisteners.push(await listen('game-remove-download-begin', (e) => { unlisteners.push(await listen('game-remove-download-finished', () => {
updateById(e.payload as string, {
install_status: InstallStatus.Removing,
clearStatus: true,
});
}));
unlisteners.push(await listen('game-remove-download-finished', (e) => {
updateById(e.payload as string, {
install_status: InstallStatus.NotInstalled,
downloaded: false,
installed: false,
local_version: null,
clearStatus: true,
});
rescanRef.current(); rescanRef.current();
})); }));
@@ -257,6 +169,5 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
totalPeerCount, totalPeerCount,
requestGames, requestGames,
markChecking, markChecking,
markInstalling,
}; };
}; };
@@ -17,19 +17,9 @@ const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.Removing, InstallStatus.Removing,
]); ]);
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
InstallStatus.Removing,
]);
export const isInProgress = (status: InstallStatus): boolean => export const isInProgress = (status: InstallStatus): boolean =>
IN_PROGRESS_INSTALL_STATUSES.has(status); IN_PROGRESS_INSTALL_STATUSES.has(status);
const isReconciledOperationStatus = (status: InstallStatus): boolean =>
RECONCILED_OPERATION_STATUSES.has(status);
export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => { export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => {
switch (op) { switch (op) {
case ActiveOperationKind.Downloading: case ActiveOperationKind.Downloading:
@@ -52,35 +42,23 @@ export const normalizeGamesListPayload = (
): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload; ): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload;
/** /**
* Reconcile a freshly received backend snapshot of a game with our prior * Reconcile a freshly received backend snapshot. Core operation status is
* locally-tracked install status. Keeps in-progress operations visible across * derived only from the backend active-operation snapshot plus installed state.
* snapshots that don't yet reflect the running operation.
*/ */
export const mergeGameUpdate = ( export const mergeGameUpdate = (
incoming: Game, incoming: Game,
previous?: Game, previous?: Game,
activeStatus?: InstallStatus, activeStatus?: InstallStatus,
hasAuthoritativeSnapshot = false,
): Game => { ): Game => {
let installStatus = InstallStatus.NotInstalled; const installStatus = activeStatus
if (activeStatus !== undefined) { ?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled);
installStatus = activeStatus;
} else if (incoming.installed) {
installStatus = InstallStatus.Installed;
} else if (
previous
&& isInProgress(previous.install_status)
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
) {
installStatus = previous.install_status;
}
const localStateChanged = previous !== undefined const localStateChanged = previous !== undefined
&& (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded); && (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded);
const activeStateReconciled = hasAuthoritativeSnapshot const statusChanged = previous !== undefined
&& (activeStatus !== undefined && previous.install_status !== installStatus;
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status))); const clearStatus = localStateChanged
const clearStatus = localStateChanged || activeStateReconciled; || (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status)));
return { return {
...incoming, ...incoming,
@@ -0,0 +1,83 @@
import {
activeStatusById,
mergeGameUpdate,
} from '../src/lib/gameState.ts';
import {
ActiveOperationKind,
GameAvailability,
InstallStatus,
type Game,
} from '../src/lib/types.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const game = (overrides: Partial<Game> = {}): Game => ({
id: 'game',
name: 'Game',
description: '',
size: 0,
downloaded: false,
installed: false,
availability: GameAvailability.LocalOnly,
install_status: InstallStatus.NotInstalled,
peer_count: 1,
...overrides,
});
Deno.test('snapshot keeps installing visible until installed state settles', () => {
const fromDownloading = game({
install_status: InstallStatus.Downloading,
});
const installing = mergeGameUpdate(
game({ downloaded: true }),
fromDownloading,
InstallStatus.Installing,
);
const installedWhileActive = mergeGameUpdate(
game({ downloaded: true, installed: true }),
installing,
InstallStatus.Installing,
);
const settled = mergeGameUpdate(
game({ downloaded: true, installed: true }),
installedWhileActive,
);
assertEquals(
installing.install_status,
InstallStatus.Installing,
'active install snapshot should render Installing',
);
assertEquals(
installedWhileActive.install_status,
InstallStatus.Installing,
'installed local state should not override an active install snapshot',
);
assertEquals(
settled.install_status,
InstallStatus.Installed,
'cleared active snapshot with installed local state should render Installed',
);
});
Deno.test('active operation snapshot is the source of busy status', () => {
const statuses = activeStatusById([
{ id: 'game', operation: ActiveOperationKind.Downloading },
{ id: 'other', operation: ActiveOperationKind.Updating },
]);
assertEquals(
statuses.get('game'),
InstallStatus.Downloading,
'download operation should render Downloading',
);
assertEquals(
statuses.get('other'),
InstallStatus.Installing,
'update operation should render Installing',
);
});
+3
View File
@@ -27,6 +27,9 @@ clippy:
test: test:
cargo test --workspace cargo test --workspace
frontend-test:
cd crates/lanspread-tauri-deno-ts && deno test --unstable-sloppy-imports tests
clean: clean:
cargo clean cargo clean