A2UI: share bundle via ClawdisKit
This commit is contained in:
@@ -50,7 +50,6 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.copy("Resources/Clawdis.icns"),
|
.copy("Resources/Clawdis.icns"),
|
||||||
.copy("Resources/CanvasA2UI"),
|
|
||||||
.copy("Resources/DeviceModels"),
|
.copy("Resources/DeviceModels"),
|
||||||
],
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
|
import ClawdisKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@@ -225,7 +226,7 @@ final class CanvasManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func hasBundledA2UIShell() -> Bool {
|
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
|
return false
|
||||||
}
|
}
|
||||||
let index = base.appendingPathComponent("index.html", isDirectory: false)
|
let index = base.appendingPathComponent("index.html", isDirectory: false)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ClawdisKit
|
||||||
import OSLog
|
import OSLog
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadBundledResourceData(subdirectory: String, relativePath: String) -> Data? {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
let url = base.appendingPathComponent(relativePath, isDirectory: false)
|
let url = base.appendingPathComponent(relativePath, isDirectory: false)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
|
import ClawdisKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@@ -319,22 +320,20 @@ enum ControlRequestHandler {
|
|||||||
guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
return Response(ok: false, message: "missing jsonl")
|
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 {
|
do {
|
||||||
try Self.validateA2UIV0_8(items)
|
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||||
} catch {
|
} catch {
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = items.map(\.value)
|
let json: String
|
||||||
let data = try JSONSerialization.data(withJSONObject: messages, options: [])
|
do {
|
||||||
let json = String(data: data, encoding: .utf8) ?? "[]"
|
json = try ClawdisCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
|
||||||
|
} catch {
|
||||||
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
|
}
|
||||||
js = """
|
js = """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
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 {
|
private static func waitForCanvasA2UI(session: String, requireBuiltinPath: Bool, timeoutMs: Int) async -> Bool {
|
||||||
let clock = ContinuousClock()
|
let clock = ContinuousClock()
|
||||||
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "ClawdisKit",
|
name: "ClawdisKit",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
|
resources: [
|
||||||
|
.process("Resources"),
|
||||||
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum ClawdisKitResources {
|
||||||
|
public static let bundle: Bundle = .module
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<clawdis-a2ui-host></clawdis-a2ui-host>
|
<clawdis-a2ui-host></clawdis-a2ui-host>
|
||||||
<script type="module" src="/__clawdis__/a2ui/a2ui.bundle.js"></script>
|
<script src="a2ui.bundle.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite struct CanvasA2UITests {
|
||||||
|
@Test func commandStringsAreStable() {
|
||||||
|
#expect(ClawdisCanvasA2UICommand.push.rawValue == "canvas.a2ui.push")
|
||||||
|
#expect(ClawdisCanvasA2UICommand.pushJSONL.rawValue == "canvas.a2ui.pushJSONL")
|
||||||
|
#expect(ClawdisCanvasA2UICommand.reset.rawValue == "canvas.a2ui.reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func jsonlDecodesAndValidatesV0_8() throws {
|
||||||
|
let jsonl = """
|
||||||
|
{"beginRendering":{"surfaceId":"main","timestamp":1}}
|
||||||
|
{"surfaceUpdate":{"surfaceId":"main","ops":[]}}
|
||||||
|
{"dataModelUpdate":{"dataModel":{"title":"Hello"}}}
|
||||||
|
{"deleteSurface":{"surfaceId":"main"}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
let messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||||
|
#expect(messages.count == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func jsonlRejectsV0_9CreateSurface() {
|
||||||
|
let jsonl = """
|
||||||
|
{"createSurface":{"surfaceId":"main"}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
#expect(throws: Error.self) {
|
||||||
|
_ = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func jsonlRejectsUnknownShape() {
|
||||||
|
let jsonl = """
|
||||||
|
{"wat":{"nope":1}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
#expect(throws: Error.self) {
|
||||||
|
_ = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { defineConfig } from "rolldown";
|
|||||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||||
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 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");
|
||||||
@@ -25,7 +26,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
file: fromHere("a2ui.bundle.js"),
|
file: outputFile,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
inlineDynamicImports: true,
|
inlineDynamicImports: true,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"protocol:gen": "tsx scripts/protocol-gen.ts",
|
"protocol:gen": "tsx scripts/protocol-gen.ts",
|
||||||
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
|
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
|
||||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdisProtocol/GatewayModels.swift",
|
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdisProtocol/GatewayModels.swift",
|
||||||
"canvas:a2ui:bundle": "pnpm -s exec tsc -p vendor/a2ui/renderers/lit/tsconfig.json && rolldown -c apps/macos/Sources/Clawdis/Resources/CanvasA2UI/rolldown.config.mjs"
|
"canvas:a2ui:bundle": "pnpm -s exec tsc -p vendor/a2ui/renderers/lit/tsconfig.json && rolldown -c apps/shared/ClawdisKit/Tools/CanvasA2UI/rolldown.config.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user