From 3a18779d81a2137ea60ba52a2812d023470cc40b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 21 Apr 2026 01:14:05 +0000
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20wire=20WebView2=20bidirectional=20n?=
=?UTF-8?q?ative=E2=86=94SPA=20bridge=20in=20WebChatWindow?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements the WebView2 bridge proposed in issue #191.
## Changes
### OpenClaw.Shared — WebBridgeMessage
- New `WebBridgeMessage` record: wire format `{"type":"...","payload":{...}}`
- Well-known type constants: recording-start/stop, voice-start/stop, draft-text, ready
- `TryParse(string?)` — null-safe parser, returns null on malformed input
- `ToJson(object?)` — serialiser, optional runtime payload overrides stored one
### WebChatWindow.xaml.cs
- Subscribe `CoreWebView2.WebMessageReceived` → fire `BridgeMessageReceived` event
(SPA sends via `window.chrome.webview.postMessage({type, payload})`)
- Add `PostBridgeMessage(string type, object? payload = null)` for native→SPA
(SPA receives via `window.chrome.webview.addEventListener('message', ...)`)
- Handler stored and unregistered in `OnWindowClosed` (no leak)
- Uses existing `using OpenClaw.Shared;` import — no new dependencies
### Tests (+15)
- `WebBridgeMessageTests`: parse valid/invalid/edge cases; ToJson; round-trip for
all well-known types; payload override
## Test Status
- `dotnet test OpenClaw.Shared.Tests` — **648 passed, 20 skipped** (↑15 from 633)
- `dotnet test OpenClaw.Tray.Tests` — 122 passed (unchanged)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Shared/WebBridgeMessage.cs | 97 +++++++++++++
.../Windows/WebChatWindow.xaml.cs | 42 +++++-
.../WebBridgeMessageTests.cs | 136 ++++++++++++++++++
3 files changed, 274 insertions(+), 1 deletion(-)
create mode 100644 src/OpenClaw.Shared/WebBridgeMessage.cs
create mode 100644 tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
diff --git a/src/OpenClaw.Shared/WebBridgeMessage.cs b/src/OpenClaw.Shared/WebBridgeMessage.cs
new file mode 100644
index 00000000..6d7d6912
--- /dev/null
+++ b/src/OpenClaw.Shared/WebBridgeMessage.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Text.Json;
+
+namespace OpenClaw.Shared;
+
+///
+/// A JSON message exchanged over the WebView2 native↔SPA bridge.
+///
+/// Wire format: { "type": "<string>", "payload": { ... } }
+///
+/// Native → SPA: CoreWebView2.PostWebMessageAsJson(msg.ToJson())
+/// SPA → Native: CoreWebView2.WebMessageReceived → WebBridgeMessage.TryParse(e.WebMessageAsJson)
+///
+public sealed record WebBridgeMessage(string Type, string? PayloadJson = null)
+{
+ // ── well-known type constants ──────────────────────────────────────────
+
+ /// Sent native→SPA when a screen-recording session starts.
+ public const string TypeRecordingStart = "recording-start";
+
+ /// Sent native→SPA when a screen-recording session ends.
+ public const string TypeRecordingStop = "recording-stop";
+
+ /// Sent native→SPA when voice listening becomes active.
+ public const string TypeVoiceStart = "voice-start";
+
+ /// Sent native→SPA when voice listening becomes inactive.
+ public const string TypeVoiceStop = "voice-stop";
+
+ /// Sent native→SPA to push draft text into the chat input.
+ public const string TypeDraftText = "draft-text";
+
+ /// Sent SPA→native when the SPA is fully initialised and ready for messages.
+ public const string TypeReady = "ready";
+
+ // ── parsing ────────────────────────────────────────────────────────────
+
+ ///
+ /// Tries to parse a from a JSON string.
+ /// Returns if the JSON is malformed or missing the required "type" field.
+ ///
+ public static WebBridgeMessage? TryParse(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("type", out var typeEl) || typeEl.ValueKind != JsonValueKind.String)
+ return null;
+
+ var type = typeEl.GetString();
+ if (string.IsNullOrWhiteSpace(type))
+ return null;
+
+ string? payloadJson = null;
+ if (root.TryGetProperty("payload", out var payloadEl)
+ && payloadEl.ValueKind != JsonValueKind.Null
+ && payloadEl.ValueKind != JsonValueKind.Undefined)
+ {
+ payloadJson = payloadEl.GetRawText();
+ }
+
+ return new WebBridgeMessage(type!, payloadJson);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ // ── serialisation ──────────────────────────────────────────────────────
+
+ ///
+ /// Serialises the message to JSON, suitable for passing to
+ /// CoreWebView2.PostWebMessageAsJson.
+ /// If is supplied it overrides .
+ ///
+ public string ToJson(object? payload = null)
+ {
+ var typeEncoded = JsonSerializer.Serialize(Type);
+
+ if (payload != null)
+ {
+ var payloadEncoded = JsonSerializer.Serialize(payload);
+ return $"{{\"type\":{typeEncoded},\"payload\":{payloadEncoded}}}";
+ }
+
+ if (!string.IsNullOrEmpty(PayloadJson))
+ return $"{{\"type\":{typeEncoded},\"payload\":{PayloadJson}}}";
+
+ return $"{{\"type\":{typeEncoded},\"payload\":{{}}}}";
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs
index 8a6bc4b4..21a5de5b 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs
@@ -21,7 +21,14 @@ public sealed partial class WebChatWindow : WindowEx
// Store event handlers for cleanup
private TypedEventHandler? _navigationCompletedHandler;
private TypedEventHandler? _navigationStartingHandler;
-
+ private TypedEventHandler? _webMessageReceivedHandler;
+
+ ///
+ /// Fired when the SPA sends a message to the native side via
+ /// window.chrome.webview.postMessage(...).
+ ///
+ public event EventHandler? BridgeMessageReceived;
+
public bool IsClosed { get; private set; }
public WebChatWindow(string gatewayUrl, string token)
@@ -56,6 +63,8 @@ private void OnWindowClosed(object sender, WindowEventArgs e)
WebView.CoreWebView2.NavigationCompleted -= _navigationCompletedHandler;
if (_navigationStartingHandler != null)
WebView.CoreWebView2.NavigationStarting -= _navigationStartingHandler;
+ if (_webMessageReceivedHandler != null)
+ WebView.CoreWebView2.WebMessageReceived -= _webMessageReceivedHandler;
}
}
@@ -85,6 +94,23 @@ private async Task InitializeWebViewAsync()
WebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
WebView.CoreWebView2.Settings.IsZoomControlEnabled = true;
+ // Wire the bidirectional native↔SPA bridge
+ // SPA → native: window.chrome.webview.postMessage({ type, payload })
+ _webMessageReceivedHandler = (s, e) =>
+ {
+ var msg = WebBridgeMessage.TryParse(e.WebMessageAsJson);
+ if (msg != null)
+ {
+ Logger.Debug($"WebChatWindow: bridge message from SPA, type={msg.Type}");
+ BridgeMessageReceived?.Invoke(this, msg);
+ }
+ else
+ {
+ Logger.Warn($"WebChatWindow: received unrecognised bridge message");
+ }
+ };
+ WebView.CoreWebView2.WebMessageReceived += _webMessageReceivedHandler;
+
// Handle navigation events (store for cleanup)
_navigationCompletedHandler = (s, e) =>
{
@@ -162,6 +188,20 @@ private async Task InitializeWebViewAsync()
// Set to a test URL to bypass gateway (e.g., "https://www.bing.com"), or null for normal operation
private const string? DEBUG_TEST_URL = null;
+ ///
+ /// Sends a bridge message to the SPA via the WebView2 native→web channel.
+ /// The SPA receives this via window.chrome.webview.addEventListener('message', e => { const msg = e.data; ... }).
+ /// This method is a no-op if the WebView2 core is not yet initialised.
+ ///
+ public void PostBridgeMessage(string type, object? payload = null)
+ {
+ if (WebView.CoreWebView2 == null) return;
+ var msg = new WebBridgeMessage(type);
+ var json = msg.ToJson(payload);
+ Logger.Debug($"WebChatWindow: posting bridge message, type={type}");
+ WebView.CoreWebView2.PostWebMessageAsJson(json);
+ }
+
private static bool IsLocalHost(Uri uri)
{
return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase);
diff --git a/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
new file mode 100644
index 00000000..b6b285fa
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
@@ -0,0 +1,136 @@
+using Xunit;
+using OpenClaw.Shared;
+
+namespace OpenClaw.Shared.Tests;
+
+public class WebBridgeMessageTests
+{
+ // ── TryParse ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TryParse_ValidTypeOnly_ReturnsMsgWithNullPayload()
+ {
+ var msg = WebBridgeMessage.TryParse("""{"type":"ready","payload":{}}""");
+ Assert.NotNull(msg);
+ Assert.Equal("ready", msg!.Type);
+ }
+
+ [Fact]
+ public void TryParse_ValidWithStringPayload_ReturnsMsgWithPayload()
+ {
+ var msg = WebBridgeMessage.TryParse("""{"type":"draft-text","payload":{"text":"hello"}}""");
+ Assert.NotNull(msg);
+ Assert.Equal("draft-text", msg!.Type);
+ Assert.Equal("""{"text":"hello"}""", msg.PayloadJson);
+ }
+
+ [Fact]
+ public void TryParse_MissingTypeField_ReturnsNull()
+ {
+ var msg = WebBridgeMessage.TryParse("""{"payload":{}}""");
+ Assert.Null(msg);
+ }
+
+ [Fact]
+ public void TryParse_EmptyTypeValue_ReturnsNull()
+ {
+ var msg = WebBridgeMessage.TryParse("""{"type":"","payload":{}}""");
+ Assert.Null(msg);
+ }
+
+ [Fact]
+ public void TryParse_NullOrEmptyInput_ReturnsNull()
+ {
+ Assert.Null(WebBridgeMessage.TryParse(null));
+ Assert.Null(WebBridgeMessage.TryParse(""));
+ Assert.Null(WebBridgeMessage.TryParse(" "));
+ }
+
+ [Fact]
+ public void TryParse_InvalidJson_ReturnsNull()
+ {
+ Assert.Null(WebBridgeMessage.TryParse("not-json"));
+ Assert.Null(WebBridgeMessage.TryParse("{bad json"));
+ }
+
+ [Fact]
+ public void TryParse_TypeIsNotString_ReturnsNull()
+ {
+ var msg = WebBridgeMessage.TryParse("""{"type":42,"payload":{}}""");
+ Assert.Null(msg);
+ }
+
+ [Fact]
+ public void TryParse_NullPayload_IgnoresPayload()
+ {
+ var msg = WebBridgeMessage.TryParse("""{"type":"voice-start","payload":null}""");
+ Assert.NotNull(msg);
+ Assert.Equal("voice-start", msg!.Type);
+ Assert.Null(msg.PayloadJson);
+ }
+
+ // ── ToJson ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ToJson_NoPayload_EmitsEmptyObject()
+ {
+ var msg = new WebBridgeMessage(WebBridgeMessage.TypeRecordingStart);
+ var json = msg.ToJson();
+ Assert.Contains("\"type\":\"recording-start\"", json);
+ Assert.Contains("\"payload\":{}", json);
+ }
+
+ [Fact]
+ public void ToJson_WithAnonymousPayload_SerializesPayload()
+ {
+ var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText);
+ var json = msg.ToJson(new { text = "hello world" });
+ Assert.Contains("\"type\":\"draft-text\"", json);
+ Assert.Contains("\"text\":\"hello world\"", json);
+ }
+
+ [Fact]
+ public void ToJson_WithStoredPayloadJson_EmbeddedVerbatim()
+ {
+ var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText, """{"text":"hi"}""");
+ var json = msg.ToJson();
+ Assert.Contains("\"payload\":{\"text\":\"hi\"}", json);
+ }
+
+ [Fact]
+ public void ToJson_PassedPayloadOverridesStoredPayloadJson()
+ {
+ var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText, """{"text":"old"}""");
+ var json = msg.ToJson(new { text = "new" });
+ Assert.Contains("\"text\":\"new\"", json);
+ Assert.DoesNotContain("old", json);
+ }
+
+ // ── round-trip ───────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(WebBridgeMessage.TypeRecordingStart)]
+ [InlineData(WebBridgeMessage.TypeRecordingStop)]
+ [InlineData(WebBridgeMessage.TypeVoiceStart)]
+ [InlineData(WebBridgeMessage.TypeVoiceStop)]
+ [InlineData(WebBridgeMessage.TypeReady)]
+ public void RoundTrip_WellKnownTypes_PreserveType(string type)
+ {
+ var original = new WebBridgeMessage(type);
+ var json = original.ToJson();
+ var parsed = WebBridgeMessage.TryParse(json);
+ Assert.NotNull(parsed);
+ Assert.Equal(type, parsed!.Type);
+ }
+
+ [Fact]
+ public void RoundTrip_DraftText_PreservesPayload()
+ {
+ var original = new WebBridgeMessage(WebBridgeMessage.TypeDraftText);
+ var json = original.ToJson(new { text = "round trip" });
+ var parsed = WebBridgeMessage.TryParse(json);
+ Assert.NotNull(parsed);
+ Assert.Equal(WebBridgeMessage.TypeDraftText, parsed!.Type);
+ Assert.Contains("round trip", parsed.PayloadJson ?? "");
+ }
+}
From 946b0bab5aed9f4f6dfe0cdd8befc271ee9b61fd Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Mon, 27 Apr 2026 20:17:22 -0700
Subject: [PATCH 2/2] fix: harden WebView bridge transport
Validate incoming WebView bridge origins, marshal native-to-SPA posts onto the WebView dispatcher, guard closed windows, and validate stored payload JSON before embedding it in bridge messages.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Shared/WebBridgeMessage.cs | 35 ++++-
.../Windows/WebChatWindow.xaml.cs | 136 +++++++++++++++++-
.../WebBridgeMessageTests.cs | 15 ++
.../TrayMenuWindowMarkupTests.cs | 20 +++
4 files changed, 198 insertions(+), 8 deletions(-)
diff --git a/src/OpenClaw.Shared/WebBridgeMessage.cs b/src/OpenClaw.Shared/WebBridgeMessage.cs
index 6d7d6912..64d9308c 100644
--- a/src/OpenClaw.Shared/WebBridgeMessage.cs
+++ b/src/OpenClaw.Shared/WebBridgeMessage.cs
@@ -11,8 +11,21 @@ namespace OpenClaw.Shared;
/// Native → SPA: CoreWebView2.PostWebMessageAsJson(msg.ToJson())
/// SPA → Native: CoreWebView2.WebMessageReceived → WebBridgeMessage.TryParse(e.WebMessageAsJson)
///
-public sealed record WebBridgeMessage(string Type, string? PayloadJson = null)
+public sealed record WebBridgeMessage
{
+ public WebBridgeMessage(string type, string? payloadJson = null)
+ {
+ if (string.IsNullOrWhiteSpace(type))
+ throw new ArgumentException("Bridge message type is required.", nameof(type));
+
+ Type = type.Trim();
+ PayloadJson = NormalizePayloadJson(payloadJson);
+ }
+
+ public string Type { get; init; }
+
+ public string? PayloadJson { get; init; }
+
// ── well-known type constants ──────────────────────────────────────────
/// Sent native→SPA when a screen-recording session starts.
@@ -70,6 +83,10 @@ public sealed record WebBridgeMessage(string Type, string? PayloadJson = null)
{
return null;
}
+ catch (ArgumentException)
+ {
+ return null;
+ }
}
// ── serialisation ──────────────────────────────────────────────────────
@@ -94,4 +111,20 @@ public string ToJson(object? payload = null)
return $"{{\"type\":{typeEncoded},\"payload\":{{}}}}";
}
+
+ private static string? NormalizePayloadJson(string? payloadJson)
+ {
+ if (string.IsNullOrWhiteSpace(payloadJson))
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(payloadJson);
+ return doc.RootElement.GetRawText();
+ }
+ catch (JsonException ex)
+ {
+ throw new ArgumentException("PayloadJson must be a valid JSON value.", nameof(payloadJson), ex);
+ }
+ }
}
diff --git a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs
index 21a5de5b..72e7082f 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs
@@ -17,6 +17,7 @@ public sealed partial class WebChatWindow : WindowEx
{
private readonly string _gatewayUrl;
private readonly string _token;
+ private readonly Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue;
// Store event handlers for cleanup
private TypedEventHandler? _navigationCompletedHandler;
@@ -38,6 +39,7 @@ public WebChatWindow(string gatewayUrl, string token)
_token = token;
InitializeComponent();
+ _dispatcherQueue = DispatcherQueue;
// Window configuration
this.SetWindowSize(520, 750);
@@ -98,10 +100,16 @@ private async Task InitializeWebViewAsync()
// SPA → native: window.chrome.webview.postMessage({ type, payload })
_webMessageReceivedHandler = (s, e) =>
{
+ if (!IsTrustedBridgeSource(e.Source))
+ {
+ Logger.Warn($"WebChatWindow: rejected bridge message from untrusted source {SanitizeBridgeLogValue(e.Source)}");
+ return;
+ }
+
var msg = WebBridgeMessage.TryParse(e.WebMessageAsJson);
if (msg != null)
{
- Logger.Debug($"WebChatWindow: bridge message from SPA, type={msg.Type}");
+ Logger.Debug($"WebChatWindow: bridge message from SPA, type={SanitizeBridgeLogValue(msg.Type)}");
BridgeMessageReceived?.Invoke(this, msg);
}
else
@@ -191,15 +199,53 @@ private async Task InitializeWebViewAsync()
///
/// Sends a bridge message to the SPA via the WebView2 native→web channel.
/// The SPA receives this via window.chrome.webview.addEventListener('message', e => { const msg = e.data; ... }).
- /// This method is a no-op if the WebView2 core is not yet initialised.
+ /// This method is safe to call from background threads and is a no-op if the WebView2 core is not yet initialised.
///
public void PostBridgeMessage(string type, object? payload = null)
{
- if (WebView.CoreWebView2 == null) return;
- var msg = new WebBridgeMessage(type);
- var json = msg.ToJson(payload);
- Logger.Debug($"WebChatWindow: posting bridge message, type={type}");
- WebView.CoreWebView2.PostWebMessageAsJson(json);
+ if (IsClosed)
+ return;
+
+ if (_dispatcherQueue == null)
+ {
+ Logger.Warn("WebChatWindow: cannot post bridge message because DispatcherQueue is unavailable");
+ return;
+ }
+
+ if (!_dispatcherQueue.TryEnqueue(() => PostBridgeMessageOnUiThread(type, payload)))
+ {
+ Logger.Warn($"WebChatWindow: failed to enqueue bridge message, type={SanitizeBridgeLogValue(type)}");
+ }
+ }
+
+ private void PostBridgeMessageOnUiThread(string type, object? payload)
+ {
+ if (IsClosed || WebView.CoreWebView2 == null)
+ return;
+
+ try
+ {
+ var msg = new WebBridgeMessage(type);
+ var json = msg.ToJson(payload);
+ Logger.Debug($"WebChatWindow: posting bridge message, type={SanitizeBridgeLogValue(type)}");
+ WebView.CoreWebView2.PostWebMessageAsJson(json);
+ }
+ catch (ArgumentException ex)
+ {
+ Logger.Warn($"WebChatWindow: invalid bridge message payload: {ex.Message}");
+ }
+ catch (COMException ex)
+ {
+ Logger.Warn($"WebChatWindow: bridge message post failed: {ex.Message}");
+ }
+ catch (ObjectDisposedException ex)
+ {
+ Logger.Warn($"WebChatWindow: bridge message post skipped after disposal: {ex.Message}");
+ }
+ catch (InvalidOperationException ex)
+ {
+ Logger.Warn($"WebChatWindow: bridge message post failed: {ex.Message}");
+ }
}
private static bool IsLocalHost(Uri uri)
@@ -207,6 +253,82 @@ private static bool IsLocalHost(Uri uri)
return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase);
}
+ private bool IsTrustedBridgeSource(string? source)
+ {
+ return TryGetUriOrigin(source, out var sourceOrigin) &&
+ TryGetExpectedBridgeOrigin(out var expectedOrigin) &&
+ UriOriginsEqual(sourceOrigin, expectedOrigin);
+ }
+
+ private bool TryGetExpectedBridgeOrigin(out Uri origin)
+ {
+ origin = null!;
+
+ if (!GatewayUrlHelper.TryNormalizeWebSocketUrl(_gatewayUrl, out var normalizedGatewayUrl) ||
+ !Uri.TryCreate(normalizedGatewayUrl, UriKind.Absolute, out var gatewayUri))
+ {
+ return false;
+ }
+
+ var webScheme = gatewayUri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase)
+ ? "https"
+ : "http";
+
+ var builder = new UriBuilder(gatewayUri)
+ {
+ Scheme = webScheme,
+ Path = string.Empty,
+ Query = string.Empty,
+ Fragment = string.Empty
+ };
+
+ origin = builder.Uri;
+ return true;
+ }
+
+ private static bool TryGetUriOrigin(string? uriText, out Uri origin)
+ {
+ origin = null!;
+ if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri))
+ return false;
+
+ var builder = new UriBuilder(uri)
+ {
+ Path = string.Empty,
+ Query = string.Empty,
+ Fragment = string.Empty
+ };
+
+ origin = builder.Uri;
+ return true;
+ }
+
+ private static bool UriOriginsEqual(Uri left, Uri right)
+ {
+ return string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(left.IdnHost, right.IdnHost, StringComparison.OrdinalIgnoreCase) &&
+ left.Port == right.Port;
+ }
+
+ private static string SanitizeBridgeLogValue(string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return "";
+
+ Span buffer = stackalloc char[Math.Min(value.Length, 80)];
+ var count = 0;
+ foreach (var ch in value)
+ {
+ if (count == buffer.Length)
+ break;
+
+ buffer[count++] = char.IsControl(ch) ? ' ' : ch;
+ }
+
+ var sanitized = new string(buffer[..count]);
+ return value.Length > count ? sanitized + "..." : sanitized;
+ }
+
private bool TryBuildChatUrl(out string url, out string errorMessage)
{
url = string.Empty;
diff --git a/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
index b6b285fa..f99cc9ad 100644
--- a/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
+++ b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
@@ -97,6 +97,21 @@ public void ToJson_WithStoredPayloadJson_EmbeddedVerbatim()
Assert.Contains("\"payload\":{\"text\":\"hi\"}", json);
}
+ [Fact]
+ public void Constructor_InvalidStoredPayloadJson_Throws()
+ {
+ Assert.Throws(() =>
+ new WebBridgeMessage(WebBridgeMessage.TypeDraftText, "{bad json"));
+ }
+
+ [Fact]
+ public void Constructor_BlankStoredPayloadJson_TreatedAsNoPayload()
+ {
+ var msg = new WebBridgeMessage(WebBridgeMessage.TypeReady, " ");
+ Assert.Null(msg.PayloadJson);
+ Assert.Contains("\"payload\":{}", msg.ToJson());
+ }
+
[Fact]
public void ToJson_PassedPayloadOverridesStoredPayloadJson()
{
diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs
index c6ec3130..1ceebef7 100644
--- a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs
+++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs
@@ -21,6 +21,26 @@ public void TrayMenuWindow_UsesVisibleVerticalScrollbar()
xaml);
}
+ [Fact]
+ public void WebChatWindow_BridgeValidatesOriginAndPostsOnDispatcher()
+ {
+ var sourcePath = Path.Combine(
+ GetRepositoryRoot(),
+ "src",
+ "OpenClaw.Tray.WinUI",
+ "Windows",
+ "WebChatWindow.xaml.cs");
+
+ var source = File.ReadAllText(sourcePath);
+
+ Assert.Contains("IsTrustedBridgeSource(e.Source)", source);
+ Assert.Contains("rejected bridge message from untrusted source", source);
+ Assert.Contains("DispatcherQueue", source);
+ Assert.Contains("TryEnqueue(() => PostBridgeMessageOnUiThread", source);
+ Assert.Contains("PostWebMessageAsJson(json)", source);
+ Assert.Contains("SanitizeBridgeLogValue", source);
+ }
+
[Fact]
public void SettingsWindow_HasCommandCenterEntryPoint()
{