diff --git a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs index 6398c9cd..5f2c8748 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using System.Runtime.InteropServices; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.Web.WebView2.Core; @@ -11,6 +12,7 @@ using OpenClawTray.Helpers; using OpenClawTray.Services; using WinUIEx; +using Windows.Foundation; using Windows.Storage.Streams; namespace OpenClawTray.Windows; @@ -47,6 +49,15 @@ public sealed partial class CanvasWindow : WindowEx "OpenClawTray", "canvas"); private FileSystemWatcher? _canvasWatcher; private long _lastReloadTicks = 0; + + private readonly DispatcherQueue? _dispatcherQueue; + private TypedEventHandler? _webMessageReceivedHandler; + + /// + /// Fired when the SPA sends a message to the native side via + /// window.chrome.webview.postMessage(...). + /// + public event EventHandler? BridgeMessageReceived; // HTML sanitization — block embedded iframes/objects/embeds/applets private static readonly Regex s_sanitizeBlock = new( @@ -219,6 +230,7 @@ public CanvasWindow() ExtendsContentIntoTitleBar = true; SetTitleBar(AppTitleBar); this.SetIcon("Assets\\openclaw.ico"); + _dispatcherQueue = DispatcherQueue; this.Closed += OnWindowClosed; // Initialize WebView2 @@ -262,7 +274,30 @@ private async void InitializeWebViewAsync() CanvasWebView.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = false; CanvasWebView.CoreWebView2.Settings.IsStatusBarEnabled = false; CanvasWebView.CoreWebView2.Settings.AreDevToolsEnabled = false; - + + // Wire the bidirectional native↔SPA bridge + // SPA → native: window.chrome.webview.postMessage({ type, payload }) + _webMessageReceivedHandler = (s, e) => + { + if (!IsTrustedBridgeSource(e.Source)) + { + Logger.Warn($"[Canvas] rejected bridge message from untrusted source {SanitizeBridgeLogValue(e.Source)}"); + return; + } + + var msg = WebBridgeMessage.TryParse(e.WebMessageAsJson); + if (msg != null) + { + Logger.Debug($"[Canvas] bridge message from SPA, type={SanitizeBridgeLogValue(msg.Type)}"); + BridgeMessageReceived?.Invoke(this, msg); + } + else + { + Logger.Warn("[Canvas] received unrecognised bridge message"); + } + }; + CanvasWebView.CoreWebView2.WebMessageReceived += _webMessageReceivedHandler; + // Inject auth token for gateway requests if (!string.IsNullOrEmpty(_trustedGatewayOrigin) && !string.IsNullOrEmpty(_gatewayToken)) { @@ -399,6 +434,14 @@ private void OnCanvasFileChanged(object sender, FileSystemEventArgs e) private void OnWindowClosed(object sender, WindowEventArgs args) { IsClosed = true; + + if (CanvasWebView.CoreWebView2 != null) + { + if (_webMessageReceivedHandler != null) + CanvasWebView.CoreWebView2.WebMessageReceived -= _webMessageReceivedHandler; + CanvasWebView.CoreWebView2.NavigationCompleted -= OnNavigationCompleted; + } + _canvasWatcher?.Dispose(); _canvasWatcher = null; } @@ -585,26 +628,6 @@ public async Task EnsureA2UIHostAsync(string url) await NavigateAndWaitAsync(url); } - public async Task SendA2UIMessageAsync(string json) - { - await EnsureWebViewReadyAsync(); - if (!_isWebViewInitialized) - throw new InvalidOperationException("WebView2 not initialized"); - - var script = BuildA2UIMessageScript(json); - return await CanvasWebView.CoreWebView2.ExecuteScriptAsync(script); - } - - public async Task ResetA2UIAsync() - { - await EnsureWebViewReadyAsync(); - if (!_isWebViewInitialized) - throw new InvalidOperationException("WebView2 not initialized"); - - var script = BuildA2UIResetScript(); - return await CanvasWebView.CoreWebView2.ExecuteScriptAsync(script); - } - private Task NavigateAndWaitAsync(string url) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -624,59 +647,121 @@ private static bool IsTrustedA2UIUrl(string url) return uri.AbsolutePath.StartsWith("/__openclaw__/a2ui/", StringComparison.OrdinalIgnoreCase); } - private static string BuildA2UIMessageScript(string json) + private Task EnsureWebViewReadyAsync() { - var escaped = json.Replace("\\", "\\\\").Replace("`", "\\`").Replace("${", "\\${"); - return $$""" - (() => { - const msg = JSON.parse(`{{escaped}}`); - const trySend = (target, method) => { - if (target && typeof target[method] === 'function') { - target[method](msg); - return true; - } + return _isWebViewInitialized ? Task.CompletedTask : _webViewReadyTcs.Task; + } + + // ── Bridge: native → SPA ─────────────────────────────────────────────── + + /// + /// 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; ... }). + /// Safe to call from background threads. 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("[Canvas] cannot post bridge message because DispatcherQueue is unavailable"); + return; + } + + if (!_dispatcherQueue.TryEnqueue(() => PostBridgeMessageOnUiThread(type, payload))) + { + Logger.Warn($"[Canvas] failed to enqueue bridge message, type={SanitizeBridgeLogValue(type)}"); + } + } + + private void PostBridgeMessageOnUiThread(string type, object? payload) + { + if (IsClosed || CanvasWebView.CoreWebView2 == null) + return; + + try + { + var msg = new WebBridgeMessage(type); + var json = msg.ToJson(payload); + Logger.Debug($"[Canvas] posting bridge message, type={SanitizeBridgeLogValue(type)}"); + CanvasWebView.CoreWebView2.PostWebMessageAsJson(json); + } + catch (ArgumentException ex) + { + Logger.Warn($"[Canvas] invalid bridge message payload: {ex.Message}"); + } + catch (COMException ex) + { + Logger.Warn($"[Canvas] bridge message post failed: {ex.Message}"); + } + catch (ObjectDisposedException ex) + { + Logger.Warn($"[Canvas] bridge message post skipped after disposal: {ex.Message}"); + } + catch (InvalidOperationException ex) + { + Logger.Warn($"[Canvas] bridge message post failed: {ex.Message}"); + } + } + + // ── Bridge: origin validation ────────────────────────────────────────── + + private bool IsTrustedBridgeSource(string? source) + { + if (!TryGetUriOrigin(source, out var sourceOrigin)) return false; - }; - if (trySend(window.__a2ui, 'receive')) return 'ok'; - if (trySend(window.__a2ui, 'push')) return 'ok'; - if (trySend(window.__a2ui, 'ingest')) return 'ok'; - if (trySend(window.a2ui, 'receive')) return 'ok'; - if (trySend(window.a2ui, 'push')) return 'ok'; - if (trySend(window.a2ui, 'ingest')) return 'ok'; - if (trySend(window.A2UI, 'receive')) return 'ok'; - if (trySend(window.A2UI, 'push')) return 'ok'; - if (trySend(window.A2UI, 'ingest')) return 'ok'; - try { window.dispatchEvent(new MessageEvent('message', { data: msg })); return 'event'; } catch {} - try { window.postMessage(msg, '*'); return 'postMessage'; } catch {} - return 'no-handler'; - })() - """; + + // Accept messages from the virtual canvas host + if (string.Equals(sourceOrigin.Scheme, "https", StringComparison.OrdinalIgnoreCase) && + string.Equals(sourceOrigin.IdnHost, "openclaw-canvas.local", StringComparison.OrdinalIgnoreCase)) + return true; + + // Accept messages from the configured gateway origin + if (!string.IsNullOrEmpty(_trustedGatewayOrigin) && + Uri.TryCreate(_trustedGatewayOrigin, UriKind.Absolute, out var gatewayUri)) + { + return string.Equals(sourceOrigin.Scheme, gatewayUri.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(sourceOrigin.IdnHost, gatewayUri.IdnHost, StringComparison.OrdinalIgnoreCase) && + sourceOrigin.Port == gatewayUri.Port; + } + + return false; } - - private static string BuildA2UIResetScript() + + private static bool TryGetUriOrigin(string? uriText, out Uri origin) { - return """ - (() => { - const tryCall = (target, method) => { - if (target && typeof target[method] === 'function') { - target[method](); - return true; - } + origin = null!; + if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri)) return false; - }; - if (tryCall(window.__a2ui, 'reset')) return 'ok'; - if (tryCall(window.__a2ui, 'clear')) return 'ok'; - if (tryCall(window.a2ui, 'reset')) return 'ok'; - if (tryCall(window.a2ui, 'clear')) return 'ok'; - if (tryCall(window.A2UI, 'reset')) return 'ok'; - if (tryCall(window.A2UI, 'clear')) return 'ok'; - return 'no-handler'; - })() - """; + + var builder = new UriBuilder(uri) + { + Path = string.Empty, + Query = string.Empty, + Fragment = string.Empty + }; + + origin = builder.Uri; + return true; } - - private Task EnsureWebViewReadyAsync() + + private static string SanitizeBridgeLogValue(string? value) { - return _isWebViewInitialized ? Task.CompletedTask : _webViewReadyTcs.Task; + 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; } } diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs index 4c4ce7f1..06da859c 100644 --- a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs +++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs @@ -59,6 +59,28 @@ public void Onboarding_UsesThemeAwareBackgroundResources() Assert.DoesNotContain("Colors.White", functionalUiSource); } + [Fact] + public void CanvasWindow_BridgeValidatesOriginAndPostsOnDispatcher() + { + var sourcePath = Path.Combine( + GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Windows", + "CanvasWindow.xaml.cs"); + + var source = File.ReadAllText(sourcePath); + + Assert.Contains("BridgeMessageReceived", source); + Assert.Contains("IsTrustedBridgeSource(e.Source)", source); + Assert.Contains("openclaw-canvas.local", source); + Assert.Contains("DispatcherQueue", source); + Assert.Contains("TryEnqueue(() => PostBridgeMessageOnUiThread", source); + Assert.Contains("PostWebMessageAsJson(json)", source); + Assert.Contains("SanitizeBridgeLogValue", source); + Assert.Contains("WebMessageReceived -= _webMessageReceivedHandler", source); + } + [Fact] public void CommandPalette_HasCommandCenterEntryPoint() {