feat: wire WebView2 native↔SPA bridge in CanvasWindow (#259)

Adds BridgeMessageReceived + PostBridgeMessage to CanvasWindow following
the same pattern as WebChatWindow (c7630fa), closing the CanvasWindow
item on the #191 checklist. Removes SendA2UIMessageAsync, ResetA2UIAsync,
and their heuristic ExecuteScriptAsync helpers; both had no active callers
and are replaced by the bridge. IsTrustedBridgeSource accepts only
_trustedGatewayOrigin and openclaw-canvas.local. Source-scan test added
in TrayMenuWindowMarkupTests.

Co-authored-by: AlexAlves87 <alexalves87@github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AlexAlves87 2026-05-07 17:19:15 +02:00 committed by GitHub
parent 2154e97afc
commit e832229a9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 179 additions and 72 deletions

View File

@ -4,6 +4,7 @@ using System.Text.RegularExpressions;
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 OpenClaw.Shared;
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<CoreWebView2, CoreWebView2WebMessageReceivedEventArgs>? _webMessageReceivedHandler;
/// <summary>
/// Fired when the SPA sends a message to the native side via
/// <c>window.chrome.webview.postMessage(...)</c>.
/// </summary>
public event EventHandler<WebBridgeMessage>? BridgeMessageReceived;
// HTML sanitization — block embedded iframes/objects/embeds/applets
private static readonly Regex s_sanitizeBlock = new(
@ -219,6 +230,7 @@ public sealed partial class CanvasWindow : WindowEx
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);
this.SetIcon("Assets\\openclaw.ico");
_dispatcherQueue = DispatcherQueue;
this.Closed += OnWindowClosed;
// Initialize WebView2
@ -262,7 +274,30 @@ public sealed partial class CanvasWindow : WindowEx
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 @@ public sealed partial class CanvasWindow : WindowEx
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 sealed partial class CanvasWindow : WindowEx
await NavigateAndWaitAsync(url);
}
public async Task<string> 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<string> 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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@ -624,59 +647,121 @@ public sealed partial class CanvasWindow : WindowEx
return uri.AbsolutePath.StartsWith("/__openclaw__/a2ui/", StringComparison.OrdinalIgnoreCase);
}
private static string BuildA2UIMessageScript(string json)
{
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 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';
})()
""";
}
private static string BuildA2UIResetScript()
{
return """
(() => {
const tryCall = (target, method) => {
if (target && typeof target[method] === 'function') {
target[method]();
return true;
}
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';
})()
""";
}
private Task EnsureWebViewReadyAsync()
{
return _isWebViewInitialized ? Task.CompletedTask : _webViewReadyTcs.Task;
}
// ── Bridge: native → SPA ───────────────────────────────────────────────
/// <summary>
/// Sends a bridge message to the SPA via the WebView2 native→web channel.
/// The SPA receives this via <c>window.chrome.webview.addEventListener('message', e => { const msg = e.data; ... })</c>.
/// Safe to call from background threads. No-op if the WebView2 core is not yet initialised.
/// </summary>
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;
// 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 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 string SanitizeBridgeLogValue(string? value)
{
if (string.IsNullOrEmpty(value))
return "";
Span<char> 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;
}
}

View File

@ -59,6 +59,28 @@ public class TrayMenuWindowMarkupTests
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()
{