diff --git a/PSLauncher.exe - Raccourci.lnk b/PSLauncher.exe - Raccourci.lnk new file mode 100644 index 0000000..01628a5 Binary files /dev/null and b/PSLauncher.exe - Raccourci.lnk differ 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 @@ + + + + + + + + + + + + + + + + + + + + + +