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:
@@ -11,6 +11,8 @@ use lanspread_db::db::{GameDB, GameFileDescription};
|
||||
use tokio::sync::{RwLock, mpsc::UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
ActiveOperation,
|
||||
ActiveOperationKind,
|
||||
InstallOperation,
|
||||
PeerEvent,
|
||||
context::{Ctx, OperationGuard, OperationKind},
|
||||
@@ -553,20 +555,14 @@ pub async fn update_and_announce_games(
|
||||
revision,
|
||||
} = scan;
|
||||
|
||||
let active_ids = ctx
|
||||
.active_operations
|
||||
.read()
|
||||
.await
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !active_ids.is_empty() {
|
||||
let active_operations = active_operation_snapshot(ctx).await;
|
||||
if !active_operations.is_empty() {
|
||||
let previous = ctx.local_library.read().await.games.clone();
|
||||
for id in active_ids {
|
||||
if let Some(summary) = previous.get(&id) {
|
||||
summaries.insert(id, summary.clone());
|
||||
for id in active_operations.iter().map(|operation| &operation.id) {
|
||||
if let Some(summary) = previous.get(id) {
|
||||
summaries.insert(id.clone(), summary.clone());
|
||||
} else {
|
||||
summaries.remove(&id);
|
||||
summaries.remove(id);
|
||||
}
|
||||
}
|
||||
game_db = GameDB::from(summaries.values().map(game_from_summary).collect());
|
||||
@@ -577,10 +573,6 @@ pub async fn update_and_announce_games(
|
||||
library_guard.update_from_scan(summaries, revision)
|
||||
};
|
||||
|
||||
let Some(delta) = delta else {
|
||||
return;
|
||||
};
|
||||
|
||||
{
|
||||
let mut db_guard = ctx.local_game_db.write().await;
|
||||
*db_guard = Some(game_db.clone());
|
||||
@@ -588,10 +580,17 @@ pub async fn update_and_announce_games(
|
||||
|
||||
let all_games = game_db.all_games().into_iter().cloned().collect::<Vec<_>>();
|
||||
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated(all_games.clone())) {
|
||||
if let Err(e) = tx_notify_ui.send(PeerEvent::LocalGamesUpdated {
|
||||
games: all_games.clone(),
|
||||
active_operations,
|
||||
}) {
|
||||
log::error!("Failed to send LocalGamesUpdated event: {e}");
|
||||
}
|
||||
|
||||
let Some(delta) = delta else {
|
||||
return;
|
||||
};
|
||||
|
||||
let peer_targets = {
|
||||
let db = ctx.peer_game_db.read().await;
|
||||
db.peer_identities()
|
||||
@@ -625,6 +624,28 @@ pub async fn update_and_announce_games(
|
||||
}
|
||||
}
|
||||
|
||||
async fn active_operation_snapshot(ctx: &Ctx) -> Vec<ActiveOperation> {
|
||||
let active_operations = ctx.active_operations.read().await;
|
||||
let mut snapshot = active_operations
|
||||
.iter()
|
||||
.map(|(id, operation)| ActiveOperation {
|
||||
id: id.clone(),
|
||||
operation: active_operation_kind(*operation),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
snapshot.sort_by(|left, right| left.id.cmp(&right.id));
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
|
||||
match operation {
|
||||
OperationKind::Downloading => ActiveOperationKind::Downloading,
|
||||
OperationKind::Installing => ActiveOperationKind::Installing,
|
||||
OperationKind::Updating => ActiveOperationKind::Updating,
|
||||
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
@@ -710,9 +731,17 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_local_update(event: PeerEvent, installed: bool, downloaded: bool) {
|
||||
let PeerEvent::LocalGamesUpdated(games) = event else {
|
||||
let PeerEvent::LocalGamesUpdated {
|
||||
games,
|
||||
active_operations,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected LocalGamesUpdated");
|
||||
};
|
||||
assert!(
|
||||
active_operations.is_empty(),
|
||||
"settled local update should not report active operations"
|
||||
);
|
||||
let game = games
|
||||
.iter()
|
||||
.find(|game| game.id == "game")
|
||||
@@ -721,6 +750,87 @@ mod tests {
|
||||
assert_eq!(game.downloaded, downloaded);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_games_update_reports_authoritative_active_operations() {
|
||||
let temp = TempDir::new("lanspread-handler-active-snapshot");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Installing);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
.await
|
||||
.expect("scan should succeed");
|
||||
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
|
||||
let PeerEvent::LocalGamesUpdated {
|
||||
games,
|
||||
active_operations,
|
||||
} = recv_event(&mut rx).await
|
||||
else {
|
||||
panic!("expected LocalGamesUpdated");
|
||||
};
|
||||
assert!(
|
||||
games.is_empty(),
|
||||
"active game should keep its previous announced state"
|
||||
);
|
||||
assert_eq!(
|
||||
active_operations,
|
||||
vec![ActiveOperation {
|
||||
id: "game".to_string(),
|
||||
operation: ActiveOperationKind::Installing,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_scan_still_reports_active_operation_snapshot() {
|
||||
let temp = TempDir::new("lanspread-handler-active-unchanged");
|
||||
let root = temp.game_root();
|
||||
write_file(&root.join("version.ini"), b"20250101");
|
||||
write_file(&root.join("game.eti"), b"archive");
|
||||
|
||||
let ctx = test_ctx(temp.path().to_path_buf());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let catalog = ctx.catalog.read().await.clone();
|
||||
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
.await
|
||||
.expect("first scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
|
||||
ctx.active_operations
|
||||
.write()
|
||||
.await
|
||||
.insert("game".to_string(), OperationKind::Updating);
|
||||
let scan = scan_local_library(temp.path(), &catalog)
|
||||
.await
|
||||
.expect("second scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
|
||||
let PeerEvent::LocalGamesUpdated {
|
||||
active_operations, ..
|
||||
} = recv_event(&mut rx).await
|
||||
else {
|
||||
panic!("expected LocalGamesUpdated");
|
||||
};
|
||||
assert_eq!(
|
||||
active_operations,
|
||||
vec![ActiveOperation {
|
||||
id: "game".to_string(),
|
||||
operation: ActiveOperationKind::Updating,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_refreshes_settled_state_after_guard_release() {
|
||||
let temp = TempDir::new("lanspread-handler-install");
|
||||
|
||||
@@ -111,8 +111,11 @@ pub enum PeerEvent {
|
||||
PeerLost(SocketAddr),
|
||||
/// The total peer count has changed.
|
||||
PeerCountUpdated(usize),
|
||||
/// Local games have been updated.
|
||||
LocalGamesUpdated(Vec<Game>),
|
||||
/// Local games have been scanned, with authoritative in-progress work.
|
||||
LocalGamesUpdated {
|
||||
games: Vec<Game>,
|
||||
active_operations: Vec<ActiveOperation>,
|
||||
},
|
||||
/// A required peer runtime component failed.
|
||||
RuntimeFailed {
|
||||
component: PeerRuntimeComponent,
|
||||
@@ -144,6 +147,26 @@ pub enum InstallOperation {
|
||||
Updating,
|
||||
}
|
||||
|
||||
/// In-progress operation snapshot attached to local library updates.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActiveOperation {
|
||||
pub id: String,
|
||||
pub operation: ActiveOperationKind,
|
||||
}
|
||||
|
||||
/// Operation kinds visible to UI reconciliation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
|
||||
pub enum ActiveOperationKind {
|
||||
/// Downloading or replacing archive files.
|
||||
Downloading,
|
||||
/// Extracting into a previously uninstalled game root.
|
||||
Installing,
|
||||
/// Replacing an existing `local/` install.
|
||||
Updating,
|
||||
/// Removing an existing `local/` install.
|
||||
Uninstalling,
|
||||
}
|
||||
|
||||
/// Commands sent to the peer system from the UI.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PeerCommand {
|
||||
|
||||
Reference in New Issue
Block a user