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
@@ -18,7 +18,9 @@ const CHECKING_PEERS_TIMEOUT_MS = 5000;
interface PendingPatch {
install_status?: InstallStatus;
downloaded?: boolean;
installed?: boolean;
local_version?: string | null;
status_message?: string;
status_level?: StatusLevel | undefined;
clearStatus?: boolean;
@@ -27,7 +29,9 @@ interface PendingPatch {
const applyPatch = (game: Game, patch: PendingPatch): Game => {
let next: Game = { ...game };
if (patch.install_status !== undefined) next.install_status = patch.install_status;
if (patch.downloaded !== undefined) next.downloaded = patch.downloaded;
if (patch.installed !== undefined) next.installed = patch.installed;
if (patch.local_version !== undefined) next.local_version = patch.local_version ?? undefined;
if (patch.clearStatus) {
next.status_message = undefined;
next.status_level = undefined;
@@ -41,7 +45,7 @@ const applyPatch = (game: Game, patch: PendingPatch): Game => {
/**
* Owns the games list and reflects every backend event (download/install/
* uninstall lifecycle, peer count) into local React state. Returns a
* uninstall/remove lifecycle, peer count) into local React state. Returns a
* fire-and-forget `markChecking` helper so action calls can immediately show a
* "Checking peers…" state with an automatic fall-back if the backend never
* emits a follow-up event.
@@ -227,6 +231,30 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
}));
unlisteners.push(await listen('game-remove-download-begin', (e) => {
updateById(e.payload as string, {
install_status: InstallStatus.Removing,
clearStatus: true,
});
}));
unlisteners.push(await listen('game-remove-download-finished', (e) => {
updateById(e.payload as string, {
install_status: InstallStatus.NotInstalled,
downloaded: false,
installed: false,
local_version: null,
clearStatus: true,
});
rescanRef.current();
}));
unlisteners.push(await listen('game-remove-download-failed', (e) => {
handleErrorEvent(e.payload as string, 'Remove failed. Please try again.', {
triggerRescan: true,
});
}));
unlisteners.push(await listen('peer-count-updated', (e) => {
setTotalPeerCount(e.payload as number);
}));