fix(launcher): keep active installations visible in game filters

Operation admission withdraws local availability before changing game files.
The UI previously received that withdrawal before the busy state, then
filtered out installations because only downloads counted as local activity.

Publish the active operation first while preserving atomic network withdrawal.
Keep active operations in All Games and Local, retain prior Installed filter
membership until settlement, and show operation-specific status labels. Local
availability and playability remain derived from the backend. Document the
installation presentation and cover withdrawal, settlement, and filter counts.

Test Plan:
- just fmt and just clippy: passed.
- just test: passed all workspace suites.
- just frontend-test: 98 passed, including installation lifecycle regressions.
- deno task build: TypeScript and Vite production build passed.
- Native GUI interaction was not manually exercised.
This commit is contained in:
2026-09-12 20:01:40 +02:00
parent 6b65a66465
commit 1a394d2825
6 changed files with 227 additions and 39 deletions
+4 -1
View File
@@ -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.
+31 -28
View File
@@ -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,
@@ -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);
}
@@ -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. */
@@ -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<GameFilter, number>,
) => {
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,
+18
View File
@@ -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')