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'); +});