v0.6 + v1.0: HMAC download URLs, launcher self-update, Inno Setup installer

Three deliverables shipped together so the next deployment cycle has a
clean distribution story.

(1) Auto-update of the launcher itself
---------------------------------------
- Models: RemoteManifest gains an optional `launcher` section
  (LauncherInfo: version, minRequired, download {url,size,sha256},
   releaseNotesUrl). Server-side, sign-manifest.php passes it through
  unchanged; admins edit versions.json with the new launcher entry +
  upload PSLauncher-X.Y.Z.exe to /builds/launcher/.
- Core: ILauncherSelfUpdater compares assembly version against
  manifest.launcher.version using the existing SemVer parser, and
  reuses DownloadManager (Range/resume/sha256 already proven on the
  game ZIPs) to download the new exe into
  %LocalAppData%/PSLauncher/selfupdate/.
- New project PSLauncher.Updater (~34 MB self-contained console exe):
  spawned by the main app with --target / --source / --pid / --launch.
  Waits for the main process to exit (or for the file lock to release),
  backs up the current exe to .bak, copies the new file in place, and
  restarts. .bak survives the swap so the user can roll back manually.
- App.csproj now declares Version=0.5.0 — currently shipped baseline.
  PSLauncher.App.csproj sets a fixed AssemblyVersion so reflection-based
  comparison works deterministically.
- MainViewModel.PromptLauncherUpdate: dialog after CheckForUpdates if
  the manifest advertises a newer launcher. Download with progress in
  the existing footer, then Application.Shutdown() so the Updater can
  do its job.

(2) Inno Setup installer
------------------------
installer/PSLauncher.iss + build-installer.ps1 produce a single
PSLauncher-Setup-X.Y.Z.exe (~80 MB) that installs into
Program Files\ASTERION VR\PSLauncher\, drops both PSLauncher.exe and
PSLauncher.Updater.exe side by side (the updater MUST live next to
the target), creates Start Menu + optional Desktop shortcuts, and
registers a clean uninstall entry. The user's %LocalAppData%
(license, logs, cache) is intentionally untouched on uninstall — same
license survives a reinstall.

build-installer.ps1 chains dotnet publish for both projects and ISCC
in one command. README explains the bump-version workflow.

(3) HMAC-signed download URLs
-----------------------------
- New PHP route GET /api/download-url/{version} (Authorization: Bearer
  <licenseKey> or ?key=...). Validates the license, checks
  download_entitlement_until >= minLicenseDate of the version, and
  returns a HMAC-signed URL (path|exp|licId, hash_hmac SHA-256, valid
  1 h) + sha256 + sizeBytes for verification.
- /builds/.htaccess routes every *.zip request to gate.php. gate.php
  validates exp, lic, sig (constant-time hash_equals), then streams
  the file with Range: support so the launcher's resume keeps working.
  Audit log gets a download_url_issued entry per request.
- Client-side wired transparently: LicenseService gains
  GetSignedDownloadUrlAsync(version) that GETs the endpoint with the
  decrypted license key from DPAPI. MainViewModel calls it before
  every download; if the endpoint returns 404/401/network-error, the
  client falls back to the manifest's plain download.url (graceful
  degradation for setups that haven't deployed gate.php yet).

Note on PHP streaming for 14 GB ZIPs: gate.php uses set_time_limit(0)
+ ignore_user_abort(true) + 1 MiB chunked fread with periodic flush.
Works on OVH mutualisé but holds a PHP-FPM slot for the duration. If
parallel downloads scale past a few clients, switch to
mod_xsendfile or migrate /builds/ to Cloudflare R2 with native
S3-presigned URLs and remove the gate entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 09:38:13 +02:00
parent b10a3fbabf
commit b7de228bc9
20 changed files with 798 additions and 2 deletions

View File

@@ -30,6 +30,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IDownloadManager _downloadManager;
private readonly IZipInstaller _zipInstaller;
private readonly ILicenseService _licenseService;
private readonly ILauncherSelfUpdater _selfUpdater;
private readonly IToastService _toastService;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MainViewModel> _logger;
@@ -158,6 +159,7 @@ public sealed partial class MainViewModel : ObservableObject
IDownloadManager downloadManager,
IZipInstaller zipInstaller,
ILicenseService licenseService,
ILauncherSelfUpdater selfUpdater,
IToastService toastService,
IServiceProvider serviceProvider,
ILogger<MainViewModel> logger)
@@ -171,6 +173,7 @@ public sealed partial class MainViewModel : ObservableObject
_downloadManager = downloadManager;
_zipInstaller = zipInstaller;
_licenseService = licenseService;
_selfUpdater = selfUpdater;
_toastService = toastService;
_serviceProvider = serviceProvider;
_logger = logger;
@@ -282,6 +285,13 @@ public sealed partial class MainViewModel : ObservableObject
_lastManifest = result.Manifest;
RebuildList();
// Détection MAJ du launcher lui-même (présent en `manifest.launcher`).
// Lancée en arrière-plan pour ne pas bloquer l'affichage des résultats produit.
if (result.Manifest?.Launcher is { } launcher && _selfUpdater.IsNewerThanCurrent(launcher))
{
_ = Application.Current.Dispatcher.BeginInvoke(() => PromptLauncherUpdate(launcher));
}
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
else if (result.LatestRemote is not null)
@@ -301,6 +311,40 @@ public sealed partial class MainViewModel : ObservableObject
}
private bool CanCheckUpdates() => !IsBusy;
private async void PromptLauncherUpdate(LauncherInfo launcher)
{
try
{
var dialog = new Views.LauncherUpdateDialog(launcher, _selfUpdater.GetCurrentVersion())
{
Owner = Application.Current.MainWindow
};
dialog.ShowDialog();
if (!dialog.UpdateRequested) return;
IsBusy = true;
StatusMessage = "Téléchargement du nouveau launcher…";
var progress = new Progress<DownloadProgress>(p =>
{
if (p.TotalBytes > 0) ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
ProgressDetail = $"⬇ Launcher v{launcher.Version} : {FormatSize(p.BytesDownloaded)} / {FormatSize(p.TotalBytes)}";
});
await _selfUpdater.DownloadAndApplyAsync(launcher, progress, CancellationToken.None);
StatusMessage = "Mise à jour du launcher en cours… le launcher va se relancer.";
await Task.Delay(800); // laisse le temps au toast / message de s'afficher
Application.Current.Shutdown();
}
catch (Exception ex)
{
_logger.LogError(ex, "Self-update failed");
MessageBox.Show($"Mise à jour du launcher échouée :\n\n{ex.Message}",
"Erreur", MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur self-update : {ex.Message}";
IsBusy = false;
}
}
[RelayCommand]
private void OpenSettings()
{
@@ -427,9 +471,13 @@ public sealed partial class MainViewModel : ObservableObject
if (!dialog.DownloadRequested) return;
}
// 3) Download
// 3) Download — tente d'abord d'obtenir une URL HMAC-signée par le serveur
// (endpoint /api/download-url/{version}). Fallback transparent sur l'URL
// publique du manifest si le serveur n'a pas le endpoint configuré.
row.State = VersionRowState.Downloading;
var url = new Uri(row.Remote.Download.Url);
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
var urlString = signed ?? row.Remote.Download.Url;
var url = new Uri(urlString);
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256);
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
StatusMessage = $"Téléchargement v{row.Version}…";