diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index e5c77bca9..01f6ef8ff 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -50,7 +50,6 @@ let package = Package( ], resources: [ .copy("Resources/Clawdis.icns"), - .copy("Resources/CanvasA2UI"), .copy("Resources/DeviceModels"), ], swiftSettings: [ diff --git a/apps/macos/Sources/Clawdis/CanvasManager.swift b/apps/macos/Sources/Clawdis/CanvasManager.swift index 7fb8a2b2c..56bd41889 100644 --- a/apps/macos/Sources/Clawdis/CanvasManager.swift +++ b/apps/macos/Sources/Clawdis/CanvasManager.swift @@ -1,5 +1,6 @@ import AppKit import ClawdisIPC +import ClawdisKit import Foundation import OSLog @@ -225,7 +226,7 @@ final class CanvasManager { } private static func hasBundledA2UIShell() -> Bool { - guard let base = Bundle.module.resourceURL?.appendingPathComponent("CanvasA2UI", isDirectory: true) else { + guard let base = ClawdisKitResources.bundle.resourceURL?.appendingPathComponent("CanvasA2UI", isDirectory: true) else { return false } let index = base.appendingPathComponent("index.html", isDirectory: false) diff --git a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift index ef9c5ab84..140a0d942 100644 --- a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift +++ b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift @@ -1,4 +1,5 @@ import Foundation +import ClawdisKit import OSLog import WebKit @@ -243,7 +244,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { } private func loadBundledResourceData(subdirectory: String, relativePath: String) -> Data? { - guard let base = Bundle.module.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else { + guard let base = ClawdisKitResources.bundle.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else { return nil } let url = base.appendingPathComponent(relativePath, isDirectory: false) diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 88ca77c1c..64333ed70 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -1,4 +1,5 @@ import ClawdisIPC +import ClawdisKit import Foundation import OSLog @@ -319,22 +320,20 @@ enum ControlRequestHandler { guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return Response(ok: false, message: "missing jsonl") } - let items: [ParsedJSONLItem] - do { - items = try Self.parseJSONL(jsonl) - } catch { - return Response(ok: false, message: "invalid jsonl: \(error.localizedDescription)") - } + let messages: [ClawdisKit.AnyCodable] do { - try Self.validateA2UIV0_8(items) + messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl) } catch { return Response(ok: false, message: error.localizedDescription) } - let messages = items.map(\.value) - let data = try JSONSerialization.data(withJSONObject: messages, options: []) - let json = String(data: data, encoding: .utf8) ?? "[]" + let json: String + do { + json = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages) + } catch { + return Response(ok: false, message: error.localizedDescription) + } js = """ (() => { try { @@ -364,56 +363,6 @@ enum ControlRequestHandler { } } - private struct ParsedJSONLItem { - let lineNumber: Int - let value: Any - } - - private static func parseJSONL(_ text: String) throws -> [ParsedJSONLItem] { - var out: [ParsedJSONLItem] = [] - var lineNumber = 0 - for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) { - lineNumber += 1 - let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) - if line.isEmpty { continue } - let data = Data(line.utf8) - let obj = try JSONSerialization.jsonObject(with: data, options: []) - out.append(ParsedJSONLItem(lineNumber: lineNumber, value: obj)) - } - return out - } - - private static func validateA2UIV0_8(_ items: [ParsedJSONLItem]) throws { - let allowed = Set(["beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"]) - for item in items { - guard let dict = item.value as? [String: Any] else { - throw NSError(domain: "A2UI", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "A2UI JSONL line \(item.lineNumber): expected a JSON object", - ]) - } - - if dict.keys.contains("createSurface") { - throw NSError(domain: "A2UI", code: 2, userInfo: [ - NSLocalizedDescriptionKey: """ - A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`). - Canvas currently supports A2UI v0.8 server→client messages (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`). - """, - ]) - } - - let matched = dict.keys.filter { allowed.contains($0) } - if matched.count != 1 { - let found = dict.keys.sorted().joined(separator: ", ") - throw NSError(domain: "A2UI", code: 3, userInfo: [ - NSLocalizedDescriptionKey: """ - A2UI JSONL line \(item.lineNumber): expected exactly one of \(allowed.sorted() - .joined(separator: ", ")); found: \(found) - """, - ]) - } - } - } - private static func waitForCanvasA2UI(session: String, requireBuiltinPath: Bool, timeoutMs: Int) async -> Bool { let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(timeoutMs)) diff --git a/apps/shared/ClawdisKit/Package.swift b/apps/shared/ClawdisKit/Package.swift index 7181fdf21..3d7c4a784 100644 --- a/apps/shared/ClawdisKit/Package.swift +++ b/apps/shared/ClawdisKit/Package.swift @@ -16,6 +16,9 @@ let package = Package( .target( name: "ClawdisKit", dependencies: [], + resources: [ + .process("Resources"), + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UICommands.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UICommands.swift new file mode 100644 index 000000000..0f1dddc5b --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UICommands.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum ClawdisCanvasA2UICommand: String, Codable, Sendable { + /// Render A2UI content on the device canvas. + case push = "canvas.a2ui.push" + /// Legacy alias for `push` when sending JSONL. + case pushJSONL = "canvas.a2ui.pushJSONL" + /// Reset the A2UI renderer state. + case reset = "canvas.a2ui.reset" +} + +public struct ClawdisCanvasA2UIPushParams: Codable, Sendable, Equatable { + public var messages: [AnyCodable] + + public init(messages: [AnyCodable]) { + self.messages = messages + } +} + +public struct ClawdisCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable { + public var jsonl: String + + public init(jsonl: String) { + self.jsonl = jsonl + } +} + diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIJSONL.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIJSONL.swift new file mode 100644 index 000000000..d76a4b433 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/CanvasA2UIJSONL.swift @@ -0,0 +1,76 @@ +import Foundation + +public enum ClawdisCanvasA2UIJSONL: Sendable { + public struct ParsedItem: Sendable { + public var lineNumber: Int + public var message: AnyCodable + + public init(lineNumber: Int, message: AnyCodable) { + self.lineNumber = lineNumber + self.message = message + } + } + + public static func parse(_ text: String) throws -> [ParsedItem] { + var out: [ParsedItem] = [] + var lineNumber = 0 + for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) { + lineNumber += 1 + let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty { continue } + let data = Data(line.utf8) + + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + out.append(ParsedItem(lineNumber: lineNumber, message: decoded)) + } + return out + } + + public static func validateV0_8(_ items: [ParsedItem]) throws { + let allowed = Set(["beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"]) + for item in items { + guard let dict = item.message.value as? [String: AnyCodable] else { + throw NSError(domain: "A2UI", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "A2UI JSONL line \(item.lineNumber): expected a JSON object", + ]) + } + + if dict.keys.contains("createSurface") { + throw NSError(domain: "A2UI", code: 2, userInfo: [ + NSLocalizedDescriptionKey: """ + A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`). + Canvas currently supports A2UI v0.8 server→client messages (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`). + """, + ]) + } + + let matched = dict.keys.filter { allowed.contains($0) } + if matched.count != 1 { + let found = dict.keys.sorted().joined(separator: ", ") + throw NSError(domain: "A2UI", code: 3, userInfo: [ + NSLocalizedDescriptionKey: """ + A2UI JSONL line \(item.lineNumber): expected exactly one of \(allowed.sorted() + .joined(separator: ", ")); found: \(found) + """, + ]) + } + } + } + + public static func decodeMessagesFromJSONL(_ text: String) throws -> [AnyCodable] { + let items = try self.parse(text) + try self.validateV0_8(items) + return items.map(\.message) + } + + public static func encodeMessagesJSONArray(_ messages: [AnyCodable]) throws -> String { + let data = try JSONEncoder().encode(messages) + guard let json = String(data: data, encoding: .utf8) else { + throw NSError(domain: "A2UI", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode messages payload as UTF-8", + ]) + } + return json + } +} + diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/ClawdisKitResources.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/ClawdisKitResources.swift new file mode 100644 index 000000000..9ea300f93 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/ClawdisKitResources.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum ClawdisKitResources { + public static let bundle: Bundle = .module +} + diff --git a/apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js b/apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js similarity index 100% rename from apps/macos/Sources/Clawdis/Resources/CanvasA2UI/a2ui.bundle.js rename to apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js diff --git a/apps/macos/Sources/Clawdis/Resources/CanvasA2UI/index.html b/apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasA2UI/index.html similarity index 88% rename from apps/macos/Sources/Clawdis/Resources/CanvasA2UI/index.html rename to apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasA2UI/index.html index 1ede52316..eef5b4d60 100644 --- a/apps/macos/Sources/Clawdis/Resources/CanvasA2UI/index.html +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasA2UI/index.html @@ -18,7 +18,6 @@