44using System . Threading ;
55using System . Threading . Tasks ;
66using System . Runtime . InteropServices ;
7+ using Microsoft . UI . Dispatching ;
78using Microsoft . UI . Xaml ;
89using Microsoft . Web . WebView2 . Core ;
910using OpenClaw . Shared ;
1011using OpenClawTray . Helpers ;
1112using OpenClawTray . Services ;
1213using WinUIEx ;
14+ using Windows . Foundation ;
1315using Windows . Storage . Streams ;
1416
1517namespace 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}
0 commit comments