From 47e2bbd4542b4bfab80cf1c8dc3f9e40e37bd49b Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 20 May 2026 23:20:53 +0200 Subject: [PATCH] feat(ui): add download progress controls Replace the downloading action button with a dedicated progress component in both card and detail views. The card now shows percent plus current speed, while the detail modal shows bytes, speed, ETA, percent, and an inline cancel affordance using the same backend progress payload. Expose download cancellation as a peer command that cancels the tracked transfer token and lets the running operation clear the authoritative active-operation snapshot. Add a View Files action that resolves the game root safely and opens it with the platform file viewer through Tauri's shell plugin. Test Plan: - just fmt - just frontend-test - just test - just build - just clippy - git diff --cached --check Refs: design reference e308009a08822e612c0175cbb9f74f859dbb790e --- crates/lanspread-peer/README.md | 8 +- crates/lanspread-peer/src/handlers.rs | 41 +++ crates/lanspread-peer/src/lib.rs | 6 + .../src-tauri/src/lib.rs | 116 +++++++- .../src/components/ActionButton.tsx | 38 ++- .../src/components/DownloadProgress.tsx | 122 ++++++++ .../src/components/StateChip.tsx | 1 + .../src/components/grid/GameCard.tsx | 17 +- .../src/components/grid/GameGrid.tsx | 11 +- .../src/components/modals/GameDetailModal.tsx | 30 +- .../src/hooks/useGameActions.ts | 22 +- .../src/lib/gameState.ts | 61 +++- .../lanspread-tauri-deno-ts/src/lib/types.ts | 4 +- .../src/styles/launcher.css | 277 +++++++++++++++++- .../src/windows/MainWindow.tsx | 3 + .../tests/gameState.test.ts | 67 +++++ 16 files changed, 776 insertions(+), 48 deletions(-) create mode 100644 crates/lanspread-tauri-deno-ts/src/components/DownloadProgress.tsx diff --git a/crates/lanspread-peer/README.md b/crates/lanspread-peer/README.md index dcbb9a0..27f7c0c 100644 --- a/crates/lanspread-peer/README.md +++ b/crates/lanspread-peer/README.md @@ -14,7 +14,8 @@ It is designed to run headless – other crates (most notably roots are announced or served. - `PeerCommand` represents the small control surface exposed to the UI layer: `ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`, - `InstallGame`, `UninstallGame`, `SetGameDir`, and `GetPeerCount`. + `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`, `CancelDownload`, + `SetGameDir`, and `GetPeerCount`. - `PeerEvent` enumerates everything the peer runtime reports back to the UI: library snapshots, download/install/uninstall lifecycle updates, runtime failures, and peer membership changes. @@ -82,6 +83,11 @@ When the UI asks to download a game: 6. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is emitted and the peer auto-runs the install transaction. +`PeerCommand::CancelDownload` cancels the tracked download token for an active +transfer. The transfer task remains responsible for clearing `active_operations`, +so the UI continues to treat active-operation snapshots as the single source of +truth for whether a download is still running. + ### Install Transactions Install, update, uninstall, downloaded-file removal, and startup recovery live diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 055ced3..15370a9 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -408,6 +408,21 @@ pub async fn handle_remove_downloaded_game_command( }); } +pub async fn handle_cancel_download_command( + ctx: &Ctx, + _tx_notify_ui: &UnboundedSender, + id: String, +) { + let cancel_token = ctx.active_downloads.read().await.get(&id).cloned(); + let Some(cancel_token) = cancel_token else { + log::warn!("Ignoring cancel request for inactive download {id}"); + return; + }; + + log::info!("Cancelling download for game {id}"); + cancel_token.cancel(); +} + fn spawn_install_operation(ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String) { let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); @@ -1504,6 +1519,32 @@ mod tests { assert!(ctx.active_downloads.read().await.is_empty()); } + #[tokio::test] + async fn cancel_download_command_only_cancels_active_token() { + let temp = TempDir::new("lanspread-handler-cancel-download"); + let ctx = test_ctx(temp.path().to_path_buf()); + let cancel = CancellationToken::new(); + ctx.active_operations + .write() + .await + .insert("game".to_string(), OperationKind::Downloading); + ctx.active_downloads + .write() + .await + .insert("game".to_string(), cancel.clone()); + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_cancel_download_command(&ctx, &tx, "game".to_string()).await; + + assert!(cancel.is_cancelled()); + assert_eq!( + ctx.active_operations.read().await.get("game"), + Some(&OperationKind::Downloading), + "the running transfer owns operation cleanup after cancellation" + ); + assert_no_event(&mut rx).await; + } + #[tokio::test] async fn update_refreshes_settled_state_before_operation_clear() { let temp = TempDir::new("lanspread-handler-update"); diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 6b94949..99e333a 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -61,6 +61,7 @@ use crate::{ context::Ctx, handlers::{ GameDetailSource, + handle_cancel_download_command, handle_connect_peer_command, handle_download_game_files_command, handle_get_game_command, @@ -235,6 +236,8 @@ pub enum PeerCommand { UninstallGame { id: String }, /// Remove downloaded archive files for an uninstalled game. RemoveDownloadedGame { id: String }, + /// Cancel an active peer download without emitting a user-facing failure. + CancelDownload { id: String }, /// Set the local game directory. SetGameDir(PathBuf), /// Request the current peer count. @@ -419,6 +422,9 @@ async fn handle_peer_commands( PeerCommand::RemoveDownloadedGame { id } => { handle_remove_downloaded_game_command(ctx, tx_notify_ui, id).await; } + PeerCommand::CancelDownload { id } => { + handle_cancel_download_command(ctx, tx_notify_ui, id).await; + } PeerCommand::SetGameDir(game_dir) => { handle_set_game_dir_command(ctx, tx_notify_ui, game_dir).await; } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 47b455b..b61f239 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ use std::fs::File; use std::{ collections::{HashMap, HashSet}, net::SocketAddr, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -279,6 +279,108 @@ async fn remove_downloaded_game( } } +#[tauri::command] +async fn cancel_download( + id: String, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { + let is_active_download = { + let active_operations = state.inner().active_operations.read().await; + matches!( + active_operations.get(&id), + Some(UiOperationKind::Downloading) + ) + }; + if !is_active_download { + log::warn!("Ignoring cancel request for inactive download: {id}"); + 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::CancelDownload { id }) { + log::error!("Failed to send message to peer: {e:?}"); + return Ok(false); + } + Ok(true) + } else { + log::warn!("Peer system not initialized yet"); + Ok(false) + } +} + +#[tauri::command] +async fn open_game_files( + id: String, + app_handle: AppHandle, + state: tauri::State<'_, LanSpreadState>, +) -> tauri::Result { + let Some(target) = resolve_game_root_for_open(&id, &state).await else { + return Ok(false); + }; + + #[allow(deprecated)] + if let Err(e) = app_handle.shell().open(target.display().to_string(), None) { + log::error!("Failed to open file viewer for {}: {e}", target.display()); + return Ok(false); + } + + Ok(true) +} + +async fn resolve_game_root_for_open( + id: &str, + state: &tauri::State<'_, LanSpreadState>, +) -> Option { + if !is_single_component_game_id(id) { + log::warn!("Ignoring file viewer request for invalid game id: {id}"); + return None; + } + if state + .inner() + .games + .read() + .await + .get_game_by_id(id) + .is_none() + { + log::warn!("Ignoring file viewer request for unknown game: {id}"); + return None; + } + + let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone()); + let Ok(root) = games_folder.canonicalize() else { + log::warn!( + "Cannot open files because game directory is unavailable: {}", + games_folder.display() + ); + return None; + }; + let target = root.join(id); + let Ok(target) = target.canonicalize() else { + log::warn!( + "Cannot open files because game root is unavailable: {}", + target.display() + ); + return None; + }; + if !target.is_dir() || !target.starts_with(&root) { + log::warn!( + "Refusing to open file viewer outside game directory: {}", + target.display() + ); + return None; + } + + Some(target) +} + +fn is_single_component_game_id(id: &str) -> bool { + let mut components = Path::new(id).components(); + matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() +} + #[tauri::command] async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result { let peer_ctrl_arc = state.inner().peer_ctrl.clone(); @@ -1328,6 +1430,16 @@ mod tests { ); } + #[test] + fn game_file_viewer_ids_must_be_single_path_components() { + assert!(is_single_component_game_id("game")); + assert!(is_single_component_game_id("game.v1")); + assert!(!is_single_component_game_id("")); + assert!(!is_single_component_game_id("../game")); + assert!(!is_single_component_game_id("nested/game")); + assert!(!is_single_component_game_id("/game")); + } + #[test] fn peer_local_snapshot_replaces_local_state_without_overwriting_catalog_metadata() { let mut alpha = game_fixture("alpha", "Catalog Alpha"); @@ -1398,6 +1510,8 @@ pub fn run() { update_game, uninstall_game, remove_downloaded_game, + cancel_download, + open_game_files, get_peer_count, get_game_thumbnail, get_unpack_logs diff --git a/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx b/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx index 09ff3eb..0812b41 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx @@ -1,6 +1,7 @@ -import { CSSProperties, JSX, MouseEvent } from 'react'; +import { JSX, MouseEvent } from 'react'; import { Icon } from './Icon'; +import { DownloadProgress } from './DownloadProgress'; import { Game, InstallStatus } from '../lib/types'; import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState'; @@ -9,6 +10,7 @@ interface Props { size?: 'md' | 'lg'; full?: boolean; onClick: () => void; + onCancelDownload?: (game: Game) => void; } const ICON_FOR_ACTION: Partial> = { @@ -18,29 +20,34 @@ const ICON_FOR_ACTION: Partial> = { download: , }; -const downloadProgressPercent = (game: Game): number | undefined => { - const progress = game.download_progress; - if (!progress || progress.total_bytes <= 0) return undefined; - - return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100)); -}; - /** Color-coded primary action: Play / Install / Update / Download / busy. */ -export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props) => { +export const ActionButton = ({ + game, + size = 'md', + full = false, + onClick, + onCancelDownload, +}: Props) => { const action = primaryActionFor(game); const isDownloading = game.install_status === InstallStatus.Downloading; - const progressPercent = downloadProgressPercent(game); + if (isDownloading) { + return ( + + ); + } + const cls = [ 'act-btn', `act-${action}`, - isDownloading ? 'act-downloading' : '', size === 'lg' ? 'act-lg' : '', full ? 'act-full' : '', ].filter(Boolean).join(' '); const disabled = action === 'busy' || action === 'disabled'; - const style = progressPercent === undefined - ? undefined - : ({ '--download-progress': `${progressPercent}%` } as CSSProperties); const handle = (e: MouseEvent) => { e.stopPropagation(); @@ -49,8 +56,7 @@ export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props }; return ( - diff --git a/crates/lanspread-tauri-deno-ts/src/components/DownloadProgress.tsx b/crates/lanspread-tauri-deno-ts/src/components/DownloadProgress.tsx new file mode 100644 index 0000000..d7f8f6a --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/components/DownloadProgress.tsx @@ -0,0 +1,122 @@ +import { CSSProperties, MouseEvent } from 'react'; + +import { Game } from '../lib/types'; +import { + downloadProgressPercent, + formatDownloadBytes, + formatDownloadEta, + formatDownloadSpeed, + formatDownloadSpeedShort, +} from '../lib/gameState'; + +import { Icon } from './Icon'; + +interface Props { + game: Game; + size?: 'md' | 'lg'; + full?: boolean; + onCancel?: (game: Game) => void; +} + +const progressStats = (game: Game) => { + const progress = game.download_progress; + const downloaded = progress?.downloaded_bytes ?? 0; + const total = progress?.total_bytes ?? game.size; + const speed = progress?.bytes_per_second ?? 0; + const remaining = Math.max(0, total - downloaded); + const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY; + + return { + pct: Math.min(99, Math.round(downloadProgressPercent(game))), + downloaded, + total, + speed, + eta: etaSeconds, + }; +}; + +export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => { + const stats = progressStats(game); + const progressStyle = { + '--download-progress': `${stats.pct}%`, + } as CSSProperties; + const className = [ + 'dl', + size === 'lg' ? 'dl-lg' : 'dl-md', + full ? 'dl-full' : '', + ].filter(Boolean).join(' '); + + const handleCancel = (event: MouseEvent) => { + event.stopPropagation(); + onCancel?.(game); + }; + + if (size === 'lg') { + return ( +
+
+
+
+ + Downloading +
+
+ + {formatDownloadBytes(stats.downloaded)} + / {formatDownloadBytes(stats.total)} + + · + {formatDownloadSpeed(stats.speed)} + · + {formatDownloadEta(stats.eta)} left +
+
+ {stats.pct} + % +
+ {onCancel && ( + + )} +
+
+ ); + } + + return ( +
+
+
+ + + {stats.pct} + % + + {formatDownloadSpeedShort(stats.speed)} +
+
+ ); +}; diff --git a/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx b/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx index c439cc3..9c49213 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx @@ -4,6 +4,7 @@ import { deriveState } from '../lib/gameState'; const LABELS: Record = { installed: 'Installed', local: 'Local', + downloading: 'Downloading', busy: 'Working', none: '', }; diff --git a/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx index e153d08..a79a8be 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/grid/GameCard.tsx @@ -15,6 +15,7 @@ interface Props { thumbnailUrl: string | null; onOpen: (game: Game) => void; onPrimary: (game: Game) => void; + onCancelDownload: (game: Game) => void; } const metaSeparator = (...parts: Array): JSX.Element[] => { @@ -27,7 +28,14 @@ const metaSeparator = (...parts: Array): JSX.Element[ return out; }; -export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Props) => { +export const GameCard = ({ + game, + aspect, + thumbnailUrl, + onOpen, + onPrimary, + onCancelDownload, +}: Props) => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); @@ -61,7 +69,12 @@ export const GameCard = ({ game, aspect, thumbnailUrl, onOpen, onPrimary }: Prop
{game.status_message ?? ''}
- onPrimary(game)} /> + onPrimary(game)} + onCancelDownload={onCancelDownload} + />
); diff --git a/crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx b/crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx index 245f960..94906d3 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/grid/GameGrid.tsx @@ -9,9 +9,17 @@ interface Props { getThumbnail: (id: string) => string | null; onOpen: (game: Game) => void; onPrimary: (game: Game) => void; + onCancelDownload: (game: Game) => void; } -export const GameGrid = ({ games, aspect, getThumbnail, onOpen, onPrimary }: Props) => ( +export const GameGrid = ({ + games, + aspect, + getThumbnail, + onOpen, + onPrimary, + onCancelDownload, +}: Props) => (
{games.map(g => ( ))}
diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx index 2fe7b02..44e0579 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx @@ -4,7 +4,7 @@ import { GameCover } from '../grid/GameCover'; import { StateChip } from '../StateChip'; import { ActionButton } from '../ActionButton'; -import { Game } from '../../lib/types'; +import { Game, InstallStatus } from '../../lib/types'; import { deriveState, isInProgress } from '../../lib/gameState'; import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format'; @@ -15,6 +15,8 @@ interface Props { onPrimary: (game: Game) => void; onUninstall: (game: Game) => void; onRemoveDownload: (game: Game) => void; + onCancelDownload: (game: Game) => void; + onViewFiles: (game: Game) => void; } const tagsFromGame = (game: Game): string[] => { @@ -29,6 +31,7 @@ const statusLabelFor = (game: Game): string => { switch (deriveState(game)) { case 'installed': return 'Installed'; case 'local': return 'Downloaded'; + case 'downloading': return 'Downloading'; case 'busy': return 'Working…'; case 'none': return 'Not downloaded'; } @@ -41,11 +44,16 @@ export const GameDetailModal = ({ onPrimary, onUninstall, onRemoveDownload, + onCancelDownload, + onViewFiles, }: Props) => { const tags = tagsFromGame(game); const canRemoveDownload = game.downloaded && !game.installed && !isInProgress(game.install_status); + const canViewFiles = game.downloaded + || game.installed + || game.install_status === InstallStatus.Downloading; return ( )} + {canViewFiles && ( + <> +
+ + + )}
diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts index 18b106c..d8a0671 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts @@ -9,13 +9,15 @@ export interface GameActions { update: (id: string) => Promise; uninstall: (id: string) => Promise; removeDownload: (id: string) => Promise; + cancelDownload: (id: string) => Promise; + viewFiles: (id: string) => Promise; } /** * Thin wrappers over the backend `run_game` / `install_game` / `update_game` * / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads * are marked as "checking peers" until the backend emits an authoritative - * operation snapshot. + * operation snapshot; cancellation waits for the backend to clear that snapshot. */ export const useGameActions = (games: UseGamesResult): GameActions => { const play = useCallback(async (id: string) => { @@ -65,5 +67,21 @@ export const useGameActions = (games: UseGamesResult): GameActions => { } }, []); - return { play, install, update, uninstall, removeDownload }; + const cancelDownload = useCallback(async (id: string) => { + try { + await invoke('cancel_download', { id }); + } catch (err) { + console.error('cancel_download failed:', err); + } + }, []); + + const viewFiles = useCallback(async (id: string) => { + try { + await invoke('open_game_files', { id }); + } catch (err) { + console.error('open_game_files failed:', err); + } + }, []); + + return { play, install, update, uninstall, removeDownload, cancelDownload, viewFiles }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts index 12735e1..587b9df 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -75,6 +75,7 @@ export const mergeGameUpdate = ( /** Visual card state — used for state chip color and action button styling. */ export const deriveState = (game: Game): DerivedState => { + if (game.install_status === InstallStatus.Downloading) return 'downloading'; if (isInProgress(game.install_status)) return 'busy'; if (game.installed) return 'installed'; if (game.downloaded) return 'local'; @@ -126,6 +127,48 @@ export const formatBytesPerSecond = (bytesPerSecond: number): string => { return `${value.toFixed(precision)} ${units[unitIndex]}`; }; +const MB = 1024 * 1024; +const GB = 1024 * 1024 * 1024; +const DECIMAL_MB = 1_000_000; + +const stripTrailingDecimalZeros = (value: string): string => + value.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, ''); + +export const downloadProgressPercent = (game: Game): number => { + const progress = game.download_progress; + if (!progress || progress.total_bytes <= 0) return 0; + + return Math.max(0, Math.min(100, (progress.downloaded_bytes / progress.total_bytes) * 100)); +}; + +export const formatDownloadSpeed = (bytesPerSecond: number): string => { + const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; + return mb >= 100 ? `${Math.round(mb)} MB/s` : `${mb.toFixed(1)} MB/s`; +}; + +export const formatDownloadSpeedShort = (bytesPerSecond: number): string => { + const mb = Math.max(0, bytesPerSecond) / DECIMAL_MB; + return `${Math.round(mb)} MB/s`; +}; + +export const formatDownloadBytes = (bytes: number): string => { + const safeBytes = Math.max(0, bytes); + if (safeBytes < GB) return `${Math.round(safeBytes / MB)} MB`; + + const gb = safeBytes / GB; + return `${stripTrailingDecimalZeros(gb >= 10 ? gb.toFixed(1) : gb.toFixed(2))} GB`; +}; + +export const formatDownloadEta = (seconds: number): string => { + if (!Number.isFinite(seconds) || seconds <= 0) return '—'; + if (seconds < 60) return `${Math.round(seconds)} s`; + + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min`; + + return `${Math.floor(minutes / 60)} h ${minutes % 60} min`; +}; + export const inProgressLabel = (game: Game): string | undefined => { switch (game.install_status) { case InstallStatus.CheckingPeers: @@ -161,19 +204,22 @@ export interface FilterCounts { installed: number; } +const isDownloading = (game: Game): boolean => + game.install_status === InstallStatus.Downloading; + const isNetworkGame = (game: Game): boolean => - game.installed || game.downloaded || game.peer_count > 0; + game.installed || game.downloaded || isDownloading(game) || game.peer_count > 0; export const countByFilter = (games: Game[]): FilterCounts => ({ all: games.filter(isNetworkGame).length, - local: games.filter(g => g.installed || g.downloaded).length, + local: games.filter(g => g.installed || g.downloaded || isDownloading(g)).length, installed: games.filter(g => g.installed).length, }); const matchesFilter = (game: Game, filter: GameFilter): boolean => { switch (filter) { case 'local': - return game.installed || game.downloaded; + return game.installed || game.downloaded || isDownloading(game); case 'installed': return game.installed; case 'all': @@ -182,10 +228,11 @@ const matchesFilter = (game: Game, filter: GameFilter): boolean => { }; const STATE_SORT_ORDER: Record = { - busy: 0, - installed: 1, - local: 2, - none: 3, + installed: 0, + local: 1, + downloading: 2, + busy: 3, + none: 4, }; const compareByState = (a: Game, b: Game): number => { diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts index 38366c1..1b17847 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/types.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -75,5 +75,5 @@ export type GameFilter = 'all' | 'local' | 'installed'; /** Library sort order. */ export type GameSort = 'az' | 'sizeDesc' | 'sizeAsc' | 'status'; -/** Visual state of a card. Derived from install/download flags. */ -export type DerivedState = 'installed' | 'local' | 'none' | 'busy'; +/** Visual state of a card. Derived from backend operation status and local flags. */ +export type DerivedState = 'installed' | 'local' | 'downloading' | 'none' | 'busy'; diff --git a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css index 8dedd8d..6016b32 100644 --- a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css +++ b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css @@ -629,6 +629,11 @@ background: var(--warn); box-shadow: 0 0 8px var(--warn); } +.state-chip[data-state="downloading"] .state-dot { + background: var(--accent); + box-shadow: 0 0 8px var(--accent); + animation: state-busy 1.2s ease-in-out infinite; +} .state-chip[data-state="busy"] .state-dot { background: var(--accent); box-shadow: 0 0 8px var(--accent); @@ -807,20 +812,6 @@ background: rgba(255, 255, 255, 0.06); border: 1px solid var(--bd-1); } -.act-downloading { - min-width: 148px; - font-variant-numeric: tabular-nums; -} -.act-lg.act-downloading { - min-width: 174px; -} -.act-progress-fill { - position: absolute; - inset: 0 auto 0 0; - width: var(--download-progress, 0%); - background: color-mix(in srgb, var(--accent) 28%, transparent); - transition: width 0.45s linear; -} .act-busy::before { content: ""; display: inline-block; @@ -846,6 +837,264 @@ cursor: not-allowed; } +/* Download progress */ +.dl { + position: relative; + overflow: hidden; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--bd-2)); + background: rgba(255, 255, 255, 0.04); + color: var(--t-1); + font: inherit; + font-variant-numeric: tabular-nums; + container-type: inline-size; + isolation: isolate; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent); +} +.dl-full { + width: 100%; +} +.dl-fill { + position: absolute; + inset: 0 auto 0 0; + z-index: 0; + width: var(--download-progress, 0%); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--accent) 38%, transparent) 0%, + color-mix(in srgb, var(--accent) 26%, transparent) 100% + ); + border-right: 1px solid color-mix(in srgb, var(--accent) 75%, transparent); + box-shadow: 2px 0 8px color-mix(in srgb, var(--accent) 35%, transparent); + transition: width 0.48s cubic-bezier(0.4, 0, 0.2, 1); +} +.dl-fill::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 115deg, + transparent 0 14px, + rgba(255, 255, 255, 0.05) 14px 22px + ); + background-size: 200% 100%; + animation: dl-stripe 1.4s linear infinite; + mix-blend-mode: screen; + opacity: 0.85; +} +@keyframes dl-stripe { + from { + background-position: 0 0; + } + to { + background-position: -36px 0; + } +} +.dl-pulse { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent); + animation: dl-pulse 1.4s ease-out infinite; + flex: 0 0 auto; +} +@keyframes dl-pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent); + } + 70% { + box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 0%, transparent); + } + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); + } +} +.dl-md { + height: 32px; + padding: 0 10px; +} +.dl-md-row { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + gap: 8px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0; +} +.dl-md .dl-pct { + display: inline-flex; + align-items: center; + color: var(--t-1); +} +.dl-md .dl-pulse { + margin-right: 6px; +} +.dl-pct-sym { + opacity: 0.55; + font-weight: 600; + margin-left: 1px; +} +.dl-md .dl-speed { + color: var(--t-2); + font-size: 11px; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +@container (max-width: 132px) { + .dl-md .dl-speed { + display: none; + } + .dl-md-row { + justify-content: center; + gap: 6px; + } +} +@container (max-width: 96px) { + .dl-md .dl-pulse { + display: none; + } +} +.density-compact .dl-md { + height: 30px; + padding: 0 9px; +} +.density-compact .dl-md-row { + font-size: 11.5px; +} +.density-compact .dl-md .dl-speed { + font-size: 10.5px; +} +.density-large .dl-md { + height: 34px; + padding: 0 12px; +} +.density-large .dl-md-row { + font-size: 13px; +} +.density-large .dl-md .dl-speed { + font-size: 11.5px; +} +.dl-lg { + height: 56px; + padding: 0; + border-radius: 9px; + flex: 1 1 auto; + min-width: 260px; +} +.dl-lg-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-rows: auto auto; + grid-template-areas: + "primary pct cancel" + "secondary pct cancel"; + align-items: center; + height: 100%; + padding: 0 14px 0 16px; + column-gap: 14px; + row-gap: 2px; +} +.dl-lg-primary { + grid-area: primary; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: 0; + text-transform: uppercase; + color: color-mix(in srgb, var(--accent) 80%, white); +} +.dl-lg-primary .dl-label { + white-space: nowrap; +} +.dl-lg-secondary { + grid-area: secondary; + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow: hidden; + font-size: 12px; + font-weight: 500; + color: var(--t-2); +} +.dl-lg-secondary .dl-bytes { + color: var(--t-1); + font-weight: 600; + white-space: nowrap; +} +.dl-lg-secondary .dl-of { + color: var(--t-2); + font-weight: 500; +} +.dl-lg-secondary .dl-speed { + color: var(--t-1); + font-weight: 600; + white-space: nowrap; +} +.dl-lg-secondary .dl-eta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dl-sep { + opacity: 0.45; +} +@container (max-width: 380px) { + .dl-lg-secondary .dl-eta, + .dl-lg-secondary .dl-sep-eta { + display: none; + } +} +.dl-lg-pct { + grid-area: pct; + color: var(--t-1); + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 0; + line-height: 1; +} +.dl-lg-pct .dl-pct-sym { + font-size: 12px; +} +.dl-cancel { + grid-area: cancel; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--bd-2); + background: rgba(255, 255, 255, 0.04); + color: var(--t-2); + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; +} +.dl-cancel:hover { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.4); + color: #fca5a5; +} + /* Ghost / secondary buttons */ .ghost-btn { display: inline-flex; diff --git a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx index da8f4e5..b4cacbd 100644 --- a/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx +++ b/crates/lanspread-tauri-deno-ts/src/windows/MainWindow.tsx @@ -152,6 +152,7 @@ export const MainWindow = () => { getThumbnail={thumbnails.get} onOpen={(g) => setOpenGameId(g.id)} onPrimary={handlePrimary} + onCancelDownload={(g) => actions.cancelDownload(g.id)} /> )} @@ -170,6 +171,8 @@ export const MainWindow = () => { onPrimary={handlePrimary} onUninstall={handleUninstall} onRemoveDownload={handleRemoveDownload} + onCancelDownload={(g) => actions.cancelDownload(g.id)} + onViewFiles={(g) => actions.viewFiles(g.id)} /> )} diff --git a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts index 470048a..5fcc41f 100644 --- a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts @@ -1,7 +1,15 @@ import { actionLabel, activeStatusById, + applyFilterAndSort, + countByFilter, + deriveState, + downloadProgressPercent, + formatDownloadBytes, formatBytesPerSecond, + formatDownloadEta, + formatDownloadSpeed, + formatDownloadSpeedShort, mergeGameUpdate, } from '../src/lib/gameState.ts'; import { @@ -134,3 +142,62 @@ Deno.test('downloading action label includes current speed', () => { 'download label should include speed', ); }); + +Deno.test('downloading state is distinct and stays on the local filter', () => { + const downloading = game({ + id: 'downloading', + name: 'Downloading', + install_status: InstallStatus.Downloading, + }); + const local = game({ + id: 'local', + name: 'Local', + downloaded: true, + }); + const remote = game({ + id: 'remote', + name: 'Remote', + peer_count: 1, + }); + + assertEquals( + deriveState(downloading), + 'downloading', + 'download operation should render the dedicated downloading state', + ); + assertEquals( + countByFilter([downloading, local, remote]).local, + 2, + 'local filter count should include in-flight downloads', + ); + assertEquals( + applyFilterAndSort([downloading, local, remote], 'local', 'status', '').length, + 2, + 'local filter should include in-flight downloads', + ); +}); + +Deno.test('download progress formatting matches the progress-bar layouts', () => { + const downloading = game({ + install_status: InstallStatus.Downloading, + download_progress: { + downloaded_bytes: 12 * 1024 * 1024 * 1024, + total_bytes: 35 * 1024 * 1024 * 1024, + bytes_per_second: 49_400_000, + }, + }); + + assertEquals( + Math.round(downloadProgressPercent(downloading)), + 34, + 'progress percent should come from backend byte counters', + ); + assertEquals(formatDownloadSpeed(49_400_000), '49.4 MB/s', 'large bar speed format'); + assertEquals(formatDownloadSpeedShort(49_400_000), '49 MB/s', 'card speed format'); + assertEquals( + formatDownloadBytes(12 * 1024 * 1024 * 1024), + '12 GB', + 'downloaded byte format should avoid noisy trailing decimals', + ); + assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact'); +});