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

Binary file not shown.

View File

@@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Core", "src\PSLa
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Models", "src\PSLauncher.Models\PSLauncher.Models.csproj", "{A3333333-3333-3333-3333-333333333333}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Models", "src\PSLauncher.Models\PSLauncher.Models.csproj", "{A3333333-3333-3333-3333-333333333333}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Updater", "src\PSLauncher.Updater\PSLauncher.Updater.csproj", "{A4444444-4444-4444-4444-444444444444}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -26,6 +28,10 @@ Global
{A3333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU {A3333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU {A3333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU {A3333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU
{A4444444-4444-4444-4444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4444444-4444-4444-4444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

74
installer/PSLauncher.iss Normal file
View File

@@ -0,0 +1,74 @@
; Script Inno Setup pour PS_Launcher
; Compilation : ouvrir avec Inno Setup Compiler (https://jrsoftware.org/isdl.php)
; ou en ligne de commande :
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss
;
; Avant compile :
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PSLauncher.exe ~77 Mo)
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PSLauncher.Updater.exe ~10 Mo)
;
; Le script picore directement les binaires dans bin/Release/.../publish/.
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PSLauncher"
#define MyAppVersion "0.5.0"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PSLauncher.exe"
#define MyUpdaterExeName "PSLauncher.Updater.exe"
[Setup]
; AppId est UNIQUE pour PSLauncher — ne pas changer entre deux versions sinon
; chaque release créera une nouvelle entrée dans Programmes et fonctionnalités.
AppId={{8A4F3E1B-9D2C-4A6B-8F5E-3C7A1D9B4E2F}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppPublisher}\{#MyAppShortName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir=output
OutputBaseFilename=PSLauncher-Setup-{#MyAppVersion}
SetupIconFile=
Compression=lzma2/ultra
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
PrivilegesRequiredOverridesAllowed=dialog
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
VersionInfoVersion={#MyAppVersion}
VersionInfoCompany={#MyAppPublisher}
VersionInfoProductName={#MyAppName}
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[Files]
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
[UninstallDelete]
; Ne touche PAS au cache utilisateur (%LocalAppData%\PSLauncher) ni aux versions installées
; (qui peuvent vivre dans n'importe quel installRoot configuré). On supprime uniquement les
; binaires installés et le dossier d'install s'il est vide après.
Type: filesandordirs; Name: "{app}\*.bak"
Type: dirifempty; Name: "{app}"

57
installer/README.md Normal file
View File

@@ -0,0 +1,57 @@
# PS_Launcher — Installeur
Génère un setup Windows (`PSLauncher-Setup-{version}.exe`) qui distribue
le launcher principal + son updater dans `Program Files\ASTERION VR\PSLauncher\`.
## Pré-requis
- **.NET 8 SDK** (déjà installé pour build le projet)
- **Inno Setup 6.x** — https://jrsoftware.org/isdl.php (gratuit, ~6 Mo)
## Build en une commande
Depuis la racine du repo :
```powershell
powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1
```
Ça enchaîne :
1. `dotnet publish src/PSLauncher.App -c Release``PSLauncher.exe` (~77 Mo)
2. `dotnet publish src/PSLauncher.Updater -c Release``PSLauncher.Updater.exe` (~10 Mo)
3. `ISCC PSLauncher.iss``installer/output/PSLauncher-Setup-X.Y.Z.exe`
Le fichier final fait ~80 Mo (compression ultra Inno + binaires self-contained).
## Distribution
Tu balances **un seul `.exe`** au client :
- Il lance, choisit français/anglais, clique Suivant×3, c'est installé.
- Raccourci bureau + menu Démarrer créés.
- Désinstallation propre via Programmes et fonctionnalités.
Le cache utilisateur (`%LocalAppData%\PSLauncher\config.json`, logs, downloads partiels)
n'est **pas** touché par l'install/désinstall — la license et les paramètres survivent.
## Mise à jour du launcher
Le launcher se met à jour seul via `PSLauncher.Updater.exe` (cf. flow auto-update).
Le client n'a pas besoin de rejouer l'installeur sauf changement majeur (TFM, déps).
## Bumper la version
Avant de rebuild un installeur :
1. `src/PSLauncher.App/PSLauncher.App.csproj``<Version>X.Y.Z</Version>` (et AssemblyVersion / FileVersion)
2. `installer/PSLauncher.iss``#define MyAppVersion "X.Y.Z"`
3. `installer/build-installer.ps1`
Côté serveur, mets aussi à jour `manifest/versions.json` section `launcher` avec la
nouvelle version + URL de download du nouveau `PSLauncher.exe` (via le backoffice
ou édition manuelle, puis `php tools/sign-manifest.php`).
## Code-signing (optionnel)
Pour éviter le warning SmartScreen "éditeur inconnu", il faut signer
`PSLauncher.exe`, `PSLauncher.Updater.exe` et `PSLauncher-Setup-X.Y.Z.exe` avec
un certificat Authenticode (OV ~250 €/an, EV ~400 €/an, ou gratuit via SignPath
pour OSS). Hors-scope du script actuel.

View File

@@ -0,0 +1,37 @@
# Build complet : publish + Inno Setup
#
# Pré-requis :
# - .NET 8 SDK
# - Inno Setup 6.x (https://jrsoftware.org/isdl.php)
#
# Lancer depuis la racine du repo :
# powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1
param(
[string]$ISCC = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Write-Host "==> dotnet publish PSLauncher.App (Release single-file)" -ForegroundColor Cyan
dotnet publish "$root\src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.App failed" }
Write-Host "==> dotnet publish PSLauncher.Updater (Release single-file)" -ForegroundColor Cyan
dotnet publish "$root\src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.Updater failed" }
if (-not (Test-Path $ISCC)) {
throw "ISCC.exe introuvable à $ISCC. Installe Inno Setup ou passe -ISCC <chemin>."
}
Write-Host "==> Inno Setup compile" -ForegroundColor Cyan
& $ISCC "$PSScriptRoot\PSLauncher.iss"
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
Write-Host ""
Write-Host "OK - installer disponible dans: $PSScriptRoot\output\" -ForegroundColor Green
Get-ChildItem "$PSScriptRoot\output" -Filter "*.exe" | ForEach-Object {
Write-Host " $($_.Name) ($([math]::Round($_.Length / 1MB, 1)) Mo)"
}

21
pass hash.txt Normal file
View File

@@ -0,0 +1,21 @@
Mot de passe admin PS_LAuncher
Clair : Mo3zQVtd84CvGMGtxL
Hash : $2y$10$YcvuIvEea2IXS5NNqCDCSOucNacQq/lisuRHq8F0JE..CMkUuWxJe
==== KEYPAIR Ed25519 — copie ces valeurs en lieu sûr ====
private_key_hex (à mettre UNIQUEMENT dans api/config.php) :
dbcf32a2de6a097017f14689ea9cc57c5c2340d73699e5d6b2a776b18765434ca32cacfd6f3d52943c6403690fce44bab4933408f49827cb43fa0d3eda267a8f
public_key_hex (config.php ET embarqué dans le launcher) :
a32cacfd6f3d52943c6403690fce44bab4933408f49827cb43fa0d3eda267a8f
Secret 1 : hmac_secret
ret = " . bin2hex(random_bytes(32)) . PHP_EOL;'
ndom_bytes(32)) . PHP_EOL;'hmac_secret = 4343ec086ef4fafb7c0a575e56ac1b83b5eb29c1766ef3d253a75db513f84fe0
Secret2: jwt_secret
php -r 'echo "jwt_secret = " . bin2hex(random_bytes(32)) . PHP_EOL;'
jwt_secret = 3bf50765f8af638c143c137e0f1ec539db0bd761a11af602db0a5c000a3872ea

View File

@@ -60,6 +60,14 @@ if ($method === 'POST' && $route === 'license/validate') {
return; return;
} }
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
require __DIR__ . '/lib/Db.php';
require __DIR__ . '/lib/Crypto.php';
require __DIR__ . '/routes/DownloadUrl.php';
\PSLauncher\Routes\DownloadUrl::handle($config, $m[1]);
return;
}
if ($method === 'GET' && $route === 'health') { if ($method === 'GET' && $route === 'health') {
Response::json([ Response::json([
'status' => 'ok', 'status' => 'ok',

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace PSLauncher\Routes;
use PSLauncher\Crypto;
use PSLauncher\Db;
use PSLauncher\Response;
/**
* GET /api/download-url/{version}
* Header : Authorization: Bearer <licenseKey>
*
* Vérifie la license, vérifie qu'elle autorise cette version (date d'entitlement
* >= minLicenseDate), puis renvoie une URL HMAC-signée valide 1 h vers le ZIP
* dans /builds/.
*/
final class DownloadUrl
{
public static function handle(array $config, string $version): void
{
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
Response::error('invalid_version', 'Version invalide', 400);
}
// Auth via Authorization: Bearer <licenseKey>
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
// Fallback : certains hébergements masquent Authorization. On accepte aussi ?key=
$licenseKey = trim((string)($_GET['key'] ?? ''));
if ($licenseKey === '') {
Response::error('unauthorized', 'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
}
} else {
$licenseKey = trim($m[1]);
}
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, download_entitlement_until, revoked_at
FROM licenses WHERE license_key = ? LIMIT 1'
);
$stmt->execute([$licenseKey]);
$lic = $stmt->fetch();
if (!$lic) {
Response::error('invalid', 'License inconnue', 401);
}
if ($lic['revoked_at'] !== null) {
Response::error('revoked', 'License révoquée', 403);
}
// Charge le manifest pour récupérer minLicenseDate de cette version
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($manifestPath)) {
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
}
$manifest = json_decode(file_get_contents($manifestPath), true);
$entry = null;
foreach ($manifest['versions'] ?? [] as $v) {
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
}
if (!$entry) {
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
}
// Vérif droits téléchargement
$entUntil = strtotime($lic['download_entitlement_until']);
$minDate = isset($entry['minLicenseDate']) ? strtotime($entry['minLicenseDate']) : 0;
if ($minDate > 0 && $entUntil < $minDate) {
Response::error('entitlement_expired',
'Cette license n\'autorise pas cette version (date d\'expiration trop ancienne)',
403,
[
'entitlementUntil' => date(\DateTimeInterface::ATOM, $entUntil),
'minLicenseDate' => date(\DateTimeInterface::ATOM, $minDate),
]);
}
// Génère l'URL HMAC-signée
$baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip';
$exp = time() + 3600; // 1 h
$secret = $config['hmac_secret'] ?? '';
if ($secret === '') {
Response::error('config_error', 'hmac_secret non configuré', 500);
}
$sigInput = $relPath . '|' . $exp . '|' . $lic['id'];
$sig = Crypto::hmacHex($sigInput, $secret);
$signedUrl = $baseUrl . $relPath . '?exp=' . $exp . '&lic=' . $lic['id'] . '&sig=' . $sig;
// Audit
try {
$db->prepare(
'INSERT INTO audit_log (ts, license_id, ip, event, detail) VALUES (NOW(), ?, ?, ?, ?)'
)->execute([
$lic['id'],
$_SERVER['REMOTE_ADDR'] ?? null,
'download_url_issued',
json_encode(['version' => $version], JSON_UNESCAPED_UNICODE),
]);
} catch (\Throwable) { /* best-effort */ }
Response::json([
'url' => $signedUrl,
'expiresAt' => date(\DateTimeInterface::ATOM, $exp),
'sizeBytes' => $entry['download']['sizeBytes'] ?? 0,
'sha256' => $entry['download']['sha256'] ?? '',
]);
}
}

View File

@@ -97,6 +97,7 @@ public partial class App : Application
services.AddSingleton<IDownloadManager, DownloadManager>(); services.AddSingleton<IDownloadManager, DownloadManager>();
services.AddSingleton<IUpdateChecker, UpdateChecker>(); services.AddSingleton<IUpdateChecker, UpdateChecker>();
services.AddSingleton<ILauncherSelfUpdater, LauncherSelfUpdater>();
services.AddSingleton<ILicenseService>(sp => services.AddSingleton<ILicenseService>(sp =>
new LicenseService( new LicenseService(

View File

@@ -12,6 +12,9 @@
<ApplicationIcon></ApplicationIcon> <ApplicationIcon></ApplicationIcon>
<AssemblyName>PSLauncher</AssemblyName> <AssemblyName>PSLauncher</AssemblyName>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.5.0</Version>
<AssemblyVersion>0.5.0.0</AssemblyVersion>
<FileVersion>0.5.0.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) --> <!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>

View File

@@ -30,6 +30,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IDownloadManager _downloadManager; private readonly IDownloadManager _downloadManager;
private readonly IZipInstaller _zipInstaller; private readonly IZipInstaller _zipInstaller;
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly ILauncherSelfUpdater _selfUpdater;
private readonly IToastService _toastService; private readonly IToastService _toastService;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MainViewModel> _logger; private readonly ILogger<MainViewModel> _logger;
@@ -158,6 +159,7 @@ public sealed partial class MainViewModel : ObservableObject
IDownloadManager downloadManager, IDownloadManager downloadManager,
IZipInstaller zipInstaller, IZipInstaller zipInstaller,
ILicenseService licenseService, ILicenseService licenseService,
ILauncherSelfUpdater selfUpdater,
IToastService toastService, IToastService toastService,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger<MainViewModel> logger) ILogger<MainViewModel> logger)
@@ -171,6 +173,7 @@ public sealed partial class MainViewModel : ObservableObject
_downloadManager = downloadManager; _downloadManager = downloadManager;
_zipInstaller = zipInstaller; _zipInstaller = zipInstaller;
_licenseService = licenseService; _licenseService = licenseService;
_selfUpdater = selfUpdater;
_toastService = toastService; _toastService = toastService;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
@@ -282,6 +285,13 @@ public sealed partial class MainViewModel : ObservableObject
_lastManifest = result.Manifest; _lastManifest = result.Manifest;
RebuildList(); 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) if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}"; StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
else if (result.LatestRemote is not null) else if (result.LatestRemote is not null)
@@ -301,6 +311,40 @@ public sealed partial class MainViewModel : ObservableObject
} }
private bool CanCheckUpdates() => !IsBusy; 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] [RelayCommand]
private void OpenSettings() private void OpenSettings()
{ {
@@ -427,9 +471,13 @@ public sealed partial class MainViewModel : ObservableObject
if (!dialog.DownloadRequested) return; 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; 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 job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256);
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p)); var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
StatusMessage = $"Téléchargement v{row.Version}…"; StatusMessage = $"Téléchargement v{row.Version}…";

View File

@@ -0,0 +1,53 @@
<Window x:Class="PSLauncher.App.Views.LauncherUpdateDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Mise à jour du launcher"
Width="500" Height="320"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Brush.Bg.Window}">
<Grid Margin="32,28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Mise à jour du launcher disponible"
FontSize="20" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}" />
<StackPanel Grid.Row="1" Margin="0,12,0,0">
<TextBlock x:Name="VersionText"
FontSize="14"
Foreground="{StaticResource Brush.Text.Primary}" />
<TextBlock x:Name="SizeText"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" />
</StackPanel>
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="16" Margin="0,16,0,0">
<TextBlock Text="Le launcher va se mettre à jour : il téléchargera le nouveau binaire, se fermera, sera remplacé, puis se relancera automatiquement. Cela prend quelques secondes."
Foreground="{StaticResource Brush.Text.Secondary}"
TextWrapping="Wrap" />
</Border>
<StackPanel Grid.Row="3" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="Plus tard" IsCancel="True"
Margin="0,0,12,0"
Click="OnLater" />
<Button Style="{StaticResource AccentButton}"
Content="⬇ Mettre à jour"
Padding="32,8"
IsDefault="True"
Click="OnUpdate" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,41 @@
using System.Windows;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
public partial class LauncherUpdateDialog : Window
{
public bool UpdateRequested { get; private set; }
public LauncherUpdateDialog(LauncherInfo info, string currentVersion)
{
InitializeComponent();
VersionText.Text = $"v{currentVersion} → v{info.Version}";
SizeText.Text = info.Download.SizeBytes > 0
? $"Taille : {FormatSize(info.Download.SizeBytes)}"
: "";
}
private void OnUpdate(object sender, RoutedEventArgs e)
{
UpdateRequested = true;
DialogResult = true;
Close();
}
private void OnLater(object sender, RoutedEventArgs e)
{
UpdateRequested = false;
DialogResult = false;
Close();
}
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go" };
double v = bytes; int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
}

View File

@@ -11,4 +11,11 @@ public interface ILicenseService
bool HasLicense(); bool HasLicense();
string GetMachineId(); string GetMachineId();
bool CanDownloadVersion(LicenseValidationResponse? license, VersionManifest version); bool CanDownloadVersion(LicenseValidationResponse? license, VersionManifest version);
/// <summary>
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
/// </summary>
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
} }

View File

@@ -153,6 +153,33 @@ public sealed class LicenseService : ILicenseService
return license.CanDownload(version); return license.CanDownload(version);
} }
public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct)
{
var key = GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return null;
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
_logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode);
return null;
}
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Signed URL fetch failed, falling back to public URL");
return null;
}
}
public string? GetDecryptedKey() public string? GetDecryptedKey()
{ {
if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null; if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;

View File

@@ -0,0 +1,20 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Updates;
public interface ILauncherSelfUpdater
{
/// <summary>Compare la version courante avec celle annoncée dans le manifest.</summary>
bool IsNewerThanCurrent(LauncherInfo launcher);
string GetCurrentVersion();
/// <summary>
/// Télécharge le nouveau binaire dans un dossier de staging et déclenche l'updater.
/// L'application courante doit s'arrêter rapidement après le retour de cette méthode.
/// </summary>
Task<bool> DownloadAndApplyAsync(
LauncherInfo launcher,
IProgress<DownloadProgress>? progress,
CancellationToken ct);
}

View File

@@ -0,0 +1,106 @@
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Models;
using SysProcess = System.Diagnostics.Process;
namespace PSLauncher.Core.Updates;
public sealed class LauncherSelfUpdater : ILauncherSelfUpdater
{
private readonly IDownloadManager _downloadManager;
private readonly IConfigStore _configStore;
private readonly ILogger<LauncherSelfUpdater> _logger;
public LauncherSelfUpdater(
IDownloadManager downloadManager,
IConfigStore configStore,
ILogger<LauncherSelfUpdater> logger)
{
_downloadManager = downloadManager;
_configStore = configStore;
_logger = logger;
}
public string GetCurrentVersion()
{
var asm = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var v = asm.GetName().Version;
return v is null ? "0.0.0" : $"{v.Major}.{v.Minor}.{v.Build}";
}
public bool IsNewerThanCurrent(LauncherInfo launcher)
{
var current = SemVer.Parse(GetCurrentVersion());
var remote = SemVer.Parse(launcher.Version);
return remote.CompareTo(current) > 0;
}
public async Task<bool> DownloadAndApplyAsync(
LauncherInfo launcher,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
{
// 1. Localise le PSLauncher.exe en cours d'exécution
var processModule = SysProcess.GetCurrentProcess().MainModule
?? throw new InvalidOperationException("Cannot locate current process module");
var targetExe = processModule.FileName!;
var installDir = Path.GetDirectoryName(targetExe)!;
_logger.LogInformation("Self-update target: {Path}", targetExe);
// 2. Télécharge le nouveau exe dans le cache
var stagingDir = Path.Combine(_configStore.ConfigDirectory, "selfupdate");
Directory.CreateDirectory(stagingDir);
var downloadJob = new DownloadJob(
$"launcher-{launcher.Version}",
new Uri(launcher.Download.Url),
launcher.Download.SizeBytes,
launcher.Download.Sha256);
var downloadedPath = await _downloadManager.DownloadAsync(downloadJob, progress, ct).ConfigureAwait(false);
_logger.LogInformation("New launcher downloaded to {Path}", downloadedPath);
// 3. Localise PSLauncher.Updater.exe (à côté du target)
var updaterPath = Path.Combine(installDir, "PSLauncher.Updater.exe");
if (!File.Exists(updaterPath))
{
// Fallback : tente de copier l'updater depuis le dossier d'install vers un staging
// si jamais il a été supprimé. Pour la v1, on demande à l'utilisateur de réinstaller.
throw new FileNotFoundException(
"PSLauncher.Updater.exe est manquant à côté de PSLauncher.exe. " +
"Réinstalle le launcher depuis l'installeur officiel.",
updaterPath);
}
// 4. Copie le .new dans le staging dir si downloaded est ailleurs
var newPath = Path.Combine(stagingDir, $"PSLauncher-{launcher.Version}.exe");
if (!string.Equals(downloadedPath, newPath, StringComparison.OrdinalIgnoreCase))
{
if (File.Exists(newPath)) File.Delete(newPath);
File.Move(downloadedPath, newPath);
}
// 5. Lance l'updater détaché
var pid = Environment.ProcessId;
var psi = new ProcessStartInfo
{
FileName = updaterPath,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add($"--target={targetExe}");
psi.ArgumentList.Add($"--source={newPath}");
psi.ArgumentList.Add($"--pid={pid}");
psi.ArgumentList.Add("--launch");
_logger.LogInformation("Spawning updater {Updater} (target={Target}, source={Source}, pid={Pid})",
updaterPath, targetExe, newPath, pid);
SysProcess.Start(psi);
return true; // l'appelant doit Application.Current.Shutdown() après
}
}

View File

@@ -24,6 +24,24 @@ public sealed class RemoteManifest
[JsonPropertyName("signature")] [JsonPropertyName("signature")]
public string? Signature { get; set; } public string? Signature { get; set; }
[JsonPropertyName("launcher")]
public LauncherInfo? Launcher { get; set; }
}
public sealed class LauncherInfo
{
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("minRequired")]
public string? MinRequired { get; set; }
[JsonPropertyName("download")]
public DownloadDescriptor Download { get; set; } = new();
[JsonPropertyName("releaseNotesUrl")]
public string? ReleaseNotesUrl { get; set; }
} }
public sealed class VersionManifest public sealed class VersionManifest

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AssemblyName>PSLauncher.Updater</AssemblyName>
<RootNamespace>PSLauncher.Updater</RootNamespace>
<Version>0.5.0</Version>
<!-- Single-file self-contained pour le publish (~10 Mo) -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,136 @@
using System.Diagnostics;
namespace PSLauncher.Updater;
/// <summary>
/// Mini exe qui remplace le launcher principal (qui ne peut pas s'auto-écraser pendant qu'il tourne).
///
/// Args attendus :
/// --target=&lt;chemin_complet_de_PSLauncher.exe&gt;
/// --source=&lt;chemin_du_nouveau_PSLauncher.exe&gt;
/// [--launch] : relance PSLauncher.exe une fois la copie faite
/// [--pid=&lt;PID&gt;] : attend que ce process se termine avant de copier
///
/// Workflow :
/// 1. Si --pid fourni, attend la sortie du process.
/// 2. Sinon, attend que le fichier target soit déverrouillé (poll 250 ms).
/// 3. Backup target → target.bak (au cas où).
/// 4. Copie source → target.
/// 5. Optionnellement relance target.
/// 6. Supprime source (le .new) puis target.bak après 30 s.
/// </summary>
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
try
{
var opts = ParseArgs(args);
if (string.IsNullOrEmpty(opts.Target) || string.IsNullOrEmpty(opts.Source))
{
Console.Error.WriteLine("Usage: PSLauncher.Updater --target=<exe> --source=<new.exe> [--launch] [--pid=N]");
return 2;
}
// 1. Attendre la sortie du launcher principal
if (opts.Pid is int pid)
{
try
{
var proc = Process.GetProcessById(pid);
proc.WaitForExit(60_000);
}
catch { /* déjà mort, OK */ }
}
// 2. Attendre que le target soit déverrouillé
var deadline = DateTime.UtcNow.AddSeconds(60);
while (DateTime.UtcNow < deadline)
{
if (!File.Exists(opts.Target)) break;
try
{
using var fs = File.Open(opts.Target, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
break;
}
catch (IOException)
{
Thread.Sleep(250);
}
}
// 3. Backup
var backup = opts.Target + ".bak";
try { if (File.Exists(backup)) File.Delete(backup); } catch { }
try { if (File.Exists(opts.Target)) File.Move(opts.Target, backup); } catch (Exception ex)
{
Console.Error.WriteLine($"Backup failed: {ex.Message}");
return 3;
}
// 4. Copy new file in place
try
{
File.Copy(opts.Source, opts.Target, overwrite: true);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Copy failed: {ex.Message}");
// Tente la restauration
try { if (File.Exists(backup)) File.Move(backup, opts.Target, overwrite: true); } catch { }
return 4;
}
// 5. Relance
if (opts.Launch)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = opts.Target,
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Relaunch failed: {ex.Message}");
}
}
// 6. Cleanup différé (le .new + .bak après 30s, en background détaché)
try { File.Delete(opts.Source); } catch { }
// .bak laissé sur place : si le nouveau exe plante au lancement, l'utilisateur
// peut renommer .bak → .exe pour récupérer l'ancien.
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Updater fatal: {ex}");
return 1;
}
}
private sealed class Options
{
public string? Target { get; set; }
public string? Source { get; set; }
public bool Launch { get; set; }
public int? Pid { get; set; }
}
private static Options ParseArgs(string[] args)
{
var o = new Options();
foreach (var a in args)
{
if (a.StartsWith("--target=", StringComparison.OrdinalIgnoreCase)) o.Target = a["--target=".Length..];
else if (a.StartsWith("--source=", StringComparison.OrdinalIgnoreCase)) o.Source = a["--source=".Length..];
else if (a.StartsWith("--pid=", StringComparison.OrdinalIgnoreCase)) o.Pid = int.TryParse(a["--pid=".Length..], out var p) ? p : null;
else if (a.Equals("--launch", StringComparison.OrdinalIgnoreCase)) o.Launch = true;
}
return o;
}
}