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:
11
src/PSLauncher.Core/Installations/IInstallationRegistry.cs
Normal file
11
src/PSLauncher.Core/Installations/IInstallationRegistry.cs
Normal 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);
|
||||
}
|
||||
12
src/PSLauncher.Core/Installations/IZipInstaller.cs
Normal file
12
src/PSLauncher.Core/Installations/IZipInstaller.cs
Normal 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);
|
||||
99
src/PSLauncher.Core/Installations/InstallationRegistry.cs
Normal file
99
src/PSLauncher.Core/Installations/InstallationRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/PSLauncher.Core/Installations/SemVer.cs
Normal file
28
src/PSLauncher.Core/Installations/SemVer.cs
Normal 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}";
|
||||
}
|
||||
99
src/PSLauncher.Core/Installations/ZipInstaller.cs
Normal file
99
src/PSLauncher.Core/Installations/ZipInstaller.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user