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:
2026-05-01 08:54:45 +02:00
commit 1c8c6803e8
48 changed files with 2269 additions and 0 deletions

View 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;
}
}