diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 3794862..0015b8c 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -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()}) } diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index e2accc7..37c04d1 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -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 diff --git a/crates/lanspread-peer/README.md b/crates/lanspread-peer/README.md index 552d2e0..dcbb9a0 100644 --- a/crates/lanspread-peer/README.md +++ b/crates/lanspread-peer/README.md @@ -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 diff --git a/crates/lanspread-peer/src/context.rs b/crates/lanspread-peer/src/context.rs index 9dd936f..be6f956 100644 --- a/crates/lanspread-peer/src/context.rs +++ b/crates/lanspread-peer/src/context.rs @@ -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. diff --git a/crates/lanspread-peer/src/events.rs b/crates/lanspread-peer/src/events.rs index 32bd4f0..c479063 100644 --- a/crates/lanspread-peer/src/events.rs +++ b/crates/lanspread-peer/src/events.rs @@ -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, } } diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index b944e6c..e8698fb 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -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, + 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, 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, + 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, @@ -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"); diff --git a/crates/lanspread-peer/src/install/mod.rs b/crates/lanspread-peer/src/install/mod.rs index ec4d465..e6f4c41 100644 --- a/crates/lanspread-peer/src/install/mod.rs +++ b/crates/lanspread-peer/src/install/mod.rs @@ -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}; diff --git a/crates/lanspread-peer/src/install/remove.rs b/crates/lanspread-peer/src/install/remove.rs new file mode 100644 index 0000000..927ee70 --- /dev/null +++ b/crates/lanspread-peer/src/install/remove.rs @@ -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 { + 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> { + 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()); + } +} diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 9c6996b..9ff05ca 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -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; } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 389fa23..4bb8d3e 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -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 { + 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 { 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 diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/ConfirmRemoveDownloadModal.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/ConfirmRemoveDownloadModal.tsx new file mode 100644 index 0000000..6287da7 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/ConfirmRemoveDownloadModal.tsx @@ -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) => ( + + +
+ +
+

Remove downloaded files?

+

+ This removes {game.name} ({formatBytes(game.size)}) from this computer. + Re-downloading can take a long time. +

+
+ + +
+
+); diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx index 2332051..2fe7b02 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx @@ -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 ( )} + {canRemoveDownload && ( + + )} diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts index df167f3..1151a26 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts @@ -8,13 +8,14 @@ export interface GameActions { install: (id: string) => Promise; update: (id: string) => Promise; uninstall: (id: string) => Promise; + removeDownload: (id: string) => Promise; } /** * 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 }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts index e01d9c9..a714aa2 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts @@ -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); })); diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts index 63f20b0..e5f060f 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -14,12 +14,14 @@ const IN_PROGRESS_INSTALL_STATUSES = new Set([ InstallStatus.Downloading, InstallStatus.Installing, InstallStatus.Uninstalling, + InstallStatus.Removing, ]); const RECONCILED_OPERATION_STATUSES = new Set([ 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; } diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts index 782d3ec..4609fbb 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/types.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -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'; diff --git a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css index 1831b98..99550d1 100644 --- a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css +++ b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css @@ -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%); diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx index 113a10f..1b087a2 100644 --- a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -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(null); + const [removeGameId, setRemoveGameId] = useState(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( + () => 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 = 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 && ( + setRemoveGameId(null)} + onConfirm={confirmRemoveDownload} /> )}