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:
@@ -11,7 +11,7 @@
|
||||
|
||||
#define MyAppName "PROSERVE Launcher"
|
||||
#define MyAppShortName "PS_Launcher"
|
||||
#define MyAppVersion "0.28.12"
|
||||
#define MyAppVersion "0.29.7"
|
||||
#define MyAppPublisher "ASTERION VR"
|
||||
#define MyAppURL "https://asterionvr.com"
|
||||
#define MyAppExeName "PS_Launcher.exe"
|
||||
|
||||
@@ -95,6 +95,46 @@ final class DownloadUrl
|
||||
]);
|
||||
}
|
||||
|
||||
// Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité).
|
||||
// Avant : on utilisait un template hardcodé `proserve-{version}.zip`,
|
||||
// ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename
|
||||
// en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence :
|
||||
// l'endpoint retournait une URL signée pointant vers un fichier inexistant
|
||||
// → 404 systématique côté client sans aucune indication serveur.
|
||||
// Maintenant : on lit `download.url` du manifest, on extrait le filename
|
||||
// via parse_url + basename, on vérifie qu'il existe physiquement dans
|
||||
// /builds/, et seulement après on signe.
|
||||
$manifestUrl = $entry['download']['url'] ?? '';
|
||||
if ($manifestUrl === '') {
|
||||
Response::error('manifest_incomplete',
|
||||
"L'entrée manifest pour v{$version} est sans download.url", 500);
|
||||
}
|
||||
$parsedUrl = parse_url($manifestUrl);
|
||||
$urlPath = $parsedUrl['path'] ?? '';
|
||||
$filename = basename($urlPath);
|
||||
// Whitelist défensive sur le filename : caractères safe + suffixe .zip.
|
||||
// Évite path traversal et autres injections via un manifest corrompu.
|
||||
if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) {
|
||||
Response::error('manifest_invalid_filename',
|
||||
"Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500);
|
||||
}
|
||||
// Vérifie que le fichier existe physiquement avant de signer une URL morte.
|
||||
// Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans
|
||||
// /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au
|
||||
// lieu d'envoyer le client en 404 silencieux, on retourne une erreur
|
||||
// serveur claire qui apparaît dans les logs PHP + client.
|
||||
$buildsDir = dirname(__DIR__, 2) . '/builds';
|
||||
$physicalPath = $buildsDir . '/' . $filename;
|
||||
if (!is_file($physicalPath)) {
|
||||
Response::error('file_missing',
|
||||
"Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.",
|
||||
500,
|
||||
[
|
||||
'manifestUrl' => $manifestUrl,
|
||||
'expectedPath' => $physicalPath,
|
||||
]);
|
||||
}
|
||||
|
||||
// Génère l'URL HMAC-signée
|
||||
// TTL : 6 h. Compromis entre :
|
||||
// - sécurité (limite la fenêtre de replay si une URL fuit)
|
||||
@@ -102,7 +142,7 @@ final class DownloadUrl
|
||||
// Pour une connexion plus lente, le client sait auto-refresher l'URL
|
||||
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
|
||||
$baseUrl = rtrim($config['base_url'], '/');
|
||||
$relPath = '/builds/proserve-' . $version . '.zip';
|
||||
$relPath = '/builds/' . $filename;
|
||||
$exp = time() + 21600; // 6 h
|
||||
$secret = $config['hmac_secret'] ?? '';
|
||||
if ($secret === '') {
|
||||
|
||||
@@ -25,6 +25,7 @@ using PSLauncher.Core.Health.OpenVRInterop;
|
||||
using PSLauncher.Core.Migrations;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.ReportTool;
|
||||
using PSLauncher.Core.SteamVr;
|
||||
using PSLauncher.Core.Updates;
|
||||
using PSLauncher.Models;
|
||||
using Serilog;
|
||||
@@ -245,6 +246,15 @@ public partial class App : Application
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
||||
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à
|
||||
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
|
||||
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.28.12</Version>
|
||||
<AssemblyVersion>0.28.12.0</AssemblyVersion>
|
||||
<FileVersion>0.28.12.0</FileVersion>
|
||||
<Version>0.29.7</Version>
|
||||
<AssemblyVersion>0.29.7.0</AssemblyVersion>
|
||||
<FileVersion>0.29.7.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -24,6 +24,7 @@ using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Migrations;
|
||||
using PSLauncher.Core.Process;
|
||||
using PSLauncher.Core.ReportTool;
|
||||
using PSLauncher.Core.SteamVr;
|
||||
using PSLauncher.Core.Updates;
|
||||
using PSLauncher.Models;
|
||||
|
||||
@@ -52,6 +53,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private readonly IReportToolDeployer _reportDeployer;
|
||||
private readonly IDocToolDeployer _docDeployer;
|
||||
private readonly IApiToolDeployer _apiDeployer;
|
||||
private readonly ISteamVrSettingsDeployer _steamVrDeployer;
|
||||
private readonly IPeerSourceResolver _peerResolver;
|
||||
private readonly IZipCacheStore _zipCacheStore;
|
||||
private readonly ILanCacheServer _lanCacheServer;
|
||||
@@ -91,6 +93,14 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
@@ -368,6 +378,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
IReportToolDeployer reportDeployer,
|
||||
IDocToolDeployer docDeployer,
|
||||
IApiToolDeployer apiDeployer,
|
||||
ISteamVrSettingsDeployer steamVrDeployer,
|
||||
IPeerSourceResolver peerResolver,
|
||||
IZipCacheStore zipCacheStore,
|
||||
ILanCacheServer lanCacheServer,
|
||||
@@ -391,6 +402,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_reportDeployer = reportDeployer;
|
||||
_docDeployer = docDeployer;
|
||||
_apiDeployer = apiDeployer;
|
||||
_steamVrDeployer = steamVrDeployer;
|
||||
_peerResolver = peerResolver;
|
||||
_zipCacheStore = zipCacheStore;
|
||||
_lanCacheServer = lanCacheServer;
|
||||
@@ -626,6 +638,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||
row.OpenFolderHandler = OpenVersionFolder;
|
||||
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
||||
row.ForceFreshDownloadHandler = r => _ = ForceFreshDownloadAsync(r);
|
||||
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
|
||||
|
||||
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
|
||||
@@ -977,6 +990,69 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
ProgressPercent = 0;
|
||||
}
|
||||
|
||||
/// <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 ------------
|
||||
|
||||
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).
|
||||
/// Les clés vides sont skippées silencieusement.
|
||||
/// </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)
|
||||
{
|
||||
var list = new List<string>();
|
||||
@@ -1314,6 +1444,13 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_activeDownloadCts = new CancellationTokenSource();
|
||||
var ct = _activeDownloadCts.Token;
|
||||
|
||||
// Note : on NE reset PAS _install404RetryConsumed ici. Le retry après
|
||||
// 404 réinvoke SafeInstallAsync(freshRow) qui re-entre dans cette
|
||||
// méthode — si on resetait, on aurait potentiellement une boucle
|
||||
// infinie 404→retry→404→retry. Le flag est seulement remis à false
|
||||
// dans le `finally` du HAUT (= install qui n'est PAS un retry) ou
|
||||
// après un install réussi.
|
||||
|
||||
try
|
||||
{
|
||||
// Si reprise d'un DL interrompu, on saute la popup de release notes :
|
||||
@@ -1370,7 +1507,37 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||||
var urlString = signed ?? row.Remote.Download.Url;
|
||||
url = new Uri(urlString);
|
||||
|
||||
// ★ Détection serveur-side bug : si l'URL signée pointe vers un
|
||||
// nom de fichier DIFFÉRENT de celui du manifest, on a un endpoint
|
||||
// /download-url cassé (construit l'URL depuis un template hardcodé
|
||||
// au lieu de lire le manifest). Continuer le DL est garanti de
|
||||
// donner 404 → on remonte une erreur claire AVANT de DL 14 Go.
|
||||
// Bug rapporté côté Asterion : DownloadUrl.php avant fix utilisait
|
||||
// `proserve-{version}.zip` au lieu de basename(manifest.download.url).
|
||||
if (signed is not null)
|
||||
{
|
||||
var signedFilename = Path.GetFileName(url.AbsolutePath);
|
||||
var manifestFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
|
||||
if (!string.Equals(signedFilename, manifestFilename, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Server-side bug : /download-url returned filename « {Signed} » but manifest references « {Manifest} ». Aborting DL — fix DownloadUrl.php server-side.",
|
||||
signedFilename, manifestFilename);
|
||||
throw new InvalidOperationException(
|
||||
$"Incohérence serveur : l'endpoint /download-url retourne un nom de fichier différent du manifest. " +
|
||||
$"Manifest : « {manifestFilename} ». Signé : « {signedFilename} ». " +
|
||||
$"→ DownloadUrl.php côté serveur doit lire le filename depuis manifest.download.url, pas via un template hardcodé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Diagnostic : log l'URL utilisée pour ce DL. Permet de comparer
|
||||
// avec l'URL après un éventuel refresh manifest pour détecter le
|
||||
// cas "le manifest serveur est aussi obsolète" (les deux URLs sont
|
||||
// identiques alors qu'on attendait un changement).
|
||||
_logger.LogInformation(
|
||||
"Install v{Version}: download URL resolved to {Url} (source: {Source})",
|
||||
row.Version, url, _currentPeerHost ?? "OVH");
|
||||
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
|
||||
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
|
||||
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
|
||||
@@ -1506,6 +1673,127 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
|
||||
}
|
||||
|
||||
// Merge SteamVR settings : si _steamvr/steamvr.vrsettings est bundled
|
||||
// dans le ZIP, tue SteamVR + Vive Business Streaming (qui le relance
|
||||
// sinon) et fusionne les blocs racine dans le fichier de l'utilisateur.
|
||||
// Best-effort : si SteamVR n'est pas installé ou si le merge échoue,
|
||||
// on log mais on ne bloque pas l'install PROSERVE — le jeu peut tourner
|
||||
// sans, juste sans le mapping tracker custom.
|
||||
try
|
||||
{
|
||||
// ÉTAPE 1 — DRY-RUN : on commence par déterminer si un vrai
|
||||
// merge changerait QUOI QUE CE SOIT. Aucun processus tué à ce
|
||||
// stade, aucun fichier touché. Bénéfice : sur un PC où la conf
|
||||
// SteamVR est déjà conforme à la release (cas typique d'une
|
||||
// ré-install ou d'un PC mis-à-jour récemment), on saute TOUT
|
||||
// le processus de fermeture sans déranger l'opérateur.
|
||||
var check = await _steamVrDeployer.CheckMergeNeededAsync(target, ct);
|
||||
switch (check.Outcome)
|
||||
{
|
||||
case SteamVrMergeCheckOutcome.NoSourceFile:
|
||||
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
|
||||
goto AfterSteamVrMerge;
|
||||
case SteamVrMergeCheckOutcome.DisabledByConfig:
|
||||
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
|
||||
goto AfterSteamVrMerge;
|
||||
case SteamVrMergeCheckOutcome.SteamNotFound:
|
||||
_logger.LogInformation(
|
||||
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
|
||||
row.Version);
|
||||
goto AfterSteamVrMerge;
|
||||
case SteamVrMergeCheckOutcome.UpToDate:
|
||||
// ★ Cas optimal : la conf est déjà alignée → on ne ferme
|
||||
// PAS SteamVR/VBS, on ne montre PAS de popup, on log juste.
|
||||
_logger.LogInformation(
|
||||
"SteamVR settings already up-to-date for v{Version} (target: {Path}) — skipping kill+merge",
|
||||
row.Version, check.TargetPath);
|
||||
goto AfterSteamVrMerge;
|
||||
case SteamVrMergeCheckOutcome.InvalidJson:
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgSteamVrMergeFailed(check.ErrorMessage ?? "?"),
|
||||
Strings.MsgBoxSteamVrMergeFailed,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
goto AfterSteamVrMerge;
|
||||
case SteamVrMergeCheckOutcome.Needed:
|
||||
// On continue vers le pop-up + kill + merge.
|
||||
break;
|
||||
}
|
||||
|
||||
// ÉTAPE 2 — Construit la liste des processus à tuer. Source :
|
||||
// les health checks de type "Process" configurés par l'opérateur.
|
||||
// C'est lui qui sait quelle VR runtime tourne sur la machine ;
|
||||
// on lui fait confiance plutôt que de hardcoder une liste obsolète.
|
||||
//
|
||||
// Sécurité : on s'assure que VIVE Business Streaming et SteamVR
|
||||
// sont DANS la liste (sinon le merge échouera car SteamVR garde
|
||||
// son fichier ouvert). On dédup case-insensitive et on ordonne
|
||||
// VBS en tête (autres "guardians" qui relancent SteamVR aussi).
|
||||
var killList = BuildSteamVrProcessKillList();
|
||||
|
||||
// ÉTAPE 3 — Pré-flight : popup d'avertissement avec liste des
|
||||
// programmes à fermer + nombre de blocs qui changeront.
|
||||
if (killList.Count > 0)
|
||||
{
|
||||
var listPreview = string.Join("\n • ", killList.Take(10));
|
||||
if (killList.Count > 10) listPreview += $"\n • … (+{killList.Count - 10})";
|
||||
var ok = ThemedMessageBox.Show(
|
||||
Strings.MsgSteamVrPreFlightBody(listPreview, check.RootKeysWouldChange),
|
||||
Strings.MsgSteamVrPreFlightTitle,
|
||||
MessageBoxButton.OKCancel, MessageBoxImage.Information,
|
||||
MessageBoxResult.OK);
|
||||
if (ok != MessageBoxResult.OK)
|
||||
{
|
||||
// Annulé par l'opérateur : on skip le merge mais on
|
||||
// continue l'install (les blocs SteamVR ne seront pas
|
||||
// mis à jour ; l'opérateur le sait).
|
||||
_logger.LogInformation(
|
||||
"User cancelled SteamVR merge phase, skipping merge but continuing install");
|
||||
goto AfterSteamVrMerge;
|
||||
}
|
||||
}
|
||||
|
||||
StatusMessage = Strings.StatusDeployingSteamVr(row.Version);
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
var svResult = await _steamVrDeployer.MergeAsync(target, killList, ct);
|
||||
switch (svResult.Status)
|
||||
{
|
||||
case SteamVrMergeStatus.Merged:
|
||||
_logger.LogInformation(
|
||||
"SteamVR merge for v{Version}: replaced={Replaced}, added={Added} (target: {Path})",
|
||||
row.Version, svResult.KeysReplaced, svResult.KeysAdded, svResult.TargetPath);
|
||||
break;
|
||||
case SteamVrMergeStatus.SkippedNoSourceFile:
|
||||
_logger.LogDebug("No _steamvr/ in v{Version} ZIP, SteamVR settings unchanged", row.Version);
|
||||
break;
|
||||
case SteamVrMergeStatus.SkippedDisabledByConfig:
|
||||
_logger.LogInformation("SteamVR merge skipped for v{Version} (disabled in config)", row.Version);
|
||||
break;
|
||||
case SteamVrMergeStatus.SteamNotFound:
|
||||
// Pas de popup — beaucoup de machines de dev n'ont pas Steam,
|
||||
// pas la peine de spammer l'opérateur.
|
||||
_logger.LogInformation(
|
||||
"SteamVR merge skipped for v{Version}: Steam not found on this machine",
|
||||
row.Version);
|
||||
break;
|
||||
case SteamVrMergeStatus.InvalidJson:
|
||||
case SteamVrMergeStatus.Failed:
|
||||
// Popup quand-même : c'est probablement un bug à investiguer
|
||||
// (ZIP corrompu, fichier SteamVR endommagé manuellement, etc.).
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgSteamVrMergeFailed(svResult.ErrorMessage ?? "?"),
|
||||
Strings.MsgBoxSteamVrMergeFailed,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SteamVR merge threw an unhandled exception (continuing install)");
|
||||
}
|
||||
AfterSteamVrMerge:
|
||||
|
||||
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
||||
if (_config.Database.AutoApplyMigrations)
|
||||
@@ -1697,6 +1985,9 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
// Reset le flag retry — un futur install (même version ou autre)
|
||||
// a droit à sa propre tentative de retry sur 404.
|
||||
_install404RetryConsumed = false;
|
||||
RebuildList();
|
||||
_toastService.ShowSuccess(
|
||||
Strings.ToastInstallSuccessTitle,
|
||||
@@ -1713,16 +2004,154 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// valeur d'avant le DL (souvent 0% ou l'ancien %).
|
||||
RefreshResumableBytes(row);
|
||||
}
|
||||
catch (InvalidDataException shaEx) when (shaEx.Message.Contains("Checksum SHA-256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Cas spécifique : SHA-256 mismatch après DL. Causes typiques :
|
||||
// • OVH (CDN) sert un ZIP obsolète parce que le cache n'a pas
|
||||
// encore expiré (TTL 24h-72h) suite à un re-build serveur
|
||||
// • Un peer LAN sert un ZIP dont le sidecar est corrompu/menteur
|
||||
// • Le manifest a été modifié manuellement sans re-build du ZIP
|
||||
//
|
||||
// Actions automatiques :
|
||||
// 1. Purge le cache local pour cette version (le ZIP que NOUS
|
||||
// avions cached avant cette tentative pourrait être obsolète
|
||||
// lui aussi ; mieux vaut tout vider pour ne pas servir du
|
||||
// contenu pourri aux autres peers du LAN)
|
||||
// 2. Affiche un message d'erreur SPÉCIFIQUE avec la source du
|
||||
// DL (peer LAN ou OVH) pour aider l'opérateur à diagnostiquer
|
||||
// 3. Suggère le menu "↻ Forcer re-téléchargement" pour réessayer
|
||||
// après que le cache CDN OVH ait expiré
|
||||
_logger.LogError(shaEx,
|
||||
"SHA-256 verify failed for v{Version} (source: {Source})",
|
||||
row.Version, _currentPeerHost ?? "OVH");
|
||||
row.State = VersionRowState.AvailableIdle;
|
||||
row.ProgressDetail = null;
|
||||
row.ProgressPercent = 0;
|
||||
try
|
||||
{
|
||||
await _zipCacheStore.InvalidateAsync(row.Version, CancellationToken.None);
|
||||
}
|
||||
catch (Exception purgeEx)
|
||||
{
|
||||
_logger.LogWarning(purgeEx, "Could not purge LAN cache for v{Version} after SHA mismatch", row.Version);
|
||||
}
|
||||
var sourceLabel = _currentPeerHost is null
|
||||
? Strings.SourceOvh
|
||||
: Strings.SourcePeer(_currentPeerHost);
|
||||
var body = Strings.MsgShaMismatchBody(row.Version, sourceLabel);
|
||||
ThemedMessageBox.Show(body, Strings.MsgBoxShaMismatch,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
StatusMessage = Strings.StatusShaMismatch(row.Version);
|
||||
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), Strings.MsgBoxShaMismatch);
|
||||
RefreshResumableBytes(row);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Install failed for {Version}", row.Version);
|
||||
row.State = VersionRowState.AvailableIdle;
|
||||
row.ProgressDetail = null;
|
||||
row.ProgressPercent = 0;
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgInstallFailed(ex.Message),
|
||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
StatusMessage = Strings.StatusError(ex.Message);
|
||||
|
||||
// Cas spécifique HTTP 404 : le fichier source a été renommé/supprimé
|
||||
// sur le serveur (typiquement après un re-upload pour buster le cache
|
||||
// CDN OVH). Le manifest local référence l'ancienne URL → la DL fail
|
||||
// en 404.
|
||||
//
|
||||
// Stratégie en 2 temps :
|
||||
// 1. Premier 404 → auto-refresh du manifest + AUTO-RETRY install
|
||||
// transparent (l'opérateur ne voit rien d'autre qu'un message
|
||||
// "rafraîchissement…" puis la nouvelle DL qui démarre).
|
||||
// 2. Deuxième 404 après retry → le manifest serveur ELLE-MÊME
|
||||
// est obsolète (le rename a été fait au filesystem sans MAJ
|
||||
// du manifest serveur). Message clair pour que l'opérateur
|
||||
// sache que c'est un problème côté serveur, pas client.
|
||||
bool is404 = ex.Message.Contains("HTTP 404", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.Message.Contains("404 on segment", StringComparison.OrdinalIgnoreCase);
|
||||
if (is404 && !_install404RetryConsumed)
|
||||
{
|
||||
// PREMIER 404 : on tente le refresh + retry une fois.
|
||||
_install404RetryConsumed = true;
|
||||
_logger.LogInformation(
|
||||
"404 detected for v{Version} (first attempt) — auto-refreshing manifest then retrying install",
|
||||
row.Version);
|
||||
|
||||
// Capture l'URL actuelle pour diagnostic (avant refresh).
|
||||
var urlBefore = row.Remote?.Download.Url;
|
||||
|
||||
bool refreshOk = false;
|
||||
try
|
||||
{
|
||||
await CheckForUpdatesAsync();
|
||||
refreshOk = true;
|
||||
}
|
||||
catch (Exception refreshEx)
|
||||
{
|
||||
_logger.LogWarning(refreshEx, "Auto-refresh manifest after 404 failed");
|
||||
}
|
||||
|
||||
// Récupère la row fraîche depuis le rebuild (l'instance peut
|
||||
// avoir changé) pour relire la nouvelle URL du manifest.
|
||||
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null)
|
||||
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version);
|
||||
var urlAfter = freshRow?.Remote?.Download.Url;
|
||||
_logger.LogInformation(
|
||||
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
|
||||
row.Version, urlBefore, urlAfter);
|
||||
|
||||
if (refreshOk && freshRow is not null && freshRow.Remote is not null
|
||||
&& !string.Equals(urlBefore, urlAfter, StringComparison.Ordinal))
|
||||
{
|
||||
// L'URL a changé → le manifest s'est bien rafraîchi avec une
|
||||
// nouvelle URL pointant vers le fichier renommé. Auto-retry
|
||||
// l'install. On déclenche via la même mécanique que le clic
|
||||
// Install (SafeInstallAsync) pour bénéficier du try/catch
|
||||
// global et de la mécanique standard.
|
||||
StatusMessage = Strings.StatusAutoRetryAfter404(row.Version);
|
||||
// ⚠ on libère les flags du finally en avance pour que le
|
||||
// retry puisse re-prendre _activeRow / IsBusy. Le finally
|
||||
// s'exécutera quand même mais idempotent (déjà null).
|
||||
_activeDownloadCts?.Dispose();
|
||||
_activeDownloadCts = null;
|
||||
_activeRow = null;
|
||||
_activeInstallTask = null;
|
||||
_currentPeerHost = null;
|
||||
IsBusy = false;
|
||||
IsDownloadActive = false;
|
||||
// Fire-and-forget : Polly et SafeInstallAsync gèrent les exceptions.
|
||||
_activeInstallTask = SafeInstallAsync(freshRow);
|
||||
return;
|
||||
}
|
||||
|
||||
// URL identique après refresh OU refresh échoué → tomber dans
|
||||
// le message "manifest serveur stale" (cas 2).
|
||||
_logger.LogWarning(
|
||||
"Manifest refresh did not produce a new URL for v{Version} — server-side manifest is also stale",
|
||||
row.Version);
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgServerSideStaleBody(row.Version),
|
||||
Strings.MsgBoxStaleManifest,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
StatusMessage = Strings.StatusManifestRefreshed;
|
||||
}
|
||||
else if (is404)
|
||||
{
|
||||
// DEUXIÈME 404 dans la même session install (déjà retried).
|
||||
// On reset le flag pour que la prochaine action user manuelle
|
||||
// (autre clic Install) reparte avec une tentative de retry fraîche.
|
||||
_install404RetryConsumed = false;
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgServerSideStaleBody(row.Version),
|
||||
Strings.MsgBoxStaleManifest,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
StatusMessage = Strings.StatusManifestRefreshed;
|
||||
}
|
||||
else
|
||||
{
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgInstallFailed(ex.Message),
|
||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
StatusMessage = Strings.StatusError(ex.Message);
|
||||
}
|
||||
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
|
||||
// Idem : si une erreur réseau interrompt le DL en cours de route, le
|
||||
// partial est plus gros qu'avant — il faut refléter le nouveau %.
|
||||
|
||||
@@ -102,6 +102,19 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
= new();
|
||||
public bool HasApiBackups => ApiBackups.Count > 0;
|
||||
|
||||
// ----- SteamVR settings merge -----
|
||||
[ObservableProperty] private bool _steamVrAutoMerge;
|
||||
[ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty;
|
||||
/// <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 -----
|
||||
[ObservableProperty] private bool _lanServerEnabled;
|
||||
[ObservableProperty] private bool _lanClientEnabled;
|
||||
@@ -257,6 +270,12 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
||||
_apiMaxBackups = config.ApiTool.MaxBackups;
|
||||
|
||||
_steamVrAutoMerge = config.SteamVr.AutoMerge;
|
||||
_steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty;
|
||||
_steamVrProcessesToKill = string.Join(
|
||||
Environment.NewLine,
|
||||
config.SteamVr.ProcessesToKill ?? new List<string>());
|
||||
|
||||
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
|
||||
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
|
||||
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
|
||||
@@ -429,6 +448,23 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
|
||||
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
|
||||
|
||||
_config.SteamVr.AutoMerge = SteamVrAutoMerge;
|
||||
_config.SteamVr.SettingsFilePathOverride = string.IsNullOrWhiteSpace(SteamVrSettingsPathOverride)
|
||||
? null : SteamVrSettingsPathOverride.Trim();
|
||||
_config.SteamVr.ProcessesToKill = (SteamVrProcessesToKill ?? string.Empty)
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l =>
|
||||
{
|
||||
var name = l.Trim();
|
||||
// Strip .exe défensif (case-insensitive) au cas où l'opérateur le tape.
|
||||
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
name = name[..^4].Trim();
|
||||
return name;
|
||||
})
|
||||
.Where(l => l.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
|
||||
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
|
||||
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
|
||||
|
||||
@@ -208,6 +208,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
||||
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
||||
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
||||
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
|
||||
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
||||
private void Launch() => LaunchHandler?.Invoke(this);
|
||||
@@ -231,6 +232,15 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
||||
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
|
||||
private bool CanRestartFromZero() => HasResumableDownload;
|
||||
|
||||
/// <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)
|
||||
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,8 @@
|
||||
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||
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}}" />
|
||||
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||
@@ -631,6 +633,8 @@
|
||||
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||
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}}" />
|
||||
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||
|
||||
@@ -650,6 +650,52 @@
|
||||
</StackPanel>
|
||||
</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 -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
|
||||
@@ -45,9 +45,20 @@ public sealed class ApiToolDeployer : IApiToolDeployer
|
||||
|
||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||
{
|
||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
||||
// Crée le dossier htdocs 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,
|
||||
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var stagingTarget = target + ".new";
|
||||
|
||||
@@ -45,9 +45,20 @@ public sealed class DocToolDeployer : IDocToolDeployer
|
||||
|
||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||
{
|
||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
||||
// Crée le dossier htdocs 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,
|
||||
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var stagingTarget = target + ".new";
|
||||
|
||||
@@ -455,16 +455,52 @@ public sealed class DownloadManager : IDownloadManager
|
||||
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
|
||||
}
|
||||
|
||||
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired").
|
||||
// On force un refresh d'URL via le callback du job, puis on jette une
|
||||
// exception transitoire pour que Polly retry avec la nouvelle URL.
|
||||
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone)
|
||||
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
|
||||
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
|
||||
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
|
||||
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
|
||||
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
|
||||
// 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)
|
||||
{
|
||||
if (job.RefreshUrlAsync is not null)
|
||||
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
||||
bool urlChanged = !Uri.Equals(url, _currentUrl);
|
||||
if (urlChanged)
|
||||
{
|
||||
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
||||
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true);
|
||||
_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)
|
||||
{
|
||||
|
||||
@@ -26,11 +26,70 @@ public sealed class ZipInstaller : IZipInstaller
|
||||
{
|
||||
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))
|
||||
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
|
||||
{
|
||||
renamedBackup = $"{targetFolder}.bak-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
|
||||
Directory.Move(targetFolder, renamedBackup);
|
||||
_logger.LogInformation(
|
||||
"Existing install at {Target} renamed to {Backup} before re-install",
|
||||
targetFolder, renamedBackup);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Move(stagingFolder, targetFolder);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Rollback : restaure le backup si on a échoué après le rename
|
||||
if (renamedBackup is not null && Directory.Exists(renamedBackup) && !Directory.Exists(targetFolder))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(renamedBackup, targetFolder);
|
||||
_logger.LogWarning("Restored previous install from {Backup} after move failure", renamedBackup);
|
||||
}
|
||||
catch (Exception restoreEx)
|
||||
{
|
||||
_logger.LogError(restoreEx,
|
||||
"FAILED to restore backup {Backup} — manual recovery needed", renamedBackup);
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
Directory.Move(stagingFolder, targetFolder);
|
||||
_logger.LogInformation("Installed to {Target}", targetFolder);
|
||||
|
||||
// Cleanup background du backup (14 Go d'install × N réinstallations
|
||||
// remplirait le disque sinon). Best-effort : si on ne peut pas
|
||||
// delete (handle Windows tenace, AV qui scanne), on log et on
|
||||
// laisse à l'opérateur (le nom .bak-{ts} le rend repérable).
|
||||
if (renamedBackup is not null)
|
||||
{
|
||||
var bakToDelete = renamedBackup;
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(bakToDelete, recursive: true);
|
||||
_logger.LogInformation("Cleaned up old install backup {Backup}", bakToDelete);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not delete old install backup {Backup} — operator can remove manually",
|
||||
bakToDelete);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return targetFolder;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -45,4 +45,20 @@ public interface IZipCacheStore
|
||||
|
||||
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,47 @@ public sealed class ZipCacheStore : IZipCacheStore
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task InvalidateAsync(string version, CancellationToken ct)
|
||||
{
|
||||
if (!VersionRegex.IsMatch(version))
|
||||
{
|
||||
_logger.LogWarning("InvalidateAsync called with invalid version: {Version}", version);
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var zipPath = GetCachedZipPath(version);
|
||||
var shaPath = GetShaPath(version);
|
||||
bool zipDeleted = false, shaDeleted = false;
|
||||
try
|
||||
{
|
||||
if (File.Exists(zipPath)) { File.Delete(zipPath); zipDeleted = true; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not delete cached ZIP for v{Version} at {Path} (file lock?)",
|
||||
version, zipPath);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (File.Exists(shaPath)) { File.Delete(shaPath); shaDeleted = true; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not delete sidecar SHA for v{Version} at {Path}",
|
||||
version, shaPath);
|
||||
}
|
||||
if (zipDeleted || shaDeleted)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Invalidated LAN cache for v{Version} (zip={ZipGone}, sidecar={ShaGone})",
|
||||
version, zipDeleted, shaDeleted);
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public IReadOnlyList<CachedZip> ListCached()
|
||||
{
|
||||
var dir = _downloadStore.GetDownloadsDirectory();
|
||||
|
||||
@@ -311,6 +311,91 @@ public static class Strings
|
||||
$"فشل التنزيل أو التثبيت:\n\n{detail}"
|
||||
);
|
||||
|
||||
// ---- SHA-256 mismatch après DL : message ciblé avec source + action ----
|
||||
public static string SourceOvh => T("OVH (CDN public)", "OVH (public CDN)", "OVH(公共 CDN)", "OVH (CDN สาธารณะ)", "OVH (CDN عام)");
|
||||
public static string SourcePeer(string host) => T(
|
||||
$"peer LAN « {host} »", $"LAN peer « {host} »", $"局域网节点 « {host} »",
|
||||
$"LAN peer « {host} »", $"عقدة LAN « {host} »");
|
||||
public static string MsgBoxShaMismatch => T(
|
||||
"Fichier téléchargé corrompu ou obsolète",
|
||||
"Downloaded file corrupted or outdated",
|
||||
"下载的文件已损坏或过时",
|
||||
"ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย",
|
||||
"الملف المنزّل تالف أو قديم"
|
||||
);
|
||||
public static string MsgShaMismatchBody(string version, string sourceLabel) => T(
|
||||
$"Le ZIP de v{version} a été téléchargé depuis {sourceLabel}, mais son SHA-256 ne correspond pas à celui attendu par le manifest.\n\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h)\n • Un peer LAN sert un ZIP avec un sidecar SHA corrompu\n • Le SHA dans le manifest a été modifié sans re-build du ZIP\n\nLe cache local pour cette version a été automatiquement purgé. Réessaye l'installation dans quelques minutes (le temps que la source se rafraîchisse) ou utilise « ↻ Forcer re-téléchargement » dans le menu \"…\" de la version.",
|
||||
$"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h)\n • A LAN peer is serving a ZIP with a corrupt SHA sidecar\n • The manifest SHA was changed without rebuilding the ZIP\n\nThe local cache for this version was automatically purged. Retry the install in a few minutes (let the source refresh) or use « ↻ Force re-download » in the version's \"…\" menu.",
|
||||
$"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n可能的原因:\n • 服务器重建后 OVH CDN 缓存尚未过期(TTL 24-72 小时)\n • 局域网节点提供带有损坏的 SHA sidecar 的 ZIP\n • 清单 SHA 已更改但未重建 ZIP\n\n此版本的本地缓存已自动清除。请在几分钟后重试安装(让源刷新)或使用版本的 \"…\" 菜单中的 \"↻ 强制重新下载\"。",
|
||||
$"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุหลังการ rebuild เซิร์ฟเวอร์ (TTL 24-72 ชม.)\n • LAN peer ให้บริการ ZIP ที่มี SHA sidecar เสียหาย\n • SHA ใน manifest ถูกแก้ไขโดยไม่ rebuild ZIP\n\nแคชในเครื่องสำหรับเวอร์ชันนี้ถูกล้างอัตโนมัติ ลองติดตั้งอีกครั้งในไม่กี่นาที (ให้ source รีเฟรช) หรือใช้ \"↻ บังคับดาวน์โหลดใหม่\" ในเมนู \"…\" ของเวอร์ชัน",
|
||||
$"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 الخاص به لا يطابق القيمة المتوقعة من البيان.\n\nالأسباب المحتملة:\n • لم تنته صلاحية ذاكرة التخزين المؤقت لـ OVH CDN بعد إعادة بناء الخادم (TTL 24-72 ساعة)\n • عقدة LAN تقدم ZIP بـ SHA sidecar تالف\n • تم تعديل SHA في البيان دون إعادة بناء ZIP\n\nتم مسح ذاكرة التخزين المؤقت المحلية لهذا الإصدار تلقائياً. أعد المحاولة بعد دقائق (دع المصدر ينعش) أو استخدم \"↻ فرض إعادة التنزيل\" في قائمة \"…\" للإصدار."
|
||||
);
|
||||
public static string StatusShaMismatch(string version) => T(
|
||||
$"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye",
|
||||
$"⚠ v{version}: SHA-256 invalid — cache purged, retry",
|
||||
$"⚠ v{version}:SHA-256 无效 — 缓存已清除,请重试",
|
||||
$"⚠ v{version}: SHA-256 ไม่ถูกต้อง — แคชถูกล้าง ลองใหม่",
|
||||
$"⚠ v{version}: SHA-256 غير صالح — تم مسح الذاكرة، أعد المحاولة"
|
||||
);
|
||||
// ---- HTTP 404 mid-DL : manifest local obsolète (fichier renommé serveur) ----
|
||||
public static string MsgBoxStaleManifest => T(
|
||||
"Manifest local obsolète",
|
||||
"Local manifest outdated",
|
||||
"本地清单已过时",
|
||||
"Manifest ในเครื่องล้าสมัย",
|
||||
"البيان المحلي قديم"
|
||||
);
|
||||
public static string MsgStaleManifestBody(string version) => T(
|
||||
$"Le fichier ZIP de v{version} a été renommé ou supprimé côté serveur (typique d'un re-upload pour invalider le cache OVH CDN).\n\nLe launcher a automatiquement re-fetché le manifest depuis le serveur — la nouvelle URL devrait maintenant être connue.\n\nRe-clique « Installer » pour réessayer avec l'URL fraîche.",
|
||||
$"The v{version} ZIP file was renamed or deleted on the server (typical of a re-upload to invalidate OVH CDN cache).\n\nThe launcher has automatically re-fetched the manifest from the server — the new URL should now be known.\n\nClick « Install » again to retry with the fresh URL.",
|
||||
$"v{version} 的 ZIP 文件已在服务器端重命名或删除(典型的重新上传以使 OVH CDN 缓存失效)。\n\n启动器已自动从服务器重新获取清单 — 新 URL 现在应该已知。\n\n再次点击 \"安装\" 以使用新 URL 重试。",
|
||||
$"ไฟล์ ZIP ของ v{version} ถูกเปลี่ยนชื่อหรือลบบนเซิร์ฟเวอร์ (ทั่วไปจากการอัปโหลดใหม่เพื่อล้างแคช OVH CDN)\n\nLauncher ดึง manifest จากเซิร์ฟเวอร์อัตโนมัติแล้ว — ตอนนี้ควรรู้ URL ใหม่\n\nคลิก \"ติดตั้ง\" อีกครั้งเพื่อลองด้วย URL ใหม่",
|
||||
$"تم إعادة تسمية أو حذف ملف ZIP الخاص بـ v{version} من جانب الخادم (نموذجي لإعادة الرفع لإبطال ذاكرة التخزين المؤقت لـ OVH CDN).\n\nقام المشغل بإعادة جلب البيان تلقائياً من الخادم — يجب أن يكون عنوان URL الجديد معروفاً الآن.\n\nانقر فوق \"تثبيت\" مرة أخرى لإعادة المحاولة باستخدام عنوان URL الجديد."
|
||||
);
|
||||
public static string StatusManifestRefreshed => T(
|
||||
"Manifest rafraîchi — re-clique Installer",
|
||||
"Manifest refreshed — click Install again",
|
||||
"清单已刷新 — 再次点击安装",
|
||||
"Manifest รีเฟรชแล้ว — คลิกติดตั้งอีกครั้ง",
|
||||
"تم تحديث البيان — انقر فوق تثبيت مرة أخرى"
|
||||
);
|
||||
public static string StatusAutoRetryAfter404(string version) => T(
|
||||
$"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…",
|
||||
$"v{version}: manifest refreshed, auto-retrying download…",
|
||||
$"v{version}:清单已刷新,自动重试下载…",
|
||||
$"v{version}: รีเฟรช manifest แล้ว ลองดาวน์โหลดใหม่อัตโนมัติ…",
|
||||
$"v{version}: تم تحديث البيان، إعادة محاولة التنزيل تلقائياً…"
|
||||
);
|
||||
public static string MsgServerSideStaleBody(string version) => T(
|
||||
$"Le téléchargement de v{version} échoue toujours en 404 même APRÈS un refresh du manifest depuis le serveur.\n\nDiagnostic :\n • Le manifest serveur lui-même est obsolète — il référence l'ancien nom de fichier qui n'existe plus.\n • Action côté serveur requise : le manifest doit être mis à jour pour pointer vers le nouveau nom du ZIP (ou re-uploader avec l'ancien nom).\n\nVérifie le backoffice serveur. Une fois le manifest corrigé, clique « Vérifier les MAJ » puis « Installer ».",
|
||||
$"v{version} download still fails with 404 EVEN AFTER refreshing the manifest from the server.\n\nDiagnosis:\n • The server-side manifest itself is outdated — it references the old filename which no longer exists.\n • Server-side action required: the manifest must be updated to point to the new ZIP filename (or re-upload with the old name).\n\nCheck the server backoffice. Once the manifest is fixed, click « Check Updates » then « Install ».",
|
||||
$"v{version} 下载即使在从服务器刷新清单后仍因 404 失败。\n\n诊断:\n • 服务器端清单本身已过时 — 它引用了不再存在的旧文件名。\n • 需要服务器端操作:清单必须更新以指向新的 ZIP 文件名(或以旧名称重新上传)。\n\n检查服务器后台。修复清单后,点击 \"检查更新\" 然后 \"安装\"。",
|
||||
$"การดาวน์โหลด v{version} ยังคงล้มเหลวด้วย 404 แม้หลังจากรีเฟรช manifest จากเซิร์ฟเวอร์\n\nการวินิจฉัย:\n • Manifest ฝั่งเซิร์ฟเวอร์เองล้าสมัย — อ้างถึงชื่อไฟล์เก่าที่ไม่มีอยู่แล้ว\n • ต้องดำเนินการฝั่งเซิร์ฟเวอร์: manifest ต้องอัปเดตให้ชี้ไปยังชื่อ ZIP ใหม่ (หรืออัปโหลดใหม่ด้วยชื่อเก่า)\n\nตรวจสอบ backoffice เซิร์ฟเวอร์ เมื่อ manifest ถูกแก้ไขแล้ว ให้คลิก \"ตรวจสอบการอัปเดต\" จากนั้น \"ติดตั้ง\"",
|
||||
$"لا يزال تنزيل v{version} يفشل بـ 404 حتى بعد تحديث البيان من الخادم.\n\nالتشخيص:\n • البيان من جانب الخادم نفسه قديم — يشير إلى اسم الملف القديم الذي لم يعد موجوداً.\n • مطلوب إجراء من جانب الخادم: يجب تحديث البيان للإشارة إلى اسم ZIP الجديد (أو إعادة الرفع بالاسم القديم).\n\nتحقق من backoffice الخادم. بعد إصلاح البيان، انقر فوق \"التحقق من التحديثات\" ثم \"تثبيت\"."
|
||||
);
|
||||
|
||||
public static string MenuForceFreshDownload => T(
|
||||
"↻ Forcer re-téléchargement (vider cache local)",
|
||||
"↻ Force re-download (purge local cache)",
|
||||
"↻ 强制重新下载(清除本地缓存)",
|
||||
"↻ บังคับดาวน์โหลดใหม่ (ล้างแคช)",
|
||||
"↻ فرض إعادة التنزيل (مسح الذاكرة)"
|
||||
);
|
||||
public static string MsgBoxForceFresh => T(
|
||||
"Forcer le re-téléchargement",
|
||||
"Force re-download",
|
||||
"强制重新下载",
|
||||
"บังคับดาวน์โหลดใหม่",
|
||||
"فرض إعادة التنزيل"
|
||||
);
|
||||
public static string MsgForceFreshConfirm(string version) => T(
|
||||
$"Vider le cache local pour v{version} et forcer un re-téléchargement complet ?\n\nCela supprimera le ZIP cache + le sidecar SHA + tout partial de DL en cours pour cette version. Utile quand le SHA-256 a changé côté serveur et que la source précédente était obsolète.",
|
||||
$"Purge the local cache for v{version} and force a full re-download?\n\nThis will delete the cached ZIP + SHA sidecar + any in-progress partial for this version. Useful when the server-side SHA-256 has changed and the previous source was stale.",
|
||||
$"清除 v{version} 的本地缓存并强制完全重新下载?\n\n这将删除缓存的 ZIP + SHA sidecar + 此版本的任何进行中的部分下载。当服务器端 SHA-256 已更改且之前的源已过时时很有用。",
|
||||
$"ล้างแคชในเครื่องสำหรับ v{version} และบังคับให้ดาวน์โหลดใหม่ทั้งหมด?\n\nสิ่งนี้จะลบ ZIP ที่แคช + SHA sidecar + การดาวน์โหลดบางส่วนที่กำลังดำเนินการสำหรับเวอร์ชันนี้ มีประโยชน์เมื่อ SHA-256 ฝั่งเซิร์ฟเวอร์เปลี่ยนแปลงและแหล่งที่มาก่อนหน้านี้ล้าสมัย",
|
||||
$"مسح ذاكرة التخزين المؤقت المحلية لـ v{version} وفرض إعادة تنزيل كاملة؟\n\nسيؤدي هذا إلى حذف ZIP المخزن مؤقتاً + SHA sidecar + أي تنزيل جزئي قيد التقدم لهذا الإصدار. مفيد عندما يتم تغيير SHA-256 على جانب الخادم وكان المصدر السابق قديماً."
|
||||
);
|
||||
|
||||
public static string MsgUninstallFailed(string detail) => T(
|
||||
$"Suppression échouée : {detail}",
|
||||
$"Uninstall failed: {detail}",
|
||||
@@ -625,6 +710,72 @@ public static class Strings
|
||||
public static string SettingsApiBackups => SettingsReportBackups;
|
||||
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
|
||||
|
||||
// ---- SteamVR settings merge (tue SteamVR + Vive Business Streaming + merge JSON) ----
|
||||
public static string StatusDeployingSteamVr(string version) => T(
|
||||
$"Configuration SteamVR pour v{version}…",
|
||||
$"Configuring SteamVR for v{version}…",
|
||||
$"正在为 v{version} 配置 SteamVR…",
|
||||
$"กำลังกำหนดค่า SteamVR สำหรับ v{version}…",
|
||||
$"جارٍ تهيئة SteamVR لـ v{version}…"
|
||||
);
|
||||
public static string MsgBoxSteamVrMergeFailed => T(
|
||||
"Configuration SteamVR échouée",
|
||||
"SteamVR configuration failed",
|
||||
"SteamVR 配置失败",
|
||||
"การกำหนดค่า SteamVR ล้มเหลว",
|
||||
"فشل تهيئة SteamVR"
|
||||
);
|
||||
public static string MsgSteamVrPreFlightTitle => T(
|
||||
"Fermeture de SteamVR & Vive Business Streaming",
|
||||
"Closing SteamVR & Vive Business Streaming",
|
||||
"正在关闭 SteamVR 和 Vive Business Streaming",
|
||||
"ปิด SteamVR และ Vive Business Streaming",
|
||||
"إغلاق SteamVR و Vive Business Streaming"
|
||||
);
|
||||
public static string MsgSteamVrPreFlightBody(string processesPreview, int rootKeysChanging) => T(
|
||||
$"{rootKeysChanging} bloc(s) de la configuration SteamVR vont être mis à jour.\n\nPour appliquer ces changements (mapping trackers, bindings), le launcher doit fermer ces programmes :\n\n • {processesPreview}\n\nIls pourront être relancés normalement après l'install. Continuer ?",
|
||||
$"{rootKeysChanging} SteamVR config block(s) will be updated.\n\nTo apply these changes (tracker mappings, bindings), the launcher must close these programs:\n\n • {processesPreview}\n\nYou'll be able to relaunch them normally after the install. Proceed?",
|
||||
$"将更新 {rootKeysChanging} 个 SteamVR 配置块。\n\n为应用这些更改(追踪器映射、绑定),启动器必须关闭这些程序:\n\n • {processesPreview}\n\n安装完成后您可以正常重新启动它们。继续吗?",
|
||||
$"จะอัปเดตบล็อกการกำหนดค่า SteamVR {rootKeysChanging} บล็อก\n\nเพื่อใช้การเปลี่ยนแปลงเหล่านี้ (การจับคู่ตัวติดตาม การผูก) ตัวเปิดต้องปิดโปรแกรมเหล่านี้:\n\n • {processesPreview}\n\nคุณสามารถเปิดใหม่ได้ตามปกติหลังติดตั้ง ดำเนินการต่อ?",
|
||||
$"سيتم تحديث {rootKeysChanging} كتلة (كتل) من تكوين SteamVR.\n\nلتطبيق هذه التغييرات (تعيينات أجهزة التتبع، الارتباطات)، يجب أن يغلق المشغل هذه البرامج:\n\n • {processesPreview}\n\nيمكنك إعادة تشغيلها بشكل طبيعي بعد التثبيت. متابعة؟"
|
||||
);
|
||||
public static string MsgSteamVrMergeFailed(string detail) => T(
|
||||
$"Le merge des settings SteamVR a échoué :\n\n{detail}\n\nLa version PROSERVE est installée et fonctionnera, mais le mapping des trackers Vive Business Streaming pourrait être incorrect. Vérifie depuis SteamVR → Paramètres → Périphériques après le premier lancement.",
|
||||
$"SteamVR settings merge failed:\n\n{detail}\n\nThe PROSERVE version is installed and will run, but Vive Business Streaming tracker bindings might be off. Verify from SteamVR → Settings → Devices after the first launch.",
|
||||
$"SteamVR 设置合并失败:\n\n{detail}\n\nPROSERVE 版本已安装且将运行,但 Vive Business Streaming 跟踪器绑定可能不正确。首次启动后请通过 SteamVR → 设置 → 设备 进行检查。",
|
||||
$"การรวมการตั้งค่า SteamVR ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วและจะทำงาน แต่การจับคู่ตัวติดตาม Vive Business Streaming อาจไม่ถูกต้อง ตรวจสอบจาก SteamVR → การตั้งค่า → อุปกรณ์ หลังเปิดครั้งแรก",
|
||||
$"فشل دمج إعدادات SteamVR:\n\n{detail}\n\nنسخة PROSERVE مثبتة وستعمل، لكن قد يكون ربط أجهزة تتبع Vive Business Streaming غير صحيح. تحقق من SteamVR ← الإعدادات ← الأجهزة بعد التشغيل الأول."
|
||||
);
|
||||
|
||||
public static string SettingsSteamVr => T(
|
||||
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||
"STEAMVR + VIVE BUSINESS STREAMING"
|
||||
);
|
||||
public static string SettingsSteamVrAutoMerge => T(
|
||||
"Fusionner steamvr.vrsettings à l'install (tue SteamVR + Vive Business Streaming, modifie le JSON, laisse SteamVR redémarrer au prochain lancement PROSERVE)",
|
||||
"Merge steamvr.vrsettings on install (kills SteamVR + Vive Business Streaming, edits the JSON, lets SteamVR restart on next PROSERVE launch)",
|
||||
"安装时合并 steamvr.vrsettings(终止 SteamVR + Vive Business Streaming,编辑 JSON,下次启动 PROSERVE 时让 SteamVR 重新启动)",
|
||||
"รวม steamvr.vrsettings เมื่อติดตั้ง (ปิด SteamVR + Vive Business Streaming, แก้ JSON, ปล่อยให้ SteamVR เริ่มใหม่เมื่อเปิด PROSERVE ครั้งถัดไป)",
|
||||
"دمج steamvr.vrsettings عند التثبيت (إنهاء SteamVR + Vive Business Streaming، تعديل JSON، السماح لـ SteamVR بإعادة التشغيل عند تشغيل PROSERVE التالي)"
|
||||
);
|
||||
public static string SettingsSteamVrSettingsPath => T(
|
||||
"Chemin vers steamvr.vrsettings (vide = auto-détection via registre Steam)",
|
||||
"Path to steamvr.vrsettings (empty = auto-detect via Steam registry)",
|
||||
"steamvr.vrsettings 路径(空 = 通过 Steam 注册表自动检测)",
|
||||
"พาธไปยัง steamvr.vrsettings (ว่าง = ตรวจหาอัตโนมัติผ่าน Steam registry)",
|
||||
"مسار steamvr.vrsettings (فارغ = اكتشاف تلقائي عبر سجل Steam)"
|
||||
);
|
||||
public static string SettingsSteamVrProcesses => T(
|
||||
"Processus à tuer avant le merge (un nom par ligne, sans .exe). Vive Business Streaming doit être en tête car il relance SteamVR.",
|
||||
"Processes to kill before merging (one name per line, no .exe). Vive Business Streaming must be first because it auto-restarts SteamVR.",
|
||||
"合并前要终止的进程(每行一个名称,无 .exe)。Vive Business Streaming 必须放在第一位,因为它会自动重启 SteamVR。",
|
||||
"โพรเซสที่ต้องปิดก่อนรวม (หนึ่งชื่อต่อบรรทัด ไม่ใส่ .exe) Vive Business Streaming ต้องอยู่บนสุดเพราะมันรีสตาร์ท SteamVR อัตโนมัติ",
|
||||
"العمليات التي يجب إنهاؤها قبل الدمج (اسم واحد لكل سطر، بدون .exe). يجب أن يكون Vive Business Streaming في المقدمة لأنه يعيد تشغيل SteamVR تلقائياً."
|
||||
);
|
||||
|
||||
// ---- Cache LAN P2P ----
|
||||
public static string StatusDownloadingFromPeer(string host, string version) => T(
|
||||
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
|
||||
|
||||
@@ -79,6 +79,12 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
||||
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 EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||
|
||||
@@ -187,6 +193,57 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
||||
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)
|
||||
{
|
||||
const string ddl = $"""
|
||||
|
||||
@@ -50,9 +50,23 @@ public sealed class ReportToolDeployer : IReportToolDeployer
|
||||
|
||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||
{
|
||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
||||
// Crée le dossier htdocs même si XAMPP n'est pas (encore) installé.
|
||||
// Cas typique : PC fraichement déployé où XAMPP sera installé après.
|
||||
// L'install PROSERVE peut tout de même s'exécuter — le déploiement
|
||||
// produira un dossier prêt à servir dès qu'Apache sera up.
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||
_logger.LogWarning(
|
||||
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||
cfg.HtdocsRoot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var stagingTarget = target + ".new";
|
||||
|
||||
114
src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
Normal file
114
src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
Normal 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);
|
||||
589
src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
Normal file
589
src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,15 @@ public sealed class LocalConfig
|
||||
/// </summary>
|
||||
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>
|
||||
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés
|
||||
/// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant
|
||||
@@ -315,6 +324,64 @@ public sealed class HealthCheckEntry
|
||||
public int? PingTimeoutMs { get; set; } = 1000;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user