From b7de228bc99c2a48aadffd99251334bc7e046486 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sat, 2 May 2026 09:38:13 +0200 Subject: [PATCH] v0.6 + v1.0: HMAC download URLs, launcher self-update, Inno Setup installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- PSLauncher.exe - Raccourci.lnk | Bin 0 -> 2566 bytes PS_Launcher.sln | 6 + installer/PSLauncher.iss | 74 ++++++++++ installer/README.md | 57 ++++++++ installer/build-installer.ps1 | 37 +++++ pass hash.txt | 21 +++ server/api/index.php | 8 ++ server/api/routes/DownloadUrl.php | 111 ++++++++++++++ src/PSLauncher.App/App.xaml.cs | 1 + src/PSLauncher.App/PSLauncher.App.csproj | 3 + .../ViewModels/MainViewModel.cs | 52 ++++++- .../Views/LauncherUpdateDialog.xaml | 53 +++++++ .../Views/LauncherUpdateDialog.xaml.cs | 41 ++++++ .../Licensing/ILicenseService.cs | 7 + .../Licensing/LicenseService.cs | 27 ++++ .../Updates/ILauncherSelfUpdater.cs | 20 +++ .../Updates/LauncherSelfUpdater.cs | 106 ++++++++++++++ src/PSLauncher.Models/RemoteManifest.cs | 18 +++ .../PSLauncher.Updater.csproj | 22 +++ src/PSLauncher.Updater/Program.cs | 136 ++++++++++++++++++ 20 files changed, 798 insertions(+), 2 deletions(-) create mode 100644 PSLauncher.exe - Raccourci.lnk create mode 100644 installer/PSLauncher.iss create mode 100644 installer/README.md create mode 100644 installer/build-installer.ps1 create mode 100644 pass hash.txt create mode 100644 server/api/routes/DownloadUrl.php create mode 100644 src/PSLauncher.App/Views/LauncherUpdateDialog.xaml create mode 100644 src/PSLauncher.App/Views/LauncherUpdateDialog.xaml.cs create mode 100644 src/PSLauncher.Core/Updates/ILauncherSelfUpdater.cs create mode 100644 src/PSLauncher.Core/Updates/LauncherSelfUpdater.cs create mode 100644 src/PSLauncher.Updater/PSLauncher.Updater.csproj create mode 100644 src/PSLauncher.Updater/Program.cs diff --git a/PSLauncher.exe - Raccourci.lnk b/PSLauncher.exe - Raccourci.lnk new file mode 100644 index 0000000000000000000000000000000000000000..01628a5e60aa1f89049c04f5b4e51a9932c80154 GIT binary patch literal 2566 zcmds3Z){Ul6hDPI1{qKW&Ot!Nq6Xah_BxhrOfYn_bR}hN`ihd}#;xrhX_whHZRh&I zOv5KlFqjZNh;hgC_H^JCs-Z^A@6tN4Gqv`13yWBBIzUc2Jt;o(*~z7a+(ECu zpQwjcQx)C*Ks%f+6vVfx0;z}gvS^KzFL%`!yyPN{Lgc0(dC5=soEqq$iChrP?RN5b zL)2wr28AcftBiT5l?}L7hLf}Dx;mQ&SRrtAG-M*>#Y=s53TT5q*WrmaqCOKZFOGE- z@qSy&0Mins++E%qrT}ytBp-!oj1m;380nOTNLkao_`cgtnRK+IX^f+E(hS zz|Sc>L|aSVh3##2io_EZoqcUZZ@js1KcrVx;II4c21O_iFR;#!FVZ=vkLux!PWwyR z9s`=;fzuMLx&rOl=kKuv;mc7_7{1W)zMh_2gxc>8b*T<`LM1v{Le_i*5i_EoYCFEV z8*&18S=gqKN)8&AS`SkavkZ1n<+_4dC!P-4EqS6X7OO%yiSc;Ck?Ux;SXJJ;U+yh) z$zs_zE!WK59FzlcyR@q8D*@EhSY$MwiCIuXC5x(8u|<`r;b^Tbh4_oWTX9$rlc*!q zMx4%hA$*PHsBs^0TcalA-ao=glrogP$iV?-vNoP6(z z)PYMw1J9gzqiOwHvD(}t?u8ALshca$&z!pTckk_pv(<>S-+}Ga0@^M0{V#gy1<$!( zXE#n|jy8Rb^?|zXBSos9iRO10^i7M zSb&XR4_ZF0iS9M!P>IlTH{=fkFV-^8lj7e3Z!L=wL-SkXM&W7{MfJ{NOv-zKKUxQp zki~xv<~O5qu+p!OM0f5x_3?(QpVD>ze8ViCBwsdC=`oD|pJZ4*?b&8-dqC`%EA>x3 ucJakj-?_g1%GAEx@oiY=2*&RU(Yt$2_sqAv=AD_D`Dx&zi$C*vvHc0GpzT}$ literal 0 HcmV?d00001 diff --git a/PS_Launcher.sln b/PS_Launcher.sln index 83b5f74..c150fec 100644 --- a/PS_Launcher.sln +++ b/PS_Launcher.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Core", "src\PSLa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Models", "src\PSLauncher.Models\PSLauncher.Models.csproj", "{A3333333-3333-3333-3333-333333333333}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSLauncher.Updater", "src\PSLauncher.Updater\PSLauncher.Updater.csproj", "{A4444444-4444-4444-4444-444444444444}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution 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}.Release|Any CPU.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss new file mode 100644 index 0000000..7d1f29a --- /dev/null +++ b/installer/PSLauncher.iss @@ -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}" diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..2c31490 --- /dev/null +++ b/installer/README.md @@ -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` → `X.Y.Z` (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. diff --git a/installer/build-installer.ps1 b/installer/build-installer.ps1 new file mode 100644 index 0000000..0c33989 --- /dev/null +++ b/installer/build-installer.ps1 @@ -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 ." +} + +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)" +} diff --git a/pass hash.txt b/pass hash.txt new file mode 100644 index 0000000..e134d09 --- /dev/null +++ b/pass hash.txt @@ -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 \ No newline at end of file diff --git a/server/api/index.php b/server/api/index.php index 994224d..cd0b4a6 100644 --- a/server/api/index.php +++ b/server/api/index.php @@ -60,6 +60,14 @@ if ($method === 'POST' && $route === 'license/validate') { 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') { Response::json([ 'status' => 'ok', diff --git a/server/api/routes/DownloadUrl.php b/server/api/routes/DownloadUrl.php new file mode 100644 index 0000000..c537e71 --- /dev/null +++ b/server/api/routes/DownloadUrl.php @@ -0,0 +1,111 @@ + + * + * 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 + $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'] ?? '', + ]); + } +} diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 6dda947..132e913 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -97,6 +97,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new LicenseService( diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 92b02e6..9015fcd 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -12,6 +12,9 @@ PSLauncher PSLauncher.App + 0.5.0 + 0.5.0.0 + 0.5.0.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 0c46fd2..ebc037f 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -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 _logger; @@ -158,6 +159,7 @@ public sealed partial class MainViewModel : ObservableObject IDownloadManager downloadManager, IZipInstaller zipInstaller, ILicenseService licenseService, + ILauncherSelfUpdater selfUpdater, IToastService toastService, IServiceProvider serviceProvider, ILogger 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(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(p => UpdateDlProgress(row, p)); StatusMessage = $"Téléchargement v{row.Version}…"; diff --git a/src/PSLauncher.App/Views/LauncherUpdateDialog.xaml b/src/PSLauncher.App/Views/LauncherUpdateDialog.xaml new file mode 100644 index 0000000..45d0322 --- /dev/null +++ b/src/PSLauncher.App/Views/LauncherUpdateDialog.xaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + +