Skip to content

Commit aa449d2

Browse files
AlexAlves87claude
andcommitted
feat: wire WebView2 native↔SPA bridge in CanvasWindow
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: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 871b959 commit aa449d2

2 files changed

Lines changed: 175 additions & 68 deletions

File tree

src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs

Lines changed: 153 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
using System.Threading;
55
using System.Threading.Tasks;
66
using System.Runtime.InteropServices;
7+
using Microsoft.UI.Dispatching;
78
using Microsoft.UI.Xaml;
89
using Microsoft.Web.WebView2.Core;
910
using OpenClaw.Shared;
1011
using OpenClawTray.Helpers;
1112
using OpenClawTray.Services;
1213
using WinUIEx;
14+
using Windows.Foundation;
1315
using Windows.Storage.Streams;
1416

1517
namespace OpenClawTray.Windows;
@@ -46,6 +48,15 @@ public sealed partial class CanvasWindow : WindowEx
4648
"OpenClawTray", "canvas");
4749
private FileSystemWatcher? _canvasWatcher;
4850
private long _lastReloadTicks = 0;
51+
52+
private readonly DispatcherQueue? _dispatcherQueue;
53+
private TypedEventHandler<CoreWebView2, CoreWebView2WebMessageReceivedEventArgs>? _webMessageReceivedHandler;
54+
55+
/// <summary>
56+
/// Fired when the SPA sends a message to the native side via
57+
/// <c>window.chrome.webview.postMessage(...)</c>.
58+
/// </summary>
59+
public event EventHandler<WebBridgeMessage>? BridgeMessageReceived;
4960

5061
// HTML sanitization — block embedded iframes/objects/embeds/applets
5162
private static readonly Regex s_sanitizeBlock = new(
@@ -213,6 +224,7 @@ public CanvasWindow()
213224
{
214225
this.InitializeComponent();
215226
this.SetIcon("Assets\\openclaw.ico");
227+
_dispatcherQueue = DispatcherQueue;
216228
this.Closed += OnWindowClosed;
217229

218230
// Initialize WebView2
@@ -256,7 +268,30 @@ private async void InitializeWebViewAsync()
256268
CanvasWebView.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = false;
257269
CanvasWebView.CoreWebView2.Settings.IsStatusBarEnabled = false;
258270
CanvasWebView.CoreWebView2.Settings.AreDevToolsEnabled = false;
259-
271+
272+
// Wire the bidirectional native↔SPA bridge
273+
// SPA → native: window.chrome.webview.postMessage({ type, payload })
274+
_webMessageReceivedHandler = (s, e) =>
275+
{
276+
if (!IsTrustedBridgeSource(e.Source))
277+
{
278+
Logger.Warn($"[Canvas] rejected bridge message from untrusted source {SanitizeBridgeLogValue(e.Source)}");
279+
return;
280+
}
281+
282+
var msg = WebBridgeMessage.TryParse(e.WebMessageAsJson);
283+
if (msg != null)
284+
{
285+
Logger.Debug($"[Canvas] bridge message from SPA, type={SanitizeBridgeLogValue(msg.Type)}");
286+
BridgeMessageReceived?.Invoke(this, msg);
287+
}
288+
else
289+
{
290+
Logger.Warn("[Canvas] received unrecognised bridge message");
291+
}
292+
};
293+
CanvasWebView.CoreWebView2.WebMessageReceived += _webMessageReceivedHandler;
294+
260295
// Inject auth token for gateway requests
261296
if (!string.IsNullOrEmpty(_trustedGatewayOrigin) && !string.IsNullOrEmpty(_gatewayToken))
262297
{
@@ -393,6 +428,14 @@ private void OnCanvasFileChanged(object sender, FileSystemEventArgs e)
393428
private void OnWindowClosed(object sender, WindowEventArgs args)
394429
{
395430
IsClosed = true;
431+
432+
if (CanvasWebView.CoreWebView2 != null)
433+
{
434+
if (_webMessageReceivedHandler != null)
435+
CanvasWebView.CoreWebView2.WebMessageReceived -= _webMessageReceivedHandler;
436+
CanvasWebView.CoreWebView2.NavigationCompleted -= OnNavigationCompleted;
437+
}
438+
396439
_canvasWatcher?.Dispose();
397440
_canvasWatcher = null;
398441
}
@@ -579,26 +622,6 @@ public async Task EnsureA2UIHostAsync(string url)
579622
await NavigateAndWaitAsync(url);
580623
}
581624

582-
public async Task<string> SendA2UIMessageAsync(string json)
583-
{
584-
await EnsureWebViewReadyAsync();
585-
if (!_isWebViewInitialized)
586-
throw new InvalidOperationException("WebView2 not initialized");
587-
588-
var script = BuildA2UIMessageScript(json);
589-
return await CanvasWebView.CoreWebView2.ExecuteScriptAsync(script);
590-
}
591-
592-
public async Task<string> ResetA2UIAsync()
593-
{
594-
await EnsureWebViewReadyAsync();
595-
if (!_isWebViewInitialized)
596-
throw new InvalidOperationException("WebView2 not initialized");
597-
598-
var script = BuildA2UIResetScript();
599-
return await CanvasWebView.CoreWebView2.ExecuteScriptAsync(script);
600-
}
601-
602625
private Task NavigateAndWaitAsync(string url)
603626
{
604627
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -618,59 +641,121 @@ private static bool IsTrustedA2UIUrl(string url)
618641
return uri.AbsolutePath.StartsWith("/__openclaw__/a2ui/", StringComparison.OrdinalIgnoreCase);
619642
}
620643

621-
private static string BuildA2UIMessageScript(string json)
644+
private Task EnsureWebViewReadyAsync()
622645
{
623-
var escaped = json.Replace("\\", "\\\\").Replace("`", "\\`").Replace("${", "\\${");
624-
return $$"""
625-
(() => {
626-
const msg = JSON.parse(`{{escaped}}`);
627-
const trySend = (target, method) => {
628-
if (target && typeof target[method] === 'function') {
629-
target[method](msg);
630-
return true;
631-
}
646+
return _isWebViewInitialized ? Task.CompletedTask : _webViewReadyTcs.Task;
647+
}
648+
649+
// ── Bridge: native → SPA ───────────────────────────────────────────────
650+
651+
/// <summary>
652+
/// Sends a bridge message to the SPA via the WebView2 native→web channel.
653+
/// The SPA receives this via <c>window.chrome.webview.addEventListener('message', e => { const msg = e.data; ... })</c>.
654+
/// Safe to call from background threads. No-op if the WebView2 core is not yet initialised.
655+
/// </summary>
656+
public void PostBridgeMessage(string type, object? payload = null)
657+
{
658+
if (IsClosed)
659+
return;
660+
661+
if (_dispatcherQueue == null)
662+
{
663+
Logger.Warn("[Canvas] cannot post bridge message because DispatcherQueue is unavailable");
664+
return;
665+
}
666+
667+
if (!_dispatcherQueue.TryEnqueue(() => PostBridgeMessageOnUiThread(type, payload)))
668+
{
669+
Logger.Warn($"[Canvas] failed to enqueue bridge message, type={SanitizeBridgeLogValue(type)}");
670+
}
671+
}
672+
673+
private void PostBridgeMessageOnUiThread(string type, object? payload)
674+
{
675+
if (IsClosed || CanvasWebView.CoreWebView2 == null)
676+
return;
677+
678+
try
679+
{
680+
var msg = new WebBridgeMessage(type);
681+
var json = msg.ToJson(payload);
682+
Logger.Debug($"[Canvas] posting bridge message, type={SanitizeBridgeLogValue(type)}");
683+
CanvasWebView.CoreWebView2.PostWebMessageAsJson(json);
684+
}
685+
catch (ArgumentException ex)
686+
{
687+
Logger.Warn($"[Canvas] invalid bridge message payload: {ex.Message}");
688+
}
689+
catch (COMException ex)
690+
{
691+
Logger.Warn($"[Canvas] bridge message post failed: {ex.Message}");
692+
}
693+
catch (ObjectDisposedException ex)
694+
{
695+
Logger.Warn($"[Canvas] bridge message post skipped after disposal: {ex.Message}");
696+
}
697+
catch (InvalidOperationException ex)
698+
{
699+
Logger.Warn($"[Canvas] bridge message post failed: {ex.Message}");
700+
}
701+
}
702+
703+
// ── Bridge: origin validation ──────────────────────────────────────────
704+
705+
private bool IsTrustedBridgeSource(string? source)
706+
{
707+
if (!TryGetUriOrigin(source, out var sourceOrigin))
632708
return false;
633-
};
634-
if (trySend(window.__a2ui, 'receive')) return 'ok';
635-
if (trySend(window.__a2ui, 'push')) return 'ok';
636-
if (trySend(window.__a2ui, 'ingest')) return 'ok';
637-
if (trySend(window.a2ui, 'receive')) return 'ok';
638-
if (trySend(window.a2ui, 'push')) return 'ok';
639-
if (trySend(window.a2ui, 'ingest')) return 'ok';
640-
if (trySend(window.A2UI, 'receive')) return 'ok';
641-
if (trySend(window.A2UI, 'push')) return 'ok';
642-
if (trySend(window.A2UI, 'ingest')) return 'ok';
643-
try { window.dispatchEvent(new MessageEvent('message', { data: msg })); return 'event'; } catch {}
644-
try { window.postMessage(msg, '*'); return 'postMessage'; } catch {}
645-
return 'no-handler';
646-
})()
647-
""";
709+
710+
// Accept messages from the virtual canvas host
711+
if (string.Equals(sourceOrigin.Scheme, "https", StringComparison.OrdinalIgnoreCase) &&
712+
string.Equals(sourceOrigin.IdnHost, "openclaw-canvas.local", StringComparison.OrdinalIgnoreCase))
713+
return true;
714+
715+
// Accept messages from the configured gateway origin
716+
if (!string.IsNullOrEmpty(_trustedGatewayOrigin) &&
717+
Uri.TryCreate(_trustedGatewayOrigin, UriKind.Absolute, out var gatewayUri))
718+
{
719+
return string.Equals(sourceOrigin.Scheme, gatewayUri.Scheme, StringComparison.OrdinalIgnoreCase) &&
720+
string.Equals(sourceOrigin.IdnHost, gatewayUri.IdnHost, StringComparison.OrdinalIgnoreCase) &&
721+
sourceOrigin.Port == gatewayUri.Port;
722+
}
723+
724+
return false;
648725
}
649-
650-
private static string BuildA2UIResetScript()
726+
727+
private static bool TryGetUriOrigin(string? uriText, out Uri origin)
651728
{
652-
return """
653-
(() => {
654-
const tryCall = (target, method) => {
655-
if (target && typeof target[method] === 'function') {
656-
target[method]();
657-
return true;
658-
}
729+
origin = null!;
730+
if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri))
659731
return false;
660-
};
661-
if (tryCall(window.__a2ui, 'reset')) return 'ok';
662-
if (tryCall(window.__a2ui, 'clear')) return 'ok';
663-
if (tryCall(window.a2ui, 'reset')) return 'ok';
664-
if (tryCall(window.a2ui, 'clear')) return 'ok';
665-
if (tryCall(window.A2UI, 'reset')) return 'ok';
666-
if (tryCall(window.A2UI, 'clear')) return 'ok';
667-
return 'no-handler';
668-
})()
669-
""";
732+
733+
var builder = new UriBuilder(uri)
734+
{
735+
Path = string.Empty,
736+
Query = string.Empty,
737+
Fragment = string.Empty
738+
};
739+
740+
origin = builder.Uri;
741+
return true;
670742
}
671-
672-
private Task EnsureWebViewReadyAsync()
743+
744+
private static string SanitizeBridgeLogValue(string? value)
673745
{
674-
return _isWebViewInitialized ? Task.CompletedTask : _webViewReadyTcs.Task;
746+
if (string.IsNullOrEmpty(value))
747+
return "";
748+
749+
Span<char> buffer = stackalloc char[Math.Min(value.Length, 80)];
750+
var count = 0;
751+
foreach (var ch in value)
752+
{
753+
if (count == buffer.Length)
754+
break;
755+
buffer[count++] = char.IsControl(ch) ? ' ' : ch;
756+
}
757+
758+
var sanitized = new string(buffer[..count]);
759+
return value.Length > count ? sanitized + "..." : sanitized;
675760
}
676761
}

tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,28 @@ public void Onboarding_UsesThemeAwareBackgroundResources()
7979
Assert.DoesNotContain("Colors.White", functionalUiSource);
8080
}
8181

82+
[Fact]
83+
public void CanvasWindow_BridgeValidatesOriginAndPostsOnDispatcher()
84+
{
85+
var sourcePath = Path.Combine(
86+
GetRepositoryRoot(),
87+
"src",
88+
"OpenClaw.Tray.WinUI",
89+
"Windows",
90+
"CanvasWindow.xaml.cs");
91+
92+
var source = File.ReadAllText(sourcePath);
93+
94+
Assert.Contains("BridgeMessageReceived", source);
95+
Assert.Contains("IsTrustedBridgeSource(e.Source)", source);
96+
Assert.Contains("openclaw-canvas.local", source);
97+
Assert.Contains("DispatcherQueue", source);
98+
Assert.Contains("TryEnqueue(() => PostBridgeMessageOnUiThread", source);
99+
Assert.Contains("PostWebMessageAsJson(json)", source);
100+
Assert.Contains("SanitizeBridgeLogValue", source);
101+
Assert.Contains("WebMessageReceived -= _webMessageReceivedHandler", source);
102+
}
103+
82104
[Fact]
83105
public void SettingsWindow_HasCommandCenterEntryPoint()
84106
{

0 commit comments

Comments
 (0)