refactor(canvas): host A2UI via gateway
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user