01712f248b
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
137 lines
3.8 KiB
TypeScript
137 lines
3.8 KiB
TypeScript
import {
|
|
actionLabel,
|
|
activeStatusById,
|
|
formatBytesPerSecond,
|
|
mergeGameUpdate,
|
|
} from '../src/lib/gameState.ts';
|
|
import {
|
|
ActiveOperationKind,
|
|
GameAvailability,
|
|
InstallStatus,
|
|
type Game,
|
|
} from '../src/lib/types.ts';
|
|
|
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
|
}
|
|
};
|
|
|
|
const game = (overrides: Partial<Game> = {}): Game => ({
|
|
id: 'game',
|
|
name: 'Game',
|
|
description: '',
|
|
size: 0,
|
|
downloaded: false,
|
|
installed: false,
|
|
availability: GameAvailability.LocalOnly,
|
|
install_status: InstallStatus.NotInstalled,
|
|
peer_count: 1,
|
|
...overrides,
|
|
});
|
|
|
|
Deno.test('snapshot keeps installing visible until installed state settles', () => {
|
|
const fromDownloading = game({
|
|
install_status: InstallStatus.Downloading,
|
|
});
|
|
const installing = mergeGameUpdate(
|
|
game({ downloaded: true }),
|
|
fromDownloading,
|
|
InstallStatus.Installing,
|
|
);
|
|
const installedWhileActive = mergeGameUpdate(
|
|
game({ downloaded: true, installed: true }),
|
|
installing,
|
|
InstallStatus.Installing,
|
|
);
|
|
const settled = mergeGameUpdate(
|
|
game({ downloaded: true, installed: true }),
|
|
installedWhileActive,
|
|
);
|
|
|
|
assertEquals(
|
|
installing.install_status,
|
|
InstallStatus.Installing,
|
|
'active install snapshot should render Installing',
|
|
);
|
|
assertEquals(
|
|
installedWhileActive.install_status,
|
|
InstallStatus.Installing,
|
|
'installed local state should not override an active install snapshot',
|
|
);
|
|
assertEquals(
|
|
settled.install_status,
|
|
InstallStatus.Installed,
|
|
'cleared active snapshot with installed local state should render Installed',
|
|
);
|
|
});
|
|
|
|
Deno.test('active operation snapshot is the source of busy status', () => {
|
|
const statuses = activeStatusById([
|
|
{ id: 'game', operation: ActiveOperationKind.Downloading },
|
|
{ id: 'other', operation: ActiveOperationKind.Updating },
|
|
]);
|
|
|
|
assertEquals(
|
|
statuses.get('game'),
|
|
InstallStatus.Downloading,
|
|
'download operation should render Downloading',
|
|
);
|
|
assertEquals(
|
|
statuses.get('other'),
|
|
InstallStatus.Installing,
|
|
'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',
|
|
);
|
|
});
|