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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user