Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/OpenClaw.Shared/WebBridgeMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System;
using System.Text.Json;

namespace OpenClaw.Shared;

/// <summary>
/// A JSON message exchanged over the WebView2 native↔SPA bridge.
///
/// Wire format: <c>{ "type": "&lt;string&gt;", "payload": { ... } }</c>
///
/// Native → SPA: <c>CoreWebView2.PostWebMessageAsJson(msg.ToJson())</c>
/// SPA → Native: <c>CoreWebView2.WebMessageReceived</c> → <c>WebBridgeMessage.TryParse(e.WebMessageAsJson)</c>
/// </summary>
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 ──────────────────────────────────────────

/// <summary>Sent native→SPA when a screen-recording session starts.</summary>
public const string TypeRecordingStart = "recording-start";

/// <summary>Sent native→SPA when a screen-recording session ends.</summary>
public const string TypeRecordingStop = "recording-stop";

/// <summary>Sent native→SPA when voice listening becomes active.</summary>
public const string TypeVoiceStart = "voice-start";

/// <summary>Sent native→SPA when voice listening becomes inactive.</summary>
public const string TypeVoiceStop = "voice-stop";

/// <summary>Sent native→SPA to push draft text into the chat input.</summary>
public const string TypeDraftText = "draft-text";

/// <summary>Sent SPA→native when the SPA is fully initialised and ready for messages.</summary>
public const string TypeReady = "ready";

// ── parsing ────────────────────────────────────────────────────────────

/// <summary>
/// Tries to parse a <see cref="WebBridgeMessage"/> from a JSON string.
/// Returns <see langword="null"/> if the JSON is malformed or missing the required "type" field.
/// </summary>
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 ──────────────────────────────────────────────────────

/// <summary>
/// Serialises the message to JSON, suitable for passing to
/// <c>CoreWebView2.PostWebMessageAsJson</c>.
/// If <paramref name="payload"/> is supplied it overrides <see cref="PayloadJson"/>.
/// </summary>
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);
}
}
}
164 changes: 163 additions & 1 deletion src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoreWebView2, CoreWebView2NavigationCompletedEventArgs>? _navigationCompletedHandler;
private TypedEventHandler<CoreWebView2, CoreWebView2NavigationStartingEventArgs>? _navigationStartingHandler;

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;

public bool IsClosed { get; private set; }

public WebChatWindow(string gatewayUrl, string token)
Expand All @@ -31,6 +39,7 @@ public WebChatWindow(string gatewayUrl, string token)
_token = token;

InitializeComponent();
_dispatcherQueue = DispatcherQueue;

// Window configuration
this.SetWindowSize(520, 750);
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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) =>
{
Expand Down Expand Up @@ -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;

/// <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>.
/// This method is safe to call from background threads and is a 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("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<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;
}

private bool TryBuildChatUrl(out string url, out string errorMessage)
{
url = string.Empty;
Expand Down
Loading
Loading