From 50da755a9241287ed16eb19e443ab3f2058c8ad4 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Wed, 27 May 2026 14:45:01 +0200 Subject: [PATCH] =?UTF-8?q?v0.29.7=20=E2=80=94=20SteamVR=20merge=20+=20r?= =?UTF-8?q?=C3=A9silience=20DL=20(404=20retry,=20force=20fresh,=20SHA=20pu?= =?UTF-8?q?rge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SteamVR + Vive Business Streaming settings merge : - Nouveau ISteamVrSettingsDeployer : si _steamvr/steamvr.vrsettings est dans le ZIP, deep-merge récursif des blocs racine dans le fichier user (clé absente → ajout, deux objets → recurse, sinon → replace ; les sous-clés cible non touchées sont préservées — crucial pour "trackers") - Auto-localisation via registre Steam (HKCU/HKLM) + fallback Program Files - Phase pré-check (CheckMergeNeededAsync) : skip silencieux si la cible est déjà à jour (deep equality) → pas de kill SteamVR/VBS inutile, pas de popup à l'opérateur. Si changements nécessaires → popup OK/Annuler avec nombre de blocs qui changeront + liste des process à fermer. - Liste de kill construite depuis les health checks de type "Process" (l'opérateur connaît déjà sa stack VR) + guardians (Vive Business Streaming en tête car il relance SteamVR auto) - Settings UI : section dédiée avec opt-in, override path, liste process éditable + affichage du path canonique attendu Résilience téléchargements : - 404 mid-DL traité comme 403/410 (URL refresh trigger) au niveau segment : on extrait le nouveau filename via /download-url server-side, retry transparent - Auto-retry install une fois après refresh manifest sur 404 : l'opérateur ne voit rien si la cause était un manifest local stale - Si retry échoue aussi en 404 → message ciblé "manifest serveur stale" (problème côté serveur, pas client) - Sur SHA-256 mismatch : auto-purge du cache LAN local (.zip + .sha256) + message d'erreur dédié avec source du DL (peer ou OVH) + nouveau menu "↻ Forcer re-téléchargement" pour purger état + cache manuellement - Détection client-side de l'incohérence "signed URL filename != manifest URL filename" avant DL : abort immédiat plutôt que 14 Go pour rien - IZipCacheStore.InvalidateAsync : nouvelle API pour purger un cache par version (utilisée par SHA mismatch handler + Force Fresh menu) Bug serveur (DownloadUrl.php) : - L'endpoint construisait l'URL signée avec un template hardcodé `/builds/proserve-{version}.zip`, ignorant complètement download.url du manifest. Conséquence : un opérateur qui rename son ZIP pour buster le cache CDN OVH (ex. proserve-full-1.5.4.zip) voyait toutes ses releases retournent du 404 silencieux côté client. - Fix : on lit basename(parse_url(entry.download.url).path), whitelist sur le filename, vérif is_file() avant de signer, erreur 500 explicite si manifest et filesystem désynchros. Strings (5 langues) : - ~20 nouvelles : SteamVR popups + status, SHA mismatch dialog + source labels, force fresh confirm + menu, manifest stale + auto-retry status Bumps : 0.29.6 (déployé hors-commit pendant la session) → 0.29.7. Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/PSLauncher.iss | 2 +- server/api/routes/DownloadUrl.php | 42 +- src/PSLauncher.App/App.xaml.cs | 10 + src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 437 ++++++++++++- .../ViewModels/SettingsViewModel.cs | 36 ++ .../ViewModels/VersionRowViewModel.cs | 10 + src/PSLauncher.App/Views/MainWindow.xaml | 4 + src/PSLauncher.App/Views/SettingsDialog.xaml | 46 ++ .../ApiTool/ApiToolDeployer.cs | 17 +- .../DocTool/DocToolDeployer.cs | 17 +- .../Downloads/DownloadManager.cs | 50 +- .../Installations/ZipInstaller.cs | 63 +- src/PSLauncher.Core/Lan/IZipCacheStore.cs | 16 + src/PSLauncher.Core/Lan/ZipCacheStore.cs | 41 ++ src/PSLauncher.Core/Localization/Strings.cs | 151 +++++ .../Migrations/DatabaseMigrationService.cs | 57 ++ .../ReportTool/ReportToolDeployer.cs | 20 +- .../SteamVr/ISteamVrSettingsDeployer.cs | 114 ++++ .../SteamVr/SteamVrSettingsDeployer.cs | 589 ++++++++++++++++++ src/PSLauncher.Models/LocalConfig.cs | 67 ++ 21 files changed, 1768 insertions(+), 27 deletions(-) create mode 100644 src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs create mode 100644 src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 1639d5a..82f342c 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.28.12" +#define MyAppVersion "0.29.7" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/server/api/routes/DownloadUrl.php b/server/api/routes/DownloadUrl.php index f4bde57..9d72ef5 100644 --- a/server/api/routes/DownloadUrl.php +++ b/server/api/routes/DownloadUrl.php @@ -95,6 +95,46 @@ final class DownloadUrl ]); } + // Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité). + // Avant : on utilisait un template hardcodé `proserve-{version}.zip`, + // ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename + // en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence : + // l'endpoint retournait une URL signée pointant vers un fichier inexistant + // → 404 systématique côté client sans aucune indication serveur. + // Maintenant : on lit `download.url` du manifest, on extrait le filename + // via parse_url + basename, on vérifie qu'il existe physiquement dans + // /builds/, et seulement après on signe. + $manifestUrl = $entry['download']['url'] ?? ''; + if ($manifestUrl === '') { + Response::error('manifest_incomplete', + "L'entrée manifest pour v{$version} est sans download.url", 500); + } + $parsedUrl = parse_url($manifestUrl); + $urlPath = $parsedUrl['path'] ?? ''; + $filename = basename($urlPath); + // Whitelist défensive sur le filename : caractères safe + suffixe .zip. + // Évite path traversal et autres injections via un manifest corrompu. + if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) { + Response::error('manifest_invalid_filename', + "Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500); + } + // Vérifie que le fichier existe physiquement avant de signer une URL morte. + // Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans + // /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au + // lieu d'envoyer le client en 404 silencieux, on retourne une erreur + // serveur claire qui apparaît dans les logs PHP + client. + $buildsDir = dirname(__DIR__, 2) . '/builds'; + $physicalPath = $buildsDir . '/' . $filename; + if (!is_file($physicalPath)) { + Response::error('file_missing', + "Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.", + 500, + [ + 'manifestUrl' => $manifestUrl, + 'expectedPath' => $physicalPath, + ]); + } + // Génère l'URL HMAC-signée // TTL : 6 h. Compromis entre : // - sécurité (limite la fenêtre de replay si une URL fuit) @@ -102,7 +142,7 @@ final class DownloadUrl // Pour une connexion plus lente, le client sait auto-refresher l'URL // pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL). $baseUrl = rtrim($config['base_url'], '/'); - $relPath = '/builds/proserve-' . $version . '.zip'; + $relPath = '/builds/' . $filename; $exp = time() + 21600; // 6 h $secret = $config['hmac_secret'] ?? ''; if ($secret === '') { diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 7105cca..26a2c9b 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -25,6 +25,7 @@ using PSLauncher.Core.Health.OpenVRInterop; using PSLauncher.Core.Migrations; using PSLauncher.Core.Process; using PSLauncher.Core.ReportTool; +using PSLauncher.Core.SteamVr; using PSLauncher.Core.Updates; using PSLauncher.Models; using Serilog; @@ -245,6 +246,15 @@ public partial class App : Application configProvider: () => sp.GetRequiredService().ApiTool, sp.GetRequiredService>())); + // Merge des settings SteamVR : si _steamvr/steamvr.vrsettings est + // présent dans le ZIP, tue SteamVR + Vive Business Streaming et + // fusionne les blocs racine dans le fichier de l'utilisateur + // (replace si existe, ajoute sinon). Voir SteamVrSettingsDeployer. + services.AddSingleton(sp => + new SteamVrSettingsDeployer( + configProvider: () => sp.GetRequiredService().SteamVr, + sp.GetRequiredService>())); + // Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà // téléchargés au lieu de re-télécharger 14 Go depuis OVH. // - ZipCacheStore : couche de persistence (downloads + sidecar SHA) diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 2ce162d..8078336 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.28.12 - 0.28.12.0 - 0.28.12.0 + 0.29.7 + 0.29.7.0 + 0.29.7.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 4b4342f..e6150ff 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -24,6 +24,7 @@ using PSLauncher.Core.Health; using PSLauncher.Core.Migrations; using PSLauncher.Core.Process; using PSLauncher.Core.ReportTool; +using PSLauncher.Core.SteamVr; using PSLauncher.Core.Updates; using PSLauncher.Models; @@ -52,6 +53,7 @@ public sealed partial class MainViewModel : ObservableObject private readonly IReportToolDeployer _reportDeployer; private readonly IDocToolDeployer _docDeployer; private readonly IApiToolDeployer _apiDeployer; + private readonly ISteamVrSettingsDeployer _steamVrDeployer; private readonly IPeerSourceResolver _peerResolver; private readonly IZipCacheStore _zipCacheStore; private readonly ILanCacheServer _lanCacheServer; @@ -91,6 +93,14 @@ public sealed partial class MainViewModel : ObservableObject /// private System.Diagnostics.Process? _runningProserve; + /// + /// True quand un install vient d'auto-retry suite à un 404 + manifest refresh. + /// Évite la boucle infinie : si l'install retry échoue ENCORE avec 404, + /// on bascule sur le message "manifest serveur aussi obsolète" au lieu de + /// retry indéfiniment. Reseté à false en début d'install et après le retry. + /// + private bool _install404RetryConsumed; + public ObservableCollection OtherVersions { get; } = new(); /// @@ -368,6 +378,7 @@ public sealed partial class MainViewModel : ObservableObject IReportToolDeployer reportDeployer, IDocToolDeployer docDeployer, IApiToolDeployer apiDeployer, + ISteamVrSettingsDeployer steamVrDeployer, IPeerSourceResolver peerResolver, IZipCacheStore zipCacheStore, ILanCacheServer lanCacheServer, @@ -391,6 +402,7 @@ public sealed partial class MainViewModel : ObservableObject _reportDeployer = reportDeployer; _docDeployer = docDeployer; _apiDeployer = apiDeployer; + _steamVrDeployer = steamVrDeployer; _peerResolver = peerResolver; _zipCacheStore = zipCacheStore; _lanCacheServer = lanCacheServer; @@ -626,6 +638,7 @@ public sealed partial class MainViewModel : ObservableObject row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r); row.OpenFolderHandler = OpenVersionFolder; row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r); + row.ForceFreshDownloadHandler = r => _ = ForceFreshDownloadAsync(r); row.ToggleAutoModeHandler = ToggleAutoModeForRow; // Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList) @@ -977,6 +990,69 @@ public sealed partial class MainViewModel : ObservableObject ProgressPercent = 0; } + /// + /// Purge TOUT pour cette version (state.json + .partial + .zip cache + sidecar + /// SHA), afin de forcer un re-DL complet au prochain Install. Utile quand le + /// SHA-256 a changé côté serveur et que la source précédente (OVH CDN, peer + /// LAN) était obsolète — le cache local du launcher pourrait perpétuer le + /// problème en servant aux peers du LAN un ZIP avec sidecar incohérent. + /// + /// Cas d'usage typique : opérateur backoffice qui a rebuild un release (ex. + /// v1.5.4) avec nouveau contenu + nouveau SHA, et un PC client qui n'arrive + /// pas à re-DL parce que sa source est en cache stale. + /// + private async Task ForceFreshDownloadAsync(VersionRowViewModel row) + { + var confirm = ThemedMessageBox.Show( + Strings.MsgForceFreshConfirm(row.Version), + Strings.MsgBoxForceFresh, + MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + + // Si un DL est actif sur cette même row, on le cancel proprement avant + // de purger (même pattern que RestartFromZeroAsync). + if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null) + { + _activeDownloadCts.Cancel(); + if (_activeInstallTask is { } running && !running.IsCompleted) + { + StatusMessage = "Annulation en cours…"; + try + { + var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10))); + if (!ReferenceEquals(awaitTask, running)) + _logger.LogWarning("Install task did not finish cancelling within 10s, proceeding with purge anyway"); + } + catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during force-fresh"); } + } + } + + // 1. Purge state.json + .partial (ces deux sont DownloadStateStore-only) + _downloadManager.DiscardResumableState(row.Version); + + // 2. Purge le cache LAN (.zip + .sha256 sidecar). Best-effort : si un + // autre process garde un handle (rare : LanCacheServer en cours de + // serve), on log warning et on continue. + try + { + await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not fully invalidate cache for v{Version} during force-fresh", row.Version); + } + + // 3. Reset UI state pour que la row reprenne "Installer" propre. + row.ResumableBytes = 0; + row.State = VersionRowState.AvailableIdle; + row.ProgressDetail = null; + row.ProgressPercent = 0; + StatusMessage = null; + ProgressDetail = null; + ProgressPercent = 0; + _logger.LogInformation("Force-fresh download purge completed for v{Version}", row.Version); + } + // ------------ Per-row actions ------------ private void LaunchVersion(VersionRowViewModel row) @@ -1170,6 +1246,60 @@ public sealed partial class MainViewModel : ObservableObject /// réflexe et obtenaient --login ou -login==Jerome à l'exécution). /// Les clés vides sont skippées silencieusement. /// + /// + /// Construit la liste finale des processus à terminer avant le merge SteamVR. + /// Source principale : les health checks de type "Process" (= les programmes + /// que l'opérateur surveille sur le bandeau de santé = ceux qu'il sait + /// présents sur sa machine VR). On y ajoute systématiquement les processus + /// "guardian" (VIVE Business Streaming, RR*, etc.) qui relancent SteamVR + /// même si l'opérateur ne les a pas dans ses health checks, puis on ordonne + /// pour que les guardians soient tués EN PREMIER. + /// + private List BuildSteamVrProcessKillList() + { + // 1) Processus configurés dans les health checks (case-insensitive). + var fromHealth = _config.HealthChecks.Checks + .Where(c => string.Equals(c.Kind, "Process", StringComparison.OrdinalIgnoreCase)) + .Select(c => (c.Target ?? string.Empty).Trim()) + .Where(t => !string.IsNullOrEmpty(t)) + .Select(StripExeSuffix); + + // 2) Fallback / défense : la liste config (qui contient VBS + SteamVR + // canoniques par défaut). Si l'opérateur n'a rien configuré en health + // checks, on aura quand même de quoi tuer ce qui faut. + var fromConfig = _config.SteamVr.ProcessesToKill + .Select(t => (t ?? string.Empty).Trim()) + .Where(t => !string.IsNullOrEmpty(t)) + .Select(StripExeSuffix); + + // 3) Dédup case-insensitive en préservant l'ORDRE D'INSERTION : on + // insère d'abord les "guardians" connus (VBS, RR*, Htc*), puis les + // health checks, puis le reste de la config. Ainsi VBS est garanti + // tué en premier (sinon il relance SteamVR avant qu'on touche au fichier). + var guardians = new[] + { + "VIVE Business Streaming", + "RRConsole", + "RRServer", + "HtcConnectionUtility", + }; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var name in guardians.Concat(fromHealth).Concat(fromConfig)) + { + if (seen.Add(name)) result.Add(name); + } + return result; + } + + private static string StripExeSuffix(string name) + { + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + return name[..^4].Trim(); + return name; + } + private static string[] BuildAutoModeCliArgs(IEnumerable args) { var list = new List(); @@ -1314,6 +1444,13 @@ public sealed partial class MainViewModel : ObservableObject _activeDownloadCts = new CancellationTokenSource(); var ct = _activeDownloadCts.Token; + // Note : on NE reset PAS _install404RetryConsumed ici. Le retry après + // 404 réinvoke SafeInstallAsync(freshRow) qui re-entre dans cette + // méthode — si on resetait, on aurait potentiellement une boucle + // infinie 404→retry→404→retry. Le flag est seulement remis à false + // dans le `finally` du HAUT (= install qui n'est PAS un retry) ou + // après un install réussi. + try { // Si reprise d'un DL interrompu, on saute la popup de release notes : @@ -1370,7 +1507,37 @@ public sealed partial class MainViewModel : ObservableObject var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct); var urlString = signed ?? row.Remote.Download.Url; url = new Uri(urlString); + + // ★ Détection serveur-side bug : si l'URL signée pointe vers un + // nom de fichier DIFFÉRENT de celui du manifest, on a un endpoint + // /download-url cassé (construit l'URL depuis un template hardcodé + // au lieu de lire le manifest). Continuer le DL est garanti de + // donner 404 → on remonte une erreur claire AVANT de DL 14 Go. + // Bug rapporté côté Asterion : DownloadUrl.php avant fix utilisait + // `proserve-{version}.zip` au lieu de basename(manifest.download.url). + if (signed is not null) + { + var signedFilename = Path.GetFileName(url.AbsolutePath); + var manifestFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath); + if (!string.Equals(signedFilename, manifestFilename, StringComparison.Ordinal)) + { + _logger.LogError( + "Server-side bug : /download-url returned filename « {Signed} » but manifest references « {Manifest} ». Aborting DL — fix DownloadUrl.php server-side.", + signedFilename, manifestFilename); + throw new InvalidOperationException( + $"Incohérence serveur : l'endpoint /download-url retourne un nom de fichier différent du manifest. " + + $"Manifest : « {manifestFilename} ». Signé : « {signedFilename} ». " + + $"→ DownloadUrl.php côté serveur doit lire le filename depuis manifest.download.url, pas via un template hardcodé."); + } + } } + // Diagnostic : log l'URL utilisée pour ce DL. Permet de comparer + // avec l'URL après un éventuel refresh manifest pour détecter le + // cas "le manifest serveur est aussi obsolète" (les deux URLs sont + // identiques alors qu'on attendait un changement). + _logger.LogInformation( + "Install v{Version}: download URL resolved to {Url} (source: {Source})", + row.Version, url, _currentPeerHost ?? "OVH"); // Callback de refresh d'URL : appelé par DownloadManager quand un segment // reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire // jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl @@ -1506,6 +1673,127 @@ public sealed partial class MainViewModel : ObservableObject _logger.LogWarning(ex, "ZIP cache prune failed (non-critical)"); } + // Merge SteamVR settings : si _steamvr/steamvr.vrsettings est bundled + // dans le ZIP, tue SteamVR + Vive Business Streaming (qui le relance + // sinon) et fusionne les blocs racine dans le fichier de l'utilisateur. + // Best-effort : si SteamVR n'est pas installé ou si le merge échoue, + // on log mais on ne bloque pas l'install PROSERVE — le jeu peut tourner + // sans, juste sans le mapping tracker custom. + try + { + // ÉTAPE 1 — DRY-RUN : on commence par déterminer si un vrai + // merge changerait QUOI QUE CE SOIT. Aucun processus tué à ce + // stade, aucun fichier touché. Bénéfice : sur un PC où la conf + // SteamVR est déjà conforme à la release (cas typique d'une + // ré-install ou d'un PC mis-à-jour récemment), on saute TOUT + // le processus de fermeture sans déranger l'opérateur. + var check = await _steamVrDeployer.CheckMergeNeededAsync(target, ct); + switch (check.Outcome) + { + case SteamVrMergeCheckOutcome.NoSourceFile: + _logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version); + goto AfterSteamVrMerge; + case SteamVrMergeCheckOutcome.DisabledByConfig: + _logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version); + goto AfterSteamVrMerge; + case SteamVrMergeCheckOutcome.SteamNotFound: + _logger.LogInformation( + "SteamVR merge skipped for v{Version}: Steam not found on this machine", + row.Version); + goto AfterSteamVrMerge; + case SteamVrMergeCheckOutcome.UpToDate: + // ★ Cas optimal : la conf est déjà alignée → on ne ferme + // PAS SteamVR/VBS, on ne montre PAS de popup, on log juste. + _logger.LogInformation( + "SteamVR settings already up-to-date for v{Version} (target: {Path}) — skipping kill+merge", + row.Version, check.TargetPath); + goto AfterSteamVrMerge; + case SteamVrMergeCheckOutcome.InvalidJson: + ThemedMessageBox.Show( + Strings.MsgSteamVrMergeFailed(check.ErrorMessage ?? "?"), + Strings.MsgBoxSteamVrMergeFailed, + MessageBoxButton.OK, MessageBoxImage.Warning); + goto AfterSteamVrMerge; + case SteamVrMergeCheckOutcome.Needed: + // On continue vers le pop-up + kill + merge. + break; + } + + // ÉTAPE 2 — Construit la liste des processus à tuer. Source : + // les health checks de type "Process" configurés par l'opérateur. + // C'est lui qui sait quelle VR runtime tourne sur la machine ; + // on lui fait confiance plutôt que de hardcoder une liste obsolète. + // + // Sécurité : on s'assure que VIVE Business Streaming et SteamVR + // sont DANS la liste (sinon le merge échouera car SteamVR garde + // son fichier ouvert). On dédup case-insensitive et on ordonne + // VBS en tête (autres "guardians" qui relancent SteamVR aussi). + var killList = BuildSteamVrProcessKillList(); + + // ÉTAPE 3 — Pré-flight : popup d'avertissement avec liste des + // programmes à fermer + nombre de blocs qui changeront. + if (killList.Count > 0) + { + var listPreview = string.Join("\n • ", killList.Take(10)); + if (killList.Count > 10) listPreview += $"\n • … (+{killList.Count - 10})"; + var ok = ThemedMessageBox.Show( + Strings.MsgSteamVrPreFlightBody(listPreview, check.RootKeysWouldChange), + Strings.MsgSteamVrPreFlightTitle, + MessageBoxButton.OKCancel, MessageBoxImage.Information, + MessageBoxResult.OK); + if (ok != MessageBoxResult.OK) + { + // Annulé par l'opérateur : on skip le merge mais on + // continue l'install (les blocs SteamVR ne seront pas + // mis à jour ; l'opérateur le sait). + _logger.LogInformation( + "User cancelled SteamVR merge phase, skipping merge but continuing install"); + goto AfterSteamVrMerge; + } + } + + StatusMessage = Strings.StatusDeployingSteamVr(row.Version); + ProgressDetail = null; + ProgressPercent = 0; + var svResult = await _steamVrDeployer.MergeAsync(target, killList, ct); + switch (svResult.Status) + { + case SteamVrMergeStatus.Merged: + _logger.LogInformation( + "SteamVR merge for v{Version}: replaced={Replaced}, added={Added} (target: {Path})", + row.Version, svResult.KeysReplaced, svResult.KeysAdded, svResult.TargetPath); + break; + case SteamVrMergeStatus.SkippedNoSourceFile: + _logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version); + break; + case SteamVrMergeStatus.SkippedDisabledByConfig: + _logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version); + break; + case SteamVrMergeStatus.SteamNotFound: + // Pas de popup — beaucoup de machines de dev n'ont pas Steam, + // pas la peine de spammer l'opérateur. + _logger.LogInformation( + "SteamVR merge skipped for v{Version}: Steam not found on this machine", + row.Version); + break; + case SteamVrMergeStatus.InvalidJson: + case SteamVrMergeStatus.Failed: + // Popup quand-même : c'est probablement un bug à investiguer + // (ZIP corrompu, fichier SteamVR endommagé manuellement, etc.). + ThemedMessageBox.Show( + Strings.MsgSteamVrMergeFailed(svResult.ErrorMessage ?? "?"), + Strings.MsgBoxSteamVrMergeFailed, + MessageBoxButton.OK, MessageBoxImage.Warning); + break; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + _logger.LogError(ex, "SteamVR merge threw an unhandled exception (continuing install)"); + } + AfterSteamVrMerge: + // 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config // a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire. if (_config.Database.AutoApplyMigrations) @@ -1697,6 +1985,9 @@ public sealed partial class MainViewModel : ObservableObject StatusMessage = Strings.StatusInstalledSuccess(row.Version); ProgressDetail = null; ProgressPercent = 0; + // Reset le flag retry — un futur install (même version ou autre) + // a droit à sa propre tentative de retry sur 404. + _install404RetryConsumed = false; RebuildList(); _toastService.ShowSuccess( Strings.ToastInstallSuccessTitle, @@ -1713,16 +2004,154 @@ public sealed partial class MainViewModel : ObservableObject // valeur d'avant le DL (souvent 0% ou l'ancien %). RefreshResumableBytes(row); } + catch (InvalidDataException shaEx) when (shaEx.Message.Contains("Checksum SHA-256", StringComparison.OrdinalIgnoreCase)) + { + // Cas spécifique : SHA-256 mismatch après DL. Causes typiques : + // • OVH (CDN) sert un ZIP obsolète parce que le cache n'a pas + // encore expiré (TTL 24h-72h) suite à un re-build serveur + // • Un peer LAN sert un ZIP dont le sidecar est corrompu/menteur + // • Le manifest a été modifié manuellement sans re-build du ZIP + // + // Actions automatiques : + // 1. Purge le cache local pour cette version (le ZIP que NOUS + // avions cached avant cette tentative pourrait être obsolète + // lui aussi ; mieux vaut tout vider pour ne pas servir du + // contenu pourri aux autres peers du LAN) + // 2. Affiche un message d'erreur SPÉCIFIQUE avec la source du + // DL (peer LAN ou OVH) pour aider l'opérateur à diagnostiquer + // 3. Suggère le menu "↻ Forcer re-téléchargement" pour réessayer + // après que le cache CDN OVH ait expiré + _logger.LogError(shaEx, + "SHA-256 verify failed for v{Version} (source: {Source})", + row.Version, _currentPeerHost ?? "OVH"); + row.State = VersionRowState.AvailableIdle; + row.ProgressDetail = null; + row.ProgressPercent = 0; + try + { + await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None); + } + catch (Exception purgeEx) + { + _logger.LogWarning(purgeEx, "Could not purge LAN cache for v{Version} after SHA mismatch", row.Version); + } + var sourceLabel = _currentPeerHost is null + ? Strings.SourceOvh + : Strings.SourcePeer(_currentPeerHost); + var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel); + ThemedMessageBox.Show(body, Strings.MsgBoxShaMismatch, + MessageBoxButton.OK, MessageBoxImage.Warning); + StatusMessage = Strings.StatusShaMismatch(row.Version); + _toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), Strings.MsgBoxShaMismatch); + RefreshResumableBytes(row); + } catch (Exception ex) { _logger.LogError(ex, "Install failed for {Version}", row.Version); row.State = VersionRowState.AvailableIdle; row.ProgressDetail = null; row.ProgressPercent = 0; - ThemedMessageBox.Show( - Strings.MsgInstallFailed(ex.Message), - Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error); - StatusMessage = Strings.StatusError(ex.Message); + + // Cas spécifique HTTP 404 : le fichier source a été renommé/supprimé + // sur le serveur (typiquement après un re-upload pour buster le cache + // CDN OVH). Le manifest local référence l'ancienne URL → la DL fail + // en 404. + // + // Stratégie en 2 temps : + // 1. Premier 404 → auto-refresh du manifest + AUTO-RETRY install + // transparent (l'opérateur ne voit rien d'autre qu'un message + // "rafraîchissement…" puis la nouvelle DL qui démarre). + // 2. Deuxième 404 après retry → le manifest serveur ELLE-MÊME + // est obsolète (le rename a été fait au filesystem sans MAJ + // du manifest serveur). Message clair pour que l'opérateur + // sache que c'est un problème côté serveur, pas client. + bool is404 = ex.Message.Contains("HTTP 404", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("404 on segment", StringComparison.OrdinalIgnoreCase); + if (is404 && !_install404RetryConsumed) + { + // PREMIER 404 : on tente le refresh + retry une fois. + _install404RetryConsumed = true; + _logger.LogInformation( + "404 detected for v{Version} (first attempt) — auto-refreshing manifest then retrying install", + row.Version); + + // Capture l'URL actuelle pour diagnostic (avant refresh). + var urlBefore = row.Remote?.Download.Url; + + bool refreshOk = false; + try + { + await CheckForUpdatesAsync(); + refreshOk = true; + } + catch (Exception refreshEx) + { + _logger.LogWarning(refreshEx, "Auto-refresh manifest after 404 failed"); + } + + // Récupère la row fraîche depuis le rebuild (l'instance peut + // avoir changé) pour relire la nouvelle URL du manifest. + var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null) + ?? OtherVersions.FirstOrDefault(r => r.Version == row.Version); + var urlAfter = freshRow?.Remote?.Download.Url; + _logger.LogInformation( + "Manifest URL for v{Version} — before: {Before}, after refresh: {After}", + row.Version, urlBefore, urlAfter); + + if (refreshOk && freshRow is not null && freshRow.Remote is not null + && !string.Equals(urlBefore, urlAfter, StringComparison.Ordinal)) + { + // L'URL a changé → le manifest s'est bien rafraîchi avec une + // nouvelle URL pointant vers le fichier renommé. Auto-retry + // l'install. On déclenche via la même mécanique que le clic + // Install (SafeInstallAsync) pour bénéficier du try/catch + // global et de la mécanique standard. + StatusMessage = Strings.StatusAutoRetryAfter404(row.Version); + // ⚠ on libère les flags du finally en avance pour que le + // retry puisse re-prendre _activeRow / IsBusy. Le finally + // s'exécutera quand même mais idempotent (déjà null). + _activeDownloadCts?.Dispose(); + _activeDownloadCts = null; + _activeRow = null; + _activeInstallTask = null; + _currentPeerHost = null; + IsBusy = false; + IsDownloadActive = false; + // Fire-and-forget : Polly et SafeInstallAsync gèrent les exceptions. + _activeInstallTask = SafeInstallAsync(freshRow); + return; + } + + // URL identique après refresh OU refresh échoué → tomber dans + // le message "manifest serveur stale" (cas 2). + _logger.LogWarning( + "Manifest refresh did not produce a new URL for v{Version} — server-side manifest is also stale", + row.Version); + ThemedMessageBox.Show( + Strings.MsgServerSideStaleBody(row.Version), + Strings.MsgBoxStaleManifest, + MessageBoxButton.OK, MessageBoxImage.Warning); + StatusMessage = Strings.StatusManifestRefreshed; + } + else if (is404) + { + // DEUXIÈME 404 dans la même session install (déjà retried). + // On reset le flag pour que la prochaine action user manuelle + // (autre clic Install) reparte avec une tentative de retry fraîche. + _install404RetryConsumed = false; + ThemedMessageBox.Show( + Strings.MsgServerSideStaleBody(row.Version), + Strings.MsgBoxStaleManifest, + MessageBoxButton.OK, MessageBoxImage.Warning); + StatusMessage = Strings.StatusManifestRefreshed; + } + else + { + ThemedMessageBox.Show( + Strings.MsgInstallFailed(ex.Message), + Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error); + StatusMessage = Strings.StatusError(ex.Message); + } _toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message); // Idem : si une erreur réseau interrompt le DL en cours de route, le // partial est plus gros qu'avant — il faut refléter le nouveau %. diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs index 9227895..d33a18a 100644 --- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -102,6 +102,19 @@ public sealed partial class SettingsViewModel : ObservableObject = new(); public bool HasApiBackups => ApiBackups.Count > 0; + // ----- SteamVR settings merge ----- + [ObservableProperty] private bool _steamVrAutoMerge; + [ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty; + /// Multi-line text bindé sur un TextBox : 1 process name par ligne (sans .exe). + [ObservableProperty] private string _steamVrProcessesToKill = string.Empty; + /// + /// Path canonique attendu si Steam est installé à l'emplacement par défaut. + /// Affiché sous le TextBox d'override pour que l'opérateur sache à quoi ressemble + /// la résolution standard et n'ait pas à le deviner / chercher dans la doc. + /// + public string SteamVrCanonicalPath => + PSLauncher.Core.SteamVr.SteamVrSettingsDeployer.CanonicalSettingsPath; + // ----- Cache LAN P2P ----- [ObservableProperty] private bool _lanServerEnabled; [ObservableProperty] private bool _lanClientEnabled; @@ -257,6 +270,12 @@ public sealed partial class SettingsViewModel : ObservableObject _apiAutoDeploy = config.ApiTool.AutoDeploy; _apiMaxBackups = config.ApiTool.MaxBackups; + _steamVrAutoMerge = config.SteamVr.AutoMerge; + _steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty; + _steamVrProcessesToKill = string.Join( + Environment.NewLine, + config.SteamVr.ProcessesToKill ?? new List()); + _autoModeFeatureEnabled = config.AutoMode.FeatureEnabled; _autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds; _autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty; @@ -429,6 +448,23 @@ public sealed partial class SettingsViewModel : ObservableObject _config.ApiTool.AutoDeploy = ApiAutoDeploy; _config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups); + _config.SteamVr.AutoMerge = SteamVrAutoMerge; + _config.SteamVr.SettingsFilePathOverride = string.IsNullOrWhiteSpace(SteamVrSettingsPathOverride) + ? null : SteamVrSettingsPathOverride.Trim(); + _config.SteamVr.ProcessesToKill = (SteamVrProcessesToKill ?? string.Empty) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(l => + { + var name = l.Trim(); + // Strip .exe défensif (case-insensitive) au cas où l'opérateur le tape. + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + name = name[..^4].Trim(); + return name; + }) + .Where(l => l.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + _config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled; _config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60); _config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion) diff --git a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs index d212402..cde43c6 100644 --- a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs +++ b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs @@ -208,6 +208,7 @@ public sealed partial class VersionRowViewModel : ObservableObject public Action? ShowReleaseNotesHandler { get; set; } public Action? OpenFolderHandler { get; set; } public Action? RestartFromZeroHandler { get; set; } + public Action? ForceFreshDownloadHandler { get; set; } [RelayCommand(CanExecute = nameof(CanLaunch))] private void Launch() => LaunchHandler?.Invoke(this); @@ -231,6 +232,15 @@ public sealed partial class VersionRowViewModel : ObservableObject private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this); private bool CanRestartFromZero() => HasResumableDownload; + /// + /// Visible quand la row n'est pas en cours de DL (sinon le user devrait + /// d'abord cancel via le bouton dédié). Pas conditionnée par "partial existe" + /// car cette action a pour but spécifique de purger TOUT (cache + partial + + /// sidecar SHA), même quand il n'y a pas de partial en cours. + /// + [RelayCommand] + private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this); + private static string FormatSize(long bytes) => bytes <= 0 ? "—" : Strings.FormatSize(bytes); } diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 3b21127..fd8fafe 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -194,6 +194,8 @@ + + + + + + + + + + + + + + + + + + + + + + ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false); + // Si la version est DÉJÀ installée (cas typique : ré-installation + // d'un build sans bumper le numéro de version pendant des tests), + // on renomme l'existant en backup horodaté pour ne pas bloquer + // l'install. Le backup est supprimé en arrière-plan après que le + // swap soit confirmé (sinon on doublerait la conso disque le temps + // de la copie). Si le swap échoue, on restaure le backup. + string? renamedBackup = null; if (Directory.Exists(targetFolder)) - throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}"); + { + renamedBackup = $"{targetFolder}.bak-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + Directory.Move(targetFolder, renamedBackup); + _logger.LogInformation( + "Existing install at {Target} renamed to {Backup} before re-install", + targetFolder, renamedBackup); + } + + try + { + Directory.Move(stagingFolder, targetFolder); + } + catch + { + // Rollback : restaure le backup si on a échoué après le rename + if (renamedBackup is not null && Directory.Exists(renamedBackup) && !Directory.Exists(targetFolder)) + { + try + { + Directory.Move(renamedBackup, targetFolder); + _logger.LogWarning("Restored previous install from {Backup} after move failure", renamedBackup); + } + catch (Exception restoreEx) + { + _logger.LogError(restoreEx, + "FAILED to restore backup {Backup} — manual recovery needed", renamedBackup); + } + } + throw; + } - Directory.Move(stagingFolder, targetFolder); _logger.LogInformation("Installed to {Target}", targetFolder); + + // Cleanup background du backup (14 Go d'install × N réinstallations + // remplirait le disque sinon). Best-effort : si on ne peut pas + // delete (handle Windows tenace, AV qui scanne), on log et on + // laisse à l'opérateur (le nom .bak-{ts} le rend repérable). + if (renamedBackup is not null) + { + var bakToDelete = renamedBackup; + _ = Task.Run(() => + { + try + { + Directory.Delete(bakToDelete, recursive: true); + _logger.LogInformation("Cleaned up old install backup {Backup}", bakToDelete); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not delete old install backup {Backup} — operator can remove manually", + bakToDelete); + } + }); + } + return targetFolder; } catch diff --git a/src/PSLauncher.Core/Lan/IZipCacheStore.cs b/src/PSLauncher.Core/Lan/IZipCacheStore.cs index b7099d3..db17d02 100644 --- a/src/PSLauncher.Core/Lan/IZipCacheStore.cs +++ b/src/PSLauncher.Core/Lan/IZipCacheStore.cs @@ -45,4 +45,20 @@ public interface IZipCacheStore /// Liste tous les ZIPs cachés valides (avec sidecar SHA présent). IReadOnlyList ListCached(); + + /// + /// Supprime AGRESSIVEMENT le ZIP + sidecar SHA d'une version donnée du cache + /// local, peu importe son âge ou si la version est encore installée. Utilisé + /// dans deux cas : + /// + /// Échec de vérif SHA après DL : on évacue l'éventuel ZIP cache de + /// la version concernée pour ne pas servir du contenu obsolète aux peers + /// LAN (et pour forcer un re-DL complet au prochain Install). + /// Action manuelle "Forcer re-téléchargement" depuis le menu UI : + /// l'opérateur veut purger explicitement (typiquement quand la source + /// OVH est en cache CDN stale et qu'il a besoin d'un re-DL frais). + /// + /// Best-effort : log les erreurs I/O mais ne throw pas. + /// + Task InvalidateAsync(string version, CancellationToken ct); } diff --git a/src/PSLauncher.Core/Lan/ZipCacheStore.cs b/src/PSLauncher.Core/Lan/ZipCacheStore.cs index a7eab30..1d9b9c1 100644 --- a/src/PSLauncher.Core/Lan/ZipCacheStore.cs +++ b/src/PSLauncher.Core/Lan/ZipCacheStore.cs @@ -149,6 +149,47 @@ public sealed class ZipCacheStore : IZipCacheStore }, ct).ConfigureAwait(false); } + public async Task InvalidateAsync(string version, CancellationToken ct) + { + if (!VersionRegex.IsMatch(version)) + { + _logger.LogWarning("InvalidateAsync called with invalid version: {Version}", version); + return; + } + + await Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + var zipPath = GetCachedZipPath(version); + var shaPath = GetShaPath(version); + bool zipDeleted = false, shaDeleted = false; + try + { + if (File.Exists(zipPath)) { File.Delete(zipPath); zipDeleted = true; } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not delete cached ZIP for v{Version} at {Path} (file lock?)", + version, zipPath); + } + try + { + if (File.Exists(shaPath)) { File.Delete(shaPath); shaDeleted = true; } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not delete sidecar SHA for v{Version} at {Path}", + version, shaPath); + } + if (zipDeleted || shaDeleted) + { + _logger.LogInformation( + "Invalidated LAN cache for v{Version} (zip={ZipGone}, sidecar={ShaGone})", + version, zipDeleted, shaDeleted); + } + }, ct).ConfigureAwait(false); + } + public IReadOnlyList ListCached() { var dir = _downloadStore.GetDownloadsDirectory(); diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index d1c6040..55a94e2 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -311,6 +311,91 @@ public static class Strings $"فشل التنزيل أو التثبيت:\n\n{detail}" ); + // ---- SHA-256 mismatch après DL : message ciblé avec source + action ---- + public static string SourceOvh => T("OVH (CDN public)", "OVH (public CDN)", "OVH(公共 CDN)", "OVH (CDN สาธารณะ)", "OVH (CDN عام)"); + public static string SourcePeer(string host) => T( + $"peer LAN « {host} »", $"LAN peer « {host} »", $"局域网节点 « {host} »", + $"LAN peer « {host} »", $"عقدة LAN « {host} »"); + public static string MsgBoxShaMismatch => T( + "Fichier téléchargé corrompu ou obsolète", + "Downloaded file corrupted or outdated", + "下载的文件已损坏或过时", + "ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย", + "الملف المنزّل تالف أو قديم" + ); + public static string MsgShaMismatchBody(string version, string sourceLabel) => T( + $"Le ZIP de v{version} a été téléchargé depuis {sourceLabel}, mais son SHA-256 ne correspond pas à celui attendu par le manifest.\n\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h)\n • Un peer LAN sert un ZIP avec un sidecar SHA corrompu\n • Le SHA dans le manifest a été modifié sans re-build du ZIP\n\nLe cache local pour cette version a été automatiquement purgé. Réessaye l'installation dans quelques minutes (le temps que la source se rafraîchisse) ou utilise « ↻ Forcer re-téléchargement » dans le menu \"…\" de la version.", + $"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h)\n • A LAN peer is serving a ZIP with a corrupt SHA sidecar\n • The manifest SHA was changed without rebuilding the ZIP\n\nThe local cache for this version was automatically purged. Retry the install in a few minutes (let the source refresh) or use « ↻ Force re-download » in the version's \"…\" menu.", + $"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n可能的原因:\n • 服务器重建后 OVH CDN 缓存尚未过期(TTL 24-72 小时)\n • 局域网节点提供带有损坏的 SHA sidecar 的 ZIP\n • 清单 SHA 已更改但未重建 ZIP\n\n此版本的本地缓存已自动清除。请在几分钟后重试安装(让源刷新)或使用版本的 \"…\" 菜单中的 \"↻ 强制重新下载\"。", + $"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุหลังการ rebuild เซิร์ฟเวอร์ (TTL 24-72 ชม.)\n • LAN peer ให้บริการ ZIP ที่มี SHA sidecar เสียหาย\n • SHA ใน manifest ถูกแก้ไขโดยไม่ rebuild ZIP\n\nแคชในเครื่องสำหรับเวอร์ชันนี้ถูกล้างอัตโนมัติ ลองติดตั้งอีกครั้งในไม่กี่นาที (ให้ source รีเฟรช) หรือใช้ \"↻ บังคับดาวน์โหลดใหม่\" ในเมนู \"…\" ของเวอร์ชัน", + $"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 الخاص به لا يطابق القيمة المتوقعة من البيان.\n\nالأسباب المحتملة:\n • لم تنته صلاحية ذاكرة التخزين المؤقت لـ OVH CDN بعد إعادة بناء الخادم (TTL 24-72 ساعة)\n • عقدة LAN تقدم ZIP بـ SHA sidecar تالف\n • تم تعديل SHA في البيان دون إعادة بناء ZIP\n\nتم مسح ذاكرة التخزين المؤقت المحلية لهذا الإصدار تلقائياً. أعد المحاولة بعد دقائق (دع المصدر ينعش) أو استخدم \"↻ فرض إعادة التنزيل\" في قائمة \"…\" للإصدار." + ); + public static string StatusShaMismatch(string version) => T( + $"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye", + $"⚠ v{version}: SHA-256 invalid — cache purged, retry", + $"⚠ v{version}:SHA-256 无效 — 缓存已清除,请重试", + $"⚠ v{version}: SHA-256 ไม่ถูกต้อง — แคชถูกล้าง ลองใหม่", + $"⚠ v{version}: SHA-256 غير صالح — تم مسح الذاكرة، أعد المحاولة" + ); + // ---- HTTP 404 mid-DL : manifest local obsolète (fichier renommé serveur) ---- + public static string MsgBoxStaleManifest => T( + "Manifest local obsolète", + "Local manifest outdated", + "本地清单已过时", + "Manifest ในเครื่องล้าสมัย", + "البيان المحلي قديم" + ); + public static string MsgStaleManifestBody(string version) => T( + $"Le fichier ZIP de v{version} a été renommé ou supprimé côté serveur (typique d'un re-upload pour invalider le cache OVH CDN).\n\nLe launcher a automatiquement re-fetché le manifest depuis le serveur — la nouvelle URL devrait maintenant être connue.\n\nRe-clique « Installer » pour réessayer avec l'URL fraîche.", + $"The v{version} ZIP file was renamed or deleted on the server (typical of a re-upload to invalidate OVH CDN cache).\n\nThe launcher has automatically re-fetched the manifest from the server — the new URL should now be known.\n\nClick « Install » again to retry with the fresh URL.", + $"v{version} 的 ZIP 文件已在服务器端重命名或删除(典型的重新上传以使 OVH CDN 缓存失效)。\n\n启动器已自动从服务器重新获取清单 — 新 URL 现在应该已知。\n\n再次点击 \"安装\" 以使用新 URL 重试。", + $"ไฟล์ ZIP ของ v{version} ถูกเปลี่ยนชื่อหรือลบบนเซิร์ฟเวอร์ (ทั่วไปจากการอัปโหลดใหม่เพื่อล้างแคช OVH CDN)\n\nLauncher ดึง manifest จากเซิร์ฟเวอร์อัตโนมัติแล้ว — ตอนนี้ควรรู้ URL ใหม่\n\nคลิก \"ติดตั้ง\" อีกครั้งเพื่อลองด้วย URL ใหม่", + $"تم إعادة تسمية أو حذف ملف ZIP الخاص بـ v{version} من جانب الخادم (نموذجي لإعادة الرفع لإبطال ذاكرة التخزين المؤقت لـ OVH CDN).\n\nقام المشغل بإعادة جلب البيان تلقائياً من الخادم — يجب أن يكون عنوان URL الجديد معروفاً الآن.\n\nانقر فوق \"تثبيت\" مرة أخرى لإعادة المحاولة باستخدام عنوان URL الجديد." + ); + public static string StatusManifestRefreshed => T( + "Manifest rafraîchi — re-clique Installer", + "Manifest refreshed — click Install again", + "清单已刷新 — 再次点击安装", + "Manifest รีเฟรชแล้ว — คลิกติดตั้งอีกครั้ง", + "تم تحديث البيان — انقر فوق تثبيت مرة أخرى" + ); + public static string StatusAutoRetryAfter404(string version) => T( + $"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…", + $"v{version}: manifest refreshed, auto-retrying download…", + $"v{version}:清单已刷新,自动重试下载…", + $"v{version}: รีเฟรช manifest แล้ว ลองดาวน์โหลดใหม่อัตโนมัติ…", + $"v{version}: تم تحديث البيان، إعادة محاولة التنزيل تلقائياً…" + ); + public static string MsgServerSideStaleBody(string version) => T( + $"Le téléchargement de v{version} échoue toujours en 404 même APRÈS un refresh du manifest depuis le serveur.\n\nDiagnostic :\n • Le manifest serveur lui-même est obsolète — il référence l'ancien nom de fichier qui n'existe plus.\n • Action côté serveur requise : le manifest doit être mis à jour pour pointer vers le nouveau nom du ZIP (ou re-uploader avec l'ancien nom).\n\nVérifie le backoffice serveur. Une fois le manifest corrigé, clique « Vérifier les MAJ » puis « Installer ».", + $"v{version} download still fails with 404 EVEN AFTER refreshing the manifest from the server.\n\nDiagnosis:\n • The server-side manifest itself is outdated — it references the old filename which no longer exists.\n • Server-side action required: the manifest must be updated to point to the new ZIP filename (or re-upload with the old name).\n\nCheck the server backoffice. Once the manifest is fixed, click « Check Updates » then « Install ».", + $"v{version} 下载即使在从服务器刷新清单后仍因 404 失败。\n\n诊断:\n • 服务器端清单本身已过时 — 它引用了不再存在的旧文件名。\n • 需要服务器端操作:清单必须更新以指向新的 ZIP 文件名(或以旧名称重新上传)。\n\n检查服务器后台。修复清单后,点击 \"检查更新\" 然后 \"安装\"。", + $"การดาวน์โหลด v{version} ยังคงล้มเหลวด้วย 404 แม้หลังจากรีเฟรช manifest จากเซิร์ฟเวอร์\n\nการวินิจฉัย:\n • Manifest ฝั่งเซิร์ฟเวอร์เองล้าสมัย — อ้างถึงชื่อไฟล์เก่าที่ไม่มีอยู่แล้ว\n • ต้องดำเนินการฝั่งเซิร์ฟเวอร์: manifest ต้องอัปเดตให้ชี้ไปยังชื่อ ZIP ใหม่ (หรืออัปโหลดใหม่ด้วยชื่อเก่า)\n\nตรวจสอบ backoffice เซิร์ฟเวอร์ เมื่อ manifest ถูกแก้ไขแล้ว ให้คลิก \"ตรวจสอบการอัปเดต\" จากนั้น \"ติดตั้ง\"", + $"لا يزال تنزيل v{version} يفشل بـ 404 حتى بعد تحديث البيان من الخادم.\n\nالتشخيص:\n • البيان من جانب الخادم نفسه قديم — يشير إلى اسم الملف القديم الذي لم يعد موجوداً.\n • مطلوب إجراء من جانب الخادم: يجب تحديث البيان للإشارة إلى اسم ZIP الجديد (أو إعادة الرفع بالاسم القديم).\n\nتحقق من backoffice الخادم. بعد إصلاح البيان، انقر فوق \"التحقق من التحديثات\" ثم \"تثبيت\"." + ); + + public static string MenuForceFreshDownload => T( + "↻ Forcer re-téléchargement (vider cache local)", + "↻ Force re-download (purge local cache)", + "↻ 强制重新下载(清除本地缓存)", + "↻ บังคับดาวน์โหลดใหม่ (ล้างแคช)", + "↻ فرض إعادة التنزيل (مسح الذاكرة)" + ); + public static string MsgBoxForceFresh => T( + "Forcer le re-téléchargement", + "Force re-download", + "强制重新下载", + "บังคับดาวน์โหลดใหม่", + "فرض إعادة التنزيل" + ); + public static string MsgForceFreshConfirm(string version) => T( + $"Vider le cache local pour v{version} et forcer un re-téléchargement complet ?\n\nCela supprimera le ZIP cache + le sidecar SHA + tout partial de DL en cours pour cette version. Utile quand le SHA-256 a changé côté serveur et que la source précédente était obsolète.", + $"Purge the local cache for v{version} and force a full re-download?\n\nThis will delete the cached ZIP + SHA sidecar + any in-progress partial for this version. Useful when the server-side SHA-256 has changed and the previous source was stale.", + $"清除 v{version} 的本地缓存并强制完全重新下载?\n\n这将删除缓存的 ZIP + SHA sidecar + 此版本的任何进行中的部分下载。当服务器端 SHA-256 已更改且之前的源已过时时很有用。", + $"ล้างแคชในเครื่องสำหรับ v{version} และบังคับให้ดาวน์โหลดใหม่ทั้งหมด?\n\nสิ่งนี้จะลบ ZIP ที่แคช + SHA sidecar + การดาวน์โหลดบางส่วนที่กำลังดำเนินการสำหรับเวอร์ชันนี้ มีประโยชน์เมื่อ SHA-256 ฝั่งเซิร์ฟเวอร์เปลี่ยนแปลงและแหล่งที่มาก่อนหน้านี้ล้าสมัย", + $"مسح ذاكرة التخزين المؤقت المحلية لـ v{version} وفرض إعادة تنزيل كاملة؟\n\nسيؤدي هذا إلى حذف ZIP المخزن مؤقتاً + SHA sidecar + أي تنزيل جزئي قيد التقدم لهذا الإصدار. مفيد عندما يتم تغيير SHA-256 على جانب الخادم وكان المصدر السابق قديماً." + ); + public static string MsgUninstallFailed(string detail) => T( $"Suppression échouée : {detail}", $"Uninstall failed: {detail}", @@ -625,6 +710,72 @@ public static class Strings public static string SettingsApiBackups => SettingsReportBackups; public static string SettingsApiMaxBackups => SettingsReportMaxBackups; + // ---- SteamVR settings merge (tue SteamVR + Vive Business Streaming + merge JSON) ---- + public static string StatusDeployingSteamVr(string version) => T( + $"Configuration SteamVR pour v{version}…", + $"Configuring SteamVR for v{version}…", + $"正在为 v{version} 配置 SteamVR…", + $"กำลังกำหนดค่า SteamVR สำหรับ v{version}…", + $"جارٍ تهيئة SteamVR لـ v{version}…" + ); + public static string MsgBoxSteamVrMergeFailed => T( + "Configuration SteamVR échouée", + "SteamVR configuration failed", + "SteamVR 配置失败", + "การกำหนดค่า SteamVR ล้มเหลว", + "فشل تهيئة SteamVR" + ); + public static string MsgSteamVrPreFlightTitle => T( + "Fermeture de SteamVR & Vive Business Streaming", + "Closing SteamVR & Vive Business Streaming", + "正在关闭 SteamVR 和 Vive Business Streaming", + "ปิด SteamVR และ Vive Business Streaming", + "إغلاق SteamVR و Vive Business Streaming" + ); + public static string MsgSteamVrPreFlightBody(string processesPreview, int rootKeysChanging) => T( + $"{rootKeysChanging} bloc(s) de la configuration SteamVR vont être mis à jour.\n\nPour appliquer ces changements (mapping trackers, bindings), le launcher doit fermer ces programmes :\n\n • {processesPreview}\n\nIls pourront être relancés normalement après l'install. Continuer ?", + $"{rootKeysChanging} SteamVR config block(s) will be updated.\n\nTo apply these changes (tracker mappings, bindings), the launcher must close these programs:\n\n • {processesPreview}\n\nYou'll be able to relaunch them normally after the install. Proceed?", + $"将更新 {rootKeysChanging} 个 SteamVR 配置块。\n\n为应用这些更改(追踪器映射、绑定),启动器必须关闭这些程序:\n\n • {processesPreview}\n\n安装完成后您可以正常重新启动它们。继续吗?", + $"จะอัปเดตบล็อกการกำหนดค่า SteamVR {rootKeysChanging} บล็อก\n\nเพื่อใช้การเปลี่ยนแปลงเหล่านี้ (การจับคู่ตัวติดตาม การผูก) ตัวเปิดต้องปิดโปรแกรมเหล่านี้:\n\n • {processesPreview}\n\nคุณสามารถเปิดใหม่ได้ตามปกติหลังติดตั้ง ดำเนินการต่อ?", + $"سيتم تحديث {rootKeysChanging} كتلة (كتل) من تكوين SteamVR.\n\nلتطبيق هذه التغييرات (تعيينات أجهزة التتبع، الارتباطات)، يجب أن يغلق المشغل هذه البرامج:\n\n • {processesPreview}\n\nيمكنك إعادة تشغيلها بشكل طبيعي بعد التثبيت. متابعة؟" + ); + public static string MsgSteamVrMergeFailed(string detail) => T( + $"Le merge des settings SteamVR a échoué :\n\n{detail}\n\nLa version PROSERVE est installée et fonctionnera, mais le mapping des trackers Vive Business Streaming pourrait être incorrect. Vérifie depuis SteamVR → Paramètres → Périphériques après le premier lancement.", + $"SteamVR settings merge failed:\n\n{detail}\n\nThe PROSERVE version is installed and will run, but Vive Business Streaming tracker bindings might be off. Verify from SteamVR → Settings → Devices after the first launch.", + $"SteamVR 设置合并失败:\n\n{detail}\n\nPROSERVE 版本已安装且将运行,但 Vive Business Streaming 跟踪器绑定可能不正确。首次启动后请通过 SteamVR → 设置 → 设备 进行检查。", + $"การรวมการตั้งค่า SteamVR ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วและจะทำงาน แต่การจับคู่ตัวติดตาม Vive Business Streaming อาจไม่ถูกต้อง ตรวจสอบจาก SteamVR → การตั้งค่า → อุปกรณ์ หลังเปิดครั้งแรก", + $"فشل دمج إعدادات SteamVR:\n\n{detail}\n\nنسخة PROSERVE مثبتة وستعمل، لكن قد يكون ربط أجهزة تتبع Vive Business Streaming غير صحيح. تحقق من SteamVR ← الإعدادات ← الأجهزة بعد التشغيل الأول." + ); + + public static string SettingsSteamVr => T( + "STEAMVR + VIVE BUSINESS STREAMING", + "STEAMVR + VIVE BUSINESS STREAMING", + "STEAMVR + VIVE BUSINESS STREAMING", + "STEAMVR + VIVE BUSINESS STREAMING", + "STEAMVR + VIVE BUSINESS STREAMING" + ); + public static string SettingsSteamVrAutoMerge => T( + "Fusionner steamvr.vrsettings à l'install (tue SteamVR + Vive Business Streaming, modifie le JSON, laisse SteamVR redémarrer au prochain lancement PROSERVE)", + "Merge steamvr.vrsettings on install (kills SteamVR + Vive Business Streaming, edits the JSON, lets SteamVR restart on next PROSERVE launch)", + "安装时合并 steamvr.vrsettings(终止 SteamVR + Vive Business Streaming,编辑 JSON,下次启动 PROSERVE 时让 SteamVR 重新启动)", + "รวม steamvr.vrsettings เมื่อติดตั้ง (ปิด SteamVR + Vive Business Streaming, แก้ JSON, ปล่อยให้ SteamVR เริ่มใหม่เมื่อเปิด PROSERVE ครั้งถัดไป)", + "دمج steamvr.vrsettings عند التثبيت (إنهاء SteamVR + Vive Business Streaming، تعديل JSON، السماح لـ SteamVR بإعادة التشغيل عند تشغيل PROSERVE التالي)" + ); + public static string SettingsSteamVrSettingsPath => T( + "Chemin vers steamvr.vrsettings (vide = auto-détection via registre Steam)", + "Path to steamvr.vrsettings (empty = auto-detect via Steam registry)", + "steamvr.vrsettings 路径(空 = 通过 Steam 注册表自动检测)", + "พาธไปยัง steamvr.vrsettings (ว่าง = ตรวจหาอัตโนมัติผ่าน Steam registry)", + "مسار steamvr.vrsettings (فارغ = اكتشاف تلقائي عبر سجل Steam)" + ); + public static string SettingsSteamVrProcesses => T( + "Processus à tuer avant le merge (un nom par ligne, sans .exe). Vive Business Streaming doit être en tête car il relance SteamVR.", + "Processes to kill before merging (one name per line, no .exe). Vive Business Streaming must be first because it auto-restarts SteamVR.", + "合并前要终止的进程(每行一个名称,无 .exe)。Vive Business Streaming 必须放在第一位,因为它会自动重启 SteamVR。", + "โพรเซสที่ต้องปิดก่อนรวม (หนึ่งชื่อต่อบรรทัด ไม่ใส่ .exe) Vive Business Streaming ต้องอยู่บนสุดเพราะมันรีสตาร์ท SteamVR อัตโนมัติ", + "العمليات التي يجب إنهاؤها قبل الدمج (اسم واحد لكل سطر، بدون .exe). يجب أن يكون Vive Business Streaming في المقدمة لأنه يعيد تشغيل SteamVR تلقائياً." + ); + // ---- Cache LAN P2P ---- public static string StatusDownloadingFromPeer(string host, string version) => T( $"📡 Téléchargement de v{version} depuis {host} (LAN)…", diff --git a/src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs b/src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs index f6caa27..3a204bb 100644 --- a/src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs +++ b/src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs @@ -79,6 +79,12 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService return new MigrationResult(Array.Empty(), AllSucceeded: true, FailureMessage: null); } + // Première barrière : vérifier que la base configurée existe, sinon la + // créer. Sans ça la toute première install d'un PC neuf (base pas + // encore présente côté MySQL) plantait dès `OpenAsync` avec + // "Unknown database 'proserveapi'" et l'install s'arrêtait là. + await EnsureDatabaseExistsAsync(ct).ConfigureAwait(false); + await using var conn = await OpenAsync(ct).ConfigureAwait(false); await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false); @@ -187,6 +193,57 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService return conn; } + /// + /// Ouvre une connexion SANS spécifier Database dans la connection + /// string, puis exécute CREATE DATABASE IF NOT EXISTS. Évite l'erreur + /// "Unknown database" à la toute première install d'un PC neuf où l'opérateur + /// n'a pas créé la base à la main. + /// + /// L'identifier est quoté avec des backticks pour gérer les noms exotiques + /// (espaces, caractères spéciaux) ; on filtre quand même les backticks + /// pour éviter une injection si la config était trafiquée à la main. + /// + private async Task EnsureDatabaseExistsAsync(CancellationToken ct) + { + var cfg = _configProvider(); + if (string.IsNullOrWhiteSpace(cfg.Database)) + { + _logger.LogWarning("Database config has no Database name — cannot ensure-exists"); + return; + } + + var pwd = _passwordProvider() ?? string.Empty; + var csb = new MySqlConnectionStringBuilder + { + Server = cfg.Host, + Port = cfg.Port, + UserID = cfg.User, + Password = pwd, + // PAS de Database = on se connecte au serveur sans base sélectionnée. + // Le user mysql doit avoir CREATE DATABASE sinon ça échouera ici — + // dans ce cas l'opérateur sait que la base doit être créée à la main. + Pooling = false, + ConnectionTimeout = 10, + DefaultCommandTimeout = 30, + }; + + // Sanitize l'identifier : MySQL identifiers peuvent contenir n'importe + // quoi entre backticks, mais on rejette les backticks internes par + // sécurité (pas d'injection même si la config est falsifiée). + var safeDbName = cfg.Database.Replace("`", ""); + var ddl = $"CREATE DATABASE IF NOT EXISTS `{safeDbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; + + await using var conn = new MySqlConnection(csb.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + await using var cmd = new MySqlCommand(ddl, conn); + var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + // affected == 1 → base créée. affected == 0 → existait déjà. + if (affected > 0) + _logger.LogInformation("Created database `{Database}` (did not exist yet)", safeDbName); + else + _logger.LogDebug("Database `{Database}` already exists, no-op", safeDbName); + } + private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct) { const string ddl = $""" diff --git a/src/PSLauncher.Core/ReportTool/ReportToolDeployer.cs b/src/PSLauncher.Core/ReportTool/ReportToolDeployer.cs index d097bb9..5f5867c 100644 --- a/src/PSLauncher.Core/ReportTool/ReportToolDeployer.cs +++ b/src/PSLauncher.Core/ReportTool/ReportToolDeployer.cs @@ -50,9 +50,23 @@ public sealed class ReportToolDeployer : IReportToolDeployer if (!Directory.Exists(cfg.HtdocsRoot)) { - _logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot); - return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target, - $"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)"); + // Crée le dossier htdocs même si XAMPP n'est pas (encore) installé. + // Cas typique : PC fraichement déployé où XAMPP sera installé après. + // L'install PROSERVE peut tout de même s'exécuter — le déploiement + // produira un dossier prêt à servir dès qu'Apache sera up. + try + { + Directory.CreateDirectory(cfg.HtdocsRoot); + _logger.LogWarning( + "HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.", + cfg.HtdocsRoot); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot); + return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target, + $"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}"); + } } var stagingTarget = target + ".new"; diff --git a/src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs b/src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs new file mode 100644 index 0000000..78447b8 --- /dev/null +++ b/src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs @@ -0,0 +1,114 @@ +namespace PSLauncher.Core.SteamVr; + +/// +/// Fusionne récursivement le steamvr.vrsettings bundled dans le ZIP +/// PROSERVE (sous-dossier _steamvr/) dans le fichier SteamVR de l'utilisateur. +/// +/// Sémantique de merge (deep, récursive) : +/// +/// Clé absente côté cible → ajoutée (clone profond du source) +/// Clé présente des deux côtés, valeurs JSON objet → recursivement fusionnées +/// (les sous-clés cible non touchées par le source sont préservées — +/// critique pour des blocs comme "trackers" qui contiennent +/// potentiellement déjà d'autres devices) +/// Clé présente, valeurs primitives ou de types différents (string, +/// bool, number, array) → la valeur source écrase celle de cible +/// Clés racine côté cible qui ne sont PAS dans le source → 100 % +/// préservées (on ne touche QUE ce que le source apporte) +/// +/// +/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne +/// rétablissent leurs valeurs en mémoire au prochain flush vers disque +/// (Vive Business Streaming en premier car il relance SteamVR sinon). +/// +public interface ISteamVrSettingsDeployer +{ + /// + /// Dry-run : détermine si un merge réel modifierait la cible. Permet à + /// MainViewModel de skipper la phase entière (popup + kill SteamVR/VBS + + /// merge) quand la cible est déjà à jour. Aucun processus tué, aucun + /// fichier touché. Bénéfice opérationnel : sur un PC où la conf SteamVR + /// est déjà conforme à la release courante, la ré-install ne dérange pas + /// l'opérateur ni les sessions VR éventuellement en cours. + /// + Task CheckMergeNeededAsync( + string installFolder, + CancellationToken ct); + + /// + /// Liste explicite des noms de processus à terminer avant le merge. + /// MainViewModel construit cette liste depuis les health checks de type + /// "Process" (= les processus que l'opérateur surveille déjà = ceux qui + /// risquent de garder un handle sur le fichier SteamVR ou de relancer + /// SteamVR). VBS doit y être en tête. Si null ou vide, on retombe sur + /// la liste par défaut de . + /// + Task MergeAsync( + string installFolder, + IReadOnlyList? processesToKill, + CancellationToken ct); +} + +/// +/// Résultat du dry-run . +/// +public enum SteamVrMergeCheckOutcome +{ + /// La cible contient déjà toutes les clés du source avec les + /// mêmes valeurs (deep equality) — aucun changement ne serait apporté. + /// MainViewModel doit skipper la phase sans déranger l'opérateur. + UpToDate, + /// Le merge apporterait au moins un changement (clé ajoutée ou + /// valeur remplacée) — MainViewModel doit proposer le pop-up et procéder. + Needed, + /// Pas de _steamvr/steamvr.vrsettings dans le ZIP — rien à faire. + NoSourceFile, + /// Option AutoMerge=false dans la config — l'opérateur l'a désactivé. + DisabledByConfig, + /// Steam pas détecté sur la machine. + SteamNotFound, + /// JSON source ou cible invalide. MainViewModel peut log warn. + InvalidJson, +} + +/// État global du check. +/// Chemin résolu vers steamvr.vrsettings. Non-null +/// si Steam a été localisé, même si le fichier n'existe pas encore. +/// Nombre de clés racine qui seraient ajoutées +/// ou modifiées. 0 ssi est . +/// Affiché dans le popup pour donner du contexte à l'opérateur. +/// Détail technique en cas d'. +public sealed record SteamVrMergeCheckResult( + SteamVrMergeCheckOutcome Outcome, + string? TargetPath, + int RootKeysWouldChange, + string? ErrorMessage = null); + +public enum SteamVrMergeStatus +{ + /// Pas de _steamvr/steamvr.vrsettings dans le ZIP, no-op silencieux. + SkippedNoSourceFile, + /// Option SteamVrConfig.AutoMerge=false, l'opérateur l'a explicitement désactivé. + SkippedDisabledByConfig, + /// Steam introuvable (ni via registre ni au path par défaut). + SteamNotFound, + /// Le fichier source ou cible n'est pas un JSON valide. + InvalidJson, + /// Tout s'est bien passé. + Merged, + /// Erreur I/O ou autre exception non récupérable. + Failed, +} + +/// Nombre de clés racine qui existaient déjà côté cible (overwritées). +/// Nombre de clés racine ajoutées (n'existaient pas côté cible). +/// Chemin du fichier modifié. Null si le merge a été skippé. +/// Nombre total de processus tués avant le merge. +/// Détail technique en cas de Failed / InvalidJson. +public sealed record SteamVrMergeResult( + SteamVrMergeStatus Status, + int KeysReplaced, + int KeysAdded, + string? TargetPath, + int ProcessesKilled, + string? ErrorMessage = null); diff --git a/src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs b/src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs new file mode 100644 index 0000000..99f9854 --- /dev/null +++ b/src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs @@ -0,0 +1,589 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; +using PSLauncher.Models; +using SysProcess = System.Diagnostics.Process; + +namespace PSLauncher.Core.SteamVr; + +/// +/// Implémentation du merge SteamVR. Voir . +/// +public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer +{ + /// Sous-dossier dans le ZIP PROSERVE où chercher le fichier source. + private const string SourceFolderName = "_steamvr"; + /// Nom canonique du fichier de settings SteamVR (côté source ET cible). + private const string SettingsFileName = "steamvr.vrsettings"; + + /// + /// Délai d'attente entre le kill des processus et l'ouverture du fichier. + /// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements + /// au shutdown ⇒ on attend que le process se termine vraiment avant de toucher + /// au fichier. 1 s suffit en pratique mais 2 s pour avoir une marge. + /// + private static readonly TimeSpan PostKillSettleDelay = TimeSpan.FromSeconds(2); + + private readonly Func _configProvider; + private readonly ILogger _logger; + + public SteamVrSettingsDeployer( + Func configProvider, + ILogger logger) + { + _configProvider = configProvider; + _logger = logger; + } + + public async Task CheckMergeNeededAsync( + string installFolder, + CancellationToken ct) + { + var config = _configProvider(); + + // 1) Fichier source présent ? + var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName); + if (!File.Exists(sourcePath)) + { + return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.NoSourceFile, null, 0); + } + + // 2) Option activée ? + if (!config.AutoMerge) + { + return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.DisabledByConfig, null, 0); + } + + // 3) Cible localisée ? + var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride) + ? config.SettingsFilePathOverride! + : FindSteamVrSettingsPath(); + if (targetPath is null) + { + return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.SteamNotFound, null, 0); + } + + // 4) Parse source. Si invalide, le vrai merge échouera de toute façon. + JsonObject sourceObj; + try + { + var sourceText = await File.ReadAllTextAsync(sourcePath, ct); + sourceObj = JsonNode.Parse(sourceText) as JsonObject + ?? throw new InvalidDataException("source root is not a JSON object"); + } + catch (Exception ex) + { + return new SteamVrMergeCheckResult( + SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0, + $"Source JSON invalide : {ex.Message}"); + } + + // 5) Si la cible n'existe pas, tout est à ajouter — merge nécessaire. + if (!File.Exists(targetPath)) + { + _logger.LogInformation( + "SteamVR target file does not exist yet ({Path}) — merge needed (will create from scratch)", + targetPath); + return new SteamVrMergeCheckResult( + SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count); + } + + // 6) Parse cible. Si invalide, on signale mais le vrai merge serait + // contraint d'écrire (= overwrite) une cible illisible. + JsonObject targetObj; + try + { + var targetText = await File.ReadAllTextAsync(targetPath, ct); + if (string.IsNullOrWhiteSpace(targetText)) + { + // Fichier vide ⇒ merge ajouterait tout le source. + return new SteamVrMergeCheckResult( + SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count); + } + targetObj = JsonNode.Parse(targetText) as JsonObject + ?? throw new InvalidDataException("target root is not a JSON object"); + } + catch (Exception ex) + { + return new SteamVrMergeCheckResult( + SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0, + $"Cible JSON invalide : {ex.Message}"); + } + + // 7) Deep-compare. On compte les clés RACINE qui changeraient (added ou + // modified) — c'est le compteur lisible pour l'opérateur. Les sous-clés + // d'un bloc qui change ne sont pas comptées individuellement. + int rootKeysChanging = 0; + foreach (var (key, srcValue) in sourceObj) + { + if (!targetObj.ContainsKey(key)) + { + rootKeysChanging++; + continue; + } + var tgtValue = targetObj[key]; + if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild) + { + if (WouldDeepMergeChange(tgtChild, srcChild)) + rootKeysChanging++; + } + else if (!JsonNodesEqual(tgtValue, srcValue)) + { + rootKeysChanging++; + } + } + + var outcome = rootKeysChanging > 0 + ? SteamVrMergeCheckOutcome.Needed + : SteamVrMergeCheckOutcome.UpToDate; + _logger.LogInformation( + "SteamVR merge check: outcome={Outcome}, rootKeysChanging={Count}, target={Path}", + outcome, rootKeysChanging, targetPath); + return new SteamVrMergeCheckResult(outcome, targetPath, rootKeysChanging); + } + + public async Task MergeAsync( + string installFolder, + IReadOnlyList? processesToKill, + CancellationToken ct) + { + var config = _configProvider(); + + // 1. Cherche le fichier source dans le ZIP extrait. Pas de fichier ⇒ + // pas de merge ⇒ silent skip (toutes les versions PROSERVE ne shippent + // pas forcément un _steamvr/). + var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName); + if (!File.Exists(sourcePath)) + { + _logger.LogDebug("No _steamvr/{File} in {Folder}, skipping SteamVR merge", + SettingsFileName, installFolder); + return new SteamVrMergeResult( + SteamVrMergeStatus.SkippedNoSourceFile, 0, 0, null, 0); + } + + // 2. Opt-out par config (peut être désactivé sur des machines de dev). + if (!config.AutoMerge) + { + _logger.LogInformation("SteamVR merge disabled by config (AutoMerge=false)"); + return new SteamVrMergeResult( + SteamVrMergeStatus.SkippedDisabledByConfig, 0, 0, null, 0); + } + + // 3. Résout le path du fichier cible (override config OU registre OU fallback). + var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride) + ? config.SettingsFilePathOverride! + : FindSteamVrSettingsPath(); + if (targetPath is null) + { + _logger.LogWarning( + "Steam install not found via registry + fallback, cannot merge SteamVR settings"); + return new SteamVrMergeResult( + SteamVrMergeStatus.SteamNotFound, 0, 0, null, 0, + "Steam introuvable (registre HKCU\\Software\\Valve\\Steam + HKLM\\Wow6432Node + %ProgramFiles(x86)%\\Steam)"); + } + + try + { + // 4. Tue SteamVR + Vive Business Streaming. Vive en PREMIER car il + // relance auto SteamVR sinon. On parse + write avec un délai de + // règlement APRES, parce que les processus flushent leurs settings + // au shutdown. + // + // Le caller (MainViewModel) construit la liste depuis les health + // checks de type Process (= les programmes que l'opérateur surveille + // déjà). Si rien n'est passé, on retombe sur la liste par défaut + // de la config — utile pour les setups sans health checks configurés. + var killList = (processesToKill is not null && processesToKill.Count > 0) + ? processesToKill + : config.ProcessesToKill; + var killed = await KillProcessesAsync(killList, ct); + if (killed > 0) + { + _logger.LogInformation( + "Killed {Count} SteamVR/VBS process(es), waiting {Delay}ms for file release", + killed, (int)PostKillSettleDelay.TotalMilliseconds); + await Task.Delay(PostKillSettleDelay, ct); + } + + // 5. Parse le JSON source. Si le fichier est invalide, on log et on + // abandonne — pas la peine de risquer un overwrite avec une cible + // potentiellement valide. + JsonObject sourceObj; + try + { + var sourceText = await File.ReadAllTextAsync(sourcePath, ct); + sourceObj = JsonNode.Parse(sourceText) as JsonObject + ?? throw new InvalidDataException("source root is not a JSON object"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Source SteamVR settings file is not valid JSON: {Path}", sourcePath); + return new SteamVrMergeResult( + SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed, + $"Source JSON invalide : {ex.Message}"); + } + + // 6. Parse le JSON cible (si absent ou vide → on part d'un objet + // vide, on créera le fichier from scratch). Si le fichier existe + // mais est invalide, on abandonne (mieux vaut un fichier intact + // que de l'écraser). + JsonObject targetObj; + if (File.Exists(targetPath)) + { + try + { + var targetText = await File.ReadAllTextAsync(targetPath, ct); + if (string.IsNullOrWhiteSpace(targetText)) + { + targetObj = new JsonObject(); + } + else + { + targetObj = JsonNode.Parse(targetText) as JsonObject + ?? throw new InvalidDataException("target root is not a JSON object"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Target SteamVR settings file is not valid JSON: {Path}", targetPath); + return new SteamVrMergeResult( + SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed, + $"Cible JSON invalide : {ex.Message}"); + } + } + else + { + _logger.LogInformation( + "Target SteamVR settings file does not exist yet, creating: {Path}", + targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + targetObj = new JsonObject(); + } + + // 7. Deep merge récursif : + // • Clé absente côté cible → ajoute (copie complète du source) + // • Clé présente des DEUX côtés ET les deux valeurs sont des objets + // JSON → recurse (préserve les sous-clés cible non touchées par le source) + // • Sinon (types différents, ou tableau, ou primitive) → le source gagne + // (full replace de la valeur) + // + // Cas concret du bloc "trackers" : si le fichier user contient déjà + // "trackers" : { "/devices/oculus/.../X" : "TrackerRole_...", + // "/devices/htc/TKR_LEFT" : "ancienne valeur" } + // et que le source apporte + // "trackers" : { "/devices/htc_business_streaming/TKR_LEFT" : "...", + // "/devices/htc_business_streaming/TKR_RIGHT" : "..." } + // → résultat fusionné = les 3 clés oculus + 2 htc_business_streaming + // (oculus préservée, ancienne htc/TKR_LEFT préservée puisque clé + // différente, htc_business_streaming/* ajoutées). + // + // Les compteurs top-level reflètent uniquement les clés RACINE + // (remplacées ou ajoutées) parce que c'est le niveau visible dans + // les logs et le seul niveau que l'opérateur a besoin de monitorer. + int replaced = 0, added = 0; + foreach (var (key, srcValue) in sourceObj.ToList()) + { + if (!targetObj.ContainsKey(key)) + { + targetObj[key] = srcValue?.DeepClone(); + added++; + continue; + } + + // Clé présente des deux côtés. Si les deux valeurs sont des + // objets JSON, on deep-merge ; sinon le source remplace. + if (srcValue is JsonObject srcChild + && targetObj[key] is JsonObject tgtChild) + { + DeepMergeInto(tgtChild, srcChild); + } + else + { + targetObj[key] = srcValue?.DeepClone(); + } + replaced++; + } + + // 8. Write atomique via .tmp + rename. SteamVR utilise un JSON + // indenté à 3 espaces dans ses propres écritures — on respecte le + // style pour minimiser le diff visuel pour un opérateur qui ouvre + // le fichier en éditeur. + var json = targetObj.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true, + }); + var tmpPath = targetPath + ".pslauncher-tmp"; + await File.WriteAllTextAsync(tmpPath, json, ct); + // File.Move(overwrite=true) est atomique sur NTFS (.NET 5+) + File.Move(tmpPath, targetPath, overwrite: true); + + _logger.LogInformation( + "SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}", + replaced, added, targetPath); + return new SteamVrMergeResult( + SteamVrMergeStatus.Merged, replaced, added, targetPath, killed); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "SteamVR merge failed unexpectedly"); + return new SteamVrMergeResult( + SteamVrMergeStatus.Failed, 0, 0, targetPath, 0, + ex.Message); + } + } + + /// + /// Path canonique de Steam x86 quand il est installé à l'emplacement + /// standard (90 % des cas). Exposé pour que la UI Settings puisse afficher + /// la valeur attendue en placeholder du TextBox d'override. + /// + public static string CanonicalSettingsPath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + "Steam", "config", "steamvr.vrsettings"); + + /// + /// Résout le path vers steamvr.vrsettings. Stratégie : on essaie les + /// 3 sources dans l'ordre, et on retourne la PREMIÈRE qui pointe vers un + /// dossier Steam qui existe vraiment sur disque. Le fichier lui-même peut + /// ne pas exister encore — on le créera au write. + /// + /// HKCU\Software\Valve\Steam → SteamPath (valeur typique : C:/Program Files (x86)/Steam) + /// HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath (typique : C:\Program Files (x86)\Steam) + /// Fallback hardcodé : %ProgramFiles(x86)%\Steam\config\steamvr.vrsettings + /// + /// Retourne null si AUCUNE des 3 sources ne pointe vers un dossier Steam + /// existant — Steam n'est probablement pas installé sur cette machine. + /// + private string? FindSteamVrSettingsPath() + { + // Énumère les candidats dans l'ordre de priorité. Pour chacun on + // vérifie que la racine Steam (= grand-parent du fichier final) existe + // physiquement avant d'accepter — sinon un opérateur qui a désinstallé + // Steam puis ré-installé ailleurs avait des fantômes du registre qui + // nous renvoyaient vers un dossier inexistant. + var candidates = new (string Path, string Source)[] + { + (TryRegistryPath(Registry.CurrentUser, @"Software\Valve\Steam", "SteamPath"), + "HKCU\\Software\\Valve\\Steam"), + (TryRegistryPath(Registry.LocalMachine, @"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath"), + "HKLM\\Wow6432Node\\Valve\\Steam"), + (CanonicalSettingsPath, "fallback %ProgramFiles(x86)%\\Steam"), + }; + + foreach (var (candidate, source) in candidates) + { + if (string.IsNullOrWhiteSpace(candidate)) continue; + // grand-parent = .../Steam (parent = .../Steam/config) + var steamRoot = Path.GetDirectoryName(Path.GetDirectoryName(candidate)); + if (steamRoot is null || !Directory.Exists(steamRoot)) + { + _logger.LogDebug( + "Skipping SteamVR candidate from {Source} (Steam root {Root} doesn't exist)", + source, steamRoot); + continue; + } + _logger.LogInformation( + "Resolved SteamVR settings target via {Source}: {Path} (file {Exists})", + source, candidate, File.Exists(candidate) ? "exists" : "will be created"); + return candidate; + } + + return null; + } + + /// + /// Lit une string sous une clé registre + sous-clé nommée, normalise les + /// slashes Unix → Windows, puis compose le path vers config\{SettingsFileName}. + /// Retourne string.Empty si la clé n'existe pas ou si la valeur est vide. + /// Wrappé en try/catch silencieux : un manque de droits sur HKLM n'est pas + /// fatal — on tombera sur le candidat suivant. + /// + private string TryRegistryPath(RegistryKey root, string subKey, string valueName) + { + try + { + using var key = root.OpenSubKey(subKey); + if (key?.GetValue(valueName) is string raw && !string.IsNullOrWhiteSpace(raw)) + { + var normalized = raw.Replace('/', '\\').TrimEnd('\\'); + return Path.Combine(normalized, "config", SettingsFileName); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Registry read failed for {Root}\\{SubKey}!{Value}", + root.Name, subKey, valueName); + } + return string.Empty; + } + + /// + /// Tue tous les processus dont le nom (sans .exe) correspond à un des + /// patterns de (case-insensitive). Best-effort : + /// si on ne peut pas killer un process (AccessDenied, déjà parti), on log + /// et on continue. Retourne le nombre total de process effectivement tués. + /// + /// L'ordre des entrées dans la liste compte : Vive Business Streaming + /// doit être en TÊTE car il relance SteamVR sinon. + /// + /// + /// Fusionne récursivement dans . + /// Règles par clé : + /// • absente côté cible → ajout (clone profond de la valeur source) + /// • présente des deux côtés ET les deux valeurs sont des objets JSON → recurse + /// • sinon → la valeur source écrase celle de cible + /// + /// Les arrays JSON sont traités comme des "primitives" (= replace complet, + /// pas de merge par index ni dédup). SteamVR n'utilise pas d'arrays dans + /// ses settings de toute façon, c'est juste pour avoir un comportement + /// prévisible si jamais. + /// + /// + /// Dry-run récursif : applique mentalement le même algo que + /// et retourne true dès qu'au moins UNE clé du + /// source serait ajoutée ou modifiée côté target. Short-circuit pour ne + /// pas parcourir le JSON entier inutilement quand on a déjà trouvé un diff. + /// + private static bool WouldDeepMergeChange(JsonObject target, JsonObject source) + { + foreach (var (key, srcValue) in source) + { + if (!target.ContainsKey(key)) return true; + var tgtValue = target[key]; + + if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild) + { + if (WouldDeepMergeChange(tgtChild, srcChild)) return true; + } + else if (!JsonNodesEqual(tgtValue, srcValue)) + { + return true; + } + } + return false; + } + + /// + /// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays, + /// primitives). System.Text.Json n'expose pas DeepEquals avant .NET 9 ; + /// on l'implémente à la main. Pour les primitives, on compare via la + /// serialization JSON par défaut — ToJsonString produit un format canonique + /// pour chaque type (number, string, bool, null), donc deux valeurs + /// logiquement égales produiront la même chaîne. + /// + private static bool JsonNodesEqual(JsonNode? a, JsonNode? b) + { + if (a is null && b is null) return true; + if (a is null || b is null) return false; + + if (a is JsonObject aObj && b is JsonObject bObj) + { + if (aObj.Count != bObj.Count) return false; + foreach (var (key, aVal) in aObj) + { + if (!bObj.TryGetPropertyValue(key, out var bVal)) return false; + if (!JsonNodesEqual(aVal, bVal)) return false; + } + return true; + } + + if (a is JsonArray aArr && b is JsonArray bArr) + { + if (aArr.Count != bArr.Count) return false; + for (int i = 0; i < aArr.Count; i++) + { + if (!JsonNodesEqual(aArr[i], bArr[i])) return false; + } + return true; + } + + if (a is JsonValue && b is JsonValue) + { + // Primitives : la sérialisation canonique gère uniformément les + // numbers (int/long/double), strings, bools, null. + return string.Equals(a.ToJsonString(), b.ToJsonString(), StringComparison.Ordinal); + } + + // Types incompatibles (ex: object vs primitive) + return false; + } + + private static void DeepMergeInto(JsonObject target, JsonObject source) + { + foreach (var (key, srcValue) in source.ToList()) + { + if (!target.ContainsKey(key)) + { + target[key] = srcValue?.DeepClone(); + continue; + } + + if (srcValue is JsonObject srcChild + && target[key] is JsonObject tgtChild) + { + DeepMergeInto(tgtChild, srcChild); + } + else + { + target[key] = srcValue?.DeepClone(); + } + } + } + + private async Task KillProcessesAsync( + IReadOnlyList processNames, + CancellationToken ct) + { + int total = 0; + foreach (var name in processNames) + { + ct.ThrowIfCancellationRequested(); + var trimmed = name?.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + // GetProcessesByName attend le nom SANS le .exe ; un opérateur peut + // l'avoir saisi avec → strip défensif. + if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + trimmed = trimmed[..^4]; + + SysProcess[] procs; + try { procs = SysProcess.GetProcessesByName(trimmed); } + catch (Exception ex) + { + _logger.LogDebug(ex, "GetProcessesByName({Name}) failed", trimmed); + continue; + } + + foreach (var p in procs) + { + try + { + if (p.HasExited) { p.Dispose(); continue; } + _logger.LogInformation("Killing process {Name} (PID {Pid})", p.ProcessName, p.Id); + p.Kill(entireProcessTree: true); + // Wait un peu pour que le process release ses handles + try { await p.WaitForExitAsync(ct); } + catch (OperationCanceledException) { throw; } + catch { /* race avec Kill possible, ok */ } + total++; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not kill process {Name} (PID {Pid}) — continuing", + p.ProcessName, p.Id); + } + finally + { + try { p.Dispose(); } catch { } + } + } + } + return total; + } +} diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs index a240aaf..2566cba 100644 --- a/src/PSLauncher.Models/LocalConfig.cs +++ b/src/PSLauncher.Models/LocalConfig.cs @@ -71,6 +71,15 @@ public sealed class LocalConfig /// public ApiToolConfig ApiTool { get; set; } = new(); + /// + /// Configuration du merge des settings SteamVR. Si le ZIP contient un sous-dossier + /// _steamvr/ avec steamvr.vrsettings, le launcher arrête SteamVR + /// et Vive Business Streaming, fusionne les blocs racine du JSON source dans le + /// fichier SteamVR de l'utilisateur (replace si la clé existe, ajoute sinon), + /// puis laisse SteamVR redémarrer naturellement quand l'utilisateur lance PROSERVE. + /// + public SteamVrConfig SteamVr { get; set; } = new(); + /// /// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés /// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant @@ -315,6 +324,64 @@ public sealed class HealthCheckEntry public int? PingTimeoutMs { get; set; } = 1000; } +/// +/// Configuration du merge SteamVR au moment de l'install d'une nouvelle version +/// PROSERVE. Le ZIP peut contenir un dossier _steamvr/steamvr.vrsettings +/// avec des blocs racine spécifiques au jeu (system.generated.openxr.*, trackers, +/// etc.). Si est true, le launcher : +/// +/// tue SteamVR + Vive Business Streaming (qui le relance auto) +/// localise steamvr.vrsettings via le registre Steam (ou ) +/// parse les deux JSON, remplace chaque clé racine du source dans la cible (ou ajoute si absente) +/// écrit le résultat atomiquement (rename .tmp → final) +/// +/// SteamVR redémarrera naturellement quand l'opérateur relance PROSERVE après +/// l'install. +/// +public sealed class SteamVrConfig +{ + /// + /// Si true, le launcher applique le merge automatiquement à chaque install + /// quand _steamvr/steamvr.vrsettings est présent dans le ZIP. Default + /// true (transparent pour l'opérateur), mais peut être désactivé pour les + /// machines de dev où le settings doit rester intact. + /// + public bool AutoMerge { get; set; } = true; + + /// + /// Path explicite vers le steamvr.vrsettings à modifier. Null → + /// auto-détection via : + /// + /// HKCU\Software\Valve\Steam → SteamPath → config\steamvr.vrsettings + /// HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath → idem + /// fallback %ProgramFilesX86%\Steam\config\steamvr.vrsettings + /// + /// + public string? SettingsFilePathOverride { get; set; } + + /// + /// Processus à tuer avant le merge (sans le suffixe .exe). Default = la + /// liste typique SteamVR + Vive Business Streaming. Vive Business Streaming + /// relance auto SteamVR ⇒ il faut le tuer EN PREMIER. Editable depuis + /// Settings pour les setups exotiques (autres runtimes XR). + /// + public List ProcessesToKill { get; set; } = new() + { + // Vive Business Streaming (à tuer en premier — relance SteamVR sinon) + "VIVE Business Streaming", + "RRConsole", + "RRServer", + "HtcConnectionUtility", + // SteamVR + "vrserver", + "vrmonitor", + "vrcompositor", + "vrdashboard", + "vrwebhelper", + "vrstartup", + }; +} + /// /// Configuration du mode auto. est le kill-switch /// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et