refactor(canvas): host A2UI via gateway
This commit is contained in:
@@ -605,9 +605,17 @@ class NodeRuntime(context: Context) {
|
||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
@@ -619,9 +627,17 @@ class NodeRuntime(context: Context) {
|
||||
} catch (err: Throwable) {
|
||||
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) {
|
||||
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 res = canvas.eval(js)
|
||||
@@ -707,7 +723,14 @@ class NodeRuntime(context: Context) {
|
||||
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 {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
if (already == "true") return true
|
||||
@@ -715,8 +738,7 @@ class NodeRuntime(context: Context) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Ensure the default canvas scaffold is loaded; A2UI is now hosted there.
|
||||
canvas.navigate("")
|
||||
canvas.navigate(a2uiUrl)
|
||||
repeat(50) {
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
|
||||
@@ -61,6 +61,7 @@ class BridgeSession(
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private var job: Job? = null
|
||||
@@ -77,10 +78,13 @@ class BridgeSession(
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
|
||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(
|
||||
@@ -209,6 +213,7 @@ class BridgeSession(
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
onConnected(name, conn.remoteAddress)
|
||||
}
|
||||
"error" -> {
|
||||
|
||||
@@ -46,7 +46,7 @@ class BridgeSessionTest {
|
||||
|
||||
val hello = reader.readLine()
|
||||
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.flush()
|
||||
|
||||
@@ -77,6 +77,7 @@ class BridgeSessionTest {
|
||||
)
|
||||
|
||||
connected.await()
|
||||
assertEquals("http://127.0.0.1:18793", session.currentCanvasHostUrl())
|
||||
val payload = session.request(method = "health", paramsJson = null)
|
||||
assertEquals("""{"value":123}""", payload)
|
||||
server.await()
|
||||
|
||||
@@ -25,6 +25,11 @@ actor BridgeSession {
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
|
||||
|
||||
private(set) var state: State = .idle
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
func currentCanvasHostUrl() -> String? {
|
||||
self.canvasHostUrl
|
||||
}
|
||||
|
||||
func currentRemoteAddress() -> String? {
|
||||
guard let endpoint = self.connection?.currentPath?.remoteEndpoint else { return nil }
|
||||
@@ -101,6 +106,7 @@ actor BridgeSession {
|
||||
if base.type == "hello-ok" {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await onConnected?(ok.serverName)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
@@ -210,6 +216,7 @@ actor BridgeSession {
|
||||
self.connection = nil
|
||||
self.queue = nil
|
||||
self.buffer = Data()
|
||||
self.canvasHostUrl = nil
|
||||
|
||||
let pending = self.pendingRPC.values
|
||||
self.pendingRPC.removeAll()
|
||||
|
||||
@@ -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) {
|
||||
switch phase {
|
||||
case .background:
|
||||
@@ -436,12 +443,22 @@ final class NodeAppModel {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
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) {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
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: """
|
||||
@@ -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) {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
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)
|
||||
|
||||
@@ -199,11 +199,6 @@ final class ScreenController {
|
||||
name: "scaffold",
|
||||
ext: "html",
|
||||
subdirectory: "CanvasScaffold")
|
||||
private static let a2uiIndexURL: URL? = ScreenController.bundledResourceURL(
|
||||
name: "index",
|
||||
ext: "html",
|
||||
subdirectory: "CanvasA2UI")
|
||||
|
||||
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
let std = url.standardizedFileURL
|
||||
@@ -212,11 +207,6 @@ final class ScreenController {
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let expected = Self.a2uiIndexURL,
|
||||
std == expected.standardizedFileURL
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -192,12 +191,12 @@ final class CanvasManager {
|
||||
if path.hasPrefix("/") { path.removeFirst() }
|
||||
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 {
|
||||
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
|
||||
return Self.hasBundledA2UIShell() ? .a2uiShell : .welcome
|
||||
return .welcome
|
||||
}
|
||||
|
||||
// Direct file or directory.
|
||||
@@ -229,11 +228,5 @@ final class CanvasManager {
|
||||
return fm.fileExists(atPath: b.path)
|
||||
}
|
||||
|
||||
private static func hasBundledA2UIShell() -> Bool {
|
||||
let bundle = ClawdisKitResources.bundle
|
||||
if bundle.url(forResource: "index", withExtension: "html", subdirectory: "CanvasA2UI") != nil {
|
||||
return true
|
||||
}
|
||||
return bundle.url(forResource: "index", withExtension: "html") != nil
|
||||
}
|
||||
// no bundled A2UI shell; scaffold fallback is purely visual
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "
|
||||
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let root: URL
|
||||
|
||||
private static let builtinPrefix = "__clawdis__/a2ui"
|
||||
|
||||
init(root: URL) {
|
||||
self.root = root
|
||||
}
|
||||
@@ -67,10 +65,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
if path.hasPrefix("/") { path.removeFirst() }
|
||||
path = path.removingPercentEncoding ?? path
|
||||
|
||||
if let builtin = self.builtinResponse(requestPath: path) {
|
||||
return builtin
|
||||
}
|
||||
|
||||
// Special-case: welcome page when root index is missing.
|
||||
if path.isEmpty {
|
||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||
@@ -78,7 +72,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
if !FileManager.default.fileExists(atPath: indexA.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")
|
||||
}
|
||||
|
||||
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.
|
||||
if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") {
|
||||
return CanvasResponse(mime: "text/html", data: data)
|
||||
@@ -214,35 +208,6 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
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? {
|
||||
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@@ -162,10 +162,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
// Only auto-reload when we are showing local canvas content.
|
||||
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 ?? ""
|
||||
if path.hasPrefix("/__clawdis__/a2ui") { return }
|
||||
if path == "/" || path.isEmpty {
|
||||
let indexA = sessionDir.appendingPathComponent("index.html", 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 }
|
||||
|
||||
// Only accept actions from local Canvas content (not arbitrary web pages).
|
||||
guard let webView = message.webView,
|
||||
webView.url?.scheme == CanvasScheme.scheme
|
||||
else { return }
|
||||
guard let webView = message.webView, let url = webView.url else { return }
|
||||
if url.scheme == CanvasScheme.scheme {
|
||||
// ok
|
||||
} else if Self.isLocalNetworkCanvasURL(url) {
|
||||
// ok
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let body: [String: Any] = {
|
||||
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`).
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,12 @@ actor GatewayConnection {
|
||||
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> {
|
||||
let id = UUID()
|
||||
let snapshot = self.lastSnapshot
|
||||
|
||||
@@ -6,7 +6,6 @@ import Foundation
|
||||
actor MacNodeRuntime {
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
@MainActor private let screenRecorder = ScreenRecordService()
|
||||
private static let a2uiShellPath = "/__clawdis__/a2ui/"
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
@@ -248,15 +247,27 @@ actor MacNodeRuntime {
|
||||
|
||||
private func ensureA2UIHost() async throws {
|
||||
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 CanvasManager.shared.show(sessionKey: "main", path: Self.a2uiShellPath)
|
||||
try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
|
||||
}
|
||||
if await self.isA2UIReady(poll: true) { return }
|
||||
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 {
|
||||
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
|
||||
while true {
|
||||
|
||||
@@ -66,9 +66,7 @@ public enum CanvasShowStatus: String, Codable, Sendable {
|
||||
case ok
|
||||
/// Local canvas target did not resolve to a file (404 page).
|
||||
case notFound
|
||||
/// Local canvas root ("/") has no index, so the built-in A2UI shell is shown.
|
||||
case a2uiShell
|
||||
/// Legacy fallback when the built-in shell isn't available (dev misconfiguration).
|
||||
/// Local scaffold fallback (e.g., no index.html present).
|
||||
case welcome
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ public struct HelloOk: Codable {
|
||||
public let server: [String: AnyCodable]
|
||||
public let features: [String: AnyCodable]
|
||||
public let snapshot: Snapshot
|
||||
public let canvashosturl: String?
|
||||
public let policy: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -61,6 +62,7 @@ public struct HelloOk: Codable {
|
||||
server: [String: AnyCodable],
|
||||
features: [String: AnyCodable],
|
||||
snapshot: Snapshot,
|
||||
canvashosturl: String?,
|
||||
policy: [String: AnyCodable]
|
||||
) {
|
||||
self.type = type
|
||||
@@ -68,6 +70,7 @@ public struct HelloOk: Codable {
|
||||
self.server = server
|
||||
self.features = features
|
||||
self.snapshot = snapshot
|
||||
self.canvashosturl = canvashosturl
|
||||
self.policy = policy
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -76,6 +79,7 @@ public struct HelloOk: Codable {
|
||||
case server
|
||||
case features
|
||||
case snapshot
|
||||
case canvashosturl = "canvasHostUrl"
|
||||
case policy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +99,12 @@ public struct BridgeHello: Codable, Sendable {
|
||||
public struct BridgeHelloOk: Codable, Sendable {
|
||||
public let type: 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.serverName = serverName
|
||||
self.canvasHostUrl = canvasHostUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,16 +85,6 @@
|
||||
pointer-events: none;
|
||||
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 {
|
||||
text-align: center;
|
||||
padding: 16px 18px;
|
||||
@@ -126,9 +116,6 @@
|
||||
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="clawdis-a2ui-wrap">
|
||||
<clawdis-a2ui-host></clawdis-a2ui-host>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -28,40 +28,4 @@ import Testing
|
||||
#expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}"))
|
||||
#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)"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ import { themeContext } from "@clawdis/a2ui-theme-context";
|
||||
|
||||
const modalStyles = css`
|
||||
dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
background: rgba(5, 8, 16, 0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
|
||||
@@ -5,7 +5,14 @@ import { defineConfig } from "rolldown";
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../../../../..");
|
||||
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 a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js");
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.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:dev": "pnpm -C ui dev",
|
||||
"ui:build": "pnpm -C ui build",
|
||||
|
||||
19
scripts/canvas-a2ui-copy.ts
Normal file
19
scripts/canvas-a2ui-copy.ts
Normal 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);
|
||||
});
|
||||
@@ -156,6 +156,10 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
||||
--define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
|
||||
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)"
|
||||
cat > "$RELAY_DIR/package.json" <<JSON
|
||||
{
|
||||
|
||||
@@ -52,7 +52,7 @@ log "==> Killing existing Clawdis instances"
|
||||
kill_all_clawdis
|
||||
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"
|
||||
|
||||
# 2) Rebuild into the same path the packager consumes (.build).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Canvas</title>
|
||||
<title>Clawdis Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
@@ -94,4 +94,36 @@ describe("canvas host", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ export const HelloOkSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
snapshot: SnapshotSchema,
|
||||
canvasHostUrl: Type.Optional(NonEmptyString),
|
||||
policy: Type.Object(
|
||||
{
|
||||
maxPayload: Type.Integer({ minimum: 1 }),
|
||||
|
||||
@@ -79,7 +79,11 @@ type BridgeInvokeResponseFrame = {
|
||||
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 BridgeErrorFrame = { type: "error"; code: string; message: string };
|
||||
|
||||
@@ -132,6 +136,7 @@ export type NodeBridgeServerOpts = {
|
||||
host: string;
|
||||
port: number; // 0 = ephemeral
|
||||
pairingBaseDir?: string;
|
||||
canvasHostPort?: number;
|
||||
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
|
||||
onRequest?: (
|
||||
nodeId: string,
|
||||
@@ -180,6 +185,15 @@ export async function startNodeBridgeServer(
|
||||
? opts.serverName.trim()
|
||||
: 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 = {
|
||||
socket: net.Socket;
|
||||
nodeInfo: NodeBridgeClientInfo;
|
||||
@@ -356,7 +370,11 @@ export async function startNodeBridgeServer(
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -466,7 +484,11 @@ export async function startNodeBridgeServer(
|
||||
};
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user