From 9bdcdabb9edd901b7467c091ee3520adac896e8a Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Fri, 1 May 2026 09:55:31 +0200 Subject: [PATCH] v0.3: resumable downloads with HTTP Range, Polly retry, persistent state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/PSLauncher.App/App.xaml.cs | 1 + .../ViewModels/MainViewModel.cs | 7 + .../ViewModels/VersionRowViewModel.cs | 18 ++ src/PSLauncher.App/Views/MainWindow.xaml | 4 +- .../Downloads/DownloadManager.cs | 291 ++++++++++++++---- .../Downloads/DownloadStateStore.cs | 111 +++++++ .../Downloads/IDownloadManager.cs | 6 + src/PSLauncher.Core/PSLauncher.Core.csproj | 1 + src/PSLauncher.Models/DownloadState.cs | 20 ++ 9 files changed, 391 insertions(+), 68 deletions(-) create mode 100644 src/PSLauncher.Core/Downloads/DownloadStateStore.cs create mode 100644 src/PSLauncher.Models/DownloadState.cs diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 9dff42c..dd5c313 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -55,6 +55,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new ManifestService( diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index c099e20..9f28376 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -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; diff --git a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs index b0ca89e..a4b043a 100644 --- a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs +++ b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs @@ -37,6 +37,24 @@ public sealed partial class VersionRowViewModel : ObservableObject [ObservableProperty] private double _progressPercent; [ObservableProperty] private string? _progressDetail; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(InstallButtonLabel))] + [NotifyPropertyChangedFor(nameof(HasResumableDownload))] + private long _resumableBytes; + + public bool HasResumableDownload => ResumableBytes > 0; + + public string InstallButtonLabel + { + get + { + if (!HasResumableDownload) return "⬇ Installer"; + if (Remote is null || Remote.Download.SizeBytes <= 0) return "↻ Reprendre"; + var pct = (double)ResumableBytes / Remote.Download.SizeBytes * 100.0; + return $"↻ Reprendre ({pct:0}%)"; + } + } + public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling; public bool IsRemoteOnly => State is VersionRowState.AvailableIdle; public bool IsBusy => State is VersionRowState.Downloading or VersionRowState.Installing or VersionRowState.Uninstalling; diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 3d30f29..c114fb8 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -102,7 +102,7 @@ Command="{Binding LaunchCommand}" Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />