v1.0.1 — Fix SteamVR merge : scope aux blocs system.generated.* uniquement

Bug : à chaque install, la popup « Vérification de la santé système / X
blocs vont être mis à jour » apparaissait alors que la source `_steamvr/
steamvr.vrsettings` n'avait pas changé entre les sous-versions. L'opérateur
voyait le launcher toucher au fichier SteamVR de manière répétitive et
inutile.

Root cause : le diff comparait TOUS les blocs racine du source vs target.
Or le fichier source contient (légitimement, pour la doc opérateur) des
blocs user-/machine-spécifiques copiés depuis une machine de référence :
  • DesktopUI       (position fenêtres SteamVR — varie par poste)
  • GpuSpeed        (calibration GPU — RTX 3080 chez l'opérateur vs autre
                     GPU chez le client)
  • LastKnown       (HMD info — Focus3 ou autre)
  • dashboard, steamvr (settings UI — installID utilisateur, etc.)
  • trackers        (mapping device — déjà configuré côté client)
Ces blocs DIFFÈRENT toujours entre la machine de référence (où le source
a été capturé) et chaque poste client → faux positif de diff systématique.

Fix : on restreint la diff + le push aux blocs racine `system.generated.*`
(typiquement system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe,
etc.) qui contiennent les bindings tracker workshop URLs — la VRAIE config
que le launcher est censé pousser. Tout le reste du fichier source est
maintenant ignoré.

Sémantique précise pour un bloc system.generated.* :
  • ABSENT côté target → push complet (les 4 leaf keys CurrentURL,
    PreviousURL, AutosaveURL, NeedToUpdateAutosave)
  • PRÉSENT côté target → check sur les seules leaf keys *_CurrentURL_openxr
    et *_PreviousURL_openxr. Si elles matchent → skip silencieux. Si elles
    diffèrent (= opérateur a updaté la binding workshop) → réécriture des
    2 leafs ciblées, les autres (AutosaveURL, NeedToUpdate) sont laissées
    intactes (gérées par SteamVR).

Cleanup : helpers `DeepMergeInto` et `WouldDeepMergeChange` retirés (plus
référencés). Nouveau helpers ciblés `OpenXrBindingUrlsDiffer` (check) et
`ReplaceOpenXrBindingUrls` (write). Doc XML mise à jour côté interface.

Bump : 1.0.0 → 1.0.1 (patch fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:13:18 +02:00
parent 73d084a703
commit 43a6070a65
4 changed files with 122 additions and 83 deletions

View File

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

View File

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

View File

@@ -1,20 +1,26 @@
namespace PSLauncher.Core.SteamVr; namespace PSLauncher.Core.SteamVr;
/// <summary> /// <summary>
/// Fusionne récursivement le <c>steamvr.vrsettings</c> bundled dans le ZIP /// Fusionne le <c>steamvr.vrsettings</c> bundled dans le ZIP PROSERVE
/// PROSERVE (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur. /// (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
/// ///
/// Sémantique de merge (deep, récursive) : /// Sémantique de merge — SCOPED aux blocs racine <c>system.generated.*</c> :
/// <list type="bullet"> /// <list type="bullet">
/// <item>Clé absente côté cible → ajoutée (clone profond du source)</item> /// <item>SEULS les blocs racine <c>system.generated.*</c> sont considérés.
/// <item>Clé présente des deux côtés, valeurs JSON objet → <b>recursivement fusionnées</b> /// Tous les autres blocs (<c>DesktopUI</c>, <c>GpuSpeed</c>, <c>LastKnown</c>,
/// (les sous-clés cible non touchées par le source sont préservées — /// <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …) sont user-/machine-
/// critique pour des blocs comme <c>"trackers"</c> qui contiennent /// spécifiques et VARIENT légitimement entre postes — le launcher ne
/// potentiellement déjà d'autres devices)</item> /// les touche jamais (sinon faux positifs de diff à chaque install).</item>
/// <item>Clé présente, valeurs primitives ou de types différents (string, /// <item>Bloc <c>system.generated.*</c> ABSENT côté cible → push complet du
/// bool, number, array) → la valeur source écrase celle de cible</item> /// bloc tel quel (les 4 leaf keys fournies par l'installer dans le ZIP).</item>
/// <item>Clés racine côté cible qui ne sont PAS dans le source → 100 % /// <item>Bloc PRÉSENT côté cible → réécriture des leaf keys
/// préservées (on ne touche QUE ce que le source apporte)</item> /// <c>*_CurrentURL_openxr</c> et <c>*_PreviousURL_openxr</c> uniquement.
/// Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) restent intactes — gérées par
/// SteamVR.</item>
/// <item>La décision "merge nécessaire" repose UNIQUEMENT sur la diff des
/// CurrentURL/PreviousURL (ou l'absence du bloc). Pas de popup si l'install
/// est ré-effectué et que ces valeurs n'ont pas bougé.</item>
/// </list> /// </list>
/// ///
/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne /// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Win32; using Microsoft.Win32;
using PSLauncher.Models; using PSLauncher.Models;
@@ -17,6 +18,32 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
/// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary> /// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
private const string SettingsFileName = "steamvr.vrsettings"; private const string SettingsFileName = "steamvr.vrsettings";
/// <summary>
/// Pattern d'identification d'un bloc auto-généré par SteamVR pour une
/// application OpenXR donnée. Ex :
/// <c>system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe</c>.
///
/// Le launcher ne considère QUE ces blocs pour la diff + le push. Tous les
/// autres blocs racine du source (<c>DesktopUI</c>, <c>GpuSpeed</c>,
/// <c>LastKnown</c>, <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …)
/// sont user-/machine-spécifiques et VARIENT légitimement entre postes —
/// les checker provoquerait des faux positifs de diff à chaque install.
/// </summary>
private static readonly Regex SystemGeneratedKey =
new(@"^system\.generated\.", RegexOptions.Compiled);
/// <summary>
/// Leaf keys dans un bloc <c>system.generated.*</c> qui pilotent la diff.
/// Si l'une ou l'autre diffère entre source et target → le bloc est marqué
/// "à mettre à jour". Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) ne sont PAS considérées pour la
/// décision de diff, mais elles sont quand même écrites lors du push initial
/// d'un bloc absent du target.
/// </summary>
private static bool IsRelevantOpenXrLeaf(string leafKey) =>
leafKey.EndsWith("_CurrentURL_openxr", StringComparison.Ordinal)
|| leafKey.EndsWith("_PreviousURL_openxr", StringComparison.Ordinal);
/// <summary> /// <summary>
/// Délai d'attente entre le kill des processus et l'ouverture du fichier. /// 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 /// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements
@@ -111,25 +138,34 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
$"Cible JSON invalide : {ex.Message}"); $"Cible JSON invalide : {ex.Message}");
} }
// 7) Deep-compare. On compte les clés RACINE qui changeraient (added ou // 7) Diff scoped : SEULS les blocs racine `system.generated.*` sont
// modified) — c'est le compteur lisible pour l'opérateur. Les sous-clés // considérés. Les autres (DesktopUI, GpuSpeed, LastKnown, dashboard,
// d'un bloc qui change ne sont pas comptées individuellement. // steamvr, trackers, …) sont user-/machine-spécifiques et varient
// légitimement entre postes — les checker générerait des faux positifs
// de diff à chaque install. Cf. SystemGeneratedKey.
//
// Pour chaque bloc system.generated.* :
// • ABSENT côté target → on push le bloc entier au merge (= compte
// comme un "change")
// • PRÉSENT côté target → on compare uniquement les leaf keys
// `*_CurrentURL_openxr` et `*_PreviousURL_openxr`. Si l'une diffère,
// c'est un "change" (l'opérateur a édité ses bindings). Sinon, skip.
int rootKeysChanging = 0; int rootKeysChanging = 0;
foreach (var (key, srcValue) in sourceObj) foreach (var (key, srcValue) in sourceObj)
{ {
// Whitelist : on n'examine QUE les blocs system.generated.*
if (!SystemGeneratedKey.IsMatch(key)) continue;
if (!targetObj.ContainsKey(key)) if (!targetObj.ContainsKey(key))
{ {
rootKeysChanging++; rootKeysChanging++;
continue; continue;
} }
var tgtValue = targetObj[key]; var tgtValue = targetObj[key];
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild) if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
{ {
if (WouldDeepMergeChange(tgtChild, srcChild)) if (OpenXrBindingUrlsDiffer(tgtChild, srcChild))
rootKeysChanging++;
}
else if (!JsonNodesEqual(tgtValue, srcValue))
{
rootKeysChanging++; rootKeysChanging++;
} }
} }
@@ -281,28 +317,38 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
// Les compteurs top-level reflètent uniquement les clés RACINE // Les compteurs top-level reflètent uniquement les clés RACINE
// (remplacées ou ajoutées) parce que c'est le niveau visible dans // (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. // les logs et le seul niveau que l'opérateur a besoin de monitorer.
// Merge scoped : on n'agit QUE sur les blocs racine system.generated.*
// (cf. justification dans CheckMergeNeededAsync).
// • ABSENT côté target → push complet du bloc tel quel
// (= ce que l'installer fournit, avec ses 4 leaf keys)
// • PRÉSENT côté target → écrase les leaf keys CurrentURL +
// PreviousURL avec les valeurs du source. Les autres leaf keys
// (AutosaveURL, NeedToUpdate) restent comme côté target — gérées
// par SteamVR, pas touchées par le launcher.
int replaced = 0, added = 0; int replaced = 0, added = 0;
foreach (var (key, srcValue) in sourceObj.ToList()) foreach (var (key, srcValue) in sourceObj.ToList())
{ {
if (!SystemGeneratedKey.IsMatch(key)) continue;
if (!targetObj.ContainsKey(key)) if (!targetObj.ContainsKey(key))
{ {
// Bloc absent côté target → push complet du source
// (les 4 leaf keys, telles que fournies par l'installer).
targetObj[key] = srcValue?.DeepClone(); targetObj[key] = srcValue?.DeepClone();
added++; added++;
continue; 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 if (srcValue is JsonObject srcChild
&& targetObj[key] is JsonObject tgtChild) && targetObj[key] is JsonObject tgtChild)
{ {
DeepMergeInto(tgtChild, srcChild); // Bloc présent → on n'écrit QUE les leaf keys CurrentURL +
// PreviousURL depuis source (replace). Si elles matchent déjà,
// c'est un no-op au final (mais on tag quand même replaced
// car le check a déclenché un merge).
int leafTouched = ReplaceOpenXrBindingUrls(tgtChild, srcChild);
if (leafTouched > 0) replaced++;
} }
else
{
targetObj[key] = srcValue?.DeepClone();
}
replaced++;
} }
// 8. Write atomique via .tmp + rename. SteamVR utilise un JSON // 8. Write atomique via .tmp + rename. SteamVR utilise un JSON
@@ -321,6 +367,7 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
_logger.LogInformation( _logger.LogInformation(
"SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}", "SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
replaced, added, targetPath); replaced, added, targetPath);
return new SteamVrMergeResult( return new SteamVrMergeResult(
SteamVrMergeStatus.Merged, replaced, added, targetPath, killed); SteamVrMergeStatus.Merged, replaced, added, targetPath, killed);
} }
@@ -431,43 +478,6 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
/// L'ordre des entrées dans la liste compte : Vive Business Streaming /// L'ordre des entrées dans la liste compte : Vive Business Streaming
/// doit être en TÊTE car il relance SteamVR sinon. /// doit être en TÊTE car il relance SteamVR sinon.
/// </summary> /// </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> /// <summary>
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays, /// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
/// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ; /// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ;
@@ -513,26 +523,49 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
return false; return false;
} }
private static void DeepMergeInto(JsonObject target, JsonObject source) /// <summary>
/// Dry-run pour un bloc <c>system.generated.*</c> déjà présent côté target.
/// Retourne true si une des leaf keys pertinentes (<c>_CurrentURL_openxr</c>
/// ou <c>_PreviousURL_openxr</c>) DIFFÈRE entre source et target. Les autres
/// leaf keys (AutosaveURL, NeedToUpdate, …) sont ignorées pour la décision.
/// </summary>
private static bool OpenXrBindingUrlsDiffer(JsonObject target, JsonObject source)
{ {
foreach (var (key, srcValue) in source.ToList()) foreach (var (leafKey, srcValue) in source)
{ {
if (!target.ContainsKey(key)) if (!IsRelevantOpenXrLeaf(leafKey)) continue;
{ if (!target.TryGetPropertyValue(leafKey, out var tgtValue)) return true; // absent du target
target[key] = srcValue?.DeepClone(); if (!JsonNodesEqual(tgtValue, srcValue)) return true; // valeur différente
continue; }
return false;
} }
if (srcValue is JsonObject srcChild /// <summary>
&& target[key] is JsonObject tgtChild) /// Merge effectif sur un bloc <c>system.generated.*</c> déjà présent côté
/// target. Réécrit les leaf keys CurrentURL et PreviousURL depuis le source,
/// laisse les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) intactes — c'est SteamVR qui les
/// gère, le launcher ne s'en mêle pas.
/// Retourne le nombre de leaf keys effectivement touchées (= 0 si source et
/// target ont déjà les mêmes URLs, sinon &gt; 0).
/// </summary>
private static int ReplaceOpenXrBindingUrls(JsonObject target, JsonObject source)
{ {
DeepMergeInto(tgtChild, srcChild); int touched = 0;
} foreach (var (leafKey, leafValue) in source.ToList())
else
{ {
target[key] = srcValue?.DeepClone(); if (!IsRelevantOpenXrLeaf(leafKey)) continue;
// Skip si target a déjà la même valeur exacte (no-op silencieux,
// pas la peine de marquer touched).
if (target.TryGetPropertyValue(leafKey, out var existing)
&& JsonNodesEqual(existing, leafValue))
{
continue;
} }
target[leafKey] = leafValue?.DeepClone();
touched++;
} }
return touched;
} }
private async Task<int> KillProcessesAsync( private async Task<int> KillProcessesAsync(