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
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
|
|
|
import {
|
|
DownloadProgressPayload,
|
|
Game,
|
|
GamesListPayload,
|
|
InstallStatus,
|
|
} from '../lib/types';
|
|
import {
|
|
activeStatusById,
|
|
isInProgress,
|
|
mergeGameUpdate,
|
|
normalizeGamesListPayload,
|
|
} from '../lib/gameState';
|
|
|
|
interface PendingPatch {
|
|
install_status?: InstallStatus;
|
|
clearStatus?: boolean;
|
|
}
|
|
|
|
const applyPatch = (game: Game, patch: PendingPatch): Game => {
|
|
let next: Game = { ...game };
|
|
if (patch.install_status !== undefined) next.install_status = patch.install_status;
|
|
if (patch.clearStatus) {
|
|
next.status_message = undefined;
|
|
next.status_level = undefined;
|
|
}
|
|
return next;
|
|
};
|
|
|
|
/**
|
|
* Owns the games list and derives card status from backend snapshots. Returns
|
|
* a fire-and-forget `markChecking` helper so action calls can immediately show
|
|
* a "Checking peers…" state until the next backend snapshot arrives.
|
|
*/
|
|
export interface UseGamesResult {
|
|
games: Game[];
|
|
setGames: React.Dispatch<React.SetStateAction<Game[]>>;
|
|
totalPeerCount: number;
|
|
requestGames: () => Promise<void>;
|
|
markChecking: (id: string) => void;
|
|
}
|
|
|
|
export const useGames = (rescanGameDir: () => void): UseGamesResult => {
|
|
const [games, setGames] = useState<Game[]>([]);
|
|
const [totalPeerCount, setTotalPeerCount] = useState(0);
|
|
const rescanRef = useRef(rescanGameDir);
|
|
rescanRef.current = rescanGameDir;
|
|
|
|
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,
|
|
})
|
|
: item
|
|
));
|
|
}, []);
|
|
|
|
const requestGames = useCallback(async () => {
|
|
try {
|
|
await invoke('request_games');
|
|
} catch (err) {
|
|
console.error('request_games failed:', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const unlisteners: UnlistenFn[] = [];
|
|
let cancelled = false;
|
|
|
|
const handleErrorEvent = (
|
|
id: string,
|
|
message: string,
|
|
{ triggerRescan = false }: { triggerRescan?: boolean } = {},
|
|
) => {
|
|
setGames(prev => prev.map(item => item.id === id
|
|
? {
|
|
...item,
|
|
install_status: item.installed
|
|
? InstallStatus.Installed
|
|
: InstallStatus.NotInstalled,
|
|
status_message: message,
|
|
status_level: 'error',
|
|
download_progress: undefined,
|
|
}
|
|
: item));
|
|
if (triggerRescan) rescanRef.current();
|
|
};
|
|
|
|
const register = async () => {
|
|
try {
|
|
unlisteners.push(await listen('games-list-updated', (event) => {
|
|
const payload = normalizeGamesListPayload(
|
|
event.payload as GamesListPayload | Game[],
|
|
);
|
|
const activeStatuses = activeStatusById(payload.active_operations);
|
|
setGames(prev => {
|
|
const previousById = new Map(prev.map(item => [item.id, item]));
|
|
return payload.games.map(game => mergeGameUpdate(
|
|
game,
|
|
previousById.get(game.id),
|
|
activeStatuses.get(game.id),
|
|
));
|
|
});
|
|
}));
|
|
|
|
unlisteners.push(await listen('game-download-failed', (e) => {
|
|
handleErrorEvent(e.payload as string, 'Download failed. Please try again.', {
|
|
triggerRescan: true,
|
|
});
|
|
}));
|
|
|
|
unlisteners.push(await listen('game-download-peers-gone', (e) => {
|
|
handleErrorEvent(e.payload as string, 'Failed: all peers gone.', {
|
|
triggerRescan: true,
|
|
});
|
|
}));
|
|
|
|
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.');
|
|
}));
|
|
|
|
unlisteners.push(await listen('game-install-finished', () => {
|
|
rescanRef.current();
|
|
}));
|
|
|
|
unlisteners.push(await listen('game-install-failed', (e) => {
|
|
handleErrorEvent(e.payload as string, 'Install failed. Please try again.');
|
|
}));
|
|
|
|
unlisteners.push(await listen('game-uninstall-failed', (e) => {
|
|
handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.');
|
|
}));
|
|
|
|
unlisteners.push(await listen('game-remove-download-finished', () => {
|
|
rescanRef.current();
|
|
}));
|
|
|
|
unlisteners.push(await listen('game-remove-download-failed', (e) => {
|
|
handleErrorEvent(e.payload as string, 'Remove failed. Please try again.', {
|
|
triggerRescan: true,
|
|
});
|
|
}));
|
|
|
|
unlisteners.push(await listen('peer-count-updated', (e) => {
|
|
setTotalPeerCount(e.payload as number);
|
|
}));
|
|
|
|
if (!cancelled) {
|
|
await invoke('request_games').catch(err =>
|
|
console.error('request_games failed:', err),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to register game listeners:', err);
|
|
}
|
|
};
|
|
|
|
void register();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
unlisteners.forEach(fn => fn());
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
games,
|
|
setGames,
|
|
totalPeerCount,
|
|
requestGames,
|
|
markChecking,
|
|
};
|
|
};
|