[Repo Assist] feat: wire WebView2 bidirectional nativeΓåöSPA bridge in WebChatWindow
Some checks failed
Build and Test / test (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
Build and Test / build (win-arm64) (push) Has been cancelled
Build and Test / build (win-x64) (push) Has been cancelled
Build and Test / build-msix (ARM64, win-arm64) (push) Has been cancelled
Build and Test / build-msix (x64, win-x64) (push) Has been cancelled
Build and Test / build-extension (arm64) (push) Has been cancelled
Build and Test / build-extension (x64) (push) Has been cancelled
Build and Test / release (push) Has been cancelled
Some checks failed
Build and Test / test (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
Build and Test / build (win-arm64) (push) Has been cancelled
Build and Test / build (win-x64) (push) Has been cancelled
Build and Test / build-msix (ARM64, win-arm64) (push) Has been cancelled
Build and Test / build-msix (x64, win-x64) (push) Has been cancelled
Build and Test / build-extension (arm64) (push) Has been cancelled
Build and Test / build-extension (x64) (push) Has been cancelled
Build and Test / release (push) Has been cancelled
Adds the WebView2 native-to-SPA bridge and hardens it with origin validation, dispatcher marshaling for native posts, closed-window guards, sanitized bridge logging, and validated payload JSON.\n\nValidated locally with build.ps1, Shared tests, and Tray tests; GitHub CI is green.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e35da4b6a4
commit
c7630fa008
130
src/OpenClaw.Shared/WebBridgeMessage.cs
Normal file
130
src/OpenClaw.Shared/WebBridgeMessage.cs
Normal file
@ -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": "<string>", "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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,11 +17,19 @@ public sealed partial class WebChatWindow : WindowEx
|
|||||||
{
|
{
|
||||||
private readonly string _gatewayUrl;
|
private readonly string _gatewayUrl;
|
||||||
private readonly string _token;
|
private readonly string _token;
|
||||||
|
private readonly Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue;
|
||||||
|
|
||||||
// Store event handlers for cleanup
|
// Store event handlers for cleanup
|
||||||
private TypedEventHandler<CoreWebView2, CoreWebView2NavigationCompletedEventArgs>? _navigationCompletedHandler;
|
private TypedEventHandler<CoreWebView2, CoreWebView2NavigationCompletedEventArgs>? _navigationCompletedHandler;
|
||||||
private TypedEventHandler<CoreWebView2, CoreWebView2NavigationStartingEventArgs>? _navigationStartingHandler;
|
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 bool IsClosed { get; private set; }
|
||||||
|
|
||||||
public WebChatWindow(string gatewayUrl, string token)
|
public WebChatWindow(string gatewayUrl, string token)
|
||||||
@ -31,6 +39,7 @@ public sealed partial class WebChatWindow : WindowEx
|
|||||||
_token = token;
|
_token = token;
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
_dispatcherQueue = DispatcherQueue;
|
||||||
|
|
||||||
// Window configuration
|
// Window configuration
|
||||||
this.SetWindowSize(520, 750);
|
this.SetWindowSize(520, 750);
|
||||||
@ -56,6 +65,8 @@ public sealed partial class WebChatWindow : WindowEx
|
|||||||
WebView.CoreWebView2.NavigationCompleted -= _navigationCompletedHandler;
|
WebView.CoreWebView2.NavigationCompleted -= _navigationCompletedHandler;
|
||||||
if (_navigationStartingHandler != null)
|
if (_navigationStartingHandler != null)
|
||||||
WebView.CoreWebView2.NavigationStarting -= _navigationStartingHandler;
|
WebView.CoreWebView2.NavigationStarting -= _navigationStartingHandler;
|
||||||
|
if (_webMessageReceivedHandler != null)
|
||||||
|
WebView.CoreWebView2.WebMessageReceived -= _webMessageReceivedHandler;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +96,29 @@ public sealed partial class WebChatWindow : WindowEx
|
|||||||
WebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
|
WebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
|
||||||
WebView.CoreWebView2.Settings.IsZoomControlEnabled = 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)
|
// Handle navigation events (store for cleanup)
|
||||||
_navigationCompletedHandler = (s, e) =>
|
_navigationCompletedHandler = (s, e) =>
|
||||||
{
|
{
|
||||||
@ -162,11 +196,139 @@ public sealed partial class WebChatWindow : WindowEx
|
|||||||
// Set to a test URL to bypass gateway (e.g., "https://www.bing.com"), or null for normal operation
|
// 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;
|
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)
|
private static bool IsLocalHost(Uri uri)
|
||||||
{
|
{
|
||||||
return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase);
|
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)
|
private bool TryBuildChatUrl(out string url, out string errorMessage)
|
||||||
{
|
{
|
||||||
url = string.Empty;
|
url = string.Empty;
|
||||||
|
|||||||
151
tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
Normal file
151
tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
using Xunit;
|
||||||
|
using OpenClaw.Shared;
|
||||||
|
|
||||||
|
namespace OpenClaw.Shared.Tests;
|
||||||
|
|
||||||
|
public class WebBridgeMessageTests
|
||||||
|
{
|
||||||
|
// ── TryParse ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_ValidTypeOnly_ReturnsMsgWithNullPayload()
|
||||||
|
{
|
||||||
|
var msg = WebBridgeMessage.TryParse("""{"type":"ready","payload":{}}""");
|
||||||
|
Assert.NotNull(msg);
|
||||||
|
Assert.Equal("ready", msg!.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_ValidWithStringPayload_ReturnsMsgWithPayload()
|
||||||
|
{
|
||||||
|
var msg = WebBridgeMessage.TryParse("""{"type":"draft-text","payload":{"text":"hello"}}""");
|
||||||
|
Assert.NotNull(msg);
|
||||||
|
Assert.Equal("draft-text", msg!.Type);
|
||||||
|
Assert.Equal("""{"text":"hello"}""", msg.PayloadJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_MissingTypeField_ReturnsNull()
|
||||||
|
{
|
||||||
|
var msg = WebBridgeMessage.TryParse("""{"payload":{}}""");
|
||||||
|
Assert.Null(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_EmptyTypeValue_ReturnsNull()
|
||||||
|
{
|
||||||
|
var msg = WebBridgeMessage.TryParse("""{"type":"","payload":{}}""");
|
||||||
|
Assert.Null(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_NullOrEmptyInput_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(WebBridgeMessage.TryParse(null));
|
||||||
|
Assert.Null(WebBridgeMessage.TryParse(""));
|
||||||
|
Assert.Null(WebBridgeMessage.TryParse(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_InvalidJson_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(WebBridgeMessage.TryParse("not-json"));
|
||||||
|
Assert.Null(WebBridgeMessage.TryParse("{bad json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_TypeIsNotString_ReturnsNull()
|
||||||
|
{
|
||||||
|
var msg = WebBridgeMessage.TryParse("""{"type":42,"payload":{}}""");
|
||||||
|
Assert.Null(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_NullPayload_IgnoresPayload()
|
||||||
|
{
|
||||||
|
var msg = WebBridgeMessage.TryParse("""{"type":"voice-start","payload":null}""");
|
||||||
|
Assert.NotNull(msg);
|
||||||
|
Assert.Equal("voice-start", msg!.Type);
|
||||||
|
Assert.Null(msg.PayloadJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ToJson ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_NoPayload_EmitsEmptyObject()
|
||||||
|
{
|
||||||
|
var msg = new WebBridgeMessage(WebBridgeMessage.TypeRecordingStart);
|
||||||
|
var json = msg.ToJson();
|
||||||
|
Assert.Contains("\"type\":\"recording-start\"", json);
|
||||||
|
Assert.Contains("\"payload\":{}", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_WithAnonymousPayload_SerializesPayload()
|
||||||
|
{
|
||||||
|
var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText);
|
||||||
|
var json = msg.ToJson(new { text = "hello world" });
|
||||||
|
Assert.Contains("\"type\":\"draft-text\"", json);
|
||||||
|
Assert.Contains("\"text\":\"hello world\"", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_WithStoredPayloadJson_EmbeddedVerbatim()
|
||||||
|
{
|
||||||
|
var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText, """{"text":"hi"}""");
|
||||||
|
var json = msg.ToJson();
|
||||||
|
Assert.Contains("\"payload\":{\"text\":\"hi\"}", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_InvalidStoredPayloadJson_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() =>
|
||||||
|
new WebBridgeMessage(WebBridgeMessage.TypeDraftText, "{bad json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_BlankStoredPayloadJson_TreatedAsNoPayload()
|
||||||
|
{
|
||||||
|
var msg = new WebBridgeMessage(WebBridgeMessage.TypeReady, " ");
|
||||||
|
Assert.Null(msg.PayloadJson);
|
||||||
|
Assert.Contains("\"payload\":{}", msg.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToJson_PassedPayloadOverridesStoredPayloadJson()
|
||||||
|
{
|
||||||
|
var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText, """{"text":"old"}""");
|
||||||
|
var json = msg.ToJson(new { text = "new" });
|
||||||
|
Assert.Contains("\"text\":\"new\"", json);
|
||||||
|
Assert.DoesNotContain("old", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── round-trip ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WebBridgeMessage.TypeRecordingStart)]
|
||||||
|
[InlineData(WebBridgeMessage.TypeRecordingStop)]
|
||||||
|
[InlineData(WebBridgeMessage.TypeVoiceStart)]
|
||||||
|
[InlineData(WebBridgeMessage.TypeVoiceStop)]
|
||||||
|
[InlineData(WebBridgeMessage.TypeReady)]
|
||||||
|
public void RoundTrip_WellKnownTypes_PreserveType(string type)
|
||||||
|
{
|
||||||
|
var original = new WebBridgeMessage(type);
|
||||||
|
var json = original.ToJson();
|
||||||
|
var parsed = WebBridgeMessage.TryParse(json);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(type, parsed!.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_DraftText_PreservesPayload()
|
||||||
|
{
|
||||||
|
var original = new WebBridgeMessage(WebBridgeMessage.TypeDraftText);
|
||||||
|
var json = original.ToJson(new { text = "round trip" });
|
||||||
|
var parsed = WebBridgeMessage.TryParse(json);
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(WebBridgeMessage.TypeDraftText, parsed!.Type);
|
||||||
|
Assert.Contains("round trip", parsed.PayloadJson ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,26 @@ public class TrayMenuWindowMarkupTests
|
|||||||
xaml);
|
xaml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WebChatWindow_BridgeValidatesOriginAndPostsOnDispatcher()
|
||||||
|
{
|
||||||
|
var sourcePath = Path.Combine(
|
||||||
|
GetRepositoryRoot(),
|
||||||
|
"src",
|
||||||
|
"OpenClaw.Tray.WinUI",
|
||||||
|
"Windows",
|
||||||
|
"WebChatWindow.xaml.cs");
|
||||||
|
|
||||||
|
var source = File.ReadAllText(sourcePath);
|
||||||
|
|
||||||
|
Assert.Contains("IsTrustedBridgeSource(e.Source)", source);
|
||||||
|
Assert.Contains("rejected bridge message from untrusted source", source);
|
||||||
|
Assert.Contains("DispatcherQueue", source);
|
||||||
|
Assert.Contains("TryEnqueue(() => PostBridgeMessageOnUiThread", source);
|
||||||
|
Assert.Contains("PostWebMessageAsJson(json)", source);
|
||||||
|
Assert.Contains("SanitizeBridgeLogValue", source);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SettingsWindow_HasCommandCenterEntryPoint()
|
public void SettingsWindow_HasCommandCenterEntryPoint()
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user