diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index cac7143..ee3b21e 100644
--- a/installer/PSLauncher.iss
+++ b/installer/PSLauncher.iss
@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
-#define MyAppVersion "1.0.0"
+#define MyAppVersion "1.0.1"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj
index 5a00034..50359f3 100644
--- a/src/PSLauncher.App/PSLauncher.App.csproj
+++ b/src/PSLauncher.App/PSLauncher.App.csproj
@@ -18,9 +18,9 @@
PROSERVE Launcher
© 2026 ASTERION VR — All rights reserved
PSLauncher.App
- 1.0.0
- 1.0.0.0
- 1.0.0.0
+ 1.0.1
+ 1.0.1.0
+ 1.0.1.0
true
diff --git a/src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs b/src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
index 78447b8..fa935dc 100644
--- a/src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
+++ b/src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
@@ -1,20 +1,26 @@
namespace PSLauncher.Core.SteamVr;
///
-/// Fusionne récursivement le steamvr.vrsettings bundled dans le ZIP
-/// PROSERVE (sous-dossier _steamvr/) dans le fichier SteamVR de l'utilisateur.
+/// Fusionne le steamvr.vrsettings bundled dans le ZIP PROSERVE
+/// (sous-dossier _steamvr/) dans le fichier SteamVR de l'utilisateur.
///
-/// Sémantique de merge (deep, récursive) :
+/// Sémantique de merge — SCOPED aux blocs racine system.generated.* :
///
-/// - Clé absente côté cible → ajoutée (clone profond du source)
-/// - Clé présente des deux côtés, valeurs JSON objet → recursivement fusionnées
-/// (les sous-clés cible non touchées par le source sont préservées —
-/// critique pour des blocs comme "trackers" qui contiennent
-/// potentiellement déjà d'autres devices)
-/// - Clé présente, valeurs primitives ou de types différents (string,
-/// bool, number, array) → la valeur source écrase celle de cible
-/// - Clés racine côté cible qui ne sont PAS dans le source → 100 %
-/// préservées (on ne touche QUE ce que le source apporte)
+/// - SEULS les blocs racine system.generated.* sont considérés.
+/// Tous les autres blocs (DesktopUI, GpuSpeed, LastKnown,
+/// dashboard, steamvr, trackers, …) sont user-/machine-
+/// spécifiques et VARIENT légitimement entre postes — le launcher ne
+/// les touche jamais (sinon faux positifs de diff à chaque install).
+/// - Bloc system.generated.* ABSENT côté cible → push complet du
+/// bloc tel quel (les 4 leaf keys fournies par l'installer dans le ZIP).
+/// - Bloc PRÉSENT côté cible → réécriture des leaf keys
+/// *_CurrentURL_openxr et *_PreviousURL_openxr uniquement.
+/// Les autres leaf keys (_AutosaveURL_openxr,
+/// _NeedToUpdateAutosave_openxr) restent intactes — gérées par
+/// SteamVR.
+/// - 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é.
///
///
/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne
diff --git a/src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs b/src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
index 99f9854..93c2079 100644
--- a/src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
+++ b/src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PSLauncher.Models;
@@ -17,6 +18,32 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
/// Nom canonique du fichier de settings SteamVR (côté source ET cible).
private const string SettingsFileName = "steamvr.vrsettings";
+ ///
+ /// Pattern d'identification d'un bloc auto-généré par SteamVR pour une
+ /// application OpenXR donnée. Ex :
+ /// system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe.
+ ///
+ /// Le launcher ne considère QUE ces blocs pour la diff + le push. Tous les
+ /// autres blocs racine du source (DesktopUI, GpuSpeed,
+ /// LastKnown, dashboard, steamvr, trackers, …)
+ /// sont user-/machine-spécifiques et VARIENT légitimement entre postes —
+ /// les checker provoquerait des faux positifs de diff à chaque install.
+ ///
+ private static readonly Regex SystemGeneratedKey =
+ new(@"^system\.generated\.", RegexOptions.Compiled);
+
+ ///
+ /// Leaf keys dans un bloc system.generated.* 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 (_AutosaveURL_openxr,
+ /// _NeedToUpdateAutosave_openxr) 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.
+ ///
+ private static bool IsRelevantOpenXrLeaf(string leafKey) =>
+ leafKey.EndsWith("_CurrentURL_openxr", StringComparison.Ordinal)
+ || leafKey.EndsWith("_PreviousURL_openxr", StringComparison.Ordinal);
+
///
/// 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
@@ -111,27 +138,36 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
$"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.
+ // 7) Diff scoped : SEULS les blocs racine `system.generated.*` sont
+ // considérés. Les autres (DesktopUI, GpuSpeed, LastKnown, dashboard,
+ // 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;
foreach (var (key, srcValue) in sourceObj)
{
+ // Whitelist : on n'examine QUE les blocs system.generated.*
+ if (!SystemGeneratedKey.IsMatch(key)) continue;
+
if (!targetObj.ContainsKey(key))
{
rootKeysChanging++;
continue;
}
var tgtValue = targetObj[key];
+
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
{
- if (WouldDeepMergeChange(tgtChild, srcChild))
+ if (OpenXrBindingUrlsDiffer(tgtChild, srcChild))
rootKeysChanging++;
}
- else if (!JsonNodesEqual(tgtValue, srcValue))
- {
- rootKeysChanging++;
- }
}
var outcome = rootKeysChanging > 0
@@ -281,28 +317,38 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
// 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.
+ // 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;
foreach (var (key, srcValue) in sourceObj.ToList())
{
+ if (!SystemGeneratedKey.IsMatch(key)) continue;
+
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();
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);
+ // 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
@@ -321,6 +367,7 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
_logger.LogInformation(
"SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
replaced, added, targetPath);
+
return new SteamVrMergeResult(
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
/// doit être en TÊTE car il relance SteamVR sinon.
///
- ///
- /// Fusionne récursivement dans .
- /// Règles par clé :
- /// • absente côté cible → ajout (clone profond de la valeur source)
- /// • présente des deux côtés ET les deux valeurs sont des objets JSON → recurse
- /// • sinon → la valeur source écrase celle de cible
- ///
- /// Les arrays JSON sont traités comme des "primitives" (= replace complet,
- /// pas de merge par index ni dédup). SteamVR n'utilise pas d'arrays dans
- /// ses settings de toute façon, c'est juste pour avoir un comportement
- /// prévisible si jamais.
- ///
- ///
- /// Dry-run récursif : applique mentalement le même algo que
- /// et retourne true dès qu'au moins UNE clé du
- /// source serait ajoutée ou modifiée côté target. Short-circuit pour ne
- /// pas parcourir le JSON entier inutilement quand on a déjà trouvé un diff.
- ///
- private static bool WouldDeepMergeChange(JsonObject target, JsonObject source)
- {
- foreach (var (key, srcValue) in source)
- {
- if (!target.ContainsKey(key)) return true;
- var tgtValue = target[key];
-
- if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
- {
- if (WouldDeepMergeChange(tgtChild, srcChild)) return true;
- }
- else if (!JsonNodesEqual(tgtValue, srcValue))
- {
- return true;
- }
- }
- return false;
- }
-
///
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
/// primitives). System.Text.Json n'expose pas DeepEquals avant .NET 9 ;
@@ -513,26 +523,49 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
return false;
}
- private static void DeepMergeInto(JsonObject target, JsonObject source)
+ ///
+ /// Dry-run pour un bloc system.generated.* déjà présent côté target.
+ /// Retourne true si une des leaf keys pertinentes (_CurrentURL_openxr
+ /// ou _PreviousURL_openxr) DIFFÈRE entre source et target. Les autres
+ /// leaf keys (AutosaveURL, NeedToUpdate, …) sont ignorées pour la décision.
+ ///
+ 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
+ if (!JsonNodesEqual(tgtValue, srcValue)) return true; // valeur différente
+ }
+ return false;
+ }
+
+ ///
+ /// Merge effectif sur un bloc system.generated.* déjà présent côté
+ /// target. Réécrit les leaf keys CurrentURL et PreviousURL depuis le source,
+ /// laisse les autres leaf keys (_AutosaveURL_openxr,
+ /// _NeedToUpdateAutosave_openxr) 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 > 0).
+ ///
+ private static int ReplaceOpenXrBindingUrls(JsonObject target, JsonObject source)
+ {
+ int touched = 0;
+ foreach (var (leafKey, leafValue) in source.ToList())
+ {
+ 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))
{
- target[key] = srcValue?.DeepClone();
continue;
}
-
- if (srcValue is JsonObject srcChild
- && target[key] is JsonObject tgtChild)
- {
- DeepMergeInto(tgtChild, srcChild);
- }
- else
- {
- target[key] = srcValue?.DeepClone();
- }
+ target[leafKey] = leafValue?.DeepClone();
+ touched++;
}
+ return touched;
}
private async Task KillProcessesAsync(