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
@@ -52,6 +52,7 @@ enum UiOperationKind {
Installing,
Updating,
Uninstalling,
RemovingDownload,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
@@ -230,6 +231,54 @@ async fn uninstall_game(
}
}
#[tauri::command]
async fn remove_downloaded_game(
id: String,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<bool> {
if state
.inner()
.active_operations
.read()
.await
.contains_key(&id)
{
log::warn!("Game already has an active operation: {id}");
return Ok(false);
}
let Some((downloaded, installed)) = state
.inner()
.games
.read()
.await
.get_game_by_id(&id)
.map(|game| (game.downloaded, game.installed))
else {
log::warn!("Ignoring downloaded-file removal for unknown game: {id}");
return Ok(false);
};
if !downloaded || installed {
log::warn!(
"Ignoring downloaded-file removal for {id}: downloaded={downloaded}, installed={installed}"
);
return Ok(false);
}
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
let peer_ctrl = peer_ctrl_arc.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl {
if let Err(e) = peer_ctrl.send(PeerCommand::RemoveDownloadedGame { id }) {
log::error!("Failed to send message to peer: {e:?}");
return Ok(false);
}
Ok(true)
} else {
log::warn!("Peer system not initialized yet");
Ok(false)
}
}
#[tauri::command]
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
let peer_ctrl_arc = state.inner().peer_ctrl.clone();
@@ -505,6 +554,7 @@ fn ui_operation_from_peer(operation: ActiveOperationKind) -> UiOperationKind {
ActiveOperationKind::Installing => UiOperationKind::Installing,
ActiveOperationKind::Updating => UiOperationKind::Updating,
ActiveOperationKind::Uninstalling => UiOperationKind::Uninstalling,
ActiveOperationKind::RemovingDownload => UiOperationKind::RemovingDownload,
}
}
@@ -1064,6 +1114,33 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
"PeerEvent::UninstallGameFailed",
);
}
PeerEvent::RemoveDownloadedGameBegin { id } => {
log::info!("PeerEvent::RemoveDownloadedGameBegin received for {id}");
emit_game_id_event(
app_handle,
"game-remove-download-begin",
&id,
"PeerEvent::RemoveDownloadedGameBegin",
);
}
PeerEvent::RemoveDownloadedGameFinished { id } => {
log::info!("PeerEvent::RemoveDownloadedGameFinished received for {id}");
emit_game_id_event(
app_handle,
"game-remove-download-finished",
&id,
"PeerEvent::RemoveDownloadedGameFinished",
);
}
PeerEvent::RemoveDownloadedGameFailed { id } => {
log::warn!("PeerEvent::RemoveDownloadedGameFailed received for {id}");
emit_game_id_event(
app_handle,
"game-remove-download-failed",
&id,
"PeerEvent::RemoveDownloadedGameFailed",
);
}
PeerEvent::PeerConnected(addr) => {
log::info!("Peer connected: {addr}");
emit_peer_addr_event(app_handle, "peer-connected", addr);
@@ -1315,6 +1392,7 @@ pub fn run() {
update_game_directory,
update_game,
uninstall_game,
remove_downloaded_game,
get_peer_count,
get_game_thumbnail,
get_unpack_logs
@@ -0,0 +1,40 @@
import { Modal } from '../Modal';
import { Icon } from '../Icon';
import { Game } from '../../lib/types';
import { formatBytes } from '../../lib/format';
interface Props {
game: Game;
onCancel: () => void;
onConfirm: (game: Game) => void;
}
export const ConfirmRemoveDownloadModal = ({ game, onCancel, onConfirm }: Props) => (
<Modal onClose={onCancel} className="confirm-modal">
<button className="modal-close" type="button" onClick={onCancel} aria-label="Close">
<Icon.close />
</button>
<div className="confirm-icon">
<Icon.trash />
</div>
<h2>Remove downloaded files?</h2>
<p>
This removes {game.name} ({formatBytes(game.size)}) from this computer.
Re-downloading can take a long time.
</p>
<div className="confirm-actions">
<button type="button" className="ghost-btn" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className="ghost-btn ghost-danger"
onClick={() => onConfirm(game)}
>
<Icon.trash />
<span>Remove files</span>
</button>
</div>
</Modal>
);
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton';
import { Game } from '../../lib/types';
import { deriveState } from '../../lib/gameState';
import { deriveState, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
interface Props {
@@ -14,6 +14,7 @@ interface Props {
onClose: () => void;
onPrimary: (game: Game) => void;
onUninstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void;
}
const tagsFromGame = (game: Game): string[] => {
@@ -33,8 +34,18 @@ const statusLabelFor = (game: Game): string => {
}
};
export const GameDetailModal = ({ game, thumbnailUrl, onClose, onPrimary, onUninstall }: Props) => {
export const GameDetailModal = ({
game,
thumbnailUrl,
onClose,
onPrimary,
onUninstall,
onRemoveDownload,
}: Props) => {
const tags = tagsFromGame(game);
const canRemoveDownload = game.downloaded
&& !game.installed
&& !isInProgress(game.install_status);
return (
<Modal onClose={onClose}>
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
@@ -102,6 +113,16 @@ export const GameDetailModal = ({ game, thumbnailUrl, onClose, onPrimary, onUnin
<span>Uninstall</span>
</button>
)}
{canRemoveDownload && (
<button
type="button"
className="ghost-btn ghost-danger"
onClick={() => onRemoveDownload(game)}
>
<Icon.trash />
<span>Remove files</span>
</button>
)}
</div>
</div>
</Modal>
@@ -8,13 +8,14 @@ export interface GameActions {
install: (id: string) => Promise<void>;
update: (id: string) => Promise<void>;
uninstall: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>;
}
/**
* Thin wrappers over the backend `run_game` / `install_game` / `update_game`
* / `uninstall_game` commands. We mark peer-backed downloads as "checking
* peers" and already-downloaded installs as "installing" up-front so the UI
* doesn't have to wait for the first backend event.
* / `uninstall_game` / `remove_downloaded_game` commands. We mark peer-backed
* downloads as "checking peers" and already-downloaded installs as "installing"
* up-front so the UI doesn't have to wait for the first backend event.
*/
export const useGameActions = (games: UseGamesResult): GameActions => {
const play = useCallback(async (id: string) => {
@@ -58,5 +59,13 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
}
}, []);
return { play, install, update, uninstall };
const removeDownload = useCallback(async (id: string) => {
try {
await invoke('remove_downloaded_game', { id });
} catch (err) {
console.error('remove_downloaded_game failed:', err);
}
}, []);
return { play, install, update, uninstall, removeDownload };
};
@@ -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);
}));
@@ -14,12 +14,14 @@ const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
InstallStatus.Removing,
]);
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
InstallStatus.Downloading,
InstallStatus.Installing,
InstallStatus.Uninstalling,
InstallStatus.Removing,
]);
export const isInProgress = (status: InstallStatus): boolean =>
@@ -37,6 +39,8 @@ export const installStatusFromActiveOperation = (op: ActiveOperationKind): Insta
return InstallStatus.Installing;
case ActiveOperationKind.Uninstalling:
return InstallStatus.Uninstalling;
case ActiveOperationKind.RemovingDownload:
return InstallStatus.Removing;
}
};
@@ -136,6 +140,8 @@ export const inProgressLabel = (status: InstallStatus): string | undefined => {
return 'Installing…';
case InstallStatus.Uninstalling:
return 'Uninstalling…';
case InstallStatus.Removing:
return 'Removing…';
default:
return undefined;
}
@@ -4,6 +4,7 @@ export enum InstallStatus {
Downloading = 'Downloading',
Installing = 'Installing',
Uninstalling = 'Uninstalling',
Removing = 'Removing',
Installed = 'Installed',
}
@@ -17,6 +18,7 @@ export enum ActiveOperationKind {
Installing = 'Installing',
Updating = 'Updating',
Uninstalling = 'Uninstalling',
RemovingDownload = 'RemovingDownload',
}
export type StatusLevel = 'info' | 'error';
@@ -295,7 +295,7 @@
padding: 4px;
background: var(--bg-3);
border: 1px solid var(--bd-2);
border-radius: 10px;
border-radius: 8px;
box-shadow:
0 16px 40px -8px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.04);
@@ -1060,6 +1060,42 @@
flex: 1;
}
.modal.confirm-modal {
width: min(420px, 100%);
padding: 28px;
background: var(--bg-2);
border-radius: 8px;
}
.confirm-icon {
display: inline-grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 999px;
color: #f87171;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.22);
}
.confirm-modal h2 {
margin: 18px 0 10px;
font-size: 19px;
line-height: 1.2;
color: var(--t-1);
}
.confirm-modal p {
margin: 0;
color: var(--t-2);
font-size: 14px;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
flex-wrap: wrap;
}
/* Settings dialog */
.settings-modal {
width: min(640px, 100%);
@@ -6,6 +6,7 @@ import { KebabItem } from '../components/topbar/KebabMenu';
import { ResultsBar } from '../components/grid/ResultsBar';
import { GameGrid } from '../components/grid/GameGrid';
import { GameDetailModal } from '../components/modals/GameDetailModal';
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal';
import { SettingsDialog } from '../components/modals/SettingsDialog';
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
@@ -50,6 +51,7 @@ export const MainWindow = () => {
const thumbnails = useThumbnails();
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const counts = useMemo(() => countByFilter(games.games), [games.games]);
@@ -65,6 +67,10 @@ export const MainWindow = () => {
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null,
[openGameId, games.games],
);
const removeGame = useMemo<Game | null>(
() => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null,
[removeGameId, games.games],
);
const pickDirectory = useCallback(async () => {
const picked = await open({ multiple: false, directory: true });
@@ -84,6 +90,15 @@ export const MainWindow = () => {
actions.uninstall(game.id);
}, [actions]);
const handleRemoveDownload = useCallback((game: Game) => {
setRemoveGameId(game.id);
}, []);
const confirmRemoveDownload = useCallback((game: Game) => {
actions.removeDownload(game.id);
setRemoveGameId(null);
}, [actions]);
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
@@ -153,6 +168,15 @@ export const MainWindow = () => {
onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary}
onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
/>
)}
{removeGame && (
<ConfirmRemoveDownloadModal
game={removeGame}
onCancel={() => setRemoveGameId(null)}
onConfirm={confirmRemoveDownload}
/>
)}