Skip to content

OpenClaw 源码解读(二十二)Canvas 画布系统

1. 引言

Canvas 画布系统是 OpenClaw 提供的可视化渲染面板——它让 AI Agent 不再局限于纯文本输出,而是拥有了一块可以展示任意 Web 内容的"屏幕"。无论是在 macOS 菜单栏弹出一个浮动面板、在 iOS 全屏显示 A2UI 界面、还是在 Android 的 WebView 中呈现交互式 Canvas,背后都是同一套架构在驱动。

本文将从 Node.js 层的 Canvas Host HTTP 服务器讲起,延伸到 A2UI 前端框架、跨平台 JS Bridge、Agent 工具接口,最后深入各原生平台的实现细节。

2. 整体架构总览

Canvas 系统的分层结构如下:

┌─────────────────────────────────────────────────────┐
│  Agent Tool Layer (canvas-tool.ts)                  │
│  7 actions: present/hide/navigate/eval/snapshot/    │
│             a2ui_push/a2ui_reset                    │
├─────────────────────────────────────────────────────┤
│  CLI Layer (register.canvas.ts)                     │
│  openclaw nodes canvas <subcommand>                 │
├─────────────────────────────────────────────────────┤
│  Gateway → Node IPC (node.invoke)                   │
├───────────────┬───────────────┬─────────────────────┤
│  macOS        │  iOS          │  Android            │
│  WKWebView    │  WKWebView    │  WebView            │
│  NSPanel      │  ScreenTab    │  Compose            │
│  CanvasScheme │  NodeAppModel │  CanvasController   │
├───────────────┴───────────────┴─────────────────────┤
│  Canvas Host Server (server.ts)                     │
│  HTTP 静态文件服务 + WebSocket Live Reload           │
├─────────────────────────────────────────────────────┤
│  A2UI Frontend (a2ui/index.html + a2ui.bundle.js)   │
│  跨平台 JS Bridge + 动态 UI 渲染                    │
├─────────────────────────────────────────────────────┤
│  File Resolver (file-resolver.ts)                   │
│  安全路径解析 + 遍历防护                             │
└─────────────────────────────────────────────────────┘

核心数据流是 Agent → Gateway → Node Invoke → Canvas 操控,同时 A2UI action 事件可以反向从 Canvas WebView 通过 JS Bridge 回传到 Agent。

![Canvas 系统六层分层架构总览](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/01-infographic-canvas-architecture-1775150670846.png)

3. Canvas Host HTTP 服务器

3.1 核心类型定义

src/canvas-host/server.ts 定义了两层抽象:

typescript
type CanvasHostHandler = {
  rootDir: string;
  basePath: string;
  handleHttpRequest: (req, res) => Promise<boolean>;
  handleUpgrade: (req, socket, head) => boolean;
  close: () => Promise<void>;
};

type CanvasHostServer = {
  port: number;
  rootDir: string;
  close: () => Promise<void>;
};

Handler 是请求处理逻辑的封装,可以独立使用(嵌入到其他 HTTP 服务器中);Server 则是包含 Handler + HTTP 监听的完整服务。这种分层设计让 Canvas Host 既能独立运行,也能作为 Gateway 的子路由挂载。

3.2 服务启动流程

createCanvasHostHandler 的初始化分为四个阶段:

阶段一:环境检测

typescript
function isDisabledByEnv() {
  if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_CANVAS_HOST)) return true;
  if (process.env.NODE_ENV === "test") return true;
  if (process.env.VITEST) return true;
  return false;
}

在测试环境下 Canvas Host 默认关闭,可通过 allowInTests 显式开启。

阶段二:根目录准备

默认根目录为 ~/.openclaw/state/canvas。如果该目录下没有 index.html,会自动写入一个内置的默认测试页面,包含 Hello/Time/Photo/Dalek 四个交互按钮。

阶段三:文件监听器

使用 chokidar 监听根目录的文件变更:

typescript
const watcher = chokidar.watch(rootReal, {
  ignoreInitial: true,
  awaitWriteFinish: {
    stabilityThreshold: 75, // 测试模式 12ms
    pollInterval: 10,       // 测试模式 5ms
  },
  ignored: [/(^|[\\/])\./, /(^|[\\/])node_modules/],
});

文件变更时触发 防抖广播(75ms 防抖间隔),通过 WebSocket 向所有连接的客户端发送 "reload" 指令。

阶段四:WebSocket Server

typescript
const wss = new WebSocketServer({ noServer: true });

使用 noServer 模式,由 HTTP upgrade 事件手动分发。WebSocket 路径为 /__openclaw__/ws

![Canvas Host 服务器四阶段启动流程](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/02-infographic-server-startup-1775150671619.png)

3.3 HTTP 请求处理

请求处理链遵循责任链模式

  1. 检查是否为 WebSocket upgrade 请求 → 返回 426
  2. 剥离 basePath 前缀(默认 /__openclaw__/canvas
  3. 仅允许 GET/HEAD 方法
  4. 通过 resolveFileWithinRoot 安全解析文件
  5. HTML 文件自动注入 live-reload 脚本
  6. 非 HTML 文件直接返回,Cache-Control: no-store

![HTTP 请求处理责任链](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/03-infographic-http-request-chain-1775150672520.png)

4. 安全文件解析器

src/canvas-host/file-resolver.ts 虽然只有 50 行,却是整个静态文件服务的安全基石。

4.1 三层安全防护

第一层:URL 规范化

typescript
function normalizeUrlPath(rawPath: string): string {
  const decoded = decodeURIComponent(rawPath || "/");
  return path.posix.normalize(decoded);
}

第二层:路径遍历拦截

typescript
if (rel.split("/").some((p) => p === "..")) {
  return null;
}

即使经过 normalize 处理后仍然包含 ..,直接拒绝。

第三层:符号链接检测

typescript
const st = await fs.lstat(candidate);
if (st.isSymbolicLink()) return null;

底层还依赖 openFileWithinRoot(来自 src/infra/fs-safe.ts),该函数使用 realpath 验证最终路径确实在根目录内,构成了第四道防线。

![安全文件解析器四层纵深防护](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/04-infographic-file-security-1775150673375.png)

4.2 目录请求处理

当请求路径是目录时(以 / 结尾或 lstat 判定为目录),自动尝试 index.html 回退——这是标准静态文件服务器的行为。

5. A2UI 模块

A2UI(App-to-UI)是 Canvas 系统中最精巧的部分——它实现了从 Agent 到 UI 的声明式界面推送

5.1 路径常量

typescript
const A2UI_PATH = "/__openclaw__/a2ui";
const CANVAS_HOST_PATH = "/__openclaw__/canvas";
const CANVAS_WS_PATH = "/__openclaw__/ws";

三个路径分别服务于:A2UI 前端资源、用户自定义 Canvas 文件、WebSocket live-reload 通道。

5.2 A2UI 资源发现

resolveA2uiRoot 在多达 10+ 个候选路径中搜索 A2UI 资源目录,覆盖了所有运行场景:

场景候选路径
源码运行(bun)<当前文件目录>/a2ui
dist 打包运行<当前文件目录>/canvas-host/a2ui
launchd 守护进程<当前文件目录>/../canvas-host/a2ui
入口脚本回退<process.argv[1]目录>/a2ui
开发回退src/canvas-host/a2uidist/canvas-host/a2ui

验证条件是 index.htmla2ui.bundle.js 必须同时存在。结果会被缓存,null 结果有 10 秒重试间隔。

![A2UI 资源发现多路径搜索机制](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/05-infographic-a2ui-discovery-1775150674123.png)

5.3 Live-Reload 注入

injectCanvasLiveReload 是一个关键函数,向 HTML 注入两段脚本:

跨平台 Action Bridge:

javascript
// iOS 桥接
window.webkit?.messageHandlers?.openclawCanvasA2UIAction?.postMessage(raw);

// Android 桥接
window.openclawCanvasA2UIAction?.postMessage(raw);

桥接脚本同时在 globalThis 上注册四个 API:

  • OpenClaw.postMessage / openclawPostMessage — 底层消息发送
  • OpenClaw.sendUserAction / openclawSendUserAction — 高级 action 发送(自动分配 UUID)

WebSocket Live-Reload 客户端:

javascript
const ws = new WebSocket(proto + "://" + location.host + "/__openclaw__/ws" + capQuery);
ws.onmessage = (ev) => {
  if (String(ev.data) === "reload") location.reload();
};

注入位置在 </body> 标签之前;如果找不到 </body>,则追加到文件末尾。

![Live-Reload 注入与跨平台 JS Bridge](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/06-infographic-live-reload-bridge-1775150675134.png)

6. A2UI 前端页面

src/canvas-host/a2ui/index.html 是 A2UI 的宿主页面,设计精致:

6.1 视觉设计

页面使用暗色主题,背景由三层径向渐变组成:

css
background:
  radial-gradient(... rgba(42, 113, 255, 0.18) ...),  /* 蓝 */
  radial-gradient(... rgba(255, 0, 138, 0.14) ...),    /* 粉 */
  radial-gradient(... rgba(0, 209, 255, 0.1) ...),     /* 青 */
  #000;

两个伪元素分别实现网格漂移动画和光晕漂移动画,给等待画面带来生命感。Android 平台有更高的饱和度(因为 Android WebView 的色彩表现不同)。

6.2 DOM 结构

html
<canvas id="openclaw-canvas"></canvas>           <!-- 全屏 2D Canvas -->
<div id="openclaw-status">...</div>              <!-- 调试状态卡片 -->
<openclaw-a2ui-host></openclaw-a2ui-host>        <!-- A2UI 自定义元素 -->
<script src="a2ui.bundle.js"></script>            <!-- A2UI 运行时 -->

四层 z-index 叠加:Canvas 元素 (z:1) → 背景动画 → 状态卡片 (z:3) → A2UI Host (z:4)。

6.3 全局 API

页面通过 window.__openclaw 暴露画布操控接口:

javascript
window.__openclaw = {
  canvas,                    // HTMLCanvasElement
  ctx,                       // CanvasRenderingContext2D
  setDebugStatusEnabled,     // (enabled: boolean) => void
  setStatus,                 // (title, subtitle) => void
};

Canvas 会自动处理 DPR(devicePixelRatio)缩放:

javascript
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

7. A2UI JSONL 协议

7.1 消息格式

A2UI 使用 JSONL(JSON Lines)格式传输 UI 描述,每行一个 JSON 对象,必须包含以下 action key 之一:

Action Key版本用途
beginRenderingv0.8开始渲染指定 surface
surfaceUpdatev0.8更新 surface 的组件树
dataModelUpdatev0.8更新数据模型
deleteSurfacev0.8删除 surface
createSurfacev0.9创建 surface(仅 v0.9)

![A2UI JSONL 协议消息格式与工作流](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/07-infographic-jsonl-protocol-1775150676007.png)

7.2 快速文本生成

buildA2UITextJsonl 展示了最简单的 A2UI 使用场景——显示一段文本:

typescript
function buildA2UITextJsonl(text: string) {
  const payloads = [
    {
      surfaceUpdate: {
        surfaceId: "main",
        components: [
          { id: "root", component: { Column: { children: { explicitList: ["text"] } } } },
          { id: "text", component: { Text: { text: { literalString: text }, usageHint: "body" } } },
        ],
      },
    },
    { beginRendering: { surfaceId: "main", root: "root" } },
  ];
  return payloads.map(JSON.stringify).join("\n");
}

两行 JSONL 就构成了一个完整的 UI 描述:先定义组件树,再触发渲染。

7.3 版本验证

validateA2UIJsonl 确保:

  • 每行是有效 JSON 对象
  • 每行恰好包含一个 action key
  • 不允许 v0.8 和 v0.9 消息混合
  • 当前 CLI 层显式拒绝 v0.9(createSurface

8. Agent Canvas Tool

src/agents/tools/canvas-tool.ts 将 Canvas 操控暴露给 AI Agent。

8.1 扁平化 Schema 设计

工具采用扁平化参数结构(所有 action 的参数合并为一个对象),而非嵌套联合类型,这是因为 Agent 工具 schema 有反对 Union 类型的约束:

typescript
const CanvasToolSchema = Type.Object({
  action: stringEnum(CANVAS_ACTIONS),
  target: Type.Optional(Type.String()),       // present
  url: Type.Optional(Type.String()),          // navigate
  javaScript: Type.Optional(Type.String()),   // eval
  outputFormat: optionalStringEnum([...]),     // snapshot
  jsonl: Type.Optional(Type.String()),        // a2ui_push
  jsonlPath: Type.Optional(Type.String()),    // a2ui_push(文件路径)
  x/y/width/height: Type.Optional(...),       // present placement
});

8.2 七个 Action

Action功能关键参数
present显示 Canvastarget/url, x/y/width/height
hide隐藏 Canvas
navigate导航到 URLurl(兼容 target)
eval执行 JavaScriptjavaScript
snapshot截图outputFormat, maxWidth, quality
a2ui_push推送 A2UI JSONLjsonl 或 jsonlPath
a2ui_reset重置 A2UI

![Agent Canvas Tool 七个操作](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/08-infographic-canvas-tool-actions-1775150676900.png)

8.3 JSONL 路径安全

readJsonlFromPath 对文件路径执行双重安全检查:

typescript
const roots = getDefaultMediaLocalRoots();
if (!isInboundPathAllowed({ filePath: resolved, roots })) {
  throw new Error("jsonlPath outside allowed roots");
}
const canonical = await fs.realpath(resolved);
if (!isInboundPathAllowed({ filePath: canonical, roots })) {
  throw new Error("jsonlPath outside allowed roots");
}

先检查字面路径,再 realpath 后再次检查——防止符号链接绕过。

8.4 截图流水线

截图 action 的处理流程:

  1. 发送 canvas.snapshot invoke 到目标 Node
  2. Node 原生层截取 WebView 内容
  3. 返回 base64 编码的图片数据
  4. 写入临时文件
  5. 经过图片大小限制处理
  6. 返回 imageResult(可直接嵌入 Agent 对话)

9. CLI Canvas 命令

src/cli/nodes-cli/register.canvas.ts 注册了完整的 CLI 子命令树:

openclaw nodes canvas
  ├── snapshot  --node <id> [--format png|jpg] [--max-width <px>]
  ├── present   --node <id> [--target <url>] [--x/y/width/height]
  ├── hide      --node <id>
  ├── navigate  --node <id> <url>
  ├── eval      --node <id> [<js>] [--js <code>]
  └── a2ui
      ├── push   --node <id> --jsonl <path> | --text <text>
      └── reset  --node <id>

所有命令都通过 invokeCanvas 封装,最终调用 Gateway 的 node.invoke RPC。a2ui push 命令有两种快捷输入方式:

  • --jsonl <path> — 从文件读取完整 JSONL
  • --text <text> — 自动生成包含该文本的 A2UI JSONL

![CLI Canvas 命令树结构](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/09-infographic-cli-commands-1775150677631.png)

10. macOS 原生实现

macOS 的 Canvas 实现是三个平台中最复杂的,包含 12 个源文件。

![macOS Canvas 原生组件架构](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/10-infographic-macos-canvas-1775150678400.png)

10.1 CanvasManager 单例

swift
@MainActor
final class CanvasManager {
    static let shared = CanvasManager()
    private var panelController: CanvasWindowController?
    private var panelSessionKey: String?
}

CanvasManager 是 Canvas 子系统的入口,通过 @MainActor 确保所有操作在主线程执行。它维护当前活跃的面板控制器,并通过 startGatewayObserver() 监听 Gateway 推送,自动导航到 A2UI 页面。

10.2 CanvasWindowController

这是 Canvas 窗口的核心控制器,继承 NSWindowController 并实现 WKNavigationDelegateNSWindowDelegate

swift
@MainActor
final class CanvasWindowController: NSWindowController {
    let sessionKey: String
    let webView: WKWebView
    private let schemeHandler: CanvasSchemeHandler
    private let watcher: CanvasFileWatcher
}

它管理着四个关键组件:

自定义 URL Scheme Handler:

通过 openclaw-canvas:// 协议,将 URL 请求映射到本地文件系统。这样 WKWebView 可以安全地加载本地 Canvas 内容,无需启动 HTTP 服务器。

文件监听器:

基于 FSEvents 的 CanvasFileWatcher(仅 12 行),当本地文件变更时自动刷新 WebView。

A2UI Action 桥接:

注入 JS 脚本,通过 WKScriptMessageHandler 将 DOM 事件回传到原生层。

窗口位置管理:

支持两种展示模式——标准窗口和浮动面板(NSPanel)。面板位置会通过 UserDefaults 持久化,并在屏幕边界内约束。

10.3 CanvasSchemeHandler

处理 openclaw-canvas:// 协议请求的完整流程:

openclaw-canvas://<session>/<path>

路径遍历检测 → 文件查找 → MIME 推断 → 返回响应

当没有 index.html 时,会显示内置的 scaffold 页面或 welcome 页面。支持的 MIME 类型覆盖了常见的 Web 资产格式。

10.4 导航策略

CanvasWindowController+Navigation.swift 定义了 WKNavigationDelegate 策略:

  • openclaw:// deep link → 交给 DeepLinkHandler
  • canvas scheme / https / http → 在面板内导航
  • 其他 URL → 通过 NSWorkspace 打开外部应用

10.5 UI 容器

CanvasChromeContainerView 实现了精致的悬停 UI:

  • 鼠标进入时淡入关闭按钮和拖拽手柄
  • 鼠标离开时淡出
  • 右下角提供窗口大小调整手柄
  • 背景使用毛玻璃效果

11. iOS 原生实现

iOS 的 Canvas 以全屏 WebView 为核心,由 RootCanvas 视图管理。

11.1 A2UI 自动导航

NodeAppModel+Canvas.swift 中的 resolveA2UIHostURL 构造 A2UI URL:

swift
func resolveA2UIHostURL() async -> String? {
    guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
    guard let base = URL(string: trimmed) else { return nil }
    if let host = base.host, LoopbackHost.isLoopback(host) {
        return nil  // 拒绝 loopback 地址
    }
    return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
}

关键细节:loopback 地址会被过滤掉(因为 iOS 设备无法访问 Gateway 的本地地址)。

11.2 连接状态联动

swift
func showA2UIOnConnectIfNeeded() async {
    guard let a2uiUrl = await resolveA2UIHostURL() else {
        self.screen.showDefaultCanvas()  // 回退到默认画面
        return
    }
    if await Self.probeTCP(url: url, timeoutSeconds: 2.5) {
        self.screen.navigate(to: a2uiUrl)
    } else {
        self.screen.showDefaultCanvas()  // TCP 探测失败也回退
    }
}

在导航到 A2UI URL 之前,先进行 TCP 端口探测(2.5 秒超时)。这是因为 WKWebView 加载失败会留下一个持久的错误遮罩,所以宁可不尝试也不要显示错误页面。

11.3 RootCanvas 视图

RootCanvas.swift 是一个功能丰富的 SwiftUI 视图,集成了:

  • Canvas WebView(ScreenTab)
  • 聊天/设置/快速设置 Sheet
  • 语音唤醒 Toast
  • 相机闪光动画
  • 连接状态药丸指示器
  • Talk Mode 语音球
  • 引导向导

12. Android 原生实现

12.1 CanvasController

CanvasController.kt 是 Android Canvas 的核心控制器:

kotlin
class CanvasController {
    @Volatile private var webView: WebView? = null
    @Volatile private var url: String? = null
    
    fun attach(webView: WebView) { ... }
    fun detach(webView: WebView) { ... }
    fun navigate(url: String) { ... }
    suspend fun eval(javaScript: String): String = ...
    suspend fun snapshotBase64(format, quality, maxWidth): String = ...
}

核心设计特点:

线程安全的 WebView 操作:

kotlin
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
    val wv = webView ?: return
    if (Looper.myLooper() == Looper.getMainLooper()) {
        block(wv)
    } else {
        wv.post { block(wv) }
    }
}

WebView 操作必须在主线程执行,withWebViewOnMain 自动处理线程调度。

截图实现:

Android 端使用 WebView.draw(Canvas) 而非 PixelCopy API,因为后者对 WebView 的支持不可靠:

kotlin
private suspend fun WebView.captureBitmap(): Bitmap =
    suspendCancellableCoroutine { cont ->
        val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
        draw(Canvas(bitmap))
        cont.resume(bitmap)
    }

12.2 CanvasScreen Composable

CanvasScreen.kt 使用 Jetpack Compose 的 AndroidView 包装 WebView:

kotlin
@Composable
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier) {
    AndroidView(factory = {
        WebView(context).apply {
            settings.javaScriptEnabled = true
            settings.domStorageEnabled = true
            // 禁用暗色模式强制(Canvas 自己管理主题)
            WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
            // 注入 A2UI Action Bridge
            addJavascriptInterface(bridge, "openclawCanvasA2UIAction")
            viewModel.canvas.attach(this)
        }
    })
}

12.3 A2UIHandler

A2UIHandler.kt 处理 A2UI 相关的所有逻辑:

URL 构建:

kotlin
fun resolveA2uiHostUrl(): String? {
    val raw = nodeUrl ?: operatorUrl
    return "${raw.trimEnd('/')}/__openclaw__/a2ui/?platform=android"
}

就绪检测:

kotlin
suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
    canvas.navigate(a2uiUrl)
    repeat(50) {  // 最多等待 6 秒
        val ready = canvas.eval(a2uiReadyCheckJS)
        if (ready == "true") return true
        delay(120)
    }
    return false
}

通过轮询 openclawA2UI.applyMessages 是否可用来判断 A2UI 运行时是否就绪。

JSONL 解码与验证:

kotlin
fun decodeA2uiMessages(command: String, paramsJson: String?): String {
    // 支持两种输入格式:
    // 1. jsonl 字段(JSONL 文本,按行分割)
    // 2. messages 数组(JSON Array)
    // 两种格式都会经过 v0.8 验证
}

12.4 A2UI Action 事件

OpenClawCanvasA2UIAction.kt 定义了 A2UI 事件的处理协议:

kotlin
fun formatAgentMessage(...): String {
    return listOf(
        "CANVAS_A2UI",
        "action=${sanitizeTagValue(actionName)}",
        "session=${sanitizeTagValue(sessionKey)}",
        "surface=${sanitizeTagValue(surfaceId)}",
        "component=${sanitizeTagValue(sourceComponentId)}",
        "host=${sanitizeTagValue(host)}",
        "instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
        "default=update_canvas",
    ).joinToString(separator = " ")
}

A2UI action 事件被格式化为结构化的标签字符串,发送到 Gateway 作为 Agent 消息。sanitizeTagValue 确保标签值中只包含安全字符。

13. 跨平台 JS Bridge 对比

特性macOSiOSAndroid
WebView 引擎WKWebViewWKWebViewWebView (Chromium)
JS 注入方式WKScriptMessageHandler服务端 HTML 注入JavascriptInterface
本地内容加载自定义 URL SchemeHTTP URLfile:///android_asset/
文件监听FSEvents
截图方式WKWebView APIWKWebView APICanvas.draw()
暗色模式系统自适应.preferredColorScheme(.dark)显式禁用 ForceDark

三个平台都通过 openclawCanvasA2UIAction 通道名实现了统一的 A2UI 事件桥接。

![跨平台 JS Bridge 实现对比](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/11-infographic-cross-platform-bridge-1775150679287.png)

14. 配置体系

Canvas Host 的配置通过 CanvasHostConfig 类型管理:

typescript
type CanvasHostConfig = {
  enabled?: boolean;     // 是否启用
  root?: string;         // 服务目录(默认 ~/.openclaw/workspace/canvas)
  port?: number;         // HTTP 端口(默认 18793)
  liveReload?: boolean;  // 是否启用热更新(默认 true)
};

配置层级中,Zod schema 验证了所有字段类型,schema.help.ts 提供了 CLI --help 的描述文本。

15. 设计亮点总结

设计点实现策略评价
文件安全四层防护(normalize + .. 拦截 + symlink 检测 + realpath 验证)纵深防御
Live Reloadchokidar + WebSocket + 防抖开发体验优秀
A2UI 资源发现10+ 候选路径 + 缓存 + 重试覆盖所有运行场景
JS Bridge统一通道名 + 三平台适配一套 Web 代码跨平台
工具 Schema扁平化参数 + 运行时验证适配 Agent 工具约束
截图安全路径双重校验(字面 + realpath)防止符号链接绕过
JSONL 验证版本检测 + action key 唯一性前端后端双重校验

![Canvas 系统七大设计亮点](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(二十二)Canvas 画布系统/12-infographic-design-highlights-1775150680151.png)

16. 推荐阅读顺序

  1. src/canvas-host/file-resolver.ts — 理解安全文件解析
  2. src/canvas-host/a2ui.ts — 理解 A2UI 资源路由和 live-reload 注入
  3. src/canvas-host/server.ts — 理解 Canvas Host 服务器完整流程
  4. src/canvas-host/a2ui/index.html — 理解前端视觉和 API 设计
  5. src/cli/nodes-cli/a2ui-jsonl.ts — 理解 A2UI JSONL 协议
  6. src/agents/tools/canvas-tool.ts — 理解 Agent 工具接口
  7. apps/macos/.../CanvasWindowController.swift — 理解 macOS 原生实现
  8. apps/android/.../CanvasController.kt — 理解 Android 原生实现
  9. apps/ios/.../NodeAppModel+Canvas.swift — 理解 iOS 原生实现

17. 思考题

  1. Canvas Host 的 chokidar 监听器在大型目录下可能产生性能问题,源码中如何处理这一场景?
  2. A2UI 为什么需要同时支持 jsonl 字符串和 messages 数组两种输入格式?
  3. iOS 端为什么在导航到 A2UI URL 前要做 TCP 探测?直接加载会有什么问题?
  4. macOS 使用自定义 URL Scheme 而 Android 使用 asset 文件,iOS 则通过 HTTP URL——三种方案各有什么优劣?
  5. A2UI v0.9 引入了 createSurface,为什么 CLI 层当前显式拒绝它?

读文档、看源码、写代码,理解 AI Agent 本质 🤖