Files
openclaw/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift
Peter Steinberger c6dc5bc764 fix(canvas): hosted pages fail on macOS with scoped Gateway auth (#105336)
* fix(canvas): harden hosted Canvas on macOS

Resolve Gateway-hosted Canvas and A2UI targets through refreshed node capabilities, keep bridge and live-reload behavior correct, and restrict native action dispatch to the exact trusted main-frame document.

Preserve scalar evaluation results, secure action IDs, and hidden state-directory watching.

Co-authored-by: GuoJiaming <804436395@qq.com>
Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
Co-authored-by: zeng-weihan <ceng.weihan@xydigit.com>

* chore(canvas): defer release note

* chore(canvas): refresh native i18n inventory

* refactor(canvas): isolate hosted surface resolution

---------

Co-authored-by: GuoJiaming <804436395@qq.com>
Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
Co-authored-by: zeng-weihan <ceng.weihan@xydigit.com>
2026-07-12 13:02:27 +01:00

166 lines
6.9 KiB
Swift

import AppKit
import Foundation
import OpenClawKit
import WebKit
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "openclawCanvasA2UIAction"
static let allMessageNames = [messageName]
private let sessionKey: String
private var expectedRemoteURL: URL?
init(sessionKey: String) {
self.sessionKey = sessionKey
super.init()
}
func setTrustedRemoteURL(_ url: URL?) {
self.expectedRemoteURL = url.flatMap {
CanvasHostedURLResolver.isCapabilityScopedA2UIURL($0) ? $0 : nil
}
}
func updateTrustForMainFrameNavigation(to url: URL) {
guard let expectedRemoteURL = self.expectedRemoteURL else { return }
// Hosted action trust is load-scoped. Once the main frame leaves the
// selected A2UI request, page navigation must never re-arm it.
if !Self.isExactRemoteSourceURL(url, expectedRemoteURL: expectedRemoteURL) {
self.expectedRemoteURL = nil
}
}
func isTrustedSourceURL(_ url: URL) -> Bool {
Self.isTrustedSourceURL(url, expectedRemoteURL: self.expectedRemoteURL)
}
static func isTrustedSourceURL(_ url: URL, expectedRemoteURL: URL?) -> Bool {
if let scheme = url.scheme?.lowercased(), CanvasScheme.allSchemes.contains(scheme) {
return true
}
return self.isExactRemoteSourceURL(url, expectedRemoteURL: expectedRemoteURL)
}
private static func isExactRemoteSourceURL(_ url: URL, expectedRemoteURL: URL?) -> Bool {
guard let expectedRemoteURL,
CanvasHostedURLResolver.isCapabilityScopedA2UIURL(expectedRemoteURL),
let actual = URLComponents(url: url, resolvingAgainstBaseURL: false),
let expected = URLComponents(url: expectedRemoteURL, resolvingAgainstBaseURL: false),
let actualScheme = actual.scheme?.lowercased(),
let expectedScheme = expected.scheme?.lowercased(),
actualScheme == expectedScheme,
actual.host?.lowercased() == expected.host?.lowercased(),
self.effectivePort(actual) == self.effectivePort(expected),
actual.user == nil,
actual.password == nil,
expected.user == nil,
expected.password == nil
else {
return false
}
return actual.percentEncodedPath == expected.percentEncodedPath &&
actual.percentEncodedQuery == expected.percentEncodedQuery
}
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard Self.allMessageNames.contains(message.name) else { return }
// Only the main in-app document or the exact capability-scoped A2UI
// document may dispatch. Other web content remains render-only.
guard message.frameInfo.isMainFrame else { return }
guard let webView = message.webView, let url = message.frameInfo.request.url else { return }
guard self.isTrustedSourceURL(url) else {
return
}
let body: [String: Any] = {
if let dict = message.body as? [String: Any] { return dict }
if let dict = message.body as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !body.isEmpty else { return }
let userActionAny = body["userAction"] ?? body
let userAction: [String: Any] = {
if let dict = userActionAny as? [String: Any] { return dict }
if let dict = userActionAny as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !userAction.isEmpty else { return }
guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "main"
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let instanceId = InstanceIdentity.instanceId.lowercased()
let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"])
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext(
actionName: name,
session: .init(key: self.sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
contextJSON: contextJSON)
let text = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext)
Task { [weak webView] in
if AppStateStore.shared.connectionMode == .local {
GatewayProcessManager.shared.setActive(true)
}
let result = await GatewayConnection.shared.sendAgent(
GatewayAgentInvocation(
message: text,
sessionKey: self.sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last,
idempotencyKey: actionId))
await MainActor.run {
guard let webView else { return }
let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId: actionId,
ok: result.ok,
error: result.error)
webView.evaluateJavaScript(js) { _, _ in }
}
if !result.ok {
canvasWindowLogger.error(
"""
A2UI action send failed name=\(name, privacy: .public) \
error=\(result.error ?? "unknown", privacy: .public)
""")
}
}
}
private static func effectivePort(_ components: URLComponents) -> Int? {
if let port = components.port { return port }
return switch components.scheme?.lowercased() {
case "http": 80
case "https": 443
default: nil
}
}
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
}