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:
2026-05-16 09:01:17 +02:00
parent b5d20c1e72
commit 95e70ef520
7 changed files with 384 additions and 38 deletions
@@ -13,7 +13,7 @@ edition = "2024"
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "lanspread_tauri_deno_ts_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
test = false
test = true
doctest = false
[lints.clippy]
@@ -36,6 +36,7 @@ base64 = { workspace = true }
eyre = { workspace = true }
log = { workspace = true }
mimalloc = { workspace = true }
serde = { workspace = true }
tauri = { workspace = true }
tauri-plugin-log = { workspace = true }
tauri-plugin-shell = { workspace = true }
@@ -17,6 +17,8 @@ use lanspread_db::db::{
GameFileDescription,
};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
InstallOperation,
PeerCommand,
PeerEvent,
@@ -49,7 +51,7 @@ struct LanSpreadState {
struct PeerEventTx(UnboundedSender<PeerEvent>);
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
enum UiOperationKind {
Downloading,
Installing,
@@ -57,6 +59,18 @@ enum UiOperationKind {
Uninstalling,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
struct UiActiveOperation {
id: String,
operation: UiOperationKind,
}
#[derive(Clone, Debug, serde::Serialize)]
struct GamesListPayload {
games: Vec<Game>,
active_operations: Vec<UiActiveOperation>,
}
struct SidecarUnpacker {
app_handle: AppHandle,
}
@@ -527,13 +541,59 @@ async fn refresh_games_list(app_handle: &AppHandle) {
drop(game_db);
if let Err(e) = app_handle.emit("games-list-updated", Some(games_to_emit)) {
let active_operations = {
let active_operations = state.active_operations.read().await;
ui_active_operations_from_map(&active_operations)
};
let payload = GamesListPayload {
games: games_to_emit,
active_operations,
};
if let Err(e) = app_handle.emit("games-list-updated", Some(payload)) {
log::error!("Failed to emit games-list-updated event: {e}");
} else {
log::info!("Emitted games-list-updated event");
}
}
fn ui_active_operations_from_map(
active_operations: &HashMap<String, UiOperationKind>,
) -> Vec<UiActiveOperation> {
let mut snapshot = active_operations
.iter()
.map(|(id, operation)| UiActiveOperation {
id: id.clone(),
operation: *operation,
})
.collect::<Vec<_>>();
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
snapshot
}
fn reconcile_active_operations(
active_operations: &mut HashMap<String, UiOperationKind>,
snapshot: &[ActiveOperation],
) {
active_operations.clear();
active_operations.extend(snapshot.iter().map(|operation| {
(
operation.id.clone(),
ui_operation_from_peer(operation.operation),
)
}));
}
fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
match operation {
ActiveOperationKind::Downloading => UiOperationKind::Downloading,
ActiveOperationKind::Installing => UiOperationKind::Installing,
ActiveOperationKind::Updating => UiOperationKind::Updating,
ActiveOperationKind::Uninstalling => UiOperationKind::Uninstalling,
}
}
#[tauri::command]
async fn update_game_directory(app_handle: tauri::AppHandle, path: String) -> tauri::Result<()> {
log::info!("update_game_directory: {path}");
@@ -816,8 +876,16 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
log::info!("PeerEvent::ListGames received");
update_game_db(games, app_handle.clone()).await;
}
PeerEvent::LocalGamesUpdated(local_games) => {
PeerEvent::LocalGamesUpdated {
games: local_games,
active_operations,
} => {
log::info!("PeerEvent::LocalGamesUpdated received");
{
let state = app_handle.state::<LanSpreadState>();
let mut ui_active_operations = state.active_operations.write().await;
reconcile_active_operations(&mut ui_active_operations, &active_operations);
}
update_local_games_in_db(local_games, app_handle.clone()).await;
}
PeerEvent::GotGameFiles {
@@ -1061,6 +1129,69 @@ async fn handle_download_finished(app_handle: &AppHandle, id: String) {
.remove(&id);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn active_operation_reconciliation_replaces_stale_ui_history() {
let mut active_operations = HashMap::from([
("stale".to_string(), UiOperationKind::Installing),
("keep".to_string(), UiOperationKind::Downloading),
]);
let snapshot = vec![
ActiveOperation {
id: "keep".to_string(),
operation: ActiveOperationKind::Updating,
},
ActiveOperation {
id: "new".to_string(),
operation: ActiveOperationKind::Uninstalling,
},
];
reconcile_active_operations(&mut active_operations, &snapshot);
assert_eq!(active_operations.len(), 2);
assert_eq!(
active_operations.get("keep"),
Some(&UiOperationKind::Updating)
);
assert_eq!(
active_operations.get("new"),
Some(&UiOperationKind::Uninstalling)
);
assert!(
!active_operations.contains_key("stale"),
"snapshot reconciliation should drop stale UI operations"
);
}
#[test]
fn active_operation_payload_is_sorted_for_stable_ui_updates() {
let active_operations = HashMap::from([
("zeta".to_string(), UiOperationKind::Downloading),
("alpha".to_string(), UiOperationKind::Installing),
]);
let snapshot = ui_active_operations_from_map(&active_operations);
assert_eq!(
snapshot,
vec![
UiActiveOperation {
id: "alpha".to_string(),
operation: UiOperationKind::Installing,
},
UiActiveOperation {
id: "zeta".to_string(),
operation: UiOperationKind::Downloading,
},
]
);
}
}
#[allow(clippy::missing_panics_doc)]
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
+83 -7
View File
@@ -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();
});