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:
@@ -55,6 +55,7 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
services.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
||||||
services.AddSingleton<IIntegrityService, IntegrityService>();
|
services.AddSingleton<IIntegrityService, IntegrityService>();
|
||||||
services.AddSingleton<IZipInstaller, ZipInstaller>();
|
services.AddSingleton<IZipInstaller, ZipInstaller>();
|
||||||
|
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
|
||||||
|
|
||||||
services.AddSingleton<IManifestService>(sp =>
|
services.AddSingleton<IManifestService>(sp =>
|
||||||
new ManifestService(
|
new ManifestService(
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
rows.Add(row);
|
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
|
// Featured : plus haute installée, sinon plus haute distante
|
||||||
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
|
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
|
||||||
FeaturedVersion = featured;
|
FeaturedVersion = featured;
|
||||||
|
|||||||
@@ -37,6 +37,24 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[ObservableProperty] private double _progressPercent;
|
[ObservableProperty] private double _progressPercent;
|
||||||
[ObservableProperty] private string? _progressDetail;
|
[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 IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
|
||||||
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
|
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
|
||||||
public bool IsBusy => State is VersionRowState.Downloading or VersionRowState.Installing or VersionRowState.Uninstalling;
|
public bool IsBusy => State is VersionRowState.Downloading or VersionRowState.Installing or VersionRowState.Uninstalling;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
Command="{Binding LaunchCommand}"
|
Command="{Binding LaunchCommand}"
|
||||||
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<Button Style="{StaticResource AccentButton}"
|
<Button Style="{StaticResource AccentButton}"
|
||||||
Content="⬇ Installer" Padding="22,8" FontSize="13"
|
Content="{Binding InstallButtonLabel}" Padding="22,8" FontSize="13"
|
||||||
Command="{Binding InstallCommand}"
|
Command="{Binding InstallCommand}"
|
||||||
Visibility="{Binding IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<StackPanel Orientation="Horizontal"
|
<StackPanel Orientation="Horizontal"
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
|
|
||||||
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||||
Style="{StaticResource AccentButton}"
|
Style="{StaticResource AccentButton}"
|
||||||
Content="⬇ INSTALLER"
|
Content="{Binding FeaturedVersion.InstallButtonLabel}"
|
||||||
FontSize="20" Padding="48,16"
|
FontSize="20" Padding="48,16"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Command="{Binding FeaturedVersion.InstallCommand}"
|
Command="{Binding FeaturedVersion.InstallCommand}"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PSLauncher.Core.Configuration;
|
using Polly;
|
||||||
|
using Polly.Retry;
|
||||||
using PSLauncher.Core.Integrity;
|
using PSLauncher.Core.Integrity;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
@@ -9,118 +12,274 @@ namespace PSLauncher.Core.Downloads;
|
|||||||
public sealed class DownloadManager : IDownloadManager
|
public sealed class DownloadManager : IDownloadManager
|
||||||
{
|
{
|
||||||
private const int BufferSize = 1 << 20; // 1 MiB
|
private const int BufferSize = 1 << 20; // 1 MiB
|
||||||
|
private const int StateFlushBytesInterval = 100 * 1024 * 1024; // 100 MiB
|
||||||
|
private const double StateFlushSecondsInterval = 5.0;
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly IConfigStore _configStore;
|
private readonly IDownloadStateStore _stateStore;
|
||||||
private readonly IIntegrityService _integrity;
|
private readonly IIntegrityService _integrity;
|
||||||
private readonly ILogger<DownloadManager> _logger;
|
private readonly ILogger<DownloadManager> _logger;
|
||||||
|
private readonly ResiliencePipeline _retryPipeline;
|
||||||
|
|
||||||
public DownloadManager(
|
public DownloadManager(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
IConfigStore configStore,
|
IDownloadStateStore stateStore,
|
||||||
IIntegrityService integrity,
|
IIntegrityService integrity,
|
||||||
ILogger<DownloadManager> logger)
|
ILogger<DownloadManager> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_configStore = configStore;
|
_stateStore = stateStore;
|
||||||
_integrity = integrity;
|
_integrity = integrity;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
|
_retryPipeline = new ResiliencePipelineBuilder()
|
||||||
|
.AddRetry(new RetryStrategyOptions
|
||||||
|
{
|
||||||
|
MaxRetryAttempts = 6,
|
||||||
|
BackoffType = DelayBackoffType.Exponential,
|
||||||
|
Delay = TimeSpan.FromSeconds(1),
|
||||||
|
MaxDelay = TimeSpan.FromSeconds(32),
|
||||||
|
UseJitter = true,
|
||||||
|
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
|
||||||
|
ex is HttpRequestException
|
||||||
|
or IOException
|
||||||
|
or TimeoutException
|
||||||
|
|| (ex is HttpResumableException re && re.IsTransient)),
|
||||||
|
OnRetry = args =>
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Download retry #{Attempt} after {Delay} : {Error}",
|
||||||
|
args.AttemptNumber + 1, args.RetryDelay, args.Outcome.Exception?.Message);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public DownloadState? GetResumableState(string version) => _stateStore.Load(version);
|
||||||
/// Télécharge le ZIP et retourne le chemin du fichier final (SHA-256 vérifié).
|
|
||||||
/// v0.2 : pas de reprise — un échec impose de tout recommencer. Resume en v0.3.
|
public void DiscardResumableState(string version) => _stateStore.Discard(version);
|
||||||
/// </summary>
|
|
||||||
public async Task<string> DownloadAsync(
|
public async Task<string> DownloadAsync(
|
||||||
DownloadJob job,
|
DownloadJob job,
|
||||||
IProgress<DownloadProgress>? progress,
|
IProgress<DownloadProgress>? progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var dir = Path.Combine(_configStore.ConfigDirectory, "downloads");
|
var partialPath = _stateStore.GetPartialPath(job.Version);
|
||||||
Directory.CreateDirectory(dir);
|
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
|
||||||
var partial = Path.Combine(dir, $"proserve-{job.Version}.zip.partial");
|
|
||||||
var final = Path.Combine(dir, $"proserve-{job.Version}.zip");
|
|
||||||
|
|
||||||
// Vérif d'espace disque sur le volume du cache (× 1.05 pour marge)
|
// 1. Récupère l'état existant (s'il y en a un, et qu'il correspond à la même URL/sha)
|
||||||
if (job.ExpectedSize > 0)
|
var existing = _stateStore.Load(job.Version);
|
||||||
|
if (existing is not null && (existing.Url != job.Url.ToString() || existing.ExpectedSha256 != job.ExpectedSha256))
|
||||||
{
|
{
|
||||||
var drive = new DriveInfo(Path.GetPathRoot(dir)!);
|
_logger.LogInformation("Cached state for {Version} mismatches new job (url/sha changed) — discarding", job.Version);
|
||||||
|
_stateStore.Discard(job.Version);
|
||||||
|
existing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long resumeFrom = 0;
|
||||||
|
if (existing is not null && File.Exists(partialPath))
|
||||||
|
{
|
||||||
|
resumeFrom = new FileInfo(partialPath).Length;
|
||||||
|
// Si la state.json est en avance par rapport au .partial réel, on s'aligne sur le .partial
|
||||||
|
if (resumeFrom != existing.DownloadedBytes)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"State/file size mismatch ({StateBytes} vs {FileBytes}) — using file size",
|
||||||
|
existing.DownloadedBytes, resumeFrom);
|
||||||
|
existing.DownloadedBytes = resumeFrom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (File.Exists(partialPath))
|
||||||
|
{
|
||||||
|
// Fichier partiel sans state : on repart à 0 par sécurité
|
||||||
|
File.Delete(partialPath);
|
||||||
|
resumeFrom = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Vérif espace disque (uniquement à l'initial, pas au resume)
|
||||||
|
if (resumeFrom == 0 && job.ExpectedSize > 0)
|
||||||
|
{
|
||||||
|
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
|
||||||
var needed = (long)(job.ExpectedSize * 1.05);
|
var needed = (long)(job.ExpectedSize * 1.05);
|
||||||
if (drive.AvailableFreeSpace < needed)
|
if (drive.AvailableFreeSpace < needed)
|
||||||
throw new IOException(
|
throw new IOException(
|
||||||
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
|
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File.Exists(partial)) File.Delete(partial);
|
var state = existing ?? new DownloadState
|
||||||
|
|
||||||
_logger.LogInformation("Downloading {Url} -> {Path}", job.Url, partial);
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
|
|
||||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
|
||||||
resp.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var total = resp.Content.Headers.ContentLength ?? job.ExpectedSize;
|
|
||||||
|
|
||||||
await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false))
|
|
||||||
await using (var dst = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, useAsync: true))
|
|
||||||
{
|
{
|
||||||
var buffer = new byte[BufferSize];
|
Version = job.Version,
|
||||||
long read = 0;
|
Url = job.Url.ToString(),
|
||||||
var sw = Stopwatch.StartNew();
|
TotalBytes = job.ExpectedSize,
|
||||||
long lastReportRead = 0;
|
DownloadedBytes = 0,
|
||||||
var lastReport = sw.Elapsed;
|
ExpectedSha256 = job.ExpectedSha256,
|
||||||
|
PartialPath = partialPath,
|
||||||
|
StartedAtUtc = DateTime.UtcNow,
|
||||||
|
LastChunkAtUtc = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
int n;
|
// 3. Tentatives avec retry — chaque tentative continue depuis la taille actuelle du .partial
|
||||||
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
await _retryPipeline.ExecuteAsync(async innerCt =>
|
||||||
{
|
{
|
||||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
|
||||||
read += n;
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var elapsed = sw.Elapsed;
|
// 4. Vérif taille finale
|
||||||
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
var actualSize = new FileInfo(partialPath).Length;
|
||||||
{
|
|
||||||
var deltaBytes = read - lastReportRead;
|
|
||||||
var deltaSeconds = (elapsed - lastReport).TotalSeconds;
|
|
||||||
var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
|
|
||||||
TimeSpan? eta = null;
|
|
||||||
if (bps > 0 && total > 0) eta = TimeSpan.FromSeconds((total - read) / bps);
|
|
||||||
progress?.Report(new DownloadProgress(read, total, bps, eta));
|
|
||||||
lastReportRead = read;
|
|
||||||
lastReport = elapsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await dst.FlushAsync(ct).ConfigureAwait(false);
|
|
||||||
progress?.Report(new DownloadProgress(read, total, 0, TimeSpan.Zero));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérif taille
|
|
||||||
var actualSize = new FileInfo(partial).Length;
|
|
||||||
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
|
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
|
||||||
{
|
{
|
||||||
File.Delete(partial);
|
_stateStore.Discard(job.Version);
|
||||||
throw new InvalidDataException(
|
throw new InvalidDataException(
|
||||||
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
|
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif SHA-256
|
// 5. Vérif SHA-256
|
||||||
if (!string.IsNullOrEmpty(job.ExpectedSha256) && !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrEmpty(job.ExpectedSha256) &&
|
||||||
|
!job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Verifying SHA-256...");
|
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
|
||||||
var sha = await _integrity.ComputeSha256Async(partial, null, ct).ConfigureAwait(false);
|
var sha = await _integrity.ComputeSha256Async(partialPath, null, ct).ConfigureAwait(false);
|
||||||
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
File.Delete(partial);
|
_stateStore.Discard(job.Version);
|
||||||
throw new InvalidDataException(
|
throw new InvalidDataException(
|
||||||
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
|
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée (à corriger côté serveur)");
|
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File.Exists(final)) File.Delete(final);
|
if (File.Exists(finalPath)) File.Delete(finalPath);
|
||||||
File.Move(partial, final);
|
File.Move(partialPath, finalPath);
|
||||||
return final;
|
_stateStore.Discard(job.Version); // efface aussi le state.json puisqu'on a fini
|
||||||
|
return finalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadChunkAsync(
|
||||||
|
DownloadJob job,
|
||||||
|
DownloadState state,
|
||||||
|
string partialPath,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
|
||||||
|
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return; // déjà tout là
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
|
||||||
|
if (resumeFrom > 0)
|
||||||
|
{
|
||||||
|
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
|
||||||
|
// If-Range : si l'ETag du serveur a changé entre temps, on veut recommencer proprement
|
||||||
|
if (!string.IsNullOrEmpty(state.Etag))
|
||||||
|
{
|
||||||
|
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(state.LastModified) &&
|
||||||
|
DateTimeOffset.TryParse(state.LastModified, out var lm))
|
||||||
|
{
|
||||||
|
req.Headers.IfRange = new RangeConditionHeaderValue(lm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
|
||||||
|
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
// Le serveur a renvoyé 200 alors qu'on demandait du Range : la ressource a changé.
|
||||||
|
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
|
||||||
|
resp.Dispose();
|
||||||
|
File.Delete(partialPath);
|
||||||
|
_stateStore.Discard(job.Version);
|
||||||
|
// On retry sera traité par Polly via une exception
|
||||||
|
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5xx / 408 / 429 → exception transitoire pour Polly
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}",
|
||||||
|
isTransient: transient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture ETag et Last-Modified du serveur si présents (pour les futures reprises)
|
||||||
|
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
|
||||||
|
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
|
||||||
|
|
||||||
|
// Total : si le serveur ne fournit pas Content-Length, on garde la valeur du manifest
|
||||||
|
var contentLength = resp.Content.Headers.ContentLength ?? 0;
|
||||||
|
var total = state.TotalBytes;
|
||||||
|
if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
|
||||||
|
total = cl;
|
||||||
|
else if (resp.StatusCode == HttpStatusCode.OK && contentLength > 0)
|
||||||
|
total = contentLength;
|
||||||
|
if (total > 0) state.TotalBytes = total;
|
||||||
|
|
||||||
|
// Si on est sur un 200 alors qu'on partait de 0, on écrit en append (FileMode.Append depuis position 0)
|
||||||
|
// Si on est sur un 206 avec resumeFrom>0, on append.
|
||||||
|
var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
|
||||||
|
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||||
|
await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
|
||||||
|
|
||||||
|
var buffer = new byte[BufferSize];
|
||||||
|
long read = resumeFrom;
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var lastReport = sw.Elapsed;
|
||||||
|
long lastReportBytes = read;
|
||||||
|
var lastFlush = sw.Elapsed;
|
||||||
|
long lastFlushBytes = read;
|
||||||
|
|
||||||
|
int n;
|
||||||
|
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
||||||
|
{
|
||||||
|
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||||
|
read += n;
|
||||||
|
state.DownloadedBytes = read;
|
||||||
|
state.LastChunkAtUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var elapsed = sw.Elapsed;
|
||||||
|
|
||||||
|
// Progression UI
|
||||||
|
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
||||||
|
{
|
||||||
|
var deltaBytes = read - lastReportBytes;
|
||||||
|
var deltaSeconds = (elapsed - lastReport).TotalSeconds;
|
||||||
|
var bps = deltaSeconds > 0 ? deltaBytes / deltaSeconds : 0;
|
||||||
|
TimeSpan? eta = null;
|
||||||
|
if (bps > 0 && state.TotalBytes > 0) eta = TimeSpan.FromSeconds((state.TotalBytes - read) / bps);
|
||||||
|
progress?.Report(new DownloadProgress(read, state.TotalBytes, bps, eta));
|
||||||
|
lastReport = elapsed;
|
||||||
|
lastReportBytes = read;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist state.json toutes les 5s ou tous les 100 Mo
|
||||||
|
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
||||||
|
read - lastFlushBytes >= StateFlushBytesInterval)
|
||||||
|
{
|
||||||
|
await dst.FlushAsync(ct).ConfigureAwait(false);
|
||||||
|
_stateStore.Save(state);
|
||||||
|
lastFlush = elapsed;
|
||||||
|
lastFlushBytes = read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dst.FlushAsync(ct).ConfigureAwait(false);
|
||||||
|
_stateStore.Save(state);
|
||||||
|
progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
|
||||||
|
internal sealed class HttpResumableException : Exception
|
||||||
|
{
|
||||||
|
public bool IsTransient { get; }
|
||||||
|
public HttpResumableException(string message, bool isTransient) : base(message)
|
||||||
|
{
|
||||||
|
IsTransient = isTransient;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
src/PSLauncher.Core/Downloads/DownloadStateStore.cs
Normal file
111
src/PSLauncher.Core/Downloads/DownloadStateStore.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.Configuration;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Downloads;
|
||||||
|
|
||||||
|
public interface IDownloadStateStore
|
||||||
|
{
|
||||||
|
string GetDownloadsDirectory();
|
||||||
|
string GetPartialPath(string version);
|
||||||
|
string GetStatePath(string version);
|
||||||
|
DownloadState? Load(string version);
|
||||||
|
void Save(DownloadState state);
|
||||||
|
void Discard(string version);
|
||||||
|
IReadOnlyList<DownloadState> ScanResumable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DownloadStateStore : IDownloadStateStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IConfigStore _configStore;
|
||||||
|
private readonly ILogger<DownloadStateStore> _logger;
|
||||||
|
|
||||||
|
public DownloadStateStore(IConfigStore configStore, ILogger<DownloadStateStore> logger)
|
||||||
|
{
|
||||||
|
_configStore = configStore;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetDownloadsDirectory()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(_configStore.ConfigDirectory, "downloads");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetPartialPath(string version) =>
|
||||||
|
Path.Combine(GetDownloadsDirectory(), $"proserve-{version}.zip.partial");
|
||||||
|
|
||||||
|
public string GetStatePath(string version) =>
|
||||||
|
Path.Combine(GetDownloadsDirectory(), $"proserve-{version}.state.json");
|
||||||
|
|
||||||
|
public DownloadState? Load(string version)
|
||||||
|
{
|
||||||
|
var path = GetStatePath(version);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
return JsonSerializer.Deserialize<DownloadState>(fs, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to load state file {Path}", path);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(DownloadState state)
|
||||||
|
{
|
||||||
|
var path = GetStatePath(state.Version);
|
||||||
|
var tmp = path + ".tmp";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var fs = File.Create(tmp))
|
||||||
|
{
|
||||||
|
JsonSerializer.Serialize(fs, state, JsonOptions);
|
||||||
|
}
|
||||||
|
File.Move(tmp, path, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to persist state for {Version}", state.Version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Discard(string version)
|
||||||
|
{
|
||||||
|
TryDelete(GetStatePath(version));
|
||||||
|
TryDelete(GetPartialPath(version));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<DownloadState> ScanResumable()
|
||||||
|
{
|
||||||
|
var dir = GetDownloadsDirectory();
|
||||||
|
var states = new List<DownloadState>();
|
||||||
|
foreach (var f in Directory.EnumerateFiles(dir, "*.state.json"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = File.OpenRead(f);
|
||||||
|
var s = JsonSerializer.Deserialize<DownloadState>(fs, JsonOptions);
|
||||||
|
if (s is not null && File.Exists(s.PartialPath)) states.Add(s);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Skip {File}", f); }
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ public interface IDownloadManager
|
|||||||
DownloadJob job,
|
DownloadJob job,
|
||||||
IProgress<DownloadProgress>? progress,
|
IProgress<DownloadProgress>? progress,
|
||||||
CancellationToken ct);
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Retourne l'état de DL persistant pour cette version, ou null s'il n'y en a pas.</summary>
|
||||||
|
DownloadState? GetResumableState(string version);
|
||||||
|
|
||||||
|
/// <summary>Supprime l'état persistant et le fichier partiel associé.</summary>
|
||||||
|
void DiscardResumableState(string version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record DownloadJob(
|
public sealed record DownloadJob(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Polly" Version="8.4.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
20
src/PSLauncher.Models/DownloadState.cs
Normal file
20
src/PSLauncher.Models/DownloadState.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace PSLauncher.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// État persistant d'un téléchargement en cours, écrit régulièrement sur disque
|
||||||
|
/// pour permettre la reprise si le launcher est fermé / crashe pendant le DL.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DownloadState
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; set; } = 1;
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
public long TotalBytes { get; set; }
|
||||||
|
public long DownloadedBytes { get; set; }
|
||||||
|
public string ExpectedSha256 { get; set; } = string.Empty;
|
||||||
|
public string? Etag { get; set; }
|
||||||
|
public string? LastModified { get; set; }
|
||||||
|
public string PartialPath { get; set; } = string.Empty;
|
||||||
|
public DateTime StartedAtUtc { get; set; }
|
||||||
|
public DateTime LastChunkAtUtc { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user