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:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 > 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user