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,11 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Installations;
public interface IInstallationRegistry
{
IReadOnlyList<InstalledVersion> Scan();
InstalledVersion? Get(string version);
bool IsInstalled(string version);
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
}

View File

@@ -0,0 +1,12 @@
namespace PSLauncher.Core.Installations;
public interface IZipInstaller
{
Task<string> InstallAsync(
string zipPath,
string targetFolder,
IProgress<InstallProgress>? progress,
CancellationToken ct);
}
public sealed record InstallProgress(long EntriesDone, long EntriesTotal, long BytesDone, long BytesTotal, string CurrentEntry);

View File

@@ -0,0 +1,99 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Installations;
public sealed partial class InstallationRegistry : IInstallationRegistry
{
private const string ExecutableName = "PROSERVE_UE_5_5.exe";
[GeneratedRegex(@"^Proserve v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
private static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger;
private readonly Func<string> _installRootProvider;
public InstallationRegistry(ILogger<InstallationRegistry> logger, Func<string> installRootProvider)
{
_logger = logger;
_installRootProvider = installRootProvider;
}
public IReadOnlyList<InstalledVersion> Scan()
{
var root = _installRootProvider();
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
_logger.LogWarning("Install root does not exist: {Root}", root);
return Array.Empty<InstalledVersion>();
}
var results = new List<InstalledVersion>();
foreach (var dir in Directory.EnumerateDirectories(root))
{
var name = Path.GetFileName(dir);
var match = VersionFolderRegex().Match(name);
if (!match.Success) continue;
var exe = Path.Combine(dir, ExecutableName);
if (!File.Exists(exe))
{
_logger.LogDebug("Skipped {Dir}: missing {Exe}", dir, ExecutableName);
continue;
}
var info = new DirectoryInfo(dir);
results.Add(new InstalledVersion(
Version: match.Groups["v"].Value,
FolderPath: dir,
ExecutablePath: exe,
InstalledAt: info.CreationTimeUtc,
SizeBytes: SafeDirectorySize(dir)));
}
return results
.OrderByDescending(v => SemVer.Parse(v.Version))
.ToList();
}
public InstalledVersion? Get(string version) =>
Scan().FirstOrDefault(v => v.Version == version);
public bool IsInstalled(string version) => Get(version) is not null;
public Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct)
{
var existing = Get(version) ?? throw new InvalidOperationException($"Version {version} not installed");
return Task.Run(() =>
{
var trash = Path.Combine(_installRootProvider(), ".trash",
$"{Path.GetFileName(existing.FolderPath)}-{DateTime.UtcNow:yyyyMMddHHmmss}");
Directory.CreateDirectory(Path.GetDirectoryName(trash)!);
Directory.Move(existing.FolderPath, trash);
try
{
Directory.Delete(trash, recursive: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete trash {Path}", trash);
}
progress?.Report(1.0);
}, ct);
}
private long SafeDirectorySize(string dir)
{
try
{
return new DirectoryInfo(dir)
.EnumerateFiles("*", SearchOption.AllDirectories)
.Sum(f => f.Length);
}
catch
{
return 0;
}
}
}

View File

@@ -0,0 +1,28 @@
namespace PSLauncher.Core.Installations;
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
{
public static SemVer Parse(string s)
{
var parts = s.Split('.');
if (parts.Length != 3
|| !int.TryParse(parts[0], out var maj)
|| !int.TryParse(parts[1], out var min)
|| !int.TryParse(parts[2], out var pat))
{
return new SemVer(0, 0, 0);
}
return new SemVer(maj, min, pat);
}
public int CompareTo(SemVer other)
{
var c = Major.CompareTo(other.Major);
if (c != 0) return c;
c = Minor.CompareTo(other.Minor);
if (c != 0) return c;
return Patch.CompareTo(other.Patch);
}
public override string ToString() => $"{Major}.{Minor}.{Patch}";
}

View File

@@ -0,0 +1,99 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
namespace PSLauncher.Core.Installations;
public sealed class ZipInstaller : IZipInstaller
{
private readonly ILogger<ZipInstaller> _logger;
public ZipInstaller(ILogger<ZipInstaller> logger) => _logger = logger;
public async Task<string> InstallAsync(
string zipPath,
string targetFolder,
IProgress<InstallProgress>? progress,
CancellationToken ct)
{
if (!File.Exists(zipPath))
throw new FileNotFoundException("ZIP not found", zipPath);
var stagingFolder = targetFolder + ".tmp";
if (Directory.Exists(stagingFolder)) Directory.Delete(stagingFolder, recursive: true);
Directory.CreateDirectory(stagingFolder);
try
{
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
if (Directory.Exists(targetFolder))
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
Directory.Move(stagingFolder, targetFolder);
_logger.LogInformation("Installed to {Target}", targetFolder);
return targetFolder;
}
catch
{
// Nettoie le staging si échec
try { if (Directory.Exists(stagingFolder)) Directory.Delete(stagingFolder, recursive: true); } catch { }
throw;
}
}
private static void ExtractZip(string zipPath, string stagingFolder, IProgress<InstallProgress>? progress, CancellationToken ct)
{
using var archive = ZipFile.OpenRead(zipPath);
long totalBytes = 0;
foreach (var e in archive.Entries) totalBytes += e.Length;
long total = archive.Entries.Count;
long doneEntries = 0;
long doneBytes = 0;
// Détecte si toutes les entrées partagent un préfixe racine commun (ex: "Proserve v1.4.6/")
// → on strip ce préfixe pour aplatir l'arbo dans stagingFolder.
var rootPrefix = DetectCommonRootPrefix(archive);
foreach (var entry in archive.Entries)
{
ct.ThrowIfCancellationRequested();
var relative = entry.FullName;
if (rootPrefix is not null && relative.StartsWith(rootPrefix, StringComparison.Ordinal))
relative = relative[rootPrefix.Length..];
if (string.IsNullOrEmpty(relative)) { doneEntries++; continue; }
var target = Path.GetFullPath(Path.Combine(stagingFolder, relative));
if (!target.StartsWith(Path.GetFullPath(stagingFolder), StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"Entrée ZIP suspecte (zip-slip) : {entry.FullName}");
if (relative.EndsWith('/') || relative.EndsWith('\\'))
{
Directory.CreateDirectory(target);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
entry.ExtractToFile(target, overwrite: true);
doneBytes += entry.Length;
}
doneEntries++;
progress?.Report(new InstallProgress(doneEntries, total, doneBytes, totalBytes, relative));
}
}
private static string? DetectCommonRootPrefix(ZipArchive archive)
{
string? prefix = null;
foreach (var e in archive.Entries)
{
var slash = e.FullName.IndexOf('/');
if (slash <= 0) return null;
var first = e.FullName[..(slash + 1)];
if (prefix is null) prefix = first;
else if (prefix != first) return null;
}
return prefix;
}
}