diff --git a/src/OpenClaw.Shared/WebBridgeMessage.cs b/src/OpenClaw.Shared/WebBridgeMessage.cs new file mode 100644 index 00000000..64d9308c --- /dev/null +++ b/src/OpenClaw.Shared/WebBridgeMessage.cs @@ -0,0 +1,130 @@ +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.WebMessageReceivedWebBridgeMessage.TryParse(e.WebMessageAsJson) +/// +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. + 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; + } + catch (ArgumentException) + { + 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\":{{}}}}"; + } + + 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 8a6bc4b4..72e7082f 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs @@ -17,11 +17,19 @@ 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; 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) @@ -31,6 +39,7 @@ public WebChatWindow(string gatewayUrl, string token) _token = token; InitializeComponent(); + _dispatcherQueue = DispatcherQueue; // Window configuration this.SetWindowSize(520, 750); @@ -56,6 +65,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 +96,29 @@ 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) => + { + 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={SanitizeBridgeLogValue(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,11 +196,139 @@ 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 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 (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) { 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 new file mode 100644 index 00000000..f99cc9ad --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs @@ -0,0 +1,151 @@ +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 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() + { + 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 ?? ""); + } +} 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() {