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:
@@ -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<DownloadChunk> = Vec::new();
|
||||
let mut last_err: Option<eyre::Report> = 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<String, Vec<SocketAddr>>,
|
||||
tx_notify_ui: &'a UnboundedSender<PeerEvent>,
|
||||
cancel_token: &'a CancellationToken,
|
||||
version_buffer: Arc<VersionIniBuffer>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
}
|
||||
|
||||
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<DownloadChunk> = Vec::new();
|
||||
let mut last_err: Option<eyre::Report> = 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<PeerEvent>,
|
||||
results: Vec<ChunkDownloadResult>,
|
||||
failed_chunks: &mut Vec<DownloadChunk>,
|
||||
last_err: &mut Option<eyre::Report>,
|
||||
) {
|
||||
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<DownloadChunk>,
|
||||
last_err: &mut Option<eyre::Report>,
|
||||
) -> 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()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user