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, "length": length,
}), }),
), ),
PeerEvent::DownloadGameFilesFinished { id } => { PeerEvent::DownloadGameFilesFinished { id } => game_id_event("download-finished", id),
("download-finished", json!({"game_id": id})) PeerEvent::DownloadGameFilesFailed { id } => game_id_event("download-failed", id),
} PeerEvent::DownloadGameFilesAllPeersGone { id } => game_id_event("download-peers-gone", id),
PeerEvent::DownloadGameFilesFailed { id } => ("download-failed", json!({"game_id": id})),
PeerEvent::DownloadGameFilesAllPeersGone { id } => {
("download-peers-gone", json!({"game_id": id}))
}
PeerEvent::InstallGameBegin { id, operation } => ( PeerEvent::InstallGameBegin { id, operation } => (
"install-begin", "install-begin",
json!({"game_id": id, "operation": install_operation_name(operation)}), json!({"game_id": id, "operation": install_operation_name(operation)}),
), ),
PeerEvent::InstallGameFinished { id } => ("install-finished", json!({"game_id": id})), PeerEvent::InstallGameFinished { id } => game_id_event("install-finished", id),
PeerEvent::InstallGameFailed { id } => ("install-failed", json!({"game_id": id})), PeerEvent::InstallGameFailed { id } => game_id_event("install-failed", id),
PeerEvent::UninstallGameBegin { id } => ("uninstall-begin", json!({"game_id": id})), PeerEvent::UninstallGameBegin { id } => game_id_event("uninstall-begin", id),
PeerEvent::UninstallGameFinished { id } => ("uninstall-finished", json!({"game_id": id})), PeerEvent::UninstallGameFinished { id } => game_id_event("uninstall-finished", id),
PeerEvent::UninstallGameFailed { id } => ("uninstall-failed", json!({"game_id": id})), PeerEvent::UninstallGameFailed { id } => game_id_event("uninstall-failed", id),
PeerEvent::NoPeersHaveGame { id } => { PeerEvent::RemoveDownloadedGameBegin { id } => game_id_event("remove-download-begin", id),
shared PeerEvent::RemoveDownloadedGameFinished { id } => {
.state game_id_event("remove-download-finished", id)
.write()
.await
.unavailable_games
.insert(id.clone());
("no-peers-have-game", json!({"game_id": 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::PeerConnected(addr) => ("peer-connected", peer_addr_json(addr)),
PeerEvent::PeerDisconnected(addr) => ("peer-disconnected", peer_addr_json(addr)), PeerEvent::PeerDisconnected(addr) => ("peer-disconnected", peer_addr_json(addr)),
PeerEvent::PeerDiscovered(addr) => ("peer-discovered", 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 { fn peer_addr_json(addr: SocketAddr) -> Value {
json!({"addr": addr.to_string()}) json!({"addr": addr.to_string()})
} }
+5
View File
@@ -128,6 +128,11 @@ Reserved per-game paths:
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership - `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
when the current intent is `None`. 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 Recovery reads `.lanspread.json` and combines the recorded intent with the
observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent observed `local/`, `.local.installing/`, and `.local.backup/` state. Intent
states `Installing`, `Updating`, and `Uninstalling` prove ownership of the 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 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 Each game root has an atomic `.lanspread.json` intent log for install-side
operations and uses Lanspread-owned `.local.installing/` and `.local.backup/` operations and uses Lanspread-owned `.local.installing/` and `.local.backup/`
directories marked by `.lanspread_owned`. Startup recovery combines the recorded directories marked by `.lanspread_owned`. Startup recovery combines the recorded
intent with the observed filesystem state and only deletes reserved directories intent with the observed filesystem state and only deletes reserved directories
when intent or marker ownership proves they belong to Lanspread. 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` ## 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 - `LanSpreadState` holds onto the peer control channel, the latest aggregated
`GameDB`, per-game operation state, the catalog set, and the user-selected `GameDB`, per-game operation state, the catalog set, and the user-selected
game directory. game directory.
- The Tauri commands (`request_games`, `install_game`, `update_game`, and - The Tauri commands (`request_games`, `install_game`, `update_game`,
`update_game_directory`) translate UI actions into `PeerCommand`s. In `remove_downloaded_game`, and `update_game_directory`) translate UI actions
into `PeerCommand`s. In
particular, `update_game_directory` validates the filesystem path before particular, `update_game_directory` validates the filesystem path before
storing it, loads the bundled catalog on first use, kicks off the peer runtime 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 on demand, and mirrors the installed/uninstalled state into the UI-facing
+2
View File
@@ -24,6 +24,8 @@ pub enum OperationKind {
Updating, Updating,
/// Removing an existing `local/` install. /// Removing an existing `local/` install.
Uninstalling, Uninstalling,
/// Removing downloaded archive files for an uninstalled game.
RemovingDownload,
} }
/// Main context for the peer system. /// 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::Installing => ActiveOperationKind::Installing,
OperationKind::Updating => ActiveOperationKind::Updating, OperationKind::Updating => ActiveOperationKind::Updating,
OperationKind::Uninstalling => ActiveOperationKind::Uninstalling, 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) { fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender<PeerEvent>, id: String) {
let ctx = ctx.clone(); let ctx = ctx.clone();
let tx_notify_ui = tx_notify_ui.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( async fn begin_operation(
ctx: &Ctx, ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>, tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -1471,6 +1536,45 @@ mod tests {
assert_local_update(recv_event(&mut rx).await, false, true); 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] #[tokio::test]
async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() { async fn path_changing_set_game_dir_is_rejected_while_operations_are_active() {
let current = TempDir::new("lanspread-handler-current-dir"); let current = TempDir::new("lanspread-handler-current-dir");
+2
View File
@@ -1,6 +1,8 @@
mod intent; mod intent;
mod remove;
mod transaction; mod transaction;
pub mod unpack; pub mod unpack;
pub use remove::remove_downloaded;
pub use transaction::{install, recover_on_startup, uninstall, update}; pub use transaction::{install, recover_on_startup, uninstall, update};
pub use unpack::{UnpackFuture, Unpacker}; 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_get_peer_count_command,
handle_install_game_command, handle_install_game_command,
handle_list_games_command, handle_list_games_command,
handle_remove_downloaded_game_command,
handle_set_game_dir_command, handle_set_game_dir_command,
handle_uninstall_game_command, handle_uninstall_game_command,
load_local_library, load_local_library,
@@ -120,6 +121,12 @@ pub enum PeerEvent {
UninstallGameFinished { id: String }, UninstallGameFinished { id: String },
/// Uninstall transaction has failed after rollback. /// Uninstall transaction has failed after rollback.
UninstallGameFailed { id: String }, 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. /// No peers have the requested game.
NoPeersHaveGame { id: String }, NoPeersHaveGame { id: String },
/// A peer has connected. /// A peer has connected.
@@ -187,6 +194,8 @@ pub enum ActiveOperationKind {
Updating, Updating,
/// Removing an existing `local/` install. /// Removing an existing `local/` install.
Uninstalling, Uninstalling,
/// Removing downloaded archive files for an uninstalled game.
RemovingDownload,
} }
/// Commands sent to the peer system from the UI. /// Commands sent to the peer system from the UI.
@@ -213,6 +222,8 @@ pub enum PeerCommand {
InstallGame { id: String }, InstallGame { id: String },
/// Remove only the `local/` install for a game. /// Remove only the `local/` install for a game.
UninstallGame { id: String }, UninstallGame { id: String },
/// Remove downloaded archive files for an uninstalled game.
RemoveDownloadedGame { id: String },
/// Set the local game directory. /// Set the local game directory.
SetGameDir(PathBuf), SetGameDir(PathBuf),
/// Request the current peer count. /// Request the current peer count.
@@ -394,6 +405,9 @@ async fn handle_peer_commands(
PeerCommand::UninstallGame { id } => { PeerCommand::UninstallGame { id } => {
handle_uninstall_game_command(ctx, tx_notify_ui, id).await; 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) => { PeerCommand::SetGameDir(game_dir) => {
handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await; handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await;
} }
@@ -52,6 +52,7 @@ enum UiOperationKind {
Installing, Installing,
Updating, Updating,
Uninstalling, Uninstalling,
RemovingDownload,
} }
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] #[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] #[tauri::command]
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> { async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
let peer_ctrl_arc = state.inner().peer_ctrl.clone(); 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::Installing => UiOperationKind::Installing,
ActiveOperationKind::Updating => UiOperationKind::Updating, ActiveOperationKind::Updating => UiOperationKind::Updating,
ActiveOperationKind::Uninstalling => UiOperationKind::Uninstalling, 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::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) => { PeerEvent::PeerConnected(addr) => {
log::info!("Peer connected: {addr}"); log::info!("Peer connected: {addr}");
emit_peer_addr_event(app_handle, "peer-connected", addr); emit_peer_addr_event(app_handle, "peer-connected", addr);
@@ -1315,6 +1392,7 @@ pub fn run() {
update_game_directory, update_game_directory,
update_game, update_game,
uninstall_game, uninstall_game,
remove_downloaded_game,
get_peer_count, get_peer_count,
get_game_thumbnail, get_game_thumbnail,
get_unpack_logs 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 { ActionButton } from '../ActionButton';
import { Game } from '../../lib/types'; import { Game } from '../../lib/types';
import { deriveState } from '../../lib/gameState'; import { deriveState, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format'; import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
interface Props { interface Props {
@@ -14,6 +14,7 @@ interface Props {
onClose: () => void; onClose: () => void;
onPrimary: (game: Game) => void; onPrimary: (game: Game) => void;
onUninstall: (game: Game) => void; onUninstall: (game: Game) => void;
onRemoveDownload: (game: Game) => void;
} }
const tagsFromGame = (game: Game): string[] => { 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 tags = tagsFromGame(game);
const canRemoveDownload = game.downloaded
&& !game.installed
&& !isInProgress(game.install_status);
return ( return (
<Modal onClose={onClose}> <Modal onClose={onClose}>
<button className="modal-close" type="button" onClick={onClose} aria-label="Close"> <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> <span>Uninstall</span>
</button> </button>
)} )}
{canRemoveDownload && (
<button
type="button"
className="ghost-btn ghost-danger"
onClick={() => onRemoveDownload(game)}
>
<Icon.trash />
<span>Remove files</span>
</button>
)}
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -8,13 +8,14 @@ export interface GameActions {
install: (id: string) => Promise<void>; install: (id: string) => Promise<void>;
update: (id: string) => Promise<void>; update: (id: string) => Promise<void>;
uninstall: (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` * Thin wrappers over the backend `run_game` / `install_game` / `update_game`
* / `uninstall_game` commands. We mark peer-backed downloads as "checking * / `uninstall_game` / `remove_downloaded_game` commands. We mark peer-backed
* peers" and already-downloaded installs as "installing" up-front so the UI * downloads as "checking peers" and already-downloaded installs as "installing"
* doesn't have to wait for the first backend event. * up-front so the UI doesn't have to wait for the first backend event.
*/ */
export const useGameActions = (games: UseGamesResult): GameActions => { export const useGameActions = (games: UseGamesResult): GameActions => {
const play = useCallback(async (id: string) => { 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 { interface PendingPatch {
install_status?: InstallStatus; install_status?: InstallStatus;
downloaded?: boolean;
installed?: boolean; installed?: boolean;
local_version?: string | null;
status_message?: string; status_message?: string;
status_level?: StatusLevel | undefined; status_level?: StatusLevel | undefined;
clearStatus?: boolean; clearStatus?: boolean;
@@ -27,7 +29,9 @@ interface PendingPatch {
const applyPatch = (game: Game, patch: PendingPatch): Game => { const applyPatch = (game: Game, patch: PendingPatch): Game => {
let next: Game = { ...game }; let next: Game = { ...game };
if (patch.install_status !== undefined) next.install_status = patch.install_status; 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.installed !== undefined) next.installed = patch.installed;
if (patch.local_version !== undefined) next.local_version = patch.local_version ?? undefined;
if (patch.clearStatus) { if (patch.clearStatus) {
next.status_message = undefined; next.status_message = undefined;
next.status_level = 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/ * 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 * fire-and-forget `markChecking` helper so action calls can immediately show a
* "Checking peers…" state with an automatic fall-back if the backend never * "Checking peers…" state with an automatic fall-back if the backend never
* emits a follow-up event. * 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.'); 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) => { unlisteners.push(await listen('peer-count-updated', (e) => {
setTotalPeerCount(e.payload as number); setTotalPeerCount(e.payload as number);
})); }));
@@ -14,12 +14,14 @@ const IN_PROGRESS_INSTALL_STATUSES = new Set<InstallStatus>([
InstallStatus.Downloading, InstallStatus.Downloading,
InstallStatus.Installing, InstallStatus.Installing,
InstallStatus.Uninstalling, InstallStatus.Uninstalling,
InstallStatus.Removing,
]); ]);
const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([ const RECONCILED_OPERATION_STATUSES = new Set<InstallStatus>([
InstallStatus.Downloading, InstallStatus.Downloading,
InstallStatus.Installing, InstallStatus.Installing,
InstallStatus.Uninstalling, InstallStatus.Uninstalling,
InstallStatus.Removing,
]); ]);
export const isInProgress = (status: InstallStatus): boolean => export const isInProgress = (status: InstallStatus): boolean =>
@@ -37,6 +39,8 @@ export const installStatusFromActiveOperation = (op: ActiveOperationKind): Insta
return InstallStatus.Installing; return InstallStatus.Installing;
case ActiveOperationKind.Uninstalling: case ActiveOperationKind.Uninstalling:
return InstallStatus.Uninstalling; return InstallStatus.Uninstalling;
case ActiveOperationKind.RemovingDownload:
return InstallStatus.Removing;
} }
}; };
@@ -136,6 +140,8 @@ export const inProgressLabel = (status: InstallStatus): string | undefined => {
return 'Installing…'; return 'Installing…';
case InstallStatus.Uninstalling: case InstallStatus.Uninstalling:
return 'Uninstalling…'; return 'Uninstalling…';
case InstallStatus.Removing:
return 'Removing…';
default: default:
return undefined; return undefined;
} }
@@ -4,6 +4,7 @@ export enum InstallStatus {
Downloading = 'Downloading', Downloading = 'Downloading',
Installing = 'Installing', Installing = 'Installing',
Uninstalling = 'Uninstalling', Uninstalling = 'Uninstalling',
Removing = 'Removing',
Installed = 'Installed', Installed = 'Installed',
} }
@@ -17,6 +18,7 @@ export enum ActiveOperationKind {
Installing = 'Installing', Installing = 'Installing',
Updating = 'Updating', Updating = 'Updating',
Uninstalling = 'Uninstalling', Uninstalling = 'Uninstalling',
RemovingDownload = 'RemovingDownload',
} }
export type StatusLevel = 'info' | 'error'; export type StatusLevel = 'info' | 'error';
@@ -295,7 +295,7 @@
padding: 4px; padding: 4px;
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--bd-2); border: 1px solid var(--bd-2);
border-radius: 10px; border-radius: 8px;
box-shadow: box-shadow:
0 16px 40px -8px rgba(0, 0, 0, 0.5), 0 16px 40px -8px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.04); 0 0 0 1px rgba(255, 255, 255, 0.04);
@@ -1060,6 +1060,42 @@
flex: 1; 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 dialog */
.settings-modal { .settings-modal {
width: min(640px, 100%); width: min(640px, 100%);
@@ -6,6 +6,7 @@ import { KebabItem } from '../components/topbar/KebabMenu';
import { ResultsBar } from '../components/grid/ResultsBar'; import { ResultsBar } from '../components/grid/ResultsBar';
import { GameGrid } from '../components/grid/GameGrid'; import { GameGrid } from '../components/grid/GameGrid';
import { GameDetailModal } from '../components/modals/GameDetailModal'; import { GameDetailModal } from '../components/modals/GameDetailModal';
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal';
import { SettingsDialog } from '../components/modals/SettingsDialog'; import { SettingsDialog } from '../components/modals/SettingsDialog';
import { NoDirectoryState } from '../components/empty/NoDirectoryState'; import { NoDirectoryState } from '../components/empty/NoDirectoryState';
import { EmptyResultsState } from '../components/empty/EmptyResultsState'; import { EmptyResultsState } from '../components/empty/EmptyResultsState';
@@ -50,6 +51,7 @@ export const MainWindow = () => {
const thumbnails = useThumbnails(); const thumbnails = useThumbnails();
const [openGameId, setOpenGameId] = useState<string | null>(null); const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const counts = useMemo(() => countByFilter(games.games), [games.games]); 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.find(g => g.id === openGameId) ?? null : null,
[openGameId, games.games], [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 pickDirectory = useCallback(async () => {
const picked = await open({ multiple: false, directory: true }); const picked = await open({ multiple: false, directory: true });
@@ -84,6 +90,15 @@ export const MainWindow = () => {
actions.uninstall(game.id); actions.uninstall(game.id);
}, [actions]); }, [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(() => [ const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) }, { kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() }, { kind: 'item', label: 'Refresh library', onClick: () => rescan() },
@@ -153,6 +168,15 @@ export const MainWindow = () => {
onClose={() => setOpenGameId(null)} onClose={() => setOpenGameId(null)}
onPrimary={handlePrimary} onPrimary={handlePrimary}
onUninstall={handleUninstall} onUninstall={handleUninstall}
onRemoveDownload={handleRemoveDownload}
/>
)}
{removeGame && (
<ConfirmRemoveDownloadModal
game={removeGame}
onCancel={() => setRemoveGameId(null)}
onConfirm={confirmRemoveDownload}
/> />
)} )}