Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 73 additions & 17 deletions src/OpenClaw.Tray.WinUI/A2UI/DataModel/DataModelStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public void ApplyDataModelUpdate(string surfaceId, string? basePath, IReadOnlyLi
if (entry.Key.Length > MaxKeyLength) continue;
if (entry.ValueString != null && entry.ValueString.Length > MaxStringValueLength) continue;
if (!IsWithinDepth(entry.ValueMap, depth: 1, max: MaxValueMapDepth)) continue;
if (!IsWithinDepth(entry.ValueArray, depth: 1, max: MaxValueMapDepth)) continue;

try
{
Expand All @@ -130,12 +131,17 @@ public void ApplyDataModelUpdate(string surfaceId, string? basePath, IReadOnlyLi
new DataModelObservable(model, _dispatcher).NotifyPaths(changed);
}

private static bool IsWithinDepth(IReadOnlyList<Protocol.DataModelEntry>? map, int depth, int max)
private static bool IsWithinDepth(IReadOnlyList<Protocol.DataModelEntry>? children, int depth, int max)
{
if (map == null) return true;
if (children == null) return true;
if (depth > max) return false;
foreach (var e in map)
foreach (var e in children)
{
// Both nested maps and nested arrays add a level — bound either path
// so a deeply-nested valueArray can't bypass the valueMap depth cap.
if (!IsWithinDepth(e.ValueMap, depth + 1, max)) return false;
if (!IsWithinDepth(e.ValueArray, depth + 1, max)) return false;
}
return true;
}

Expand Down Expand Up @@ -241,14 +247,14 @@ public JsonObject CloneRoot()
if (obj[tok] == null)
{
if (!createMissing) return (null, null, false, -1);
var nextIsIndex = int.TryParse(tokens[i + 1], out _);
var nextIsIndex = TryParseArrayIndex(tokens[i + 1], out _);
obj[tok] = nextIsIndex ? new JsonArray() : new JsonObject();
}
cursor = obj[tok];
}
else if (cursor is JsonArray arr)
{
if (!int.TryParse(tok, out var ai)) return (null, null, false, -1);
if (!TryParseArrayIndex(tok, out var ai)) return (null, null, false, -1);
while (createMissing && arr.Count <= ai) arr.Add(null);
if (ai < 0 || ai >= arr.Count) return (null, null, false, -1);
cursor = arr[ai];
Expand All @@ -260,11 +266,28 @@ public JsonObject CloneRoot()
}

var last = tokens[^1];
if (cursor is JsonArray finalArr && int.TryParse(last, out var idx))
if (cursor is JsonArray finalArr && TryParseArrayIndex(last, out var idx))
return (finalArr, last, true, idx);
return (cursor, last, false, -1);
}

private static bool TryParseArrayIndex(string token, out int index)
{
index = -1;
if (token.Length == 0) return false;
if (token.Length > 1 && token[0] == '0') return false;

foreach (var c in token)
if (c < '0' || c > '9')
return false;

return int.TryParse(
token,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out index);
}

private static List<string> SplitPointer(string pointer)
{
var p = pointer.StartsWith('/') ? pointer.Substring(1) : pointer;
Expand Down Expand Up @@ -356,24 +379,57 @@ internal void NotifyPaths(IEnumerable<string> paths)
var current = key;
while (true)
{
List<Action>? subs;
lock (_model.Subscribers)
{
_model.Subscribers.TryGetValue(current, out subs);
subs = subs == null ? null : new List<Action>(subs);
}
if (subs != null)
{
foreach (var s in subs)
if (fired.Add(s)) Dispatch(s);
}
DispatchSubscribers(current, fired);
if (current == "/" || string.IsNullOrEmpty(current)) break;
var slash = current.LastIndexOf('/');
current = slash <= 0 ? "/" : current.Substring(0, slash);
}

// Replacing a container at /x also changes /x/... descendants. Most
// component bindings subscribe to the exact leaf path they read, so
// notify those subtree subscribers as well.
DispatchDescendantSubscribers(key, fired);
}
}

private void DispatchSubscribers(string key, HashSet<Action> fired)
{
List<Action>? subs;
lock (_model.Subscribers)
{
_model.Subscribers.TryGetValue(key, out subs);
subs = subs == null ? null : new List<Action>(subs);
}

if (subs == null) return;

foreach (var s in subs)
if (fired.Add(s))
Dispatch(s);
}

private void DispatchDescendantSubscribers(string key, HashSet<Action> fired)
{
List<Action> subs = [];
var prefix = key == "/" ? "/" : key + "/";

lock (_model.Subscribers)
{
foreach (var (subscriberPath, callbacks) in _model.Subscribers)
{
if (subscriberPath == key)
continue;

if (key == "/" || subscriberPath.StartsWith(prefix, StringComparison.Ordinal))
subs.AddRange(callbacks);
}
}

foreach (var s in subs)
if (fired.Add(s))
Dispatch(s);
}

internal void NotifyAllPaths()
{
List<Action> all;
Expand Down
75 changes: 72 additions & 3 deletions src/OpenClaw.Tray.WinUI/A2UI/Protocol/A2UIProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ public sealed record A2UIComponentDef
}

/// <summary>
/// Single dataModelUpdate.contents entry. Exactly one of Value*/ValueMap is
/// expected on the wire; we expose them all and let the store apply whichever
/// is set.
/// Single dataModelUpdate.contents entry. Exactly one of
/// Value*/ValueMap/ValueArray is expected on the wire; we expose them all and
/// let the store apply whichever is set.
/// </summary>
public sealed record DataModelEntry
{
Expand All @@ -85,6 +85,12 @@ public sealed record DataModelEntry
public bool? ValueBoolean { get; init; }
/// <summary>An adjacency-list map: each item is itself a DataModelEntry.</summary>
public IReadOnlyList<DataModelEntry>? ValueMap { get; init; }
/// <summary>
/// An ordered array (v0.8 <c>valueArray</c>): each item is a value-typed
/// <see cref="DataModelEntry"/> whose <see cref="Key"/> is ignored. Items
/// may themselves be scalars, maps, or nested arrays.
/// </summary>
public IReadOnlyList<DataModelEntry>? ValueArray { get; init; }

/// <summary>Convert this entry's value to a JsonNode for storage.</summary>
public JsonNode? ToJsonNode()
Expand All @@ -99,6 +105,13 @@ public sealed record DataModelEntry
obj[entry.Key] = entry.ToJsonNode();
return obj;
}
if (ValueArray != null)
{
var arr = new JsonArray();
foreach (var entry in ValueArray)
arr.Add(entry.ToJsonNode());
return arr;
}
return null;
}
}
Expand Down Expand Up @@ -350,6 +363,7 @@ private static string Truncate(string s, int max) =>
ValueNumber = e["valueNumber"] is JsonValue jvn && jvn.TryGetValue<double>(out var n) ? n : null,
ValueBoolean = e["valueBoolean"] is JsonValue jvb && jvb.TryGetValue<bool>(out var b) ? b : null,
ValueMap = ParseValueMap(e["valueMap"] as JsonArray),
ValueArray = ParseValueArray(e["valueArray"] as JsonArray),
};
return entry;
}
Expand All @@ -367,6 +381,61 @@ private static string Truncate(string s, int max) =>
return list;
}

/// <summary>
/// Parse a v0.8 <c>valueArray</c>. Each element is a value-typed object
/// with no key (e.g. <c>{ "valueString": "admin" }</c>). For robustness we
/// also tolerate bare primitives (<c>["a", 1, true]</c>) that an agent may
/// emit. A JSON <c>null</c> element is preserved as an explicit null slot so
/// array indices stay stable for position-sensitive consumers — matching how
/// a value-less wrapped object (<c>{}</c>) round-trips. Elements of an
/// unsupported kind are dropped, matching <see cref="ParseValueMap"/>'s
/// skip-bad-item tolerance.
/// </summary>
private static IReadOnlyList<DataModelEntry>? ParseValueArray(JsonArray? arr)
{
if (arr == null) return null;
var list = new List<DataModelEntry>(arr.Count);
foreach (var item in arr)
{
// A JSON null element surfaces as a C# null inside the JsonArray.
// Preserve it as a value-less entry (ToJsonNode → null) rather than
// dropping it, so [a, null, b] stays length 3.
if (item is null) { list.Add(NullArrayElement); continue; }
var element = ParseArrayElement(item);
if (element != null) list.Add(element);
}
return list;
}

/// <summary>Shared value-less entry representing a JSON null array slot.</summary>
private static readonly DataModelEntry NullArrayElement = new() { Key = string.Empty };

private static DataModelEntry? ParseArrayElement(JsonNode? item)
{
if (item is JsonObject e)
{
return new DataModelEntry
{
Key = string.Empty,
ValueString = OptionalString(e, "valueString"),
ValueNumber = e["valueNumber"] is JsonValue jvn && jvn.TryGetValue<double>(out var n) ? n : null,
ValueBoolean = e["valueBoolean"] is JsonValue jvb && jvb.TryGetValue<bool>(out var b) ? b : null,
ValueMap = ParseValueMap(e["valueMap"] as JsonArray),
ValueArray = ParseValueArray(e["valueArray"] as JsonArray),
};
}
// Bare primitive tolerance. Check string first (a JSON number/bool does
// not satisfy TryGetValue<string>), then bool (a JSON number does not
// satisfy TryGetValue<bool>), then number.
if (item is JsonValue v)
{
if (v.TryGetValue<string>(out var s)) return new DataModelEntry { Key = string.Empty, ValueString = s };
if (v.TryGetValue<bool>(out var bl)) return new DataModelEntry { Key = string.Empty, ValueBoolean = bl };
if (v.TryGetValue<double>(out var d)) return new DataModelEntry { Key = string.Empty, ValueNumber = d };
}
return null;
}

private static string RequireString(JsonObject o, string key)
{
if (o[key] is JsonValue jv && jv.TryGetValue<string>(out var s) && !string.IsNullOrEmpty(s))
Expand Down
13 changes: 11 additions & 2 deletions src/OpenClaw.Tray.WinUI/A2UI/Rendering/IComponentRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,12 @@ public void MarkSecretPath(string? path)
{
if (SecretPaths == null) return;
if (string.IsNullOrEmpty(path)) return;
SecretPaths.Add(NormalizePath(path));
var normalized = NormalizePath(path);
SecretPaths.Add(normalized);

var canonical = SecretRedactor.CanonicalizeLenientArrayIndices(normalized);
if (!string.Equals(canonical, normalized, StringComparison.Ordinal))
SecretPaths.Add(canonical);
}

/// <summary>True if <paramref name="path"/> is a secret path (registered or matches denylist).</summary>
Expand Down Expand Up @@ -210,7 +215,11 @@ public bool IsSecretPath(string? path)
var path = val.Path!;
if (!IsAllowedPath(path, allowed)) continue;
if (IsSecretPath(path)) continue;
result[key] = DataModel.Read(path)?.DeepClone();
var clone = DataModel.Read(path)?.DeepClone();
var registered = SecretPaths == null
? System.Collections.Frozen.FrozenSet<string>.Empty
: (IReadOnlySet<string>)SecretPaths;
result[key] = SecretRedactor.RedactInPlace(clone, path, registered);
}
}
return result;
Expand Down
Loading
Loading