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,27 @@
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
public sealed class InstalledVersionViewModel
{
public InstalledVersion Model { get; }
public InstalledVersionViewModel(InstalledVersion model) => Model = model;
public string Display => $"v{Model.Version}";
public string Subtitle =>
$"{FormatSize(Model.SizeBytes)} • Installée le {Model.InstalledAt.ToLocalTime():dd/MM/yyyy}";
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
public override string ToString() => Display;
}

View File

@@ -0,0 +1,359 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using PSLauncher.App.Views;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.Process;
using PSLauncher.Core.Updates;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
public sealed partial class MainViewModel : ObservableObject
{
private readonly IInstallationRegistry _registry;
private readonly IProcessLauncher _processLauncher;
private readonly IConfigStore _configStore;
private readonly LocalConfig _config;
private readonly IManifestService _manifestService;
private readonly IUpdateChecker _updateChecker;
private readonly IDownloadManager _downloadManager;
private readonly IZipInstaller _zipInstaller;
private readonly ILogger<MainViewModel> _logger;
private CancellationTokenSource? _downloadCts;
public ObservableCollection<InstalledVersionViewModel> InstalledVersions { get; } = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusBadge))]
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
[NotifyPropertyChangedFor(nameof(HeroTitle))]
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
private InstalledVersionViewModel? _selectedVersion;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusBadge))]
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
private VersionManifest? _availableUpdate;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
private string? _statusMessage;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
[NotifyPropertyChangedFor(nameof(StatusBadge))]
[NotifyPropertyChangedFor(nameof(PrimaryActionLabel))]
[NotifyPropertyChangedFor(nameof(PrimaryActionEnabled))]
[NotifyCanExecuteChangedFor(nameof(PrimaryActionCommand))]
[NotifyCanExecuteChangedFor(nameof(CheckForUpdatesCommand))]
private bool _isBusy;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
private double _progressPercent;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
private string? _progressDetail;
public string StatusBadge
{
get
{
if (IsBusy) return "● En cours";
if (AvailableUpdate is not null) return "🔔 MAJ disponible";
if (SelectedVersion is null) return "Aucune version";
return "● Installée";
}
}
public string PrimaryActionLabel
{
get
{
if (IsBusy) return "Veuillez patienter…";
if (AvailableUpdate is not null && SelectedVersion is null)
return $"⬇ INSTALLER v{AvailableUpdate.Version}";
if (AvailableUpdate is not null)
return $"⬇ TÉLÉCHARGER v{AvailableUpdate.Version}";
return SelectedVersion is null ? "Aucune version disponible" : "▶ LANCER";
}
}
public bool PrimaryActionEnabled => !IsBusy && (SelectedVersion is not null || AvailableUpdate is not null);
public string HeroTitle
{
get
{
if (AvailableUpdate is not null) return $"PROSERVE — v{AvailableUpdate.Version}";
return SelectedVersion is null ? "PROSERVE" : $"PROSERVE — {SelectedVersion.Display}";
}
}
public string LicenseSummary => "License : non configurée (v0.4)";
public string EmptyHint =>
$"Aucune version trouvée dans :\n{_config.InstallRoot}\n\n" +
"Configure l'URL serveur dans config.json puis clique « Vérifier les mises à jour », " +
"ou place un dossier « Proserve v{X.Y.Z} » contenant PROSERVE_UE_5_5.exe à cet emplacement.";
public Visibility EmptyHintVisibility => InstalledVersions.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
public Visibility FooterVisibility => IsBusy || !string.IsNullOrEmpty(StatusMessage) ? Visibility.Visible : Visibility.Collapsed;
public string FooterText
{
get
{
if (!string.IsNullOrEmpty(ProgressDetail)) return ProgressDetail;
return StatusMessage ?? string.Empty;
}
}
public MainViewModel(
IInstallationRegistry registry,
IProcessLauncher processLauncher,
IConfigStore configStore,
LocalConfig config,
IManifestService manifestService,
IUpdateChecker updateChecker,
IDownloadManager downloadManager,
IZipInstaller zipInstaller,
ILogger<MainViewModel> logger)
{
_registry = registry;
_processLauncher = processLauncher;
_configStore = configStore;
_config = config;
_manifestService = manifestService;
_updateChecker = updateChecker;
_downloadManager = downloadManager;
_zipInstaller = zipInstaller;
_logger = logger;
Refresh();
}
[RelayCommand]
private void Refresh()
{
InstalledVersions.Clear();
foreach (var v in _registry.Scan())
InstalledVersions.Add(new InstalledVersionViewModel(v));
SelectedVersion = !string.IsNullOrEmpty(_config.LastLaunchedVersion)
? InstalledVersions.FirstOrDefault(v => v.Model.Version == _config.LastLaunchedVersion)
?? InstalledVersions.FirstOrDefault()
: InstalledVersions.FirstOrDefault();
OnPropertyChanged(nameof(EmptyHint));
OnPropertyChanged(nameof(EmptyHintVisibility));
}
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
private async Task CheckForUpdatesAsync()
{
IsBusy = true;
StatusMessage = "Vérification des mises à jour…";
try
{
var result = await _updateChecker.CheckAsync(CancellationToken.None);
if (result.Error is not null)
{
StatusMessage = $"Erreur : {result.Error}";
AvailableUpdate = null;
return;
}
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
{
AvailableUpdate = result.LatestRemote;
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
}
else
{
AvailableUpdate = null;
StatusMessage = result.LatestRemote is not null
? $"À jour (v{result.LatestRemote.Version})"
: "Aucune version distante trouvée";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Update check failed");
StatusMessage = $"Erreur : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private bool CanCheckUpdates() => !IsBusy;
[RelayCommand(CanExecute = nameof(PrimaryActionEnabled))]
private async Task PrimaryActionAsync()
{
if (AvailableUpdate is not null)
{
await DownloadAndInstallAsync(AvailableUpdate);
return;
}
if (SelectedVersion is null) return;
try
{
_processLauncher.Launch(SelectedVersion.Model);
_config.LastLaunchedVersion = SelectedVersion.Model.Version;
_configStore.Save(_config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to launch");
MessageBox.Show(
$"Impossible de lancer la version :\n\n{ex.Message}",
"Erreur de lancement",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async Task DownloadAndInstallAsync(VersionManifest version)
{
// 1) Récupère release notes et propose dialog
string notes;
try
{
notes = !string.IsNullOrEmpty(version.ReleaseNotesUrl)
? await _manifestService.FetchReleaseNotesAsync(version.ReleaseNotesUrl, CancellationToken.None)
: "_Aucune release note fournie._";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch release notes");
notes = "_Release notes indisponibles._";
}
var dialog = new UpdateAvailableDialog(version, notes)
{
Owner = Application.Current.MainWindow
};
dialog.ShowDialog();
if (!dialog.DownloadRequested) return;
IsBusy = true;
_downloadCts = new CancellationTokenSource();
try
{
var url = new Uri(version.Download.Url);
var job = new DownloadJob(version.Version, url, version.Download.SizeBytes, version.Download.Sha256);
var progress = new Progress<DownloadProgress>(p => UpdateDlProgress(version.Version, p));
StatusMessage = $"Téléchargement v{version.Version}…";
var zipPath = await _downloadManager.DownloadAsync(job, progress, _downloadCts.Token);
// 2) Extraction
StatusMessage = $"Installation v{version.Version}…";
var folderName = version.GetInstallFolderName();
var target = Path.Combine(_config.InstallRoot, folderName);
var installProgress = new Progress<InstallProgress>(ip =>
{
if (ip.BytesTotal <= 0) return;
ProgressPercent = (double)ip.BytesDone / ip.BytesTotal * 100.0;
ProgressDetail = $"Extraction : {ip.EntriesDone}/{ip.EntriesTotal} fichiers ({FormatSize(ip.BytesDone)} / {FormatSize(ip.BytesTotal)})";
});
await _zipInstaller.InstallAsync(zipPath, target, installProgress, _downloadCts.Token);
// 3) Nettoyage du ZIP téléchargé (le user peut le rétablir depuis le serveur s'il en a besoin)
try { File.Delete(zipPath); } catch { /* non critique */ }
StatusMessage = $"v{version.Version} installée avec succès";
ProgressDetail = null;
ProgressPercent = 0;
AvailableUpdate = null;
Refresh();
}
catch (OperationCanceledException)
{
StatusMessage = "Téléchargement annulé";
}
catch (Exception ex)
{
_logger.LogError(ex, "Download/install failed");
MessageBox.Show(
$"Le téléchargement ou l'installation a échoué :\n\n{ex.Message}",
"Erreur",
MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
}
finally
{
_downloadCts?.Dispose();
_downloadCts = null;
IsBusy = false;
}
}
private void UpdateDlProgress(string version, DownloadProgress p)
{
if (p.TotalBytes > 0)
ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
var sb = new System.Text.StringBuilder();
sb.Append("⬇ v").Append(version).Append(" : ");
sb.Append(FormatSize(p.BytesDownloaded));
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
if (p.Eta is { } eta && eta.TotalSeconds > 1) sb.Append(" • ETA ").Append(FormatEta(eta));
ProgressDetail = sb.ToString();
}
[RelayCommand]
private void OpenFolder()
{
var path = SelectedVersion?.Model.FolderPath ?? _config.InstallRoot;
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) return;
Process.Start(new ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
Verb = "open"
});
}
[RelayCommand]
private void CancelDownload()
{
_downloadCts?.Cancel();
}
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "0 o";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
private static string FormatEta(TimeSpan eta)
{
if (eta.TotalHours >= 1) return $"{(int)eta.TotalHours}h{eta.Minutes:D2}";
if (eta.TotalMinutes >= 1) return $"{(int)eta.TotalMinutes}m{eta.Seconds:D2}";
return $"{(int)eta.TotalSeconds}s";
}
}