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()})
}
+5
View File
@@ -128,6 +128,11 @@ Reserved per-game paths:
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
when the current intent is `None`.
Downloaded-file removal is not an uninstall transaction. It removes the whole
game root only for a catalog ID that is a single direct child of the configured
game directory, has a regular root-level `version.ini`, and has no `local/`,
`.local.installing/`, or `.local.backup/` path.
Recovery reads `.lanspread.json` and combines the recorded intent with the
observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent
states `Installing`, `Updating`, and `Uninstalling` prove ownership of the
+9 -3
View File
@@ -84,12 +84,17 @@ When the UI asks to download a game:
### Install Transactions
Install, update, uninstall, and startup recovery live under `src/install/`.
Install, update, uninstall, downloaded-file removal, and startup recovery live
under `src/install/`.
Each game root has an atomic `.lanspread.json` intent log for install-side
operations and uses Lanspread-owned `.local.installing/` and `.local.backup/`
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
intent with the observed filesystem state and only deletes reserved directories
when intent or marker ownership proves they belong to Lanspread.
Downloaded-file removal is deliberately separate from uninstall: it only accepts
catalog IDs that are direct children of the configured game directory, refuses
installed or in-flight roots, and deletes the whole game root only after finding
a regular root-level `version.ini` sentinel.
## Integration with `lanspread-tauri-deno-ts`
@@ -99,8 +104,9 @@ The Tauri application embeds this crate in
- `LanSpreadState` holds onto the peer control channel, the latest aggregated
`GameDB`, per-game operation state, the catalog set, and the user-selected
game directory.
- The Tauri commands (`request_games`, `install_game`, `update_game`, and
`update_game_directory`) translate UI actions into `PeerCommand`s. In
- The Tauri commands (`request_games`, `install_game`, `update_game`,
`remove_downloaded_game`, and `update_game_directory`) translate UI actions
into `PeerCommand`s. In
particular, `update_game_directory` validates the filesystem path before
storing it, loads the bundled catalog on first use, kicks off the peer runtime
on demand, and mirrors the installed/uninstalled state into the UI-facing
+2
View File
@@ -24,6 +24,8 @@ pub enum OperationKind {
Updating,
/// Removing an existing `local/` install.
Uninstalling,
/// Removing downloaded archive files for an uninstalled game.
RemovingDownload,
}
/// Main context for the peer system.
+1
View File
@@ -59,6 +59,7 @@ fn active_operation_kind(operation: OperationKind) -> ActiveOperationKind {
OperationKind::Installing => ActiveOperationKind::Installing,
OperationKind::Updating => ActiveOperationKind::Updating,
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling,
OperationKind::RemovingDownload => ActiveOperationKind::RemovingDownload,
}
}
+104
View File
@@ -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");
+2
View File
@@ -1,6 +1,8 @@
mod intent;
mod remove;
mod transaction;
pub mod unpack;
pub use remove::remove_downloaded;
pub use transaction::{install, recover_on_startup, uninstall, update};
pub use unpack::{UnpackFuture, Unpacker};
+212
View File
@@ -0,0 +1,212 @@
use std::{
ffi::OsStr,
io::ErrorKind,
path::{Component, Path, PathBuf},
};
use eyre::{WrapErr, bail};
const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
const BACKUP_DIR: &str = ".local.backup";
const VERSION_INI: &str = "version.ini";
/// Remove the downloaded files for an uninstalled game root.
///
/// This is intentionally stricter than the scanner: callers must pass a catalog
/// id that is a single path component, the target must be a direct child of the
/// configured game directory, and the root must still look like a downloaded
/// but uninstalled game immediately before recursive deletion.
pub async fn remove_downloaded(game_dir: &Path, id: &str) -> eyre::Result<()> {
validate_game_id(id)?;
let game_dir = canonical_game_dir(game_dir).await?;
let game_root = game_dir.join(id);
let Some(root_metadata) = symlink_metadata_if_exists(&game_root).await? else {
return Ok(());
};
if root_metadata.file_type().is_symlink() {
bail!(
"refusing to remove symlink game root {}",
game_root.display()
);
}
if !root_metadata.is_dir() {
bail!(
"refusing to remove non-directory game root {}",
game_root.display()
);
}
let game_root = tokio::fs::canonicalize(&game_root)
.await
.wrap_err_with(|| format!("failed to canonicalize {}", game_root.display()))?;
ensure_direct_child(&game_dir, &game_root, id)?;
ensure_downloaded_uninstalled_root(&game_root).await?;
tokio::fs::remove_dir_all(&game_root)
.await
.wrap_err_with(|| format!("failed to remove downloaded game {}", game_root.display()))
}
fn validate_game_id(id: &str) -> eyre::Result<()> {
let mut components = Path::new(id).components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
_ => bail!("refusing to remove invalid game id {id:?}"),
}
}
async fn canonical_game_dir(game_dir: &Path) -> eyre::Result<PathBuf> {
let game_dir = tokio::fs::canonicalize(game_dir)
.await
.wrap_err_with(|| format!("failed to canonicalize game dir {}", game_dir.display()))?;
let metadata = tokio::fs::metadata(&game_dir).await?;
if !metadata.is_dir() {
bail!("game dir is not a directory: {}", game_dir.display());
}
Ok(game_dir)
}
fn ensure_direct_child(game_dir: &Path, game_root: &Path, id: &str) -> eyre::Result<()> {
if game_root == game_dir
|| game_root.parent() != Some(game_dir)
|| game_root.file_name() != Some(OsStr::new(id))
{
bail!(
"refusing to remove game root outside direct game-dir child: {}",
game_root.display()
);
}
Ok(())
}
async fn ensure_downloaded_uninstalled_root(game_root: &Path) -> eyre::Result<()> {
let version_path = game_root.join(VERSION_INI);
let version_metadata = tokio::fs::symlink_metadata(&version_path)
.await
.wrap_err_with(|| format!("download sentinel is missing: {}", version_path.display()))?;
if version_metadata.file_type().is_symlink() || !version_metadata.is_file() {
bail!(
"refusing to remove game without a regular version.ini sentinel: {}",
game_root.display()
);
}
ensure_absent(&game_root.join(LOCAL_DIR), "local install").await?;
ensure_absent(&game_root.join(INSTALLING_DIR), "install staging").await?;
ensure_absent(&game_root.join(BACKUP_DIR), "install backup").await
}
async fn ensure_absent(path: &Path, label: &str) -> eyre::Result<()> {
if symlink_metadata_if_exists(path).await?.is_some() {
bail!(
"refusing to remove game root with {label}: {}",
path.display()
);
}
Ok(())
}
async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs::Metadata>> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::test_support::TempDir;
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
#[tokio::test]
async fn remove_downloaded_deletes_only_requested_game_root() {
let temp = TempDir::new("lanspread-remove-download");
let root = temp.game_root();
let sibling = temp.path().join("sibling");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("game.eti"), b"archive");
write_file(&sibling.join("version.ini"), b"20250101");
remove_downloaded(temp.path(), "game")
.await
.expect("downloaded game root should be removed");
assert!(!root.exists());
assert!(sibling.join("version.ini").is_file());
}
#[tokio::test]
async fn remove_downloaded_refuses_installed_game() {
let temp = TempDir::new("lanspread-remove-installed");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("payload.txt"), b"installed");
let err = remove_downloaded(temp.path(), "game")
.await
.expect_err("installed game must not be removed");
assert!(err.to_string().contains("local install"));
assert!(root.join("version.ini").is_file());
assert!(root.join("local").join("payload.txt").is_file());
}
#[tokio::test]
async fn remove_downloaded_refuses_missing_download_sentinel() {
let temp = TempDir::new("lanspread-remove-missing-sentinel");
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
let err = remove_downloaded(temp.path(), "game")
.await
.expect_err("undownloaded game root must not be removed");
assert!(err.to_string().contains("download sentinel is missing"));
assert!(root.join("game.eti").is_file());
}
#[tokio::test]
async fn remove_downloaded_rejects_path_traversal_id() {
let temp = TempDir::new("lanspread-remove-traversal");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
let err = remove_downloaded(temp.path(), "../game")
.await
.expect_err("path traversal id must be rejected");
assert!(err.to_string().contains("invalid game id"));
assert!(root.join("version.ini").is_file());
}
#[cfg(unix)]
#[tokio::test]
async fn remove_downloaded_refuses_symlink_game_root() {
use std::os::unix::fs::symlink;
let temp = TempDir::new("lanspread-remove-symlink");
let outside = temp.path().join("outside");
write_file(&outside.join("version.ini"), b"20250101");
symlink(&outside, temp.game_root()).expect("symlink should be created");
let err = remove_downloaded(temp.path(), "game")
.await
.expect_err("symlink game root must be rejected");
assert!(err.to_string().contains("symlink game root"));
assert!(outside.join("version.ini").is_file());
}
}
+14
View File
@@ -67,6 +67,7 @@ use crate::{
handle_get_peer_count_command,
handle_install_game_command,
handle_list_games_command,
handle_remove_downloaded_game_command,
handle_set_game_dir_command,
handle_uninstall_game_command,
load_local_library,
@@ -120,6 +121,12 @@ pub enum PeerEvent {
UninstallGameFinished { id: String },
/// Uninstall transaction has failed after rollback.
UninstallGameFailed { id: String },
/// Downloaded archive removal has started for an uninstalled game.
RemoveDownloadedGameBegin { id: String },
/// Downloaded archive removal has completed successfully.
RemoveDownloadedGameFinished { id: String },
/// Downloaded archive removal has failed before deleting the game root.
RemoveDownloadedGameFailed { id: String },
/// No peers have the requested game.
NoPeersHaveGame { id: String },
/// A peer has connected.
@@ -187,6 +194,8 @@ pub enum ActiveOperationKind {
Updating,
/// Removing an existing `local/` install.
Uninstalling,
/// Removing downloaded archive files for an uninstalled game.
RemovingDownload,
}
/// Commands sent to the peer system from the UI.
@@ -213,6 +222,8 @@ pub enum PeerCommand {
InstallGame { id: String },
/// Remove only the `local/` install for a game.
UninstallGame { id: String },
/// Remove downloaded archive files for an uninstalled game.
RemoveDownloadedGame { id: String },
/// Set the local game directory.
SetGameDir(PathBuf),
/// Request the current peer count.
@@ -394,6 +405,9 @@ async fn handle_peer_commands(
PeerCommand::UninstallGame { id } => {
handle_uninstall_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::RemoveDownloadedGame { id } => {
handle_remove_downloaded_game_command(ctx, tx_notify_ui, id).await;
}
PeerCommand::SetGameDir(game_dir) => {
handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await;
}
@@ -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}
/>
)}