v0.3: resumable downloads with HTTP Range, Polly retry, persistent state

This is the robustness layer needed for real 14 GB builds. A 99%-complete
download that gets interrupted no longer means re-downloading 14 GB.

DownloadManager
---------------
- Range: bytes={resumeFrom}- on every (re)attempt; reads resumeFrom from
  the actual size of the .partial file so retries always resume from real
  on-disk state, not from a remembered counter.
- If-Range: ETag (or Last-Modified fallback). When the server returns 200
  instead of 206 we know the resource changed under us, so we discard
  .partial and start fresh.
- Polly resilience pipeline: 6 retries, exponential 1-32s with jitter,
  on HttpRequestException / IOException / TimeoutException / 5xx / 408 /
  429. Each retry re-evaluates resumeFrom from disk, so the server is
  asked only for what's actually missing.
- state.json persisted every 5s OR every 100 MiB, whichever comes first,
  via atomic write-then-rename. Holds url, total, downloaded, sha256,
  etag, last-modified, and the .partial path.
- Disk-space check happens once at fresh-start (1.05x expected size); a
  resume doesn't redo it.
- On final success: SHA-256 of the assembled .partial verified, then
  atomic rename to .zip and state.json deleted.

DownloadStateStore
------------------
- New IDownloadStateStore in PSLauncher.Core/Downloads.
- Stores under %LocalAppData%/PSLauncher/downloads/.
- Save / Load / Discard / ScanResumable. Tolerates malformed state files
  by ignoring them.

UI hint
-------
VersionRowViewModel now has ResumableBytes; when > 0, the install button
label switches to "↻  Reprendre (X%)" computed from
ResumableBytes / Remote.Download.SizeBytes. MainViewModel.RebuildList
queries IDownloadManager.GetResumableState(version) for each remote-only
row and populates ResumableBytes. Both the featured hero card and the
compact rows bind to InstallButtonLabel.

API change
----------
IDownloadManager gains GetResumableState(version) and
DiscardResumableState(version) so callers (and the UI) can reason about
in-progress downloads without poking at the filesystem directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 09:55:31 +02:00
parent 6128f7d220
commit 9bdcdabb9e
9 changed files with 391 additions and 68 deletions

View File

@@ -130,6 +130,13 @@ public sealed partial class MainViewModel : ObservableObject
rows.Add(row);
}
// Marque les rows distantes qui ont un DL en pause (partial + state.json présents)
foreach (var r in rows.Where(r => r.IsRemoteOnly))
{
var st = _downloadManager.GetResumableState(r.Version);
if (st is not null) r.ResumableBytes = st.DownloadedBytes;
}
// Featured : plus haute installée, sinon plus haute distante
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
FeaturedVersion = featured;