Compare commits

..

10 Commits

Author SHA1 Message Date
ddidderr ebeee2d90a fix(settings): name descending size sort explicitly
The library sort setting used `size` for largest-first sorting while the
ascending option used `sizeAsc`. That made the pair asymmetric and left the
current settings model carrying a legacy-looking key.

Rename the current descending key to `sizeDesc` in the type, menu, and sort
logic. Stored `size` values are normalized to `sizeDesc` on read, so existing
users keep the same largest-first behavior while new writes use the explicit
key.

Test Plan:
- deno task build
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build
- git diff --check

Refs: local review feedback
2026-05-19 21:28:40 +02:00
ddidderr 59efe9e2d7 fix(ui): close detail modal when removing downloads
Confirming removal from the game detail modal used to clear only the
confirmation modal state. The detail modal remained open for the same game while
the removal operation was in flight, which could show stale removing or
post-removal state around the closed confirmation dialog.

Close the detail modal when it is showing the game whose downloaded copy is being
removed. Other open detail state is left alone so the change stays scoped to the
confirmed removal flow.

Test Plan:
- deno task build
- RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just build
- git diff --check

Refs: local review feedback
2026-05-19 21:28:23 +02:00
ddidderr 62ceb063ac 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
2026-05-19 21:00:44 +02:00
ddidderr 74d9266723 fix(ui): show installing for downloaded games
The redesigned action hook marked every accepted install command as Checking
Peers. That is correct while the launcher is asking peers for file details, but
it is wrong for a game that is already downloaded and only needs local archive
installation.

Track the already-downloaded path separately and optimistically show Installing
until the backend install lifecycle event arrives. Peer-backed downloads keep the
existing Checking Peers state.

Test Plan:
- git diff --check

Refs: user redesign nitpick about install button state
2026-05-19 20:49:22 +02:00
ddidderr 50698f9a7d feat(ui): add search clear button
Search now exposes a small icon-only clear button whenever a query is present.
Clicking it clears the term in one step and returns focus to the input so users
can immediately type a replacement.

The button uses the existing topbar styling language and a compact circled-x
icon alongside the keyboard hint.

Test Plan:
- git diff --check

Refs: user redesign nitpick about one-click search clearing
2026-05-19 20:48:46 +02:00
ddidderr a6130fc687 fix(ui): handle enter and escape in search
The search field should behave like a transient launcher search control. Enter
now blurs the input while preserving the current term, and Escape clears the
term before blurring the input.

Test Plan:
- git diff --check

Refs: user redesign nitpick about search keyboard behavior
2026-05-19 20:48:12 +02:00
ddidderr 2af55981c3 fix(ui): make animated background drift subtly
The animated background had an animation assigned, but the layers painted at the
viewport size so the background-position changes were effectively static. Give
the ambient light layers larger paint areas and drift them slowly so the
animated option visibly moves without becoming distracting.

Reduced-motion users keep the same static background.

Test Plan:
- git diff --check

Refs: user redesign nitpick about animated background not moving
2026-05-19 20:47:55 +02:00
ddidderr e5235948df fix(ui): default covers to square
Fresh launcher profiles should start with square covers when no stored UI
settings exist. Existing stored settings still pass through the normal sanitize
path and keep their selected aspect.

Test Plan:
- git diff --check

Refs: user redesign nitpick about no-config cover aspect
2026-05-19 20:47:15 +02:00
ddidderr 25f92c9b0b feat(ui): add smallest-first size sort
The redesign only offered a largest-first size sort. Keep the existing `size`
preference value as largest for compatibility with saved settings and add a new
ascending size key for users who want to find small downloads first.

The sort menu now exposes both size directions and the sorter handles the new
smallest-first option directly.

Test Plan:
- git diff --check

Refs: user redesign nitpick about Size (smallest) sort
2026-05-19 20:47:01 +02:00
ddidderr bcaf28dcee fix(ui): count all-games filter from network games
The launcher redesign showed the All Games pill count from the full bundled
catalog. That made the counter report every row in game.db even though the All
Games filter itself only shows games that are visible on the current network or
present locally.

Use the same network-visible predicate for the counter and the filter. The pill
count and results total now describe the displayed network library instead of
the baked catalog size.

Test Plan:
- git diff --check

Refs: user redesign nitpick about All Games counter
2026-05-19 20:46:31 +02:00
22 changed files with 729 additions and 45 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
@@ -67,6 +67,12 @@ export const Icon = {
<path d="m4 4 8 8M12 4l-8 8" /> <path d="m4 4 8 8M12 4l-8 8" />
</svg> </svg>
), ),
clearCircle: (p: Props) => (
<svg viewBox="0 0 16 16" width="14" height="14" strokeWidth={1.7} {...baseStroke} {...p}>
<circle cx="8" cy="8" r="5.5" />
<path d="m6 6 4 4M10 6l-4 4" />
</svg>
),
check: (p: Props) => ( check: (p: Props) => (
<svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={2} {...baseStroke} {...p}> <svg viewBox="0 0 16 16" width="12" height="12" strokeWidth={2} {...baseStroke} {...p}>
<path d="m3 8 3.5 3.5L13 5" /> <path d="m3 8 3.5 3.5L13 5" />
@@ -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>
@@ -26,6 +26,8 @@ export const SearchField = ({ value, onChange }: Props) => {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, []); }, []);
const blurInput = () => inputRef.current?.blur();
return ( return (
<div className="search"> <div className="search">
<Icon.search /> <Icon.search />
@@ -35,8 +37,31 @@ export const SearchField = ({ value, onChange }: Props) => {
placeholder="Search games" placeholder="Search games"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
blurInput();
} else if (e.key === 'Escape') {
e.preventDefault();
onChange('');
blurInput();
}
}}
spellCheck={false} spellCheck={false}
/> />
{value && (
<button
className="search-clear"
type="button"
aria-label="Clear search"
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
>
<Icon.clearCircle />
</button>
)}
<span className="search-kbd">/</span> <span className="search-kbd">/</span>
</div> </div>
); );
@@ -5,7 +5,8 @@ import { GameSort } from '../../lib/types';
const OPTIONS: ReadonlyArray<{ key: GameSort; label: string }> = [ const OPTIONS: ReadonlyArray<{ key: GameSort; label: string }> = [
{ key: 'az', label: 'Name (AZ)' }, { key: 'az', label: 'Name (AZ)' },
{ key: 'size', label: 'Size (largest)' }, { key: 'sizeDesc', label: 'Size (largest)' },
{ key: 'sizeAsc', label: 'Size (smallest)' },
{ key: 'status', label: 'Status' }, { key: 'status', label: 'Status' },
]; ];
@@ -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. For install + update we mark the game as * / `uninstall_game` / `remove_downloaded_game` commands. We mark peer-backed
* "checking peers" up-front through the games hook so the UI doesn't have to * downloads as "checking peers" and already-downloaded installs as "installing"
* 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) => {
@@ -28,7 +29,14 @@ export const useGameActions = (games: UseGamesResult): GameActions => {
const install = useCallback(async (id: string) => { const install = useCallback(async (id: string) => {
try { try {
const success = await invoke<boolean>('install_game', { id }); const success = await invoke<boolean>('install_game', { id });
if (success) games.markChecking(id); if (!success) return;
const game = games.games.find(item => item.id === id);
if (game?.downloaded && !game.installed) {
games.markInstalling(id);
} else {
games.markChecking(id);
}
} catch (err) { } catch (err) {
console.error('install_game failed:', err); console.error('install_game failed:', err);
} }
@@ -51,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.
@@ -52,6 +56,7 @@ export interface UseGamesResult {
totalPeerCount: number; totalPeerCount: number;
requestGames: () => Promise<void>; requestGames: () => Promise<void>;
markChecking: (id: string) => void; markChecking: (id: string) => void;
markInstalling: (id: string) => void;
cancelChecking: (id: string) => void; cancelChecking: (id: string) => void;
} }
@@ -95,6 +100,15 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
}, CHECKING_PEERS_TIMEOUT_MS); }, CHECKING_PEERS_TIMEOUT_MS);
}, [cancelChecking]); }, [cancelChecking]);
const markInstalling = useCallback((id: string) => {
cancelChecking(id);
setGames(prev => prev.map(item =>
item.id === id
? applyPatch(item, { install_status: InstallStatus.Installing, clearStatus: true })
: item,
));
}, [cancelChecking]);
const requestGames = useCallback(async () => { const requestGames = useCallback(async () => {
try { try {
await invoke('request_games'); await invoke('request_games');
@@ -217,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);
})); }));
@@ -247,6 +285,7 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => {
totalPeerCount, totalPeerCount,
requestGames, requestGames,
markChecking, markChecking,
markInstalling,
cancelChecking, cancelChecking,
}; };
}; };
@@ -17,6 +17,9 @@ export interface UISettings {
filter: GameFilter; filter: GameFilter;
} }
type StoredGameSort = GameSort | 'size';
type StoredUISettings = Partial<Omit<UISettings, 'sort'> & { sort: StoredGameSort }>;
export const ACCENT_OPTIONS = [ export const ACCENT_OPTIONS = [
{ value: '#3b82f6', label: 'Blue' }, { value: '#3b82f6', label: 'Blue' },
{ value: '#22d3ee', label: 'Cyan' }, { value: '#22d3ee', label: 'Cyan' },
@@ -48,20 +51,24 @@ export const DEFAULT_SETTINGS: UISettings = {
accent: '#3b82f6', accent: '#3b82f6',
bg: 'gradient', bg: 'gradient',
density: 'normal', density: 'normal',
aspect: 'box', aspect: 'square',
sort: 'status', sort: 'status',
filter: 'local', filter: 'local',
}; };
const sanitize = (raw: Partial<UISettings> | undefined): UISettings => ({ const sanitize = (raw: StoredUISettings | undefined): UISettings => ({
accent: raw?.accent ?? DEFAULT_SETTINGS.accent, accent: raw?.accent ?? DEFAULT_SETTINGS.accent,
bg: raw?.bg ?? DEFAULT_SETTINGS.bg, bg: raw?.bg ?? DEFAULT_SETTINGS.bg,
density: raw?.density ?? DEFAULT_SETTINGS.density, density: raw?.density ?? DEFAULT_SETTINGS.density,
aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect, aspect: raw?.aspect ?? DEFAULT_SETTINGS.aspect,
sort: raw?.sort ?? DEFAULT_SETTINGS.sort, sort: sanitizeSort(raw?.sort),
filter: raw?.filter ?? DEFAULT_SETTINGS.filter, filter: raw?.filter ?? DEFAULT_SETTINGS.filter,
}); });
const sanitizeSort = (sort: StoredGameSort | undefined): GameSort => (
sort === 'size' ? 'sizeDesc' : sort ?? DEFAULT_SETTINGS.sort
);
export interface UseSettings { export interface UseSettings {
settings: UISettings; settings: UISettings;
set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void; set: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
@@ -82,7 +89,7 @@ export const useSettings = (): UseSettings => {
const init = async () => { const init = async () => {
try { try {
const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS); const store = await load(SETTINGS_FILE, SETTINGS_FILE_OPTIONS);
const saved = await store.get<Partial<UISettings>>(UI_SETTINGS_KEY); const saved = await store.get<StoredUISettings>(UI_SETTINGS_KEY);
if (!cancelled) { if (!cancelled) {
setSettings(sanitize(saved ?? undefined)); setSettings(sanitize(saved ?? undefined));
} }
@@ -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;
} }
@@ -157,8 +163,11 @@ export interface FilterCounts {
installed: number; installed: number;
} }
const isNetworkGame = (game: Game): boolean =>
game.installed || game.downloaded || game.peer_count > 0;
export const countByFilter = (games: Game[]): FilterCounts => ({ export const countByFilter = (games: Game[]): FilterCounts => ({
all: games.length, all: games.filter(isNetworkGame).length,
local: games.filter(g => g.installed || g.downloaded).length, local: games.filter(g => g.installed || g.downloaded).length,
installed: games.filter(g => g.installed).length, installed: games.filter(g => g.installed).length,
}); });
@@ -170,7 +179,7 @@ const matchesFilter = (game: Game, filter: GameFilter): boolean => {
case 'installed': case 'installed':
return game.installed; return game.installed;
case 'all': case 'all':
return game.installed || game.downloaded || game.peer_count > 0; return isNetworkGame(game);
} }
}; };
@@ -204,8 +213,10 @@ export const applyFilterAndSort = (
switch (sort) { switch (sort) {
case 'az': case 'az':
return [...list].sort((a, b) => a.name.localeCompare(b.name)); return [...list].sort((a, b) => a.name.localeCompare(b.name));
case 'size': case 'sizeDesc':
return [...list].sort((a, b) => b.size - a.size); return [...list].sort((a, b) => b.size - a.size);
case 'sizeAsc':
return [...list].sort((a, b) => a.size - b.size);
case 'status': case 'status':
return [...list].sort(compareByState); return [...list].sort(compareByState);
} }
@@ -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';
@@ -60,7 +62,7 @@ export interface GamesListPayload {
export type GameFilter = 'all' | 'local' | 'installed'; export type GameFilter = 'all' | 'local' | 'installed';
/** Library sort order. */ /** Library sort order. */
export type GameSort = 'az' | 'size' | 'status'; export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status';
/** Visual state of a card. Derived from install/download flags. */ /** Visual state of a card. Derived from install/download flags. */
export type DerivedState = 'installed' | 'local' | 'none' | 'busy'; export type DerivedState = 'installed' | 'local' | 'none' | 'busy';
@@ -36,15 +36,23 @@
transparent 55% transparent 55%
), ),
linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%); linear-gradient(180deg, #0c1218 0%, var(--bg-0) 100%);
background-size: 100% 100%; background-size: 145% 130%, 140% 125%, 100% 100%;
animation: bgshift 18s ease-in-out infinite alternate; animation: bgshift 22s ease-in-out infinite alternate;
} }
@keyframes bgshift { @keyframes bgshift {
0% { 0% {
background-position: 0% 0%, 0% 0%, 0% 0%; background-position: 0% 0%, 100% 0%, 0% 0%;
}
50% {
background-position: 12% 6%, 82% 9%, 0% 0%;
} }
100% { 100% {
background-position: 10% 4%, -6% 2%, 0% 0%; background-position: 4% 10%, 92% 16%, 0% 0%;
}
}
@media (prefers-reduced-motion: reduce) {
.bg-animated {
animation: none;
} }
} }
@@ -215,6 +223,22 @@
.search input::placeholder { .search input::placeholder {
color: var(--t-3); color: var(--t-3);
} }
.search-clear {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--t-3);
cursor: pointer;
}
.search-clear:hover {
color: var(--t-1);
background: rgba(255, 255, 255, 0.08);
}
.search-kbd { .search-kbd {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
@@ -271,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);
@@ -1036,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,16 @@ 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);
setOpenGameId(current => current === game.id ? null : current);
}, [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 +169,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}
/> />
)} )}