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:
2026-05-19 21:00:44 +02:00
parent 74d9266723
commit 62ceb063ac
18 changed files with 628 additions and 31 deletions
+27 -20
View File
@@ -460,31 +460,24 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
"length": length,
}),
),
PeerEvent::DownloadGameFilesFinished { id } => {
("download-finished", json!({"game_id": id}))
}
PeerEvent::DownloadGameFilesFailed { id } => ("download-failed", json!({"game_id": id})),
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
("download-peers-gone", json!({"game_id": id}))
}
PeerEvent::DownloadGameFilesFinished { id } => game_id_event("download-finished", id),
PeerEvent::DownloadGameFilesFailed { id } => game_id_event("download-failed", id),
PeerEvent::DownloadGameFilesAllPeersGone { id } => game_id_event("download-peers-gone", id),
PeerEvent::InstallGameBegin { id, operation } => (
"install-begin",
json!({"game_id": id, "operation": install_operation_name(operation)}),
),
PeerEvent::InstallGameFinished { id } => ("install-finished", json!({"game_id": id})),
PeerEvent::InstallGameFailed { id } => ("install-failed", json!({"game_id": id})),
PeerEvent::UninstallGameBegin { id } => ("uninstall-begin", json!({"game_id": id})),
PeerEvent::UninstallGameFinished { id } => ("uninstall-finished", json!({"game_id": id})),
PeerEvent::UninstallGameFailed { id } => ("uninstall-failed", json!({"game_id": id})),
PeerEvent::NoPeersHaveGame { id } => {
shared
.state
.write()
.await
.unavailable_games
.insert(id.clone());
("no-peers-have-game", json!({"game_id": id}))
PeerEvent::InstallGameFinished { id } => game_id_event("install-finished", id),
PeerEvent::InstallGameFailed { id } => game_id_event("install-failed", id),
PeerEvent::UninstallGameBegin { id } => game_id_event("uninstall-begin", id),
PeerEvent::UninstallGameFinished { id } => game_id_event("uninstall-finished", id),
PeerEvent::UninstallGameFailed { id } => game_id_event("uninstall-failed", id),
PeerEvent::RemoveDownloadedGameBegin { id } => game_id_event("remove-download-begin", id),
PeerEvent::RemoveDownloadedGameFinished { id } => {
game_id_event("remove-download-finished", id)
}
PeerEvent::RemoveDownloadedGameFailed { id } => game_id_event("remove-download-failed", id),
PeerEvent::NoPeersHaveGame { id } => no_peers_event(shared, id).await,
PeerEvent::PeerConnected(addr) => ("peer-connected", peer_addr_json(addr)),
PeerEvent::PeerDisconnected(addr) => ("peer-disconnected", peer_addr_json(addr)),
PeerEvent::PeerDiscovered(addr) => ("peer-discovered", peer_addr_json(addr)),
@@ -497,6 +490,20 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
}
}
fn game_id_event(kind: &'static str, id: String) -> (&'static str, Value) {
(kind, json!({"game_id": id}))
}
async fn no_peers_event(shared: &SharedState, id: String) -> (&'static str, Value) {
shared
.state
.write()
.await
.unavailable_games
.insert(id.clone());
game_id_event("no-peers-have-game", id)
}
fn peer_addr_json(addr: SocketAddr) -> Value {
json!({"addr": addr.to_string()})
}