v0.29.7 — SteamVR merge + résilience DL (404 retry, force fresh, SHA purge)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:45:01 +02:00
parent 01793eb32d
commit 50da755a92
21 changed files with 1768 additions and 27 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.28.12" #define MyAppVersion "0.29.7"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe" #define MyAppExeName "PS_Launcher.exe"

View File

@@ -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 // Génère l'URL HMAC-signée
// TTL : 6 h. Compromis entre : // TTL : 6 h. Compromis entre :
// - sécurité (limite la fenêtre de replay si une URL fuit) // - 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 // Pour une connexion plus lente, le client sait auto-refresher l'URL
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL). // pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
$baseUrl = rtrim($config['base_url'], '/'); $baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip'; $relPath = '/builds/' . $filename;
$exp = time() + 21600; // 6 h $exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? ''; $secret = $config['hmac_secret'] ?? '';
if ($secret === '') { if ($secret === '') {

View File

@@ -25,6 +25,7 @@ using PSLauncher.Core.Health.OpenVRInterop;
using PSLauncher.Core.Migrations; using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process; using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool; using PSLauncher.Core.ReportTool;
using PSLauncher.Core.SteamVr;
using PSLauncher.Core.Updates; using PSLauncher.Core.Updates;
using PSLauncher.Models; using PSLauncher.Models;
using Serilog; using Serilog;
@@ -245,6 +246,15 @@ public partial class App : Application
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool, configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
sp.GetRequiredService<ILogger<ApiToolDeployer>>())); sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
// 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<ISteamVrSettingsDeployer>(sp =>
new SteamVrSettingsDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().SteamVr,
sp.GetRequiredService<ILogger<SteamVrSettingsDeployer>>()));
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà // 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. // téléchargés au lieu de re-télécharger 14 Go depuis OVH.
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA) // - ZipCacheStore : couche de persistence (downloads + sidecar SHA)

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product> <Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.28.12</Version> <Version>0.29.7</Version>
<AssemblyVersion>0.28.12.0</AssemblyVersion> <AssemblyVersion>0.29.7.0</AssemblyVersion>
<FileVersion>0.28.12.0</FileVersion> <FileVersion>0.29.7.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) --> <!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>

View File

@@ -24,6 +24,7 @@ using PSLauncher.Core.Health;
using PSLauncher.Core.Migrations; using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process; using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool; using PSLauncher.Core.ReportTool;
using PSLauncher.Core.SteamVr;
using PSLauncher.Core.Updates; using PSLauncher.Core.Updates;
using PSLauncher.Models; using PSLauncher.Models;
@@ -52,6 +53,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IReportToolDeployer _reportDeployer; private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer; private readonly IDocToolDeployer _docDeployer;
private readonly IApiToolDeployer _apiDeployer; private readonly IApiToolDeployer _apiDeployer;
private readonly ISteamVrSettingsDeployer _steamVrDeployer;
private readonly IPeerSourceResolver _peerResolver; private readonly IPeerSourceResolver _peerResolver;
private readonly IZipCacheStore _zipCacheStore; private readonly IZipCacheStore _zipCacheStore;
private readonly ILanCacheServer _lanCacheServer; private readonly ILanCacheServer _lanCacheServer;
@@ -91,6 +93,14 @@ public sealed partial class MainViewModel : ObservableObject
/// </summary> /// </summary>
private System.Diagnostics.Process? _runningProserve; private System.Diagnostics.Process? _runningProserve;
/// <summary>
/// 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.
/// </summary>
private bool _install404RetryConsumed;
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new(); public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
/// <summary> /// <summary>
@@ -368,6 +378,7 @@ public sealed partial class MainViewModel : ObservableObject
IReportToolDeployer reportDeployer, IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer, IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer, IApiToolDeployer apiDeployer,
ISteamVrSettingsDeployer steamVrDeployer,
IPeerSourceResolver peerResolver, IPeerSourceResolver peerResolver,
IZipCacheStore zipCacheStore, IZipCacheStore zipCacheStore,
ILanCacheServer lanCacheServer, ILanCacheServer lanCacheServer,
@@ -391,6 +402,7 @@ public sealed partial class MainViewModel : ObservableObject
_reportDeployer = reportDeployer; _reportDeployer = reportDeployer;
_docDeployer = docDeployer; _docDeployer = docDeployer;
_apiDeployer = apiDeployer; _apiDeployer = apiDeployer;
_steamVrDeployer = steamVrDeployer;
_peerResolver = peerResolver; _peerResolver = peerResolver;
_zipCacheStore = zipCacheStore; _zipCacheStore = zipCacheStore;
_lanCacheServer = lanCacheServer; _lanCacheServer = lanCacheServer;
@@ -626,6 +638,7 @@ public sealed partial class MainViewModel : ObservableObject
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r); row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder; row.OpenFolderHandler = OpenVersionFolder;
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r); row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
row.ForceFreshDownloadHandler = r => _ = ForceFreshDownloadAsync(r);
row.ToggleAutoModeHandler = ToggleAutoModeForRow; row.ToggleAutoModeHandler = ToggleAutoModeForRow;
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList) // Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
@@ -977,6 +990,69 @@ public sealed partial class MainViewModel : ObservableObject
ProgressPercent = 0; ProgressPercent = 0;
} }
/// <summary>
/// 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.
/// </summary>
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 ------------ // ------------ Per-row actions ------------
private void LaunchVersion(VersionRowViewModel row) private void LaunchVersion(VersionRowViewModel row)
@@ -1170,6 +1246,60 @@ public sealed partial class MainViewModel : ObservableObject
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution). /// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
/// Les clés vides sont skippées silencieusement. /// Les clés vides sont skippées silencieusement.
/// </summary> /// </summary>
/// <summary>
/// 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.
/// </summary>
private List<string> 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<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>();
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<AutoModeArg> args) private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
{ {
var list = new List<string>(); var list = new List<string>();
@@ -1314,6 +1444,13 @@ public sealed partial class MainViewModel : ObservableObject
_activeDownloadCts = new CancellationTokenSource(); _activeDownloadCts = new CancellationTokenSource();
var ct = _activeDownloadCts.Token; 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 try
{ {
// Si reprise d'un DL interrompu, on saute la popup de release notes : // 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 signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
var urlString = signed ?? row.Remote.Download.Url; var urlString = signed ?? row.Remote.Download.Url;
url = new Uri(urlString); 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 // 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 // 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 // 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)"); _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 // 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. // a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
if (_config.Database.AutoApplyMigrations) if (_config.Database.AutoApplyMigrations)
@@ -1697,6 +1985,9 @@ public sealed partial class MainViewModel : ObservableObject
StatusMessage = Strings.StatusInstalledSuccess(row.Version); StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null; ProgressDetail = null;
ProgressPercent = 0; 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(); RebuildList();
_toastService.ShowSuccess( _toastService.ShowSuccess(
Strings.ToastInstallSuccessTitle, Strings.ToastInstallSuccessTitle,
@@ -1713,16 +2004,154 @@ public sealed partial class MainViewModel : ObservableObject
// valeur d'avant le DL (souvent 0% ou l'ancien %). // valeur d'avant le DL (souvent 0% ou l'ancien %).
RefreshResumableBytes(row); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Install failed for {Version}", row.Version); _logger.LogError(ex, "Install failed for {Version}", row.Version);
row.State = VersionRowState.AvailableIdle; row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null; row.ProgressDetail = null;
row.ProgressPercent = 0; row.ProgressPercent = 0;
// 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( ThemedMessageBox.Show(
Strings.MsgInstallFailed(ex.Message), Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error); Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = Strings.StatusError(ex.Message); StatusMessage = Strings.StatusError(ex.Message);
}
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message); _toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
// Idem : si une erreur réseau interrompt le DL en cours de route, le // 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 %. // partial est plus gros qu'avant — il faut refléter le nouveau %.

View File

@@ -102,6 +102,19 @@ public sealed partial class SettingsViewModel : ObservableObject
= new(); = new();
public bool HasApiBackups => ApiBackups.Count > 0; public bool HasApiBackups => ApiBackups.Count > 0;
// ----- SteamVR settings merge -----
[ObservableProperty] private bool _steamVrAutoMerge;
[ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty;
/// <summary>Multi-line text bindé sur un TextBox : 1 process name par ligne (sans .exe).</summary>
[ObservableProperty] private string _steamVrProcessesToKill = string.Empty;
/// <summary>
/// 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.
/// </summary>
public string SteamVrCanonicalPath =>
PSLauncher.Core.SteamVr.SteamVrSettingsDeployer.CanonicalSettingsPath;
// ----- Cache LAN P2P ----- // ----- Cache LAN P2P -----
[ObservableProperty] private bool _lanServerEnabled; [ObservableProperty] private bool _lanServerEnabled;
[ObservableProperty] private bool _lanClientEnabled; [ObservableProperty] private bool _lanClientEnabled;
@@ -257,6 +270,12 @@ public sealed partial class SettingsViewModel : ObservableObject
_apiAutoDeploy = config.ApiTool.AutoDeploy; _apiAutoDeploy = config.ApiTool.AutoDeploy;
_apiMaxBackups = config.ApiTool.MaxBackups; _apiMaxBackups = config.ApiTool.MaxBackups;
_steamVrAutoMerge = config.SteamVr.AutoMerge;
_steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty;
_steamVrProcessesToKill = string.Join(
Environment.NewLine,
config.SteamVr.ProcessesToKill ?? new List<string>());
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled; _autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds; _autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty; _autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
@@ -429,6 +448,23 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ApiTool.AutoDeploy = ApiAutoDeploy; _config.ApiTool.AutoDeploy = ApiAutoDeploy;
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups); _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.FeatureEnabled = AutoModeFeatureEnabled;
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60); _config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion) _config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)

View File

@@ -208,6 +208,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; } public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; } public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; } public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))] [RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this); private void Launch() => LaunchHandler?.Invoke(this);
@@ -231,6 +232,15 @@ public sealed partial class VersionRowViewModel : ObservableObject
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this); private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
private bool CanRestartFromZero() => HasResumableDownload; private bool CanRestartFromZero() => HasResumableDownload;
/// <summary>
/// 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.
/// </summary>
[RelayCommand]
private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this);
private static string FormatSize(long bytes) private static string FormatSize(long bytes)
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes); => bytes <= 0 ? "—" : Strings.FormatSize(bytes);
} }

View File

@@ -194,6 +194,8 @@
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}" <MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> <Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}" <MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
@@ -631,6 +633,8 @@
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}" <MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> <Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}" <MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"

View File

@@ -650,6 +650,52 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- SteamVR : merge _steamvr/steamvr.vrsettings → fichier user à l'install -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVr}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<CheckBox IsChecked="{Binding SteamVrAutoMerge}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsSteamVrAutoMerge}" />
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrSettingsPath}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
<TextBox Text="{Binding SteamVrSettingsPathOverride, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<!-- Affiche le path canonique attendu si Steam est à l'emplacement
par défaut. Évite à l'opérateur d'avoir à chercher dans la doc. -->
<TextBlock FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0"
TextWrapping="Wrap">
<Run Text="Auto-détection standard : " />
<Run Text="{Binding SteamVrCanonicalPath, Mode=OneWay}"
FontFamily="Consolas" />
</TextBlock>
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrProcesses}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
<TextBox Text="{Binding SteamVrProcessesToKill, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="120" MaxHeight="200"
VerticalScrollBarVisibility="Auto" />
</StackPanel>
</Border>
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée --> <!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"

View File

@@ -45,9 +45,20 @@ public sealed class ApiToolDeployer : IApiToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot)) if (!Directory.Exists(cfg.HtdocsRoot))
{ {
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot); // Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
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, return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)"); $"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
} }
var stagingTarget = target + ".new"; var stagingTarget = target + ".new";

View File

@@ -45,9 +45,20 @@ public sealed class DocToolDeployer : IDocToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot)) if (!Directory.Exists(cfg.HtdocsRoot))
{ {
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot); // Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
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, return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)"); $"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
} }
var stagingTarget = target + ".new"; var stagingTarget = target + ".new";

View File

@@ -455,16 +455,52 @@ public sealed class DownloadManager : IDownloadManager
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true); throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
} }
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired"). // 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
// On force un refresh d'URL via le callback du job, puis on jette une // expired"), OU le fichier a été renommé/supprimé côté serveur (cas
// exception transitoire pour que Polly retry avec la nouvelle URL. // typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone) // OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
{ // l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
if (job.RefreshUrlAsync is not null) // vers le fichier actuellement présent sur le serveur. Si refresh donne
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
//
// Note importante : on compare à `url` (la valeur utilisée dans la
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
// (= URL réellement utilisée par CETTE requête, OLD) détecte
// correctement que _currentUrl est maintenant NEW → retry. Si on
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
// un faux 404 fatal sur tous les segments sauf le premier.
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|| resp.StatusCode == HttpStatusCode.Gone
|| resp.StatusCode == HttpStatusCode.NotFound;
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
{ {
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false); await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true); bool urlChanged = !Uri.Equals(url, _currentUrl);
if (urlChanged)
{
_logger.LogInformation(
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
(int)resp.StatusCode, seg.Index, url, _currentUrl);
throw new HttpResumableException(
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
isTransient: true);
} }
// URL unchanged → soit le serveur insiste sur la même URL morte,
// soit le refresh a fallback sur le manifest cached (donc stale).
// 404 non-transient pour arrêter Polly et remonter une erreur claire
// qui guide l'opérateur vers « Vérifier les MAJ ».
if (resp.StatusCode == HttpStatusCode.NotFound)
{
throw new HttpResumableException(
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
isTransient: false);
}
throw new HttpResumableException(
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
} }
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {

View File

@@ -26,11 +26,70 @@ public sealed class ZipInstaller : IZipInstaller
{ {
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false); await Task.Run(() => 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)) 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); 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;
}
_logger.LogInformation("Installed to {Target}", 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; return targetFolder;
} }
catch catch

View File

@@ -45,4 +45,20 @@ public interface IZipCacheStore
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary> /// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
IReadOnlyList<CachedZip> ListCached(); IReadOnlyList<CachedZip> ListCached();
/// <summary>
/// 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 :
/// <list type="number">
/// <item>É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).</item>
/// <item>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).</item>
/// </list>
/// Best-effort : log les erreurs I/O mais ne throw pas.
/// </summary>
Task InvalidateAsync(string version, CancellationToken ct);
} }

View File

@@ -149,6 +149,47 @@ public sealed class ZipCacheStore : IZipCacheStore
}, ct).ConfigureAwait(false); }, 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<CachedZip> ListCached() public IReadOnlyList<CachedZip> ListCached()
{ {
var dir = _downloadStore.GetDownloadsDirectory(); var dir = _downloadStore.GetDownloadsDirectory();

View File

@@ -311,6 +311,91 @@ public static class Strings
$"فشل التنزيل أو التثبيت:\n\n{detail}" $"فشل التنزيل أو التثبيت:\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( public static string MsgUninstallFailed(string detail) => T(
$"Suppression échouée : {detail}", $"Suppression échouée : {detail}",
$"Uninstall failed: {detail}", $"Uninstall failed: {detail}",
@@ -625,6 +710,72 @@ public static class Strings
public static string SettingsApiBackups => SettingsReportBackups; public static string SettingsApiBackups => SettingsReportBackups;
public static string SettingsApiMaxBackups => SettingsReportMaxBackups; 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 ---- // ---- Cache LAN P2P ----
public static string StatusDownloadingFromPeer(string host, string version) => T( public static string StatusDownloadingFromPeer(string host, string version) => T(
$"📡 Téléchargement de v{version} depuis {host} (LAN)…", $"📡 Téléchargement de v{version} depuis {host} (LAN)…",

View File

@@ -79,6 +79,12 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null); return new MigrationResult(Array.Empty<MigrationOutcome>(), 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 using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false); await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
@@ -187,6 +193,57 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
return conn; return conn;
} }
/// <summary>
/// Ouvre une connexion SANS spécifier <c>Database</c> dans la connection
/// string, puis exécute <c>CREATE DATABASE IF NOT EXISTS</c>. É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.
/// </summary>
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) private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
{ {
const string ddl = $""" const string ddl = $"""

View File

@@ -50,9 +50,23 @@ public sealed class ReportToolDeployer : IReportToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot)) if (!Directory.Exists(cfg.HtdocsRoot))
{ {
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot); // 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, return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)"); $"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
} }
var stagingTarget = target + ".new"; var stagingTarget = target + ".new";

View File

@@ -0,0 +1,114 @@
namespace PSLauncher.Core.SteamVr;
/// <summary>
/// Fusionne récursivement le <c>steamvr.vrsettings</c> bundled dans le ZIP
/// PROSERVE (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
///
/// Sémantique de merge (deep, récursive) :
/// <list type="bullet">
/// <item>Clé absente côté cible → ajoutée (clone profond du source)</item>
/// <item>Clé présente des deux côtés, valeurs JSON objet → <b>recursivement fusionnées</b>
/// (les sous-clés cible non touchées par le source sont préservées —
/// critique pour des blocs comme <c>"trackers"</c> qui contiennent
/// potentiellement déjà d'autres devices)</item>
/// <item>Clé présente, valeurs primitives ou de types différents (string,
/// bool, number, array) → la valeur source écrase celle de cible</item>
/// <item>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)</item>
/// </list>
///
/// 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).
/// </summary>
public interface ISteamVrSettingsDeployer
{
/// <summary>
/// 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. <b>Aucun processus tué, aucun
/// fichier touché.</b> 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.
/// </summary>
Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
string installFolder,
CancellationToken ct);
/// <param name="processesToKill">
/// 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 <see cref="SteamVrConfig.ProcessesToKill"/>.
/// </param>
Task<SteamVrMergeResult> MergeAsync(
string installFolder,
IReadOnlyList<string>? processesToKill,
CancellationToken ct);
}
/// <summary>
/// Résultat du dry-run <see cref="ISteamVrSettingsDeployer.CheckMergeNeededAsync"/>.
/// </summary>
public enum SteamVrMergeCheckOutcome
{
/// <summary>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.</summary>
UpToDate,
/// <summary>Le merge apporterait au moins un changement (clé ajoutée ou
/// valeur remplacée) — MainViewModel doit proposer le pop-up et procéder.</summary>
Needed,
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP — rien à faire.</summary>
NoSourceFile,
/// <summary>Option <c>AutoMerge=false</c> dans la config — l'opérateur l'a désactivé.</summary>
DisabledByConfig,
/// <summary>Steam pas détecté sur la machine.</summary>
SteamNotFound,
/// <summary>JSON source ou cible invalide. MainViewModel peut log warn.</summary>
InvalidJson,
}
/// <param name="Outcome">État global du check.</param>
/// <param name="TargetPath">Chemin résolu vers <c>steamvr.vrsettings</c>. Non-null
/// si Steam a été localisé, même si le fichier n'existe pas encore.</param>
/// <param name="RootKeysWouldChange">Nombre de clés racine qui seraient ajoutées
/// ou modifiées. <c>0</c> ssi <see cref="Outcome"/> est <see cref="SteamVrMergeCheckOutcome.UpToDate"/>.
/// Affiché dans le popup pour donner du contexte à l'opérateur.</param>
/// <param name="ErrorMessage">Détail technique en cas d'<see cref="SteamVrMergeCheckOutcome.InvalidJson"/>.</param>
public sealed record SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome Outcome,
string? TargetPath,
int RootKeysWouldChange,
string? ErrorMessage = null);
public enum SteamVrMergeStatus
{
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP, no-op silencieux.</summary>
SkippedNoSourceFile,
/// <summary>Option <c>SteamVrConfig.AutoMerge=false</c>, l'opérateur l'a explicitement désactivé.</summary>
SkippedDisabledByConfig,
/// <summary>Steam introuvable (ni via registre ni au path par défaut).</summary>
SteamNotFound,
/// <summary>Le fichier source ou cible n'est pas un JSON valide.</summary>
InvalidJson,
/// <summary>Tout s'est bien passé.</summary>
Merged,
/// <summary>Erreur I/O ou autre exception non récupérable.</summary>
Failed,
}
/// <param name="KeysReplaced">Nombre de clés racine qui existaient déjà côté cible (overwritées).</param>
/// <param name="KeysAdded">Nombre de clés racine ajoutées (n'existaient pas côté cible).</param>
/// <param name="TargetPath">Chemin du fichier modifié. Null si le merge a été skippé.</param>
/// <param name="ProcessesKilled">Nombre total de processus tués avant le merge.</param>
/// <param name="ErrorMessage">Détail technique en cas de Failed / InvalidJson.</param>
public sealed record SteamVrMergeResult(
SteamVrMergeStatus Status,
int KeysReplaced,
int KeysAdded,
string? TargetPath,
int ProcessesKilled,
string? ErrorMessage = null);

View File

@@ -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;
/// <summary>
/// Implémentation du merge SteamVR. Voir <see cref="ISteamVrSettingsDeployer"/>.
/// </summary>
public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
{
/// <summary>Sous-dossier dans le ZIP PROSERVE où chercher le fichier source.</summary>
private const string SourceFolderName = "_steamvr";
/// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
private const string SettingsFileName = "steamvr.vrsettings";
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan PostKillSettleDelay = TimeSpan.FromSeconds(2);
private readonly Func<SteamVrConfig> _configProvider;
private readonly ILogger<SteamVrSettingsDeployer> _logger;
public SteamVrSettingsDeployer(
Func<SteamVrConfig> configProvider,
ILogger<SteamVrSettingsDeployer> logger)
{
_configProvider = configProvider;
_logger = logger;
}
public async Task<SteamVrMergeCheckResult> 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<SteamVrMergeResult> MergeAsync(
string installFolder,
IReadOnlyList<string>? 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);
}
}
/// <summary>
/// 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.
/// </summary>
public static string CanonicalSettingsPath { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
"Steam", "config", "steamvr.vrsettings");
/// <summary>
/// Résout le path vers <c>steamvr.vrsettings</c>. 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.
/// <list type="number">
/// <item>HKCU\Software\Valve\Steam → SteamPath (valeur typique : <c>C:/Program Files (x86)/Steam</c>)</item>
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath (typique : <c>C:\Program Files (x86)\Steam</c>)</item>
/// <item>Fallback hardcodé : <c>%ProgramFiles(x86)%\Steam\config\steamvr.vrsettings</c></item>
/// </list>
/// Retourne null si AUCUNE des 3 sources ne pointe vers un dossier Steam
/// existant — Steam n'est probablement pas installé sur cette machine.
/// </summary>
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;
}
/// <summary>
/// Lit une string sous une clé registre + sous-clé nommée, normalise les
/// slashes Unix → Windows, puis compose le path vers <c>config\{SettingsFileName}</c>.
/// 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.
/// </summary>
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;
}
/// <summary>
/// Tue tous les processus dont le nom (sans .exe) correspond à un des
/// patterns de <paramref name="processNames"/> (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.
/// </summary>
/// <summary>
/// Fusionne récursivement <paramref name="source"/> dans <paramref name="target"/>.
/// 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.
/// </summary>
/// <summary>
/// Dry-run récursif : applique mentalement le même algo que
/// <see cref="DeepMergeInto"/> 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.
/// </summary>
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;
}
/// <summary>
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
/// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> 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.
/// </summary>
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<int> KillProcessesAsync(
IReadOnlyList<string> 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;
}
}

View File

@@ -71,6 +71,15 @@ public sealed class LocalConfig
/// </summary> /// </summary>
public ApiToolConfig ApiTool { get; set; } = new(); public ApiToolConfig ApiTool { get; set; } = new();
/// <summary>
/// Configuration du merge des settings SteamVR. Si le ZIP contient un sous-dossier
/// <c>_steamvr/</c> avec <c>steamvr.vrsettings</c>, 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.
/// </summary>
public SteamVrConfig SteamVr { get; set; } = new();
/// <summary> /// <summary>
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés /// 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 /// 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; public int? PingTimeoutMs { get; set; } = 1000;
} }
/// <summary>
/// Configuration du merge SteamVR au moment de l'install d'une nouvelle version
/// PROSERVE. Le ZIP peut contenir un dossier <c>_steamvr/steamvr.vrsettings</c>
/// avec des blocs racine spécifiques au jeu (system.generated.openxr.*, trackers,
/// etc.). Si <see cref="AutoMerge"/> est true, le launcher :
/// <list type="number">
/// <item>tue SteamVR + Vive Business Streaming (qui le relance auto)</item>
/// <item>localise <c>steamvr.vrsettings</c> via le registre Steam (ou <see cref="SettingsFilePathOverride"/>)</item>
/// <item>parse les deux JSON, remplace chaque clé racine du source dans la cible (ou ajoute si absente)</item>
/// <item>écrit le résultat atomiquement (rename .tmp → final)</item>
/// </list>
/// SteamVR redémarrera naturellement quand l'opérateur relance PROSERVE après
/// l'install.
/// </summary>
public sealed class SteamVrConfig
{
/// <summary>
/// Si true, le launcher applique le merge automatiquement à chaque install
/// quand <c>_steamvr/steamvr.vrsettings</c> 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.
/// </summary>
public bool AutoMerge { get; set; } = true;
/// <summary>
/// Path explicite vers le <c>steamvr.vrsettings</c> à modifier. Null →
/// auto-détection via :
/// <list type="number">
/// <item>HKCU\Software\Valve\Steam → SteamPath → config\steamvr.vrsettings</item>
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath → idem</item>
/// <item>fallback %ProgramFilesX86%\Steam\config\steamvr.vrsettings</item>
/// </list>
/// </summary>
public string? SettingsFilePathOverride { get; set; }
/// <summary>
/// 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).
/// </summary>
public List<string> 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",
};
}
/// <summary> /// <summary>
/// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch /// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch
/// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et /// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et