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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PSLauncher.App/Views/LauncherUpdateDialog.xaml.cs b/src/PSLauncher.App/Views/LauncherUpdateDialog.xaml.cs
new file mode 100644
index 0000000..3530f0e
--- /dev/null
+++ b/src/PSLauncher.App/Views/LauncherUpdateDialog.xaml.cs
@@ -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]}";
+ }
+}
diff --git a/src/PSLauncher.Core/Licensing/ILicenseService.cs b/src/PSLauncher.Core/Licensing/ILicenseService.cs
index fc8547d..f58c65c 100644
--- a/src/PSLauncher.Core/Licensing/ILicenseService.cs
+++ b/src/PSLauncher.Core/Licensing/ILicenseService.cs
@@ -11,4 +11,11 @@ public interface ILicenseService
bool HasLicense();
string GetMachineId();
bool CanDownloadVersion(LicenseValidationResponse? license, VersionManifest version);
+
+ ///
+ /// 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.
+ ///
+ Task GetSignedDownloadUrlAsync(string version, CancellationToken ct);
}
diff --git a/src/PSLauncher.Core/Licensing/LicenseService.cs b/src/PSLauncher.Core/Licensing/LicenseService.cs
index 14f7332..f0c8ff4 100644
--- a/src/PSLauncher.Core/Licensing/LicenseService.cs
+++ b/src/PSLauncher.Core/Licensing/LicenseService.cs
@@ -153,6 +153,33 @@ public sealed class LicenseService : ILicenseService
return license.CanDownload(version);
}
+ public async Task 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()
{
if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
diff --git a/src/PSLauncher.Core/Updates/ILauncherSelfUpdater.cs b/src/PSLauncher.Core/Updates/ILauncherSelfUpdater.cs
new file mode 100644
index 0000000..677d279
--- /dev/null
+++ b/src/PSLauncher.Core/Updates/ILauncherSelfUpdater.cs
@@ -0,0 +1,20 @@
+using PSLauncher.Models;
+
+namespace PSLauncher.Core.Updates;
+
+public interface ILauncherSelfUpdater
+{
+ /// Compare la version courante avec celle annoncée dans le manifest.
+ bool IsNewerThanCurrent(LauncherInfo launcher);
+
+ string GetCurrentVersion();
+
+ ///
+ /// 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.
+ ///
+ Task DownloadAndApplyAsync(
+ LauncherInfo launcher,
+ IProgress? progress,
+ CancellationToken ct);
+}
diff --git a/src/PSLauncher.Core/Updates/LauncherSelfUpdater.cs b/src/PSLauncher.Core/Updates/LauncherSelfUpdater.cs
new file mode 100644
index 0000000..81b9beb
--- /dev/null
+++ b/src/PSLauncher.Core/Updates/LauncherSelfUpdater.cs
@@ -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 _logger;
+
+ public LauncherSelfUpdater(
+ IDownloadManager downloadManager,
+ IConfigStore configStore,
+ ILogger 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 DownloadAndApplyAsync(
+ LauncherInfo launcher,
+ IProgress? 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
+ }
+}
diff --git a/src/PSLauncher.Models/RemoteManifest.cs b/src/PSLauncher.Models/RemoteManifest.cs
index 85c9d02..80828e7 100644
--- a/src/PSLauncher.Models/RemoteManifest.cs
+++ b/src/PSLauncher.Models/RemoteManifest.cs
@@ -24,6 +24,24 @@ public sealed class RemoteManifest
[JsonPropertyName("signature")]
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
diff --git a/src/PSLauncher.Updater/PSLauncher.Updater.csproj b/src/PSLauncher.Updater/PSLauncher.Updater.csproj
new file mode 100644
index 0000000..e62d687
--- /dev/null
+++ b/src/PSLauncher.Updater/PSLauncher.Updater.csproj
@@ -0,0 +1,22 @@
+
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ latest
+ PSLauncher.Updater
+ PSLauncher.Updater
+ 0.5.0
+
+
+ true
+ true
+ win-x64
+ true
+ true
+ false
+
+
+
diff --git a/src/PSLauncher.Updater/Program.cs b/src/PSLauncher.Updater/Program.cs
new file mode 100644
index 0000000..bcb75ae
--- /dev/null
+++ b/src/PSLauncher.Updater/Program.cs
@@ -0,0 +1,136 @@
+using System.Diagnostics;
+
+namespace PSLauncher.Updater;
+
+///
+/// Mini exe qui remplace le launcher principal (qui ne peut pas s'auto-écraser pendant qu'il tourne).
+///
+/// Args attendus :
+/// --target=<chemin_complet_de_PSLauncher.exe>
+/// --source=<chemin_du_nouveau_PSLauncher.exe>
+/// [--launch] : relance PSLauncher.exe une fois la copie faite
+/// [--pid=<PID>] : 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.
+///
+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= --source= [--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;
+ }
+}