feat(peer): remove downloaded game files safely
Downloaded but uninstalled games can still occupy significant disk space. Add a separate removal path for that state instead of overloading uninstall, which is reserved for deleting only `local/` installs. The peer runtime now exposes `RemoveDownloadedGame` with matching lifecycle and active-operation events. The filesystem delete is intentionally strict: the id must be a catalog game and a single path component, the target must be a direct child of the configured game directory, the root must not be a symlink, it must have a regular root-level `version.ini`, and it must not contain `local/`, `.local.installing/`, or `.local.backup/`. Only then do we recursively remove the game root. The Tauri bridge exposes this as `remove_downloaded_game`, the frontend shows a matching danger action only for downloaded-but-uninstalled games, and a confirmation dialog warns that re-downloading can take a long time. Test Plan: - git diff --check - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build Refs: user redesign nitpick about removing downloaded uninstalled games
This commit is contained in:
@@ -385,6 +385,18 @@ pub async fn handle_uninstall_game_command(
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn handle_remove_downloaded_game_command(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
ctx.task_tracker.clone().spawn(async move {
|
||||
run_remove_downloaded_operation(&ctx, &tx_notify_ui, id).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
|
||||
let ctx = ctx.clone();
|
||||
let tx_notify_ui = tx_notify_ui.clone();
|
||||
@@ -560,6 +572,59 @@ async fn run_uninstall_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerE
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_remove_downloaded_operation(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
id: String,
|
||||
) {
|
||||
if !catalog_contains(ctx, &id).await {
|
||||
log::warn!("Ignoring downloaded-file removal for non-catalog game {id}");
|
||||
return;
|
||||
}
|
||||
|
||||
if !begin_operation(ctx, tx_notify_ui, &id, OperationKind::RemovingDownload).await {
|
||||
log::warn!("Operation for {id} already in progress; ignoring downloaded-file removal");
|
||||
return;
|
||||
}
|
||||
|
||||
let game_dir = { ctx.game_dir.read().await.clone() };
|
||||
let operation_guard = OperationGuard::new(
|
||||
id.clone(),
|
||||
ctx.active_operations.clone(),
|
||||
tx_notify_ui.clone(),
|
||||
);
|
||||
let result = {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::RemoveDownloadedGameBegin { id: id.clone() },
|
||||
);
|
||||
|
||||
install::remove_downloaded(&game_dir, &id).await
|
||||
};
|
||||
end_operation(ctx, tx_notify_ui, &id).await;
|
||||
operation_guard.disarm();
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
PeerEvent::RemoveDownloadedGameFinished { id: id.clone() },
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Downloaded-file removal failed for {id}: {err}");
|
||||
events::send(
|
||||
tx_notify_ui,
|
||||
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(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
@@ -1471,6 +1536,45 @@ mod tests {
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_downloaded_refreshes_settled_state_after_guard_release() {
|
||||
let temp = TempDir::new("lanspread-handler-remove-downloaded");
|
||||
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("initial scan should succeed");
|
||||
update_and_announce_games(&ctx, &tx, scan).await;
|
||||
assert_local_update(recv_event(&mut rx).await, false, true);
|
||||
|
||||
run_remove_downloaded_operation(&ctx, &tx, "game".to_string()).await;
|
||||
|
||||
assert_active_update(
|
||||
recv_event(&mut rx).await,
|
||||
active_update("game", ActiveOperationKind::RemovingDownload),
|
||||
);
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
PeerEvent::RemoveDownloadedGameBegin { id } if id == "game"
|
||||
));
|
||||
assert_active_update(recv_event(&mut rx).await, Vec::new());
|
||||
assert!(matches!(
|
||||
recv_event(&mut rx).await,
|
||||
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!(ctx.active_operations.read().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() {
|
||||
let current = TempDir::new("lanspread-handler-current-dir");
|
||||
|
||||
Reference in New Issue
Block a user