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"}""")
|
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)
|
||||||
|
|||||||
@@ -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" -> {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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`).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"indentStyle": "space"
|
"indentStyle": "space"
|
||||||
},
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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\\\""
|
--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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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; }
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user