feat(macos): add Canvas A2UI renderer

This commit is contained in:
Peter Steinberger
2025-12-17 11:35:06 +01:00
parent 1cdebb68a0
commit cdb5ddb2da
408 changed files with 73598 additions and 32 deletions

View File

@@ -51,6 +51,7 @@ let package = Package(
resources: [
.copy("Resources/Clawdis.icns"),
.copy("Resources/WebChat"),
.copy("Resources/CanvasA2UI"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),

View File

@@ -28,20 +28,17 @@ final class CanvasManager {
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
let isWebTarget = Self.isWebTarget(normalizedTarget)
if let controller = self.panelController, self.panelSessionKey == session {
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
// Existing session: only navigate when an explicit target was provided.
if let normalizedTarget {
controller.goto(path: normalizedTarget)
controller.load(target: normalizedTarget)
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: normalizedTarget,
isWebTarget: isWebTarget)
effectiveTarget: normalizedTarget)
}
return CanvasShowResult(
@@ -72,8 +69,7 @@ final class CanvasManager {
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: effectiveTarget,
isWebTarget: isWebTarget)
effectiveTarget: effectiveTarget)
}
func hide(sessionKey: String) {
@@ -111,18 +107,29 @@ final class CanvasManager {
// MARK: - Helpers
private static func isWebTarget(_ target: String?) -> Bool {
guard let target, let url = URL(string: target), let scheme = url.scheme?.lowercased() else { return false }
return scheme == "https" || scheme == "http"
private static func directURL(for target: String?) -> URL? {
guard let target else { return nil }
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" || scheme == "file" { return url }
}
// Convenience: existing absolute paths resolve as local files.
if trimmed.hasPrefix("/"), FileManager.default.fileExists(atPath: trimmed) {
return URL(fileURLWithPath: trimmed)
}
return nil
}
private func makeShowResult(
directory: String,
target: String?,
effectiveTarget: String,
isWebTarget: Bool) -> CanvasShowResult
effectiveTarget: String) -> CanvasShowResult
{
if isWebTarget, let url = URL(string: effectiveTarget) {
if let url = Self.directURL(for: effectiveTarget) {
return CanvasShowResult(
directory: directory,
target: target,
@@ -151,12 +158,12 @@ final class CanvasManager {
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
// Root special-case: welcome page when no index exists.
// Root special-case: built-in shell 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 .welcome
return Self.hasBundledA2UIShell() ? .a2uiShell : .welcome
}
// Direct file or directory.
@@ -187,6 +194,14 @@ final class CanvasManager {
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
return fm.fileExists(atPath: b.path)
}
private static func hasBundledA2UIShell() -> Bool {
guard let base = Bundle.module.resourceURL?.appendingPathComponent("CanvasA2UI", isDirectory: true) else {
return false
}
let index = base.appendingPathComponent("index.html", isDirectory: false)
return FileManager.default.fileExists(atPath: index.path)
}
}
private extension String {

View File

@@ -7,6 +7,8 @@ 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
}
@@ -64,6 +66,10 @@ 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)
@@ -71,7 +77,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
{
return self.welcomePage(sessionRoot: sessionRoot)
return self.a2uiShellPage(sessionRoot: sessionRoot)
}
}
@@ -197,6 +203,54 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html(body, title: "Canvas")
}
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
// Default Canvas UX: when no index exists, show the built-in A2UI shell.
if let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: "index.html") {
return CanvasResponse(mime: "text/html", data: data)
}
// Fallback for dev misconfiguration: show the classic welcome page.
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: String
if trimmed == Self.builtinPrefix || trimmed == Self.builtinPrefix + "/" {
relative = "index.html"
} else {
relative = 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(subdirectory: "CanvasA2UI", relativePath: 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(subdirectory: String, relativePath: String) -> Data? {
guard let base = Bundle.module.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else {
return nil
}
let url = base.appendingPathComponent(relativePath, isDirectory: false)
return try? Data(contentsOf: url)
}
private func textEncodingName(forMimeType mimeType: String) -> String? {
if mimeType.hasPrefix("text/") { return "utf-8" }
switch mimeType {

View File

@@ -100,7 +100,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if case let .panel(anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.goto(path: path)
self.load(target: path)
}
return
}
@@ -109,7 +109,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.goto(path: path)
self.load(target: path)
}
self.onVisibilityChanged?(true)
}
@@ -124,14 +124,27 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.onVisibilityChanged?(false)
}
func goto(path: String) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http"
{
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" {
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
if scheme == "file" {
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
// Convenience: absolute paths resolve as local files when they exist.
if trimmed.hasPrefix("/"), FileManager.default.fileExists(atPath: trimmed) {
let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
@@ -144,10 +157,16 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
}
private func loadFile(_ url: URL) {
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
let accessDir = fileURL.deletingLastPathComponent()
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
}
func eval(javaScript: String) async -> String {
await withCheckedContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in

View File

@@ -67,6 +67,9 @@ enum ControlRequestHandler {
case let .canvasSnapshot(session, outPath):
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
case let .canvasA2UI(session, command, jsonl):
return await self.handleCanvasA2UI(session: session, command: command, jsonl: jsonl)
case .nodeList:
return await self.handleNodeList()
@@ -228,6 +231,86 @@ enum ControlRequestHandler {
}
}
private static func handleCanvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?) async -> Response {
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
do {
// Ensure the Canvas is visible and the default page is loaded.
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: session, path: "/")
}
let ready = await Self.waitForCanvasA2UI(session: session, timeoutMs: 2_000)
guard ready else { return Response(ok: false, message: "A2UI not ready") }
let js: String
switch command {
case .reset:
js = """
(() => {
if (!globalThis.clawdisA2UI) { return "missing clawdisA2UI"; }
globalThis.clawdisA2UI.reset();
return "ok";
})()
"""
case .pushJSONL:
guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return Response(ok: false, message: "missing jsonl")
}
let messages: [Any]
do {
messages = try Self.parseJSONL(jsonl)
} catch {
return Response(ok: false, message: "invalid jsonl: \(error.localizedDescription)")
}
let data = try JSONSerialization.data(withJSONObject: messages, options: [])
let json = String(data: data, encoding: .utf8) ?? "[]"
js = """
(() => {
if (!globalThis.clawdisA2UI) { return "missing clawdisA2UI"; }
const messages = \(json);
globalThis.clawdisA2UI.applyMessages(messages);
return "ok";
})()
"""
}
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: js)
return Response(ok: true, payload: Data(result.utf8))
} catch {
return Response(ok: false, message: error.localizedDescription)
}
}
private static func parseJSONL(_ text: String) throws -> [Any] {
var out: [Any] = []
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
let data = Data(line.utf8)
let obj = try JSONSerialization.jsonObject(with: data, options: [])
out.append(obj)
}
return out
}
private static func waitForCanvasA2UI(session: String, timeoutMs: Int) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
while clock.now < deadline {
do {
let res = try await CanvasManager.shared.eval(
sessionKey: session,
javaScript: "(() => globalThis.clawdisA2UI ? 'ready' : '')()")
if res == "ready" { return true }
} catch {
// Ignore; keep waiting.
}
try? await Task.sleep(nanoseconds: 60_000_000)
}
return false
}
private static func handleNodeList() async -> Response {
let ids = await BridgeServer.shared.connectedNodeIds()
let payload = (try? JSONSerialization.data(

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,197 @@
import { html, css, LitElement } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { ContextProvider } from "@lit/context";
import { v0_8 } from "@a2ui/lit";
import "@a2ui/lit/ui";
import { themeContext } from "@clawdis/a2ui-theme-context";
const empty = Object.freeze({});
const emptyClasses = () => ({});
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
const clawdisTheme = {
components: {
AudioPlayer: emptyClasses(),
Button: emptyClasses(),
Card: emptyClasses(),
Column: emptyClasses(),
CheckBox: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
DateTimeInput: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Divider: emptyClasses(),
Image: {
all: emptyClasses(),
icon: emptyClasses(),
avatar: emptyClasses(),
smallFeature: emptyClasses(),
mediumFeature: emptyClasses(),
largeFeature: emptyClasses(),
header: emptyClasses(),
},
Icon: emptyClasses(),
List: emptyClasses(),
Modal: { backdrop: emptyClasses(), element: emptyClasses() },
MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Row: emptyClasses(),
Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } },
Text: {
all: emptyClasses(),
h1: emptyClasses(),
h2: emptyClasses(),
h3: emptyClasses(),
h4: emptyClasses(),
h5: emptyClasses(),
caption: emptyClasses(),
body: emptyClasses(),
},
TextField: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
Video: emptyClasses(),
},
elements: {
a: emptyClasses(),
audio: emptyClasses(),
body: emptyClasses(),
button: emptyClasses(),
h1: emptyClasses(),
h2: emptyClasses(),
h3: emptyClasses(),
h4: emptyClasses(),
h5: emptyClasses(),
iframe: emptyClasses(),
input: emptyClasses(),
p: emptyClasses(),
pre: emptyClasses(),
textarea: emptyClasses(),
video: emptyClasses(),
},
markdown: {
p: [],
h1: [],
h2: [],
h3: [],
h4: [],
h5: [],
ul: [],
ol: [],
li: [],
a: [],
strong: [],
em: [],
},
additionalStyles: {
Card: {
background: "linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))",
border: "1px solid rgba(255,255,255,.09)",
borderRadius: "14px",
padding: "14px",
boxShadow: "0 10px 30px rgba(0,0,0,.35)",
},
Column: { gap: "10px" },
Row: { gap: "10px", alignItems: "center" },
Divider: { opacity: "0.25" },
Button: {
background: "linear-gradient(135deg, #22c55e 0%, #06b6d4 100%)",
border: "0",
borderRadius: "12px",
padding: "10px 14px",
color: "#071016",
fontWeight: "650",
cursor: "pointer",
boxShadow: "0 10px 25px rgba(6, 182, 212, 0.18)",
},
Text: {
...textHintStyles(),
h1: { fontSize: "20px", fontWeight: "750", margin: "0 0 6px 0" },
h2: { fontSize: "16px", fontWeight: "700", margin: "0 0 6px 0" },
body: { fontSize: "13px", lineHeight: "1.4" },
caption: { opacity: "0.8" },
},
TextField: { display: "grid", gap: "6px" },
Image: { borderRadius: "12px" },
},
};
class ClawdisA2UIHost extends LitElement {
static properties = {
surfaces: { state: true },
};
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
#themeProvider = new ContextProvider(this, {
context: themeContext,
initialValue: clawdisTheme,
});
surfaces = [];
static styles = css`
:host {
display: block;
height: 100%;
box-sizing: border-box;
padding: 12px;
}
#surfaces {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
height: 100%;
overflow: auto;
padding-bottom: 24px;
}
`;
connectedCallback() {
super.connectedCallback();
globalThis.clawdisA2UI = {
applyMessages: (messages) => this.applyMessages(messages),
reset: () => this.reset(),
getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
};
this.#syncSurfaces();
}
applyMessages(messages) {
if (!Array.isArray(messages)) {
throw new Error("A2UI: expected messages array");
}
this.#processor.processMessages(messages);
this.#syncSurfaces();
return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
}
reset() {
this.#processor.clearSurfaces();
this.#syncSurfaces();
return { ok: true };
}
#syncSurfaces() {
this.surfaces = Array.from(this.#processor.getSurfaces().entries());
}
render() {
if (this.surfaces.length === 0) {
return html`<div style="opacity:.8; padding: 10px;">
<div style="font-weight: 700; margin-bottom: 6px;">Canvas (A2UI)</div>
<div>Waiting for A2UI messages…</div>
</div>`;
}
return html`<section id="surfaces">
${repeat(
this.surfaces,
([surfaceId]) => surfaceId,
([surfaceId, surface]) => html`<a2ui-surface
.surfaceId=${surfaceId}
.surface=${surface}
.processor=${this.#processor}
></a2ui-surface>`
)}
</section>`;
}
}
customElements.define("clawdis-a2ui-host", ClawdisA2UIHost);

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas</title>
<style>
:root { color-scheme: light dark; }
html, body { height: 100%; margin: 0; }
body {
font: 13px -apple-system, system-ui;
background: #0b1020;
color: #e5e7eb;
overflow: hidden;
}
clawdis-a2ui-host { display: block; height: 100%; }
</style>
</head>
<body>
<clawdis-a2ui-host></clawdis-a2ui-host>
<script type="module" src="/__clawdis__/a2ui/a2ui.bundle.js"></script>
</body>
</html>

View File

@@ -0,0 +1,33 @@
import path from "node:path";
import { defineConfig } from "rolldown";
const here = path.dirname(new URL(import.meta.url).pathname);
const repoRoot = path.resolve(here, "../../../../../..");
const fromHere = (p) => path.resolve(here, p);
const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src");
const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js");
export default defineConfig({
input: fromHere("bootstrap.js"),
treeshake: false,
resolve: {
alias: {
"@a2ui/lit": path.resolve(a2uiLitDist, "index.js"),
"@a2ui/lit/ui": path.resolve(a2uiLitDist, "0.8/ui/ui.js"),
"@clawdis/a2ui-theme-context": a2uiThemeContext,
"@lit/context": path.resolve(repoRoot, "node_modules/@lit/context/index.js"),
"@lit/context/": path.resolve(repoRoot, "node_modules/@lit/context/"),
"@lit-labs/signals": path.resolve(repoRoot, "node_modules/@lit-labs/signals/index.js"),
"@lit-labs/signals/": path.resolve(repoRoot, "node_modules/@lit-labs/signals/"),
lit: path.resolve(repoRoot, "node_modules/lit/index.js"),
"lit/": path.resolve(repoRoot, "node_modules/lit/"),
},
},
output: {
file: fromHere("a2ui.bundle.js"),
format: "esm",
inlineDynamicImports: true,
sourcemap: false,
},
});

View File

@@ -248,6 +248,8 @@ struct ClawdisCLI {
return ParsedCLIRequest(
request: .canvasShow(session: session, path: target, placement: placement),
kind: .generic)
case "a2ui":
return try self.parseCanvasA2UI(args: &args)
case "hide":
var session = "main"
while !args.isEmpty {
@@ -288,6 +290,44 @@ struct ClawdisCLI {
}
}
private static func parseCanvasA2UI(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help }
switch sub {
case "push":
var session = "main"
var jsonlPath: String?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--jsonl": jsonlPath = args.popFirst()
default: break
}
}
guard let jsonlPath else { throw CLIError.help }
let jsonl = try String(contentsOfFile: jsonlPath, encoding: .utf8)
return ParsedCLIRequest(
request: .canvasA2UI(session: session, command: .pushJSONL, jsonl: jsonl),
kind: .generic)
case "reset":
var session = "main"
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
default: break
}
}
return ParsedCLIRequest(
request: .canvasA2UI(session: session, command: .reset, jsonl: nil),
kind: .generic)
default:
throw CLIError.help
}
}
private static func parseCamera(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help }
switch sub {
@@ -473,8 +513,10 @@ struct ClawdisCLI {
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
Canvas:
clawdis-mac canvas show [--session <key>] [--target </...|https://...>]
clawdis-mac canvas show [--session <key>] [--target </...|https://...|file://...>]
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
clawdis-mac canvas a2ui push --jsonl <path> [--session <key>]
clawdis-mac canvas a2ui reset [--session <key>]
clawdis-mac canvas hide [--session <key>]
clawdis-mac canvas eval --js <code> [--session <key>]
clawdis-mac canvas snapshot [--out <path>] [--session <key>]

View File

@@ -60,13 +60,15 @@ public struct CanvasPlacement: Codable, Sendable {
public enum CanvasShowStatus: String, Codable, Sendable {
/// Panel was shown, but no navigation occurred (no target passed and session already existed).
case shown
/// Target was an http(s) URL.
/// Target was a direct URL (http(s) or file).
case web
/// Local canvas target resolved to an existing file.
case ok
/// Local canvas target did not resolve to a file (404 page).
case notFound
/// Local canvas root ("/") has no index, so the welcome page is shown.
/// 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).
case welcome
}
@@ -96,6 +98,13 @@ public struct CanvasShowResult: Codable, Sendable {
}
}
// MARK: - Canvas A2UI
public enum CanvasA2UICommand: String, Codable, Sendable {
case pushJSONL
case reset
}
public enum Request: Sendable {
case notify(
title: String,
@@ -117,6 +126,7 @@ public enum Request: Sendable {
case canvasHide(session: String)
case canvasEval(session: String, javaScript: String)
case canvasSnapshot(session: String, outPath: String?)
case canvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?)
case nodeList
case nodeInvoke(nodeId: String, command: String, paramsJSON: String?)
case cameraSnap(facing: CameraFacing?, maxWidth: Int?, quality: Double?, outPath: String?)
@@ -151,6 +161,8 @@ extension Request: Codable {
case path
case javaScript
case outPath
case canvasA2UICommand
case jsonl
case facing
case maxWidth
case quality
@@ -173,6 +185,7 @@ extension Request: Codable {
case canvasHide
case canvasEval
case canvasSnapshot
case canvasA2UI
case nodeList
case nodeInvoke
case cameraSnap
@@ -237,6 +250,12 @@ extension Request: Codable {
try container.encode(session, forKey: .session)
try container.encodeIfPresent(outPath, forKey: .outPath)
case let .canvasA2UI(session, command, jsonl):
try container.encode(Kind.canvasA2UI, forKey: .type)
try container.encode(session, forKey: .session)
try container.encode(command, forKey: .canvasA2UICommand)
try container.encodeIfPresent(jsonl, forKey: .jsonl)
case .nodeList:
try container.encode(Kind.nodeList, forKey: .type)
@@ -321,6 +340,12 @@ extension Request: Codable {
let outPath = try container.decodeIfPresent(String.self, forKey: .outPath)
self = .canvasSnapshot(session: session, outPath: outPath)
case .canvasA2UI:
let session = try container.decode(String.self, forKey: .session)
let command = try container.decode(CanvasA2UICommand.self, forKey: .canvasA2UICommand)
let jsonl = try container.decodeIfPresent(String.self, forKey: .jsonl)
self = .canvasA2UI(session: session, command: command, jsonl: jsonl)
case .nodeList:
self = .nodeList

View File

@@ -161,5 +161,13 @@ struct ControlRequestHandlerTests {
}
#expect(snap.ok == false)
#expect(snap.message == "Canvas disabled by user")
let a2ui = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
try await ControlRequestHandler.process(request: .canvasA2UI(session: "s", command: .reset, jsonl: nil))
}
}
#expect(a2ui.ok == false)
#expect(a2ui.message == "Canvas disabled by user")
}
}