Initial scaffolding: PS_Launcher v0.2
Client (C# / .NET 8 / WPF, MVVM via CommunityToolkit.Mvvm):
- PSLauncher.App: WPF UI dark theme (Epic-style sidebar + hero + big play button)
with UpdateAvailableDialog rendering Markdown release notes via Markdig.Wpf.
- PSLauncher.Core: services for installation registry (scans Proserve v{X.Y.Z}/),
process launcher, manifest fetch, SHA-256 integrity, HTTP download with
progress, ZIP install via .tmp + atomic rename, update orchestrator.
- PSLauncher.Models: RemoteManifest, InstalledVersion, LocalConfig DTOs.
Server (PHP 8 for OVH mutualisé, deployed under www/PS_Launcher/):
- Front controller + routes /manifest and /releasenotes/{version}.
- Static signed-manifest workflow with tools/sign-manifest.php CLI to
recompute SHA-256 and sizeBytes after each ZIP upload.
- .htaccess: HTTPS redirect, rewrite, security headers.
- config.example.php template; real config.php is gitignored.
Cohabiting versions: each release lives in its own Proserve v{version}/ folder
under installRoot. Old versions are never deleted automatically.
Roadmap: v0.3 = HTTP Range resume + Polly retry + state.json,
v0.4 = MySQL license + Ed25519 signatures + DPAPI, v0.5 = settings/UX polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
126
src/PSLauncher.Core/Downloads/DownloadManager.cs
Normal file
126
src/PSLauncher.Core/Downloads/DownloadManager.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.Integrity;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Downloads;
|
||||
|
||||
public sealed class DownloadManager : IDownloadManager
|
||||
{
|
||||
private const int BufferSize = 1 << 20; // 1 MiB
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly IIntegrityService _integrity;
|
||||
private readonly ILogger<DownloadManager> _logger;
|
||||
|
||||
public DownloadManager(
|
||||
HttpClient http,
|
||||
IConfigStore configStore,
|
||||
IIntegrityService integrity,
|
||||
ILogger<DownloadManager> logger)
|
||||
{
|
||||
_http = http;
|
||||
_configStore = configStore;
|
||||
_integrity = integrity;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<string> DownloadAsync(
|
||||
DownloadJob job,
|
||||
IProgress<DownloadProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var dir = Path.Combine(_configStore.ConfigDirectory, "downloads");
|
||||
Directory.CreateDirectory(dir);
|
||||
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)
|
||||
if (job.ExpectedSize > 0)
|
||||
{
|
||||
var drive = new DriveInfo(Path.GetPathRoot(dir)!);
|
||||
var needed = (long)(job.ExpectedSize * 1.05);
|
||||
if (drive.AvailableFreeSpace < needed)
|
||||
throw new IOException(
|
||||
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
|
||||
}
|
||||
|
||||
if (File.Exists(partial)) File.Delete(partial);
|
||||
|
||||
_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];
|
||||
long read = 0;
|
||||
var sw = Stopwatch.StartNew();
|
||||
long lastReportRead = 0;
|
||||
var lastReport = sw.Elapsed;
|
||||
|
||||
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;
|
||||
|
||||
var elapsed = sw.Elapsed;
|
||||
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
||||
{
|
||||
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)
|
||||
{
|
||||
File.Delete(partial);
|
||||
throw new InvalidDataException(
|
||||
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
|
||||
}
|
||||
|
||||
// Vérif SHA-256
|
||||
if (!string.IsNullOrEmpty(job.ExpectedSha256) && !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Verifying SHA-256...");
|
||||
var sha = await _integrity.ComputeSha256Async(partial, null, ct).ConfigureAwait(false);
|
||||
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(partial);
|
||||
throw new InvalidDataException(
|
||||
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée (à corriger côté serveur)");
|
||||
}
|
||||
|
||||
if (File.Exists(final)) File.Delete(final);
|
||||
File.Move(partial, final);
|
||||
return final;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user