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