From 01712f248befc71d417ddfe56e1662c76d7973ef Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 20 May 2026 22:11:09 +0200 Subject: [PATCH] feat(ui): show download progress and speed in the action button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the action button only said "Downloading…" with no indication of how far along the transfer was or how fast it was going. With multi-gigabyte game payloads on a LAN this gave the user no signal whether the download had stalled, was hitting the wire fast, or was about to finish. Wire a sampled byte-level progress channel from the download pipeline up to the action button: - New `DownloadProgressTracker` in `crates/lanspread-peer/src/download/progress.rs` holds the total expected bytes plus two atomic counters: `downloaded_bytes` (deduplicated per `(relative_path, offset)` chunk key, used for the bar) and `transferred_bytes` (raw cumulative, used for the speed sample). The dedup prevents a retried chunk from double-counting toward completion while still letting speed reflect actual wire activity including retry waste, which is the more useful metric for "is the link doing anything right now?". - `sample_download_progress` wraps the transfer future, emits an initial 0 B/s snapshot, then samples on a 500 ms interval (`MissedTickBehavior::Skip` so a stalled downloader does not generate a thundering herd of catch-up ticks) and emits one final snapshot when the future resolves, so the UI sees the closing state before `DownloadGameFilesFinished` arrives. - New `PeerEvent::DownloadGameFilesProgress(DownloadProgress)` variant carries `{ id, downloaded_bytes, total_bytes, bytes_per_second }`. The Tauri shell forwards it as `game-download-progress`; the JSONL harness emits it as `download-progress`. - Orchestrator and retry paths refactored to thread a single shared `Arc` through both the initial transfer and any retry attempts. New `TransferContext`, `RetryContext`, and `ChunkPlanContext` structs absorb the parameter-list growth that came with adding the tracker. Frontend rendering honors the snapshot-is-authoritative decision from commit `5df82aa` ("fix(ui): derive operation status from snapshots"): - `Game.download_progress` is an ephemeral overlay carried alongside the card, not a status field. `mergeGameUpdate` preserves it only while `install_status === Downloading` and otherwise clears it on the next snapshot, so the games-list snapshot remains the single authority for when the bar should disappear. - The `game-download-progress` listener writes ONLY `download_progress` — it does not touch `install_status`, `status_message`, or `status_level`. This preserves the rule that lifecycle events never mutate card status. - No `game-download-finished` listener; snapshot reconciliation clears the overlay automatically when status leaves Downloading. - `ActionButton` renders a percentage fill behind the icon/label via a `--download-progress` CSS custom property; the existing `.act-busy` spinner is layered above the fill with `z-index: 1`. `act-downloading` widens the button to avoid label jitter as the speed number changes (tabular-nums). - `actionLabel` for the Downloading status now appends a formatted speed ("Downloading… 12.5 MB/s") via the new `formatBytesPerSecond` helper. Test Plan: - `just test` — Rust workspace tests including new progress tracker unit tests (`tracker_counts_only_new_bytes_for_a_retried_chunk`, `tracker_clamps_reported_bytes_to_total`). - `just frontend-test` — Deno tests including `download progress is preserved only while actively downloading` and `downloading action label includes current speed`. - `just clippy` — clean. - Manual: download a multi-GB game from a peer and watch the action button fill, speed update on the half-second, and reset cleanly on completion. Refs: download progress visibility, snapshot-authoritative UI architecture --- crates/lanspread-peer-cli/src/main.rs | 9 + crates/lanspread-peer/src/download/mod.rs | 1 + .../src/download/orchestrator.rs | 369 +++++++++++------- .../lanspread-peer/src/download/progress.rs | 257 ++++++++++++ crates/lanspread-peer/src/download/retry.rs | 51 ++- .../lanspread-peer/src/download/transport.rs | 69 ++-- crates/lanspread-peer/src/lib.rs | 11 + .../src-tauri/src/lib.rs | 5 + .../src/components/ActionButton.tsx | 22 +- .../src/hooks/useGames.ts | 17 +- .../src/lib/gameState.ts | 28 +- .../lanspread-tauri-deno-ts/src/lib/types.ts | 11 + .../src/styles/launcher.css | 26 +- .../tests/gameState.test.ts | 53 +++ 14 files changed, 724 insertions(+), 205 deletions(-) create mode 100644 crates/lanspread-peer/src/download/progress.rs diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 32753d7..0f4be2a 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -461,6 +461,15 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s download_chunk_finished_event(shared, id, peer_addr, relative_path, offset, length) .await } + PeerEvent::DownloadGameFilesProgress(progress) => ( + "download-progress", + json!({ + "game_id": progress.id, + "downloaded_bytes": progress.downloaded_bytes, + "total_bytes": progress.total_bytes, + "bytes_per_second": progress.bytes_per_second, + }), + ), PeerEvent::DownloadGameFilesFinished { id } => { download_terminal_event(shared, "download-finished", id).await } diff --git a/crates/lanspread-peer/src/download/mod.rs b/crates/lanspread-peer/src/download/mod.rs index 6ba854d..d468a56 100644 --- a/crates/lanspread-peer/src/download/mod.rs +++ b/crates/lanspread-peer/src/download/mod.rs @@ -2,6 +2,7 @@ mod orchestrator; mod planning; +mod progress; mod retry; mod transport; mod version_ini; diff --git a/crates/lanspread-peer/src/download/orchestrator.rs b/crates/lanspread-peer/src/download/orchestrator.rs index cd0719f..dc38479 100644 --- a/crates/lanspread-peer/src/download/orchestrator.rs +++ b/crates/lanspread-peer/src/download/orchestrator.rs @@ -1,12 +1,24 @@ -use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{ + collections::HashMap, + net::SocketAddr, + path::{Path, PathBuf}, + sync::Arc, +}; use lanspread_db::db::GameFileDescription; use tokio::sync::mpsc::UnboundedSender; use tokio_util::sync::CancellationToken; use super::{ - planning::{DownloadChunk, build_peer_plans, extract_version_descriptor, prepare_game_storage}, - retry::retry_failed_chunks, + planning::{ + ChunkDownloadResult, + DownloadChunk, + build_peer_plans, + extract_version_descriptor, + prepare_game_storage, + }, + progress::{DownloadProgressTracker, sample_download_progress}, + retry::{RetryContext, retry_failed_chunks}, transport::download_from_peer, version_ini::{ VersionIniBuffer, @@ -71,148 +83,32 @@ pub async fn download_game_files( id: game_id.to_string(), })?; - let plans = build_peer_plans(&peers, &transfer_descs, &file_peer_map); + let progress_tracker = DownloadProgressTracker::new(total_download_bytes(&transfer_descs)); + let transfer_ctx = TransferContext { + game_id, + games_folder: &games_folder, + peers: &peers, + file_peer_map: &file_peer_map, + tx_notify_ui: &tx_notify_ui, + cancel_token: &cancel_token, + version_buffer: version_buffer.clone(), + progress_tracker: progress_tracker.clone(), + }; + let transfer_result = sample_download_progress( + game_id, + progress_tracker, + tx_notify_ui.clone(), + download_transfer_chunks(&transfer_ctx, &transfer_descs), + ) + .await; - let mut tasks = Vec::new(); - for (peer_addr, plan) in plans { - let base_dir = games_folder.clone(); - let game_id = game_id.to_string(); - let cancel_token = cancel_token.clone(); - let version_buffer = version_buffer.clone(); - tasks.push(tokio::spawn(async move { - download_from_peer( - peer_addr, - &game_id, - plan, - base_dir, - &cancel_token, - Some(version_buffer), - ) - .await - })); - } - - let mut failed_chunks: Vec = Vec::new(); - let mut last_err: Option = None; - - for handle in tasks { - if cancel_token.is_cancelled() { - rollback_version_ini_transaction(&game_root).await; - eyre::bail!("download cancelled for game {game_id}"); - } - - match handle.await { - Ok(Ok(results)) => { - if cancel_token.is_cancelled() { - rollback_version_ini_transaction(&game_root).await; - eyre::bail!("download cancelled for game {game_id}"); - } - - for chunk_result in results { - match chunk_result.result { - Ok(()) => { - let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished { - id: game_id.to_string(), - peer_addr: chunk_result.peer_addr, - relative_path: chunk_result.chunk.relative_path, - offset: chunk_result.chunk.offset, - length: chunk_result.chunk.length, - }); - } - Err(e) => { - log::warn!( - "Failed to download chunk from {}: {e}", - chunk_result.peer_addr - ); - if chunk_result.chunk.retry_count < MAX_RETRY_COUNT { - let mut retry_chunk = chunk_result.chunk; - retry_chunk.retry_count += 1; - retry_chunk.last_peer = Some(chunk_result.peer_addr); - failed_chunks.push(retry_chunk); - } else { - last_err = Some(eyre::eyre!( - "Max retries exceeded for chunk: {}", - chunk_result.chunk.relative_path - )); - } - } - } - } - } - Ok(Err(_)) | Err(_) if cancel_token.is_cancelled() => { - rollback_version_ini_transaction(&game_root).await; - eyre::bail!("download cancelled for game {game_id}"); - } - Ok(Err(e)) => last_err = Some(e), - Err(e) => last_err = Some(eyre::eyre!("task join error: {e}")), - } - } - - // Retry failed chunks if any - if !failed_chunks.is_empty() && !peers.is_empty() { - if cancel_token.is_cancelled() { - rollback_version_ini_transaction(&game_root).await; - eyre::bail!("download cancelled for game {game_id}"); - } - - log::info!("Retrying {} failed chunks", failed_chunks.len()); - - let retry_results = match retry_failed_chunks( - failed_chunks, - &peers, - &games_folder, - game_id, - &file_peer_map, - &cancel_token, - Some(version_buffer.clone()), - ) - .await - { - Ok(results) => results, - Err(_) if cancel_token.is_cancelled() => { - rollback_version_ini_transaction(&game_root).await; - eyre::bail!("download cancelled for game {game_id}"); - } - Err(err) => { - last_err = Some(err); - Vec::new() - } - }; - - for chunk_result in retry_results { - if cancel_token.is_cancelled() { - rollback_version_ini_transaction(&game_root).await; - eyre::bail!("download cancelled for game {game_id}"); - } - - match chunk_result.result { - Ok(()) => { - let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished { - id: game_id.to_string(), - peer_addr: chunk_result.peer_addr, - relative_path: chunk_result.chunk.relative_path, - offset: chunk_result.chunk.offset, - length: chunk_result.chunk.length, - }); - } - Err(e) => { - log::error!("Retry failed for chunk: {e}"); - last_err = Some(e); - } - } - } - } - - if cancel_token.is_cancelled() { + if let Err(err) = transfer_result { rollback_version_ini_transaction(&game_root).await; - eyre::bail!("download cancelled for game {game_id}"); - } - - if let Some(err) = last_err { - rollback_version_ini_transaction(&game_root).await; - tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { - id: game_id.to_string(), - })?; + if !cancel_token.is_cancelled() { + tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { + id: game_id.to_string(), + })?; + } return Err(err); } @@ -229,3 +125,190 @@ pub async fn download_game_files( })?; Ok(()) } + +struct TransferContext<'a> { + game_id: &'a str, + games_folder: &'a Path, + peers: &'a [SocketAddr], + file_peer_map: &'a HashMap>, + tx_notify_ui: &'a UnboundedSender, + cancel_token: &'a CancellationToken, + version_buffer: Arc, + progress_tracker: Arc, +} + +async fn download_transfer_chunks( + ctx: &TransferContext<'_>, + transfer_descs: &[GameFileDescription], +) -> eyre::Result<()> { + let plans = build_peer_plans(ctx.peers, transfer_descs, ctx.file_peer_map); + + let mut tasks = Vec::new(); + for (peer_addr, plan) in plans { + let base_dir = ctx.games_folder.to_path_buf(); + let game_id = ctx.game_id.to_string(); + let cancel_token = ctx.cancel_token.clone(); + let version_buffer = ctx.version_buffer.clone(); + let progress_tracker = ctx.progress_tracker.clone(); + tasks.push(tokio::spawn(async move { + download_from_peer( + peer_addr, + &game_id, + plan, + base_dir, + &cancel_token, + Some(version_buffer), + progress_tracker, + ) + .await + })); + } + + let mut failed_chunks: Vec = Vec::new(); + let mut last_err: Option = None; + + for handle in tasks { + if ctx.cancel_token.is_cancelled() { + eyre::bail!("download cancelled for game {}", ctx.game_id); + } + + match handle.await { + Ok(Ok(results)) => { + if ctx.cancel_token.is_cancelled() { + eyre::bail!("download cancelled for game {}", ctx.game_id); + } + + collect_chunk_results( + ctx.game_id, + ctx.tx_notify_ui, + results, + &mut failed_chunks, + &mut last_err, + ); + } + Ok(Err(_)) | Err(_) if ctx.cancel_token.is_cancelled() => { + eyre::bail!("download cancelled for game {}", ctx.game_id); + } + Ok(Err(e)) => last_err = Some(e), + Err(e) => last_err = Some(eyre::eyre!("task join error: {e}")), + } + } + + if !failed_chunks.is_empty() && !ctx.peers.is_empty() { + retry_chunks(ctx, failed_chunks, &mut last_err).await?; + } + + if ctx.cancel_token.is_cancelled() { + eyre::bail!("download cancelled for game {}", ctx.game_id); + } + + if let Some(err) = last_err { + return Err(err); + } + + Ok(()) +} + +fn collect_chunk_results( + game_id: &str, + tx_notify_ui: &UnboundedSender, + results: Vec, + failed_chunks: &mut Vec, + last_err: &mut Option, +) { + for chunk_result in results { + match chunk_result.result { + Ok(()) => { + let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished { + id: game_id.to_string(), + peer_addr: chunk_result.peer_addr, + relative_path: chunk_result.chunk.relative_path, + offset: chunk_result.chunk.offset, + length: chunk_result.chunk.length, + }); + } + Err(e) => { + log::warn!( + "Failed to download chunk from {}: {e}", + chunk_result.peer_addr + ); + if chunk_result.chunk.retry_count < MAX_RETRY_COUNT { + let mut retry_chunk = chunk_result.chunk; + retry_chunk.retry_count += 1; + retry_chunk.last_peer = Some(chunk_result.peer_addr); + failed_chunks.push(retry_chunk); + } else { + *last_err = Some(eyre::eyre!( + "Max retries exceeded for chunk: {}", + chunk_result.chunk.relative_path + )); + } + } + } + } +} + +async fn retry_chunks( + ctx: &TransferContext<'_>, + failed_chunks: Vec, + last_err: &mut Option, +) -> eyre::Result<()> { + if ctx.cancel_token.is_cancelled() { + eyre::bail!("download cancelled for game {}", ctx.game_id); + } + + log::info!("Retrying {} failed chunks", failed_chunks.len()); + + let retry_ctx = RetryContext { + peers: ctx.peers, + base_dir: ctx.games_folder, + game_id: ctx.game_id, + file_peer_map: ctx.file_peer_map, + cancel_token: ctx.cancel_token, + version_buffer: Some(ctx.version_buffer.clone()), + progress_tracker: ctx.progress_tracker.clone(), + }; + let retry_results = match retry_failed_chunks(failed_chunks, &retry_ctx).await { + Ok(results) => results, + Err(_) if ctx.cancel_token.is_cancelled() => { + eyre::bail!("download cancelled for game {}", ctx.game_id); + } + Err(err) => { + *last_err = Some(err); + Vec::new() + } + }; + + for chunk_result in retry_results { + if ctx.cancel_token.is_cancelled() { + eyre::bail!("download cancelled for game {}", ctx.game_id); + } + + match chunk_result.result { + Ok(()) => { + let _ = ctx + .tx_notify_ui + .send(PeerEvent::DownloadGameFileChunkFinished { + id: ctx.game_id.to_string(), + peer_addr: chunk_result.peer_addr, + relative_path: chunk_result.chunk.relative_path, + offset: chunk_result.chunk.offset, + length: chunk_result.chunk.length, + }); + } + Err(e) => { + log::error!("Retry failed for chunk: {e}"); + *last_err = Some(e); + } + } + } + + Ok(()) +} + +fn total_download_bytes(file_descs: &[GameFileDescription]) -> u64 { + file_descs + .iter() + .filter(|desc| !desc.is_dir) + .fold(0u64, |total, desc| total.saturating_add(desc.file_size())) +} diff --git a/crates/lanspread-peer/src/download/progress.rs b/crates/lanspread-peer/src/download/progress.rs new file mode 100644 index 0000000..113b984 --- /dev/null +++ b/crates/lanspread-peer/src/download/progress.rs @@ -0,0 +1,257 @@ +use std::{ + collections::HashMap, + future::Future, + sync::{ + Arc, + Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +use tokio::{ + sync::mpsc::UnboundedSender, + time::{self, MissedTickBehavior}, +}; + +use crate::{DownloadProgress, PeerEvent, events}; + +const DOWNLOAD_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500); + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ChunkProgressKey { + relative_path: String, + offset: u64, +} + +pub(super) struct DownloadProgressTracker { + total_bytes: u64, + downloaded_bytes: AtomicU64, + transferred_bytes: AtomicU64, + chunks: Mutex>, +} + +impl DownloadProgressTracker { + pub(super) fn new(total_bytes: u64) -> Arc { + Arc::new(Self { + total_bytes, + downloaded_bytes: AtomicU64::new(0), + transferred_bytes: AtomicU64::new(0), + chunks: Mutex::new(HashMap::new()), + }) + } + + pub(super) fn track_chunk( + self: &Arc, + relative_path: &str, + offset: u64, + expected_bytes: u64, + ) -> ChunkProgress { + ChunkProgress { + tracker: self.clone(), + key: ChunkProgressKey { + relative_path: relative_path.to_string(), + offset, + }, + expected_bytes, + received_bytes: 0, + } + } + + fn raw_downloaded_bytes(&self) -> u64 { + self.downloaded_bytes.load(Ordering::Relaxed) + } + + fn raw_transferred_bytes(&self) -> u64 { + self.transferred_bytes.load(Ordering::Relaxed) + } + + fn reported_downloaded_bytes(&self) -> u64 { + let downloaded = self.raw_downloaded_bytes(); + if self.total_bytes == 0 { + downloaded + } else { + downloaded.min(self.total_bytes) + } + } + + fn record_transferred_bytes(&self, byte_count: u64) { + add_saturating(&self.transferred_bytes, byte_count); + } + + fn record_chunk_bytes(&self, key: &ChunkProgressKey, received_bytes: u64) { + let mut chunks = self + .chunks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let delta = match chunks.get_mut(key) { + Some(previous) if received_bytes > *previous => { + let delta = received_bytes - *previous; + *previous = received_bytes; + delta + } + Some(_) => 0, + None => { + chunks.insert(key.clone(), received_bytes); + received_bytes + } + }; + drop(chunks); + + if delta > 0 { + add_saturating(&self.downloaded_bytes, delta); + } + } + + fn snapshot(&self, id: &str, bytes_per_second: u64) -> DownloadProgress { + DownloadProgress { + id: id.to_string(), + downloaded_bytes: self.reported_downloaded_bytes(), + total_bytes: self.total_bytes, + bytes_per_second, + } + } +} + +pub(super) struct ChunkProgress { + tracker: Arc, + key: ChunkProgressKey, + expected_bytes: u64, + received_bytes: u64, +} + +impl ChunkProgress { + pub(super) fn record_bytes(&mut self, byte_count: usize) { + let byte_count = u64::try_from(byte_count).unwrap_or(u64::MAX); + self.tracker.record_transferred_bytes(byte_count); + self.received_bytes = self.received_bytes.saturating_add(byte_count); + + let reportable_bytes = if self.expected_bytes == 0 { + self.received_bytes + } else { + self.received_bytes.min(self.expected_bytes) + }; + self.tracker.record_chunk_bytes(&self.key, reportable_bytes); + } +} + +fn add_saturating(counter: &AtomicU64, delta: u64) { + let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + Some(current.saturating_add(delta)) + }); +} + +struct ProgressSampler { + id: String, + tracker: Arc, + tx_notify_ui: UnboundedSender, + last_bytes: u64, + last_at: Instant, +} + +impl ProgressSampler { + fn new( + id: String, + tracker: Arc, + tx_notify_ui: UnboundedSender, + ) -> Self { + Self { + id, + tracker, + tx_notify_ui, + last_bytes: 0, + last_at: Instant::now(), + } + } + + fn emit_initial(&mut self) { + self.last_bytes = self.tracker.raw_transferred_bytes(); + self.last_at = Instant::now(); + self.emit(0); + } + + fn emit_current(&mut self) { + let now = Instant::now(); + let bytes = self.tracker.raw_transferred_bytes(); + let speed = bytes_per_second( + bytes.saturating_sub(self.last_bytes), + now.duration_since(self.last_at), + ); + + self.last_bytes = bytes; + self.last_at = now; + self.emit(speed); + } + + fn emit(&self, bytes_per_second: u64) { + events::send( + &self.tx_notify_ui, + PeerEvent::DownloadGameFilesProgress(self.tracker.snapshot(&self.id, bytes_per_second)), + ); + } +} + +fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 { + let millis = elapsed.as_millis().max(1); + let rate = u128::from(bytes).saturating_mul(1_000) / millis; + u64::try_from(rate).unwrap_or(u64::MAX) +} + +pub(super) async fn sample_download_progress( + id: &str, + tracker: Arc, + tx_notify_ui: UnboundedSender, + future: F, +) -> T +where + F: Future, +{ + let mut sampler = ProgressSampler::new(id.to_string(), tracker, tx_notify_ui); + sampler.emit_initial(); + + let mut interval = time::interval(DOWNLOAD_PROGRESS_UPDATE_INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + interval.tick().await; + + tokio::pin!(future); + loop { + tokio::select! { + result = &mut future => { + sampler.emit_current(); + return result; + } + _ = interval.tick() => sampler.emit_current(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracker_counts_only_new_bytes_for_a_retried_chunk() { + let tracker = DownloadProgressTracker::new(100); + let mut first_attempt = tracker.track_chunk("game/file.bin", 0, 100); + first_attempt.record_bytes(40); + first_attempt.record_bytes(10); + + let mut retry = tracker.track_chunk("game/file.bin", 0, 100); + retry.record_bytes(25); + retry.record_bytes(50); + + assert_eq!(tracker.reported_downloaded_bytes(), 75); + assert_eq!(tracker.raw_transferred_bytes(), 125); + } + + #[test] + fn tracker_clamps_reported_bytes_to_total() { + let tracker = DownloadProgressTracker::new(10); + let mut chunk = tracker.track_chunk("game/file.bin", 0, 0); + chunk.record_bytes(25); + + assert_eq!(tracker.raw_downloaded_bytes(), 25); + assert_eq!(tracker.reported_downloaded_bytes(), 10); + } +} diff --git a/crates/lanspread-peer/src/download/retry.rs b/crates/lanspread-peer/src/download/retry.rs index 923fe53..814e70f 100644 --- a/crates/lanspread-peer/src/download/retry.rs +++ b/crates/lanspread-peer/src/download/retry.rs @@ -10,6 +10,7 @@ use tokio_util::sync::CancellationToken; use super::{ planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, resolve_file_peers}, + progress::DownloadProgressTracker, transport::download_from_peer, version_ini::VersionIniBuffer, }; @@ -52,6 +53,16 @@ struct RetryAttempt { result: eyre::Result>, } +pub(super) struct RetryContext<'a> { + pub(super) peers: &'a [SocketAddr], + pub(super) base_dir: &'a Path, + pub(super) game_id: &'a str, + pub(super) file_peer_map: &'a HashMap>, + pub(super) cancel_token: &'a CancellationToken, + pub(super) version_buffer: Option>, + pub(super) progress_tracker: Arc, +} + fn plan_retry_batch( queue: &mut VecDeque, peers: &[SocketAddr], @@ -96,19 +107,17 @@ fn plan_retry_batch( async fn run_retry_batch( retry_plans: HashMap, - base_dir: &Path, - game_id: &str, - cancel_token: &CancellationToken, - version_buffer: Option>, + ctx: &RetryContext<'_>, ) -> eyre::Result> { let mut attempts = FuturesUnordered::new(); for (peer_addr, plan) in retry_plans { let retry_chunks = plan.chunks.clone(); - let base_dir = base_dir.to_path_buf(); - let game_id = game_id.to_string(); - let cancel_token = cancel_token.clone(); - let version_buffer = version_buffer.clone(); + let base_dir = ctx.base_dir.to_path_buf(); + let game_id = ctx.game_id.to_string(); + let cancel_token = ctx.cancel_token.clone(); + let version_buffer = ctx.version_buffer.clone(); + let progress_tracker = ctx.progress_tracker.clone(); attempts.push(async move { let result = download_from_peer( @@ -118,6 +127,7 @@ async fn run_retry_batch( base_dir, &cancel_token, version_buffer, + progress_tracker, ) .await; RetryAttempt { @@ -131,8 +141,8 @@ async fn run_retry_batch( let mut results = Vec::new(); while !attempts.is_empty() { let result = tokio::select! { - () = cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {game_id}"); + () = ctx.cancel_token.cancelled() => { + eyre::bail!("download cancelled for game {}", ctx.game_id); } result = attempts.next() => result.expect("retry attempt should exist"), }; @@ -208,32 +218,21 @@ fn handle_retry_attempt_error( /// Retries downloading failed chunks. pub(super) async fn retry_failed_chunks( failed_chunks: Vec, - peers: &[SocketAddr], - base_dir: &Path, - game_id: &str, - file_peer_map: &HashMap>, - cancel_token: &CancellationToken, - version_buffer: Option>, + ctx: &RetryContext<'_>, ) -> eyre::Result> { let mut final_results = Vec::new(); let mut queue: VecDeque = failed_chunks.into_iter().collect(); while !queue.is_empty() { - ensure_not_cancelled(cancel_token, game_id)?; + ensure_not_cancelled(ctx.cancel_token, ctx.game_id)?; - let retry_plans = plan_retry_batch(&mut queue, peers, file_peer_map, &mut final_results); + let retry_plans = + plan_retry_batch(&mut queue, ctx.peers, ctx.file_peer_map, &mut final_results); if retry_plans.is_empty() { continue; } - let attempts = run_retry_batch( - retry_plans, - base_dir, - game_id, - cancel_token, - version_buffer.clone(), - ) - .await?; + let attempts = run_retry_batch(retry_plans, ctx).await?; for attempt in attempts { let RetryAttempt { diff --git a/crates/lanspread-peer/src/download/transport.rs b/crates/lanspread-peer/src/download/transport.rs index 717b775..36a94c3 100644 --- a/crates/lanspread-peer/src/download/transport.rs +++ b/crates/lanspread-peer/src/download/transport.rs @@ -18,6 +18,7 @@ use tokio_util::{ use super::{ planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan}, + progress::DownloadProgressTracker, version_ini::VersionIniBuffer, }; use crate::{ @@ -65,11 +66,12 @@ async fn receive_chunk( base_dir: &Path, chunk: &DownloadChunk, version_buffer: Option>, + progress_tracker: Arc, ) -> eyre::Result<()> { if let Some(buffer) = version_buffer && buffer.matches(&chunk.relative_path) { - return download_version_ini_chunk(rx, chunk, &buffer).await; + return download_version_ini_chunk(rx, chunk, &buffer, progress_tracker).await; } // Validate the path to prevent directory traversal @@ -88,15 +90,19 @@ async fn receive_chunk( let mut remaining = chunk.length; let mut received_bytes = 0u64; + let mut progress = + progress_tracker.track_chunk(&chunk.relative_path, chunk.offset, chunk.length); while let Some(bytes) = rx.receive().await? { file.write_all(&bytes).await?; - received_bytes += bytes.len() as u64; + progress.record_bytes(bytes.len()); + let byte_count = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + received_bytes = received_bytes.saturating_add(byte_count); if remaining == 0 { continue; } - remaining = remaining.saturating_sub(bytes.len() as u64); + remaining = remaining.saturating_sub(byte_count); if remaining == 0 { break; } @@ -127,8 +133,9 @@ async fn receive_chunk_result( chunk: DownloadChunk, rx: ReceiveStream, version_buffer: Option>, + progress_tracker: Arc, ) -> ChunkDownloadResult { - let result = receive_chunk(rx, &base_dir, &chunk, version_buffer).await; + let result = receive_chunk(rx, &base_dir, &chunk, version_buffer, progress_tracker).await; ChunkDownloadResult { chunk, result, @@ -140,9 +147,13 @@ async fn download_version_ini_chunk( mut rx: ReceiveStream, chunk: &DownloadChunk, buffer: &VersionIniBuffer, + progress_tracker: Arc, ) -> eyre::Result<()> { let mut received = Vec::new(); + let mut progress = + progress_tracker.track_chunk(&chunk.relative_path, chunk.offset, chunk.length); while let Some(bytes) = rx.receive().await? { + progress.record_bytes(bytes.len()); received.extend_from_slice(&bytes); } @@ -207,53 +218,59 @@ fn failed_plan_results( .collect() } +struct ChunkPlanContext<'a> { + peer_addr: SocketAddr, + game_id: &'a str, + base_dir: &'a Path, + cancel_token: &'a CancellationToken, + version_buffer: Option>, + progress_tracker: Arc, +} + async fn download_chunk_plan( conn: &mut Connection, - peer_addr: SocketAddr, - game_id: &str, chunks: Vec, - base_dir: &Path, - cancel_token: &CancellationToken, - version_buffer: Option>, + ctx: &ChunkPlanContext<'_>, ) -> eyre::Result> { let mut pending: VecDeque = chunks.into(); let mut in_flight = FuturesUnordered::new(); let mut results = Vec::new(); let window = PEER_DOWNLOAD_STREAM_WINDOW.max(1); - let base_dir = base_dir.to_path_buf(); + let base_dir = ctx.base_dir.to_path_buf(); while !pending.is_empty() || !in_flight.is_empty() { while in_flight.len() < window { let Some(chunk) = pending.pop_front() else { break; }; - ensure_download_not_cancelled(cancel_token, game_id)?; + ensure_download_not_cancelled(ctx.cancel_token, ctx.game_id)?; log::info!( "Downloading chunk {} (offset {}, length {}) from {}", chunk.relative_path, chunk.offset, chunk.length, - peer_addr + ctx.peer_addr ); - match open_chunk_stream(conn, game_id, &chunk).await { + match open_chunk_stream(conn, ctx.game_id, &chunk).await { Ok(rx) => { in_flight.push(receive_chunk_result( - peer_addr, + ctx.peer_addr, base_dir.clone(), chunk, rx, - version_buffer.clone(), + ctx.version_buffer.clone(), + ctx.progress_tracker.clone(), )); } Err(err) => { let reason = format!("failed to open chunk stream: {err}"); - results.push(failed_chunk_result(chunk, peer_addr, reason.clone())); + results.push(failed_chunk_result(chunk, ctx.peer_addr, reason.clone())); while let Some(chunk) = pending.pop_front() { results.push(failed_chunk_result( chunk, - peer_addr, + ctx.peer_addr, format!("peer stream unavailable after earlier open failure: {reason}"), )); } @@ -267,8 +284,8 @@ async fn download_chunk_plan( } let result = tokio::select! { - () = cancel_token.cancelled() => { - eyre::bail!("download cancelled for game {game_id}"); + () = ctx.cancel_token.cancelled() => { + eyre::bail!("download cancelled for game {}", ctx.game_id); } result = in_flight.next() => result.expect("in-flight chunk stream should exist"), }; @@ -286,6 +303,7 @@ pub(super) async fn download_from_peer( games_folder: PathBuf, cancel_token: &CancellationToken, version_buffer: Option>, + progress_tracker: Arc, ) -> eyre::Result> { if plan.chunks.is_empty() { return Ok(Vec::new()); @@ -303,17 +321,16 @@ pub(super) async fn download_from_peer( } let base_dir = games_folder; - - let results = download_chunk_plan( - &mut conn, + let chunk_ctx = ChunkPlanContext { peer_addr, game_id, - plan.chunks, - &base_dir, + base_dir: &base_dir, cancel_token, version_buffer, - ) - .await?; + progress_tracker, + }; + + let results = download_chunk_plan(&mut conn, plan.chunks, &chunk_ctx).await?; Ok(results) } diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 9ff05ca..6b94949 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -100,6 +100,8 @@ pub enum PeerEvent { offset: u64, length: u64, }, + /// Download progress sampled while game files are being received. + DownloadGameFilesProgress(DownloadProgress), /// Download has completed successfully. DownloadGameFilesFinished { id: String }, /// Download has failed. @@ -152,6 +154,15 @@ pub enum PeerEvent { }, } +/// Sampled byte progress for one active game download. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct DownloadProgress { + pub id: String, + pub downloaded_bytes: u64, + pub total_bytes: u64, + pub bytes_per_second: u64, +} + /// Long-running peer runtime components reported in failure events. #[derive(Clone, Copy, Debug, strum::IntoStaticStr)] pub enum PeerRuntimeComponent { 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 4bb8d3e..47b455b 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -1038,6 +1038,11 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { {relative_path} offset {offset} length {length} from {peer_addr}" ); } + PeerEvent::DownloadGameFilesProgress(progress) => { + if let Err(e) = app_handle.emit("game-download-progress", Some(progress)) { + log::error!("Failed to emit game-download-progress event: {e}"); + } + } PeerEvent::DownloadGameFilesFinished { id } => { handle_download_finished(app_handle, id); } diff --git a/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx b/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx index 1a44ed8..09ff3eb 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/ActionButton.tsx @@ -1,7 +1,7 @@ -import { JSX, MouseEvent } from 'react'; +import { CSSProperties, JSX, MouseEvent } from 'react'; import { Icon } from './Icon'; -import { Game } from '../lib/types'; +import { Game, InstallStatus } from '../lib/types'; import { actionLabel, primaryActionFor, PrimaryAction } from '../lib/gameState'; interface Props { @@ -18,16 +18,29 @@ 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) => { const action = primaryActionFor(game); + const isDownloading = game.install_status === InstallStatus.Downloading; + const progressPercent = downloadProgressPercent(game); 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(); @@ -36,9 +49,10 @@ export const ActionButton = ({ game, size = 'md', full = false, onClick }: Props }; return ( - ); }; diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts index 936e54d..aaa087a 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { + DownloadProgressPayload, Game, GamesListPayload, InstallStatus, @@ -51,7 +52,10 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { const markChecking = useCallback((id: string) => { setGames(prev => prev.map(item => item.id === id && !isInProgress(item.install_status) - ? applyPatch(item, { install_status: InstallStatus.CheckingPeers, clearStatus: true }) + ? applyPatch(item, { + install_status: InstallStatus.CheckingPeers, + clearStatus: true, + }) : item )); }, []); @@ -81,6 +85,7 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { : InstallStatus.NotInstalled, status_message: message, status_level: 'error', + download_progress: undefined, } : item)); if (triggerRescan) rescanRef.current(); @@ -115,6 +120,16 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { }); })); + unlisteners.push(await listen('game-download-progress', (e) => { + const { id, ...download_progress } = e.payload as DownloadProgressPayload; + setGames(prev => prev.map(item => item.id === id + ? { + ...item, + download_progress, + } + : item)); + })); + unlisteners.push(await listen('game-no-peers', (e) => { handleErrorEvent(e.payload as string, 'No peers currently have this game.'); })); diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts index 8665b3a..12735e1 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -66,6 +66,9 @@ export const mergeGameUpdate = ( install_status: installStatus, status_message: clearStatus ? undefined : previous?.status_message, status_level: clearStatus ? undefined : previous?.status_level, + download_progress: installStatus === InstallStatus.Downloading + ? previous?.download_progress + : undefined, peer_count: incoming.peer_count ?? 0, }; }; @@ -108,12 +111,29 @@ export const primaryActionFor = (game: Game): PrimaryAction => { return 'play'; }; -export const inProgressLabel = (status: InstallStatus): string | undefined => { - switch (status) { +export const formatBytesPerSecond = (bytesPerSecond: number): string => { + const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; + let value = Math.max(0, bytesPerSecond); + let unitIndex = 0; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex += 1; + } + + if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`; + const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(precision)} ${units[unitIndex]}`; +}; + +export const inProgressLabel = (game: Game): string | undefined => { + switch (game.install_status) { case InstallStatus.CheckingPeers: return 'Checking peers…'; case InstallStatus.Downloading: - return 'Downloading…'; + return game.download_progress + ? `Downloading… ${formatBytesPerSecond(game.download_progress.bytes_per_second)}` + : 'Downloading…'; case InstallStatus.Installing: return 'Installing…'; case InstallStatus.Uninstalling: @@ -126,7 +146,7 @@ export const inProgressLabel = (status: InstallStatus): string | undefined => { }; export const actionLabel = (game: Game): string => { - const busy = inProgressLabel(game.install_status); + const busy = inProgressLabel(game); if (busy) return busy; if (isUnavailable(game)) return 'Unavailable'; if (!game.installed) return game.downloaded ? 'Install' : 'Download'; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/types.ts b/crates/lanspread-tauri-deno-ts/src/lib/types.ts index 58223e1..38366c1 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/types.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/types.ts @@ -23,6 +23,16 @@ export enum ActiveOperationKind { export type StatusLevel = 'info' | 'error'; +export interface DownloadProgress { + downloaded_bytes: number; + total_bytes: number; + bytes_per_second: number; +} + +export interface DownloadProgressPayload extends DownloadProgress { + id: string; +} + export interface Game { id: string; name: string; @@ -45,6 +55,7 @@ export interface Game { genre?: string; status_message?: string; status_level?: StatusLevel; + download_progress?: DownloadProgress; peer_count: number; } diff --git a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css index 99550d1..8dedd8d 100644 --- a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css +++ b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css @@ -737,14 +737,21 @@ font: inherit; font-weight: 600; font-size: 12.5px; - letter-spacing: 0.005em; + letter-spacing: 0; cursor: pointer; + position: relative; + overflow: hidden; transition: transform 0.12s, filter 0.12s, background 0.15s; white-space: nowrap; } +.act-btn > svg, +.act-btn > .act-label { + position: relative; + z-index: 1; +} .act-btn:hover:not(:disabled) { filter: brightness(1.12); } @@ -800,6 +807,20 @@ 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; @@ -809,6 +830,9 @@ border: 1.6px solid color-mix(in srgb, var(--accent) 60%, transparent); border-top-color: var(--accent); animation: spin 0.9s linear infinite; + position: relative; + z-index: 1; + flex: 0 0 auto; } @keyframes spin { to { diff --git a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts index c7eecb6..470048a 100644 --- a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts @@ -1,5 +1,7 @@ import { + actionLabel, activeStatusById, + formatBytesPerSecond, mergeGameUpdate, } from '../src/lib/gameState.ts'; import { @@ -81,3 +83,54 @@ Deno.test('active operation snapshot is the source of busy status', () => { 'update operation should render Installing', ); }); + +Deno.test('download progress is preserved only while actively downloading', () => { + const downloading = game({ + install_status: InstallStatus.Downloading, + download_progress: { + downloaded_bytes: 50, + total_bytes: 100, + bytes_per_second: 12_500_000, + }, + }); + + const stillDownloading = mergeGameUpdate( + game(), + downloading, + InstallStatus.Downloading, + ); + const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading); + + assertEquals( + stillDownloading.download_progress?.downloaded_bytes, + 50, + 'active download snapshot should keep progress', + ); + assertEquals( + settled.download_progress, + undefined, + 'settled snapshot should clear progress', + ); +}); + +Deno.test('downloading action label includes current speed', () => { + const downloading = game({ + install_status: InstallStatus.Downloading, + download_progress: { + downloaded_bytes: 50, + total_bytes: 100, + bytes_per_second: 12_500_000, + }, + }); + + assertEquals( + formatBytesPerSecond(12_500_000), + '12.5 MB/s', + 'speed formatter should use compact decimal units', + ); + assertEquals( + actionLabel(downloading), + 'Downloading… 12.5 MB/s', + 'download label should include speed', + ); +});