fix(ui): reconcile active operations from local scans
Local operation spinners were driven by begin, finish, and failure event history. If one of those lifecycle events was missed, the Tauri bridge could keep a stale active operation and the React state would keep showing an in-progress spinner until restart. Peer local scan updates now carry an authoritative active-operation snapshot. The peer still suppresses active game roots from peer-facing library deltas, but it emits LocalGamesUpdated to the UI even when no library delta changed so the snapshot can clear stale state after rollback or completion. The Tauri bridge replaces its active-operation map from that snapshot, emits it with the games-list payload, and the React merge uses it to restore download, install, update, and uninstall spinners from current peer state rather than event history alone. This also enables the Tauri lib unit-test target so the reconciliation helper can stay covered by the workspace test recipe. Test Plan: - git diff --check - just fmt - just clippy - just test Follow-up-Plan: FOLLOW_UP_2.md
This commit is contained in:
@@ -55,6 +55,23 @@ enum GameAvailability {
|
||||
LocalOnly = 'LocalOnly',
|
||||
}
|
||||
|
||||
enum ActiveOperationKind {
|
||||
Downloading = 'Downloading',
|
||||
Installing = 'Installing',
|
||||
Updating = 'Updating',
|
||||
Uninstalling = 'Uninstalling',
|
||||
}
|
||||
|
||||
interface ActiveOperation {
|
||||
id: string;
|
||||
operation: ActiveOperationKind;
|
||||
}
|
||||
|
||||
interface GamesListPayload {
|
||||
games: Game[];
|
||||
active_operations?: ActiveOperation[];
|
||||
}
|
||||
|
||||
interface GameThumbnailProps {
|
||||
gameId: string;
|
||||
alt: string;
|
||||
@@ -95,27 +112,78 @@ const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
|
||||
InstallStatus.Uninstalling,
|
||||
]);
|
||||
|
||||
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
|
||||
InstallStatus.Downloading,
|
||||
InstallStatus.Installing,
|
||||
InstallStatus.Uninstalling,
|
||||
]);
|
||||
|
||||
const isInProgressInstallStatus = (status: InstallStatus): boolean => {
|
||||
return IN_PROGRESS_INSTALL_STATUSES.has(status);
|
||||
};
|
||||
|
||||
const mergeGameUpdate = (game: Game, previous?: Game): Game => {
|
||||
const isReconciledOperationStatus = (status: InstallStatus): boolean => {
|
||||
return RECONCILED_OPERATION_STATUSES.has(status);
|
||||
};
|
||||
|
||||
const installStatusFromActiveOperation = (operation: ActiveOperationKind): InstallStatus => {
|
||||
switch (operation) {
|
||||
case ActiveOperationKind.Downloading:
|
||||
return InstallStatus.Downloading;
|
||||
case ActiveOperationKind.Installing:
|
||||
case ActiveOperationKind.Updating:
|
||||
return InstallStatus.Installing;
|
||||
case ActiveOperationKind.Uninstalling:
|
||||
return InstallStatus.Uninstalling;
|
||||
}
|
||||
};
|
||||
|
||||
const activeStatusById = (activeOperations: ActiveOperation[] = []): Map<string, InstallStatus> => {
|
||||
return new Map(activeOperations.map(operation => [
|
||||
operation.id,
|
||||
installStatusFromActiveOperation(operation.operation),
|
||||
]));
|
||||
};
|
||||
|
||||
const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesListPayload => {
|
||||
if (Array.isArray(payload)) {
|
||||
return { games: payload };
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const mergeGameUpdate = (
|
||||
game: Game,
|
||||
previous?: Game,
|
||||
activeStatus?: InstallStatus,
|
||||
hasAuthoritativeSnapshot = false,
|
||||
): Game => {
|
||||
let installStatus = InstallStatus.NotInstalled;
|
||||
if (game.installed) {
|
||||
if (activeStatus !== undefined) {
|
||||
installStatus = activeStatus;
|
||||
} else if (game.installed) {
|
||||
installStatus = InstallStatus.Installed;
|
||||
} else if (previous && isInProgressInstallStatus(previous.install_status)) {
|
||||
} else if (
|
||||
previous
|
||||
&& isInProgressInstallStatus(previous.install_status)
|
||||
&& (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers)
|
||||
) {
|
||||
installStatus = previous.install_status;
|
||||
}
|
||||
|
||||
const localStateChanged = previous !== undefined
|
||||
&& (previous.installed !== game.installed || previous.downloaded !== game.downloaded);
|
||||
const activeStateReconciled = hasAuthoritativeSnapshot
|
||||
&& (activeStatus !== undefined
|
||||
|| (previous !== undefined && isReconciledOperationStatus(previous.install_status)));
|
||||
const clearStatus = localStateChanged || activeStateReconciled;
|
||||
|
||||
return {
|
||||
...game,
|
||||
availability: game.availability ?? (game.downloaded ? GameAvailability.Ready : GameAvailability.LocalOnly),
|
||||
install_status: installStatus,
|
||||
status_message: localStateChanged ? undefined : previous?.status_message,
|
||||
status_level: localStateChanged ? undefined : previous?.status_level,
|
||||
status_message: clearStatus ? undefined : previous?.status_message,
|
||||
status_level: clearStatus ? undefined : previous?.status_level,
|
||||
peer_count: game.peer_count ?? 0,
|
||||
};
|
||||
};
|
||||
@@ -356,11 +424,19 @@ const App = () => {
|
||||
// Listen for games-list-updated events
|
||||
const unlisten_games = await listen('games-list-updated', (event) => {
|
||||
console.log('🗲 Received games-list-updated event');
|
||||
const games = event.payload as Game[];
|
||||
const payload = normalizeGamesListPayload(event.payload as GamesListPayload | Game[]);
|
||||
const games = payload.games;
|
||||
const activeStatuses = activeStatusById(payload.active_operations);
|
||||
const hasAuthoritativeSnapshot = payload.active_operations !== undefined;
|
||||
console.log(`🎮 ${games.length} Games received`);
|
||||
setGameItems(prev => {
|
||||
const previousById = new Map(prev.map(item => [item.id, item]));
|
||||
return games.map(game => mergeGameUpdate(game, previousById.get(game.id)));
|
||||
return games.map(game => mergeGameUpdate(
|
||||
game,
|
||||
previousById.get(game.id),
|
||||
activeStatuses.get(game.id),
|
||||
hasAuthoritativeSnapshot,
|
||||
));
|
||||
});
|
||||
void getInitialGameDir();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user