feat(ui): show download progress and speed in the action button
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<DownloadProgressTracker>` 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
This commit is contained in:
@@ -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<Arc<VersionIniBuffer>>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
) -> 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<Arc<VersionIniBuffer>>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
) -> 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<DownloadProgressTracker>,
|
||||
) -> 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<Arc<VersionIniBuffer>>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
}
|
||||
|
||||
async fn download_chunk_plan(
|
||||
conn: &mut Connection,
|
||||
peer_addr: SocketAddr,
|
||||
game_id: &str,
|
||||
chunks: Vec<DownloadChunk>,
|
||||
base_dir: &Path,
|
||||
cancel_token: &CancellationToken,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
ctx: &ChunkPlanContext<'_>,
|
||||
) -> eyre::Result<Vec<ChunkDownloadResult>> {
|
||||
let mut pending: VecDeque<DownloadChunk> = 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<Arc<VersionIniBuffer>>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
) -> eyre::Result<Vec<ChunkDownloadResult>> {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user