refactor(canvas): host A2UI via gateway

This commit is contained in:
Peter Steinberger
2025-12-20 12:17:27 +00:00
parent 13ebbd1a2b
commit ed001a5f55
28 changed files with 385 additions and 354 deletions

View File

@@ -605,9 +605,17 @@ class NodeRuntime(context: Context) {
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
} }
ClawdisCanvasA2UICommand.Reset.rawValue -> { ClawdisCanvasA2UICommand.Reset.rawValue -> {
val ready = ensureA2uiReady() val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) { if (!ready) {
return BridgeSession.InvokeResult.error(code = "A2UI_NOT_READY", message = "A2UI not ready") return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
} }
val res = canvas.eval(a2uiResetJS) val res = canvas.eval(a2uiResetJS)
BridgeSession.InvokeResult.ok(res) BridgeSession.InvokeResult.ok(res)
@@ -619,9 +627,17 @@ class NodeRuntime(context: Context) {
} catch (err: Throwable) { } catch (err: Throwable) {
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload") return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
} }
val ready = ensureA2uiReady() val a2uiUrl = resolveA2uiHostUrl()
?: return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) { if (!ready) {
return BridgeSession.InvokeResult.error(code = "A2UI_NOT_READY", message = "A2UI not ready") return BridgeSession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
} }
val js = a2uiApplyMessagesJS(messages) val js = a2uiApplyMessagesJS(messages)
val res = canvas.eval(js) val res = canvas.eval(js)
@@ -707,7 +723,14 @@ class NodeRuntime(context: Context) {
return code to "$code: $message" return code to "$code: $message"
} }
private suspend fun ensureA2uiReady(): Boolean { private fun resolveA2uiHostUrl(): String? {
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "${base}/__clawdis__/a2ui/"
}
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
try { try {
val already = canvas.eval(a2uiReadyCheckJS) val already = canvas.eval(a2uiReadyCheckJS)
if (already == "true") return true if (already == "true") return true
@@ -715,8 +738,7 @@ class NodeRuntime(context: Context) {
// ignore // ignore
} }
// Ensure the default canvas scaffold is loaded; A2UI is now hosted there. canvas.navigate(a2uiUrl)
canvas.navigate("")
repeat(50) { repeat(50) {
try { try {
val ready = canvas.eval(a2uiReadyCheckJS) val ready = canvas.eval(a2uiReadyCheckJS)

View File

@@ -61,6 +61,7 @@ class BridgeSession(
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex() private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>() private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
private var desired: Pair<BridgeEndpoint, Hello>? = null private var desired: Pair<BridgeEndpoint, Hello>? = null
private var job: Job? = null private var job: Job? = null
@@ -77,10 +78,13 @@ class BridgeSession(
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
job?.cancelAndJoin() job?.cancelAndJoin()
job = null job = null
canvasHostUrl = null
onDisconnected("Offline") onDisconnected("Offline")
} }
} }
fun currentCanvasHostUrl(): String? = canvasHostUrl
suspend fun sendEvent(event: String, payloadJson: String?) { suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return val conn = currentConnection ?: return
conn.sendJson( conn.sendJson(
@@ -209,6 +213,7 @@ class BridgeSession(
when (first["type"].asStringOrNull()) { when (first["type"].asStringOrNull()) {
"hello-ok" -> { "hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge" val name = first["serverName"].asStringOrNull() ?: "Bridge"
canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
onConnected(name, conn.remoteAddress) onConnected(name, conn.remoteAddress)
} }
"error" -> { "error" -> {

View File

@@ -46,7 +46,7 @@ class BridgeSessionTest {
val hello = reader.readLine() val hello = reader.readLine()
assertTrue(hello.contains("\"type\":\"hello\"")) assertTrue(hello.contains("\"type\":\"hello\""))
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""") writer.write("""{"type":"hello-ok","serverName":"Test Bridge","canvasHostUrl":"http://127.0.0.1:18793"}""")
writer.write("\n") writer.write("\n")
writer.flush() writer.flush()
@@ -77,6 +77,7 @@ class BridgeSessionTest {
) )
connected.await() connected.await()
assertEquals("http://127.0.0.1:18793", session.currentCanvasHostUrl())
val payload = session.request(method = "health", paramsJson = null) val payload = session.request(method = "health", paramsJson = null)
assertEquals("""{"value":123}""", payload) assertEquals("""{"value":123}""", payload)
server.await() server.await()

View File

@@ -25,6 +25,11 @@ actor BridgeSession {
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private(set) var state: State = .idle private(set) var state: State = .idle
private var canvasHostUrl: String?
func currentCanvasHostUrl() -> String? {
self.canvasHostUrl
}
func currentRemoteAddress() -> String? { func currentRemoteAddress() -> String? {
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil } guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
@@ -101,6 +106,7 @@ actor BridgeSession {
if base.type == "hello-ok" { if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data) let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName) self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName) await onConnected?(ok.serverName)
} else if base.type == "error" { } else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data) let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
@@ -210,6 +216,7 @@ actor BridgeSession {
self.connection = nil self.connection = nil
self.queue = nil self.queue = nil
self.buffer = Data() self.buffer = Data()
self.canvasHostUrl = nil
let pending = self.pendingRPC.values let pending = self.pendingRPC.values
self.pendingRPC.removeAll() self.pendingRPC.removeAll()

View File

@@ -143,6 +143,13 @@ final class NodeAppModel {
} }
} }
private func resolveA2UIHostURL() async -> String? {
guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
func setScenePhase(_ phase: ScenePhase) { func setScenePhase(_ phase: ScenePhase) {
switch phase { switch phase {
case .background: case .background:
@@ -436,12 +443,22 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCanvasA2UICommand.reset.rawValue: case ClawdisCanvasA2UICommand.reset.rawValue:
self.screen.showDefaultCanvas() guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: ClawdisNodeError(code: .unavailable, message: "A2UI not ready")) error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
} }
let json = try await self.screen.eval(javaScript: """ let json = try await self.screen.eval(javaScript: """
@@ -468,12 +485,22 @@ final class NodeAppModel {
} }
} }
self.screen.showDefaultCanvas() guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: ClawdisNodeError(code: .unavailable, message: "A2UI not ready")) error: ClawdisNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
} }
let messagesJSON = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages) let messagesJSON = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages)

View File

@@ -199,11 +199,6 @@ final class ScreenController {
name: "scaffold", name: "scaffold",
ext: "html", ext: "html",
subdirectory: "CanvasScaffold") subdirectory: "CanvasScaffold")
private static let a2uiIndexURL: URL? = ScreenController.bundledResourceURL(
name: "index",
ext: "html",
subdirectory: "CanvasA2UI")
func isTrustedCanvasUIURL(_ url: URL) -> Bool { func isTrustedCanvasUIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false } guard url.isFileURL else { return false }
let std = url.standardizedFileURL let std = url.standardizedFileURL
@@ -212,11 +207,6 @@ final class ScreenController {
{ {
return true return true
} }
if let expected = Self.a2uiIndexURL,
std == expected.standardizedFileURL
{
return true
}
return false return false
} }

View File

@@ -1,6 +1,5 @@
import AppKit import AppKit
import ClawdisIPC import ClawdisIPC
import ClawdisKit
import Foundation import Foundation
import OSLog import OSLog
@@ -192,12 +191,12 @@ final class CanvasManager {
if path.hasPrefix("/") { path.removeFirst() } if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path path = path.removingPercentEncoding ?? path
// Root special-case: built-in shell page when no index exists. // Root special-case: built-in scaffold page when no index exists.
if path.isEmpty { if path.isEmpty {
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false) let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false) let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok } if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
return Self.hasBundledA2UIShell() ? .a2uiShell : .welcome return .welcome
} }
// Direct file or directory. // Direct file or directory.
@@ -229,11 +228,5 @@ final class CanvasManager {
return fm.fileExists(atPath: b.path) return fm.fileExists(atPath: b.path)
} }
private static func hasBundledA2UIShell() -> Bool { // no bundled A2UI shell; scaffold fallback is purely visual
let bundle = ClawdisKitResources.bundle
if bundle.url(forResource: "index", withExtension: "html", subdirectory: "CanvasA2UI") != nil {
return true
}
return bundle.url(forResource: "index", withExtension: "html") != nil
}
} }

View File

@@ -8,8 +8,6 @@ private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL private let root: URL
private static let builtinPrefix = "__clawdis__/a2ui"
init(root: URL) { init(root: URL) {
self.root = root self.root = root
} }
@@ -67,10 +65,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if path.hasPrefix("/") { path.removeFirst() } if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path path = path.removingPercentEncoding ?? path
if let builtin = self.builtinResponse(requestPath: path) {
return builtin
}
// Special-case: welcome page when root index is missing. // Special-case: welcome page when root index is missing.
if path.isEmpty { if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false) let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
@@ -78,7 +72,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if !FileManager.default.fileExists(atPath: indexA.path), if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path) !FileManager.default.fileExists(atPath: indexB.path)
{ {
return self.a2uiShellPage(sessionRoot: sessionRoot) return self.scaffoldPage(sessionRoot: sessionRoot)
} }
} }
@@ -204,7 +198,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html(body, title: "Canvas") return self.html(body, title: "Canvas")
} }
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse { private func scaffoldPage(sessionRoot: URL) -> CanvasResponse {
// Default Canvas UX: when no index exists, show the built-in scaffold page. // Default Canvas UX: when no index exists, show the built-in scaffold page.
if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") { if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") {
return CanvasResponse(mime: "text/html", data: data) return CanvasResponse(mime: "text/html", data: data)
@@ -214,35 +208,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.welcomePage(sessionRoot: sessionRoot) return self.welcomePage(sessionRoot: sessionRoot)
} }
private func builtinResponse(requestPath: String) -> CanvasResponse? {
let trimmed = requestPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed == Self.builtinPrefix
|| trimmed == Self.builtinPrefix + "/"
|| trimmed.hasPrefix(Self.builtinPrefix + "/")
else { return nil }
let relative = if trimmed == Self.builtinPrefix || trimmed == Self.builtinPrefix + "/" {
"index.html"
} else {
String(trimmed.dropFirst((Self.builtinPrefix + "/").count))
}
if relative.isEmpty { return self.html("Not Found", title: "Canvas: 404") }
if relative.contains("..") || relative.contains("\\") {
return self.html("Forbidden", title: "Canvas: 403")
}
guard let data = self.loadBundledResourceData(relativePath: "CanvasA2UI/\(relative)") else {
return self.html("Not Found", title: "Canvas: 404")
}
let ext = (relative as NSString).pathExtension
let mime = CanvasScheme.mimeType(forExtension: ext)
return CanvasResponse(mime: mime, data: data)
}
private func loadBundledResourceData(relativePath: String) -> Data? { private func loadBundledResourceData(relativePath: String) -> Data? {
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }

View File

@@ -162,10 +162,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// Only auto-reload when we are showing local canvas content. // Only auto-reload when we are showing local canvas content.
guard webView.url?.scheme == CanvasScheme.scheme else { return } guard webView.url?.scheme == CanvasScheme.scheme else { return }
// Avoid reloading the built-in A2UI shell due to filesystem noise (it does not depend on session
// files).
let path = webView.url?.path ?? "" let path = webView.url?.path ?? ""
if path.hasPrefix("/__clawdis__/a2ui") { return }
if path == "/" || path.isEmpty { if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
@@ -608,9 +605,14 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
guard message.name == Self.messageName else { return } guard message.name == Self.messageName else { return }
// Only accept actions from local Canvas content (not arbitrary web pages). // Only accept actions from local Canvas content (not arbitrary web pages).
guard let webView = message.webView, guard let webView = message.webView, let url = webView.url else { return }
webView.url?.scheme == CanvasScheme.scheme if url.scheme == CanvasScheme.scheme {
else { return } // ok
} else if Self.isLocalNetworkCanvasURL(url) {
// ok
} else {
return
}
let body: [String: Any] = { let body: [String: Any] = {
if let dict = message.body as? [String: Any] { return dict } if let dict = message.body as? [String: Any] { return dict }
@@ -693,6 +695,43 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
} }
} }
private static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
}
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
return false
}
if host == "localhost" { return true }
if host.hasSuffix(".local") { return true }
if host.hasSuffix(".ts.net") { return true }
if host.hasSuffix(".tailscale.net") { return true }
if !host.contains("."), !host.contains(":") { return true }
if let ipv4 = Self.parseIPv4(host) {
return Self.isLocalNetworkIPv4(ipv4)
}
return false
}
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
if a == 10 { return true }
if a == 172, (16...31).contains(Int(b)) { return true }
if a == 192, b == 168 { return true }
if a == 127 { return true }
if a == 169, b == 254 { return true }
if a == 100, (64...127).contains(Int(b)) { return true }
return false
}
// Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`). // Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
} }

View File

@@ -172,6 +172,12 @@ actor GatewayConnection {
self.lastSnapshot = nil self.lastSnapshot = nil
} }
func canvasHostUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> { func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
let id = UUID() let id = UUID()
let snapshot = self.lastSnapshot let snapshot = self.lastSnapshot

View File

@@ -6,7 +6,6 @@ import Foundation
actor MacNodeRuntime { actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService() private let cameraCapture = CameraCaptureService()
@MainActor private let screenRecorder = ScreenRecordService() @MainActor private let screenRecorder = ScreenRecordService()
private static let a2uiShellPath = "/__clawdis__/a2ui/"
// swiftlint:disable:next function_body_length // swiftlint:disable:next function_body_length
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -248,15 +247,27 @@ actor MacNodeRuntime {
private func ensureA2UIHost() async throws { private func ensureA2UIHost() async throws {
if await self.isA2UIReady() { return } if await self.isA2UIReady() { return }
guard let a2uiUrl = await self.resolveA2UIHostUrl() else {
throw NSError(domain: "Canvas", code: 30, userInfo: [
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
])
}
_ = try await MainActor.run { _ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: "main", path: Self.a2uiShellPath) try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
} }
if await self.isA2UIReady(poll: true) { return } if await self.isA2UIReady(poll: true) { return }
throw NSError(domain: "Canvas", code: 31, userInfo: [ throw NSError(domain: "Canvas", code: 31, userInfo: [
NSLocalizedDescriptionKey: "A2UI not ready", NSLocalizedDescriptionKey: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
]) ])
} }
private func resolveA2UIHostUrl() async -> String? {
guard let raw = await GatewayConnection.shared.canvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let baseUrl = URL(string: trimmed) else { return nil }
return baseUrl.appendingPathComponent("__clawdis__/a2ui/").absoluteString
}
private func isA2UIReady(poll: Bool = false) async -> Bool { private func isA2UIReady(poll: Bool = false) async -> Bool {
let deadline = poll ? Date().addingTimeInterval(6.0) : Date() let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
while true { while true {

View File

@@ -66,9 +66,7 @@ public enum CanvasShowStatus: String, Codable, Sendable {
case ok case ok
/// Local canvas target did not resolve to a file (404 page). /// Local canvas target did not resolve to a file (404 page).
case notFound case notFound
/// Local canvas root ("/") has no index, so the built-in A2UI shell is shown. /// Local scaffold fallback (e.g., no index.html present).
case a2uiShell
/// Legacy fallback when the built-in shell isn't available (dev misconfiguration).
case welcome case welcome
} }

View File

@@ -53,6 +53,7 @@ public struct HelloOk: Codable {
public let server: [String: AnyCodable] public let server: [String: AnyCodable]
public let features: [String: AnyCodable] public let features: [String: AnyCodable]
public let snapshot: Snapshot public let snapshot: Snapshot
public let canvashosturl: String?
public let policy: [String: AnyCodable] public let policy: [String: AnyCodable]
public init( public init(
@@ -61,6 +62,7 @@ public struct HelloOk: Codable {
server: [String: AnyCodable], server: [String: AnyCodable],
features: [String: AnyCodable], features: [String: AnyCodable],
snapshot: Snapshot, snapshot: Snapshot,
canvashosturl: String?,
policy: [String: AnyCodable] policy: [String: AnyCodable]
) { ) {
self.type = type self.type = type
@@ -68,6 +70,7 @@ public struct HelloOk: Codable {
self.server = server self.server = server
self.features = features self.features = features
self.snapshot = snapshot self.snapshot = snapshot
self.canvashosturl = canvashosturl
self.policy = policy self.policy = policy
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -76,6 +79,7 @@ public struct HelloOk: Codable {
case server case server
case features case features
case snapshot case snapshot
case canvashosturl = "canvasHostUrl"
case policy case policy
} }
} }

View File

@@ -99,10 +99,12 @@ public struct BridgeHello: Codable, Sendable {
public struct BridgeHelloOk: Codable, Sendable { public struct BridgeHelloOk: Codable, Sendable {
public let type: String public let type: String
public let serverName: String public let serverName: String
public let canvasHostUrl: String?
public init(type: String = "hello-ok", serverName: String) { public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) {
self.type = type self.type = type
self.serverName = serverName self.serverName = serverName
self.canvasHostUrl = canvasHostUrl
} }
} }

View File

@@ -85,16 +85,6 @@
pointer-events: none; pointer-events: none;
z-index: 3; z-index: 3;
} }
#clawdis-a2ui-wrap {
position: fixed;
inset: 0;
display: none;
z-index: 2;
}
#clawdis-a2ui-wrap clawdis-a2ui-host {
display: block;
height: 100%;
}
#clawdis-status .card { #clawdis-status .card {
text-align: center; text-align: center;
padding: 16px 18px; padding: 16px 18px;
@@ -126,9 +116,6 @@
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div> <div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div> </div>
</div> </div>
<div id="clawdis-a2ui-wrap">
<clawdis-a2ui-host></clawdis-a2ui-host>
</div>
<script> <script>
(() => { (() => {
const canvas = document.getElementById('clawdis-canvas'); const canvas = document.getElementById('clawdis-canvas');
@@ -170,92 +157,6 @@
}; };
})(); })();
(() => {
const wrap = document.getElementById('clawdis-a2ui-wrap');
if (!wrap) return;
const candidates = [
// iOS (SwiftPM resources flattened)
"a2ui.bundle.js",
// Android (assets keep directory structure)
"../CanvasA2UI/a2ui.bundle.js",
"CanvasA2UI/a2ui.bundle.js",
];
const loadScript = (src) =>
new Promise((resolve, reject) => {
const el = document.createElement("script");
el.src = src;
el.async = true;
el.onload = () => resolve();
el.onerror = () => reject(new Error(`failed to load ${src}`));
document.head.appendChild(el);
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const installVisibilityHooks = () => {
const api = globalThis.clawdisA2UI;
if (!api || typeof api.applyMessages !== "function") return false;
if (globalThis.__clawdisA2UIVisibilityHooksInstalled) return true;
globalThis.__clawdisA2UIVisibilityHooksInstalled = true;
const show = () => { wrap.style.display = "block"; };
const hide = () => { wrap.style.display = "none"; };
const sync = () => {
try {
const surfaces =
typeof api.getSurfaces === "function" ? api.getSurfaces() : [];
if (Array.isArray(surfaces) && surfaces.length > 0) show();
else hide();
} catch {
hide();
}
};
const origApply = api.applyMessages.bind(api);
api.applyMessages = (messages) => {
const res = origApply(messages);
sync();
return res;
};
const origReset = api.reset.bind(api);
api.reset = () => {
const res = origReset();
hide();
return res;
};
hide();
return true;
};
(async () => {
if (globalThis.clawdisA2UI) {
installVisibilityHooks();
return;
}
let loaded = false;
for (const src of candidates) {
try {
await loadScript(src);
loaded = true;
break;
} catch {
// try next
}
}
if (!loaded) return;
// Wait for custom element upgrade + connectedCallback to publish globalThis.clawdisA2UI.
for (let i = 0; i < 60; i += 1) {
if (installVisibilityHooks()) return;
await sleep(50);
}
})().catch(() => {});
})();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -28,40 +28,4 @@ import Testing
#expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}")) #expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}"))
#expect(msg.hasSuffix(" default=update_canvas")) #expect(msg.hasSuffix(" default=update_canvas"))
} }
@Test func a2uiBundleSupportsAndroidBridgeFallback() throws {
guard let url = ClawdisKitResources.bundle.url(forResource: "a2ui.bundle", withExtension: "js")
else {
throw NSError(domain: "Tests", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Missing resource a2ui.bundle.js",
])
}
let js = try String(contentsOf: url, encoding: .utf8)
#expect(js.contains("clawdisCanvasA2UIAction"))
#expect(js.contains("globalThis.clawdisCanvasA2UIAction"))
}
@Test func a2uiBundleWrapsDynamicCssValues() throws {
guard let url = ClawdisKitResources.bundle.url(forResource: "a2ui.bundle", withExtension: "js")
else {
throw NSError(domain: "Tests", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Missing resource a2ui.bundle.js",
])
}
let js = try String(contentsOf: url, encoding: .utf8)
#expect(js.contains("blur(${r(statusBlur)})"))
#expect(js.contains("box-shadow: ${r(statusShadow)}"))
}
@Test func a2uiBundleStylesModalBackdrop() throws {
guard let url = ClawdisKitResources.bundle.url(forResource: "a2ui.bundle", withExtension: "js")
else {
throw NSError(domain: "Tests", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Missing resource a2ui.bundle.js",
])
}
let js = try String(contentsOf: url, encoding: .utf8)
#expect(js.contains("::backdrop"))
#expect(js.contains("backdrop-filter: blur(6px)"))
}
} }

View File

@@ -8,14 +8,17 @@ import { themeContext } from "@clawdis/a2ui-theme-context";
const modalStyles = css` const modalStyles = css`
dialog { dialog {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 24px; padding: 24px;
border: none; border: none;
background: none; background: rgba(5, 8, 16, 0.65);
display: flex; backdrop-filter: blur(6px);
align-items: center; display: grid;
justify-content: center; place-items: center;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
} }
dialog::backdrop { dialog::backdrop {

View File

@@ -5,7 +5,14 @@ import { defineConfig } from "rolldown";
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../../../.."); const repoRoot = path.resolve(here, "../../../../..");
const fromHere = (p) => path.resolve(here, p); const fromHere = (p) => path.resolve(here, p);
const outputFile = path.resolve(here, "../../Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js"); const outputFile = path.resolve(
here,
"../../../../..",
"src",
"canvas-host",
"a2ui",
"a2ui.bundle.js",
);
const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src"); const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src");
const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js"); const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js");

View File

@@ -5,6 +5,11 @@
"indentWidth": 2, "indentWidth": 2,
"indentStyle": "space" "indentStyle": "space"
}, },
"files": {
"includes": [
"src/**/*.ts"
]
},
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {

View File

@@ -10,7 +10,7 @@
"scripts": { "scripts": {
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"docs:list": "tsx scripts/docs-list.ts", "docs:list": "tsx scripts/docs-list.ts",
"build": "tsc -p tsconfig.json", "build": "pnpm canvas:a2ui:bundle && tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
"ui:install": "pnpm -C ui install", "ui:install": "pnpm -C ui install",
"ui:dev": "pnpm -C ui dev", "ui:dev": "pnpm -C ui dev",
"ui:build": "pnpm -C ui build", "ui:build": "pnpm -C ui build",

View File

@@ -0,0 +1,19 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.join(repoRoot, "src", "canvas-host", "a2ui");
const outDir = path.join(repoRoot, "dist", "canvas-host", "a2ui");
async function main() {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.cp(srcDir, outDir, { recursive: true });
}
main().catch((err) => {
console.error(String(err));
process.exit(1);
});

View File

@@ -156,6 +156,10 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
--define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\"" --define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
chmod +x "$CLI_OUT" chmod +x "$CLI_OUT"
echo "🎨 Copying gateway A2UI host assets"
rm -rf "$RELAY_DIR/a2ui"
cp -R "$ROOT_DIR/src/canvas-host/a2ui" "$RELAY_DIR/a2ui"
echo "📄 Writing embedded runtime package.json (Pi compatibility)" echo "📄 Writing embedded runtime package.json (Pi compatibility)"
cat > "$RELAY_DIR/package.json" <<JSON cat > "$RELAY_DIR/package.json" <<JSON
{ {

View File

@@ -52,7 +52,7 @@ log "==> Killing existing Clawdis instances"
kill_all_clawdis kill_all_clawdis
stop_launch_agent stop_launch_agent
# Bundle built-in Canvas A2UI shell (single-file JS, shipped in the app bundle). # Bundle Gateway-hosted Canvas A2UI assets.
run_step "bundle canvas a2ui" bash -lc "cd '${ROOT_DIR}' && pnpm canvas:a2ui:bundle" run_step "bundle canvas a2ui" bash -lc "cd '${ROOT_DIR}' && pnpm canvas:a2ui:bundle"
# 2) Rebuild into the same path the packager consumes (.build). # 2) Rebuild into the same path the packager consumes (.build).

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas</title> <title>Clawdis Canvas</title>
<style> <style>
:root { color-scheme: light dark; } :root { color-scheme: light dark; }
html, body { height: 100%; margin: 0; } html, body { height: 100%; margin: 0; }

View File

@@ -94,4 +94,36 @@ describe("canvas host", () => {
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
} }
}); });
it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
const server = await startCanvasHost({
runtime: defaultRuntime,
rootDir: dir,
port: 0,
listenHost: "127.0.0.1",
allowInTests: true,
});
try {
const res = await fetch(
`http://127.0.0.1:${server.port}/__clawdis__/a2ui/`,
);
const html = await res.text();
expect(res.status).toBe(200);
expect(html).toContain("clawdis-a2ui-host");
expect(html).toContain("clawdisCanvasA2UIAction");
const bundleRes = await fetch(
`http://127.0.0.1:${server.port}/__clawdis__/a2ui/a2ui.bundle.js`,
);
const js = await bundleRes.text();
expect(bundleRes.status).toBe(200);
expect(js).toContain("clawdisA2UI");
} finally {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
});
}); });

View File

@@ -108,6 +108,7 @@ export const HelloOkSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
), ),
snapshot: SnapshotSchema, snapshot: SnapshotSchema,
canvasHostUrl: Type.Optional(NonEmptyString),
policy: Type.Object( policy: Type.Object(
{ {
maxPayload: Type.Integer({ minimum: 1 }), maxPayload: Type.Integer({ minimum: 1 }),

View File

@@ -79,7 +79,11 @@ type BridgeInvokeResponseFrame = {
error?: { code: string; message: string } | null; error?: { code: string; message: string } | null;
}; };
type BridgeHelloOkFrame = { type: "hello-ok"; serverName: string }; type BridgeHelloOkFrame = {
type: "hello-ok";
serverName: string;
canvasHostUrl?: string;
};
type BridgePairOkFrame = { type: "pair-ok"; token: string }; type BridgePairOkFrame = { type: "pair-ok"; token: string };
type BridgeErrorFrame = { type: "error"; code: string; message: string }; type BridgeErrorFrame = { type: "error"; code: string; message: string };
@@ -132,6 +136,7 @@ export type NodeBridgeServerOpts = {
host: string; host: string;
port: number; // 0 = ephemeral port: number; // 0 = ephemeral
pairingBaseDir?: string; pairingBaseDir?: string;
canvasHostPort?: number;
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void; onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
onRequest?: ( onRequest?: (
nodeId: string, nodeId: string,
@@ -180,6 +185,15 @@ export async function startNodeBridgeServer(
? opts.serverName.trim() ? opts.serverName.trim()
: os.hostname(); : os.hostname();
const buildCanvasHostUrl = (socket: net.Socket) => {
const port = opts.canvasHostPort;
if (!port) return undefined;
const host = socket.localAddress?.trim();
if (!host) return undefined;
const formatted = host.includes(":") ? `[${host}]` : host;
return `http://${formatted}:${port}`;
};
type ConnectionState = { type ConnectionState = {
socket: net.Socket; socket: net.Socket;
nodeInfo: NodeBridgeClientInfo; nodeInfo: NodeBridgeClientInfo;
@@ -356,7 +370,11 @@ export async function startNodeBridgeServer(
opts.pairingBaseDir, opts.pairingBaseDir,
); );
connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame); send({
type: "hello-ok",
serverName,
canvasHostUrl: buildCanvasHostUrl(socket),
} satisfies BridgeHelloOkFrame);
await opts.onAuthenticated?.(nodeInfo); await opts.onAuthenticated?.(nodeInfo);
}; };
@@ -466,7 +484,11 @@ export async function startNodeBridgeServer(
}; };
connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame); send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame); send({
type: "hello-ok",
serverName,
canvasHostUrl: buildCanvasHostUrl(socket),
} satisfies BridgeHelloOkFrame);
await opts.onAuthenticated?.(nodeInfo); await opts.onAuthenticated?.(nodeInfo);
}; };