Day 18 / 共 20 天 · 第 4 周 运行时/安全/部署

原生应用

Day 16-17 讲完"执行怎么安全";今天换个方向:OpenClaw 不只是服务端——它有 macOS/iOS/Android 原生 App(Swift/Kotlin)。Gateway(大脑)跑在你的服务器上,够不到你手机的相机和位置——原生 App 以 "node" 角色连上来,把设备能力借给助理。明天(Day 19)再看这么多语言/端怎么统一构建。

📍 你在整门课的位置(第 4 周 · 运行时/安全/部署)
D15 归一化 D16 沙箱运行时 D17 安全纵深 D18 原生App D19 构建SDK D20 部署收官
🤔 痛点:纯服务端的助理,为什么"残废"? 你的助理跑在一台云服务器(或家里的小主机)上。你在外面对它说"帮我看看车停哪了,拍张照记下位置"——可服务器没有相机、不知道你的 GPS、听不到你的声音。它有大脑却没有身体。纯服务端能想能说,却摸不到任何物理世界。今天的原生 App 就是来给这颗大脑接上"手脚和感官"的。
💡 用一个类比兜住整天(「大脑接义肢」世界观) 今天全程一个画面:一颗大脑(Gateway)远程操控几只义肢/感官(你的各台设备)。手机以 "node" 角色连上大脑,就成了大脑长在远处的一只"手+眼睛+耳朵"——大脑要拍照就"命令"这只手去按快门,结果再传回来。但义肢不能随便被人接管:连接前要对暗号(Ed25519 签名握手:大脑给一个一次性口令 nonce,义肢用只有自己知道的私钥签名答上来,证明"我是你注册过的那只手,不是冒牌货")。Swift 和 Kotlin 是两套语言,但必须说同一句暗号、按同一个字节格式(线级协议一致),否则大脑听不懂。Swabble 则是耳朵上的"唤醒开关"——平时装睡,听到"clawd"才睁眼,全程在本地不上网(隐私)。记住"大脑接义肢、连接要对暗号",今天全通。
L01

五个原生目标

目标技术角色
macOS(apps/macos)SwiftUI 菜单栏 App托管本地运行时 + 连网关
iOS(apps/ios)SwiftUI + Watch/Share/Live Activitynode(暴露相机/定位/…)
Android(apps/android,~113 Kotlin)Jetpack Composenode
共享 OpenClawKit(apps/shared)Swift 包iOS+macOS 共用协议/客户端
Swabble(/Swabble,Swift)本地离线语音守护唤醒词门(非网关客户端)
读法:601 个 Swift 文件 + 113 个 Kotlin 文件,实现三份保持"线级兼容"的网关客户端协议:Swift(iOS+macOS 共享 OpenClawKit)、Kotlin(Android)。Swabble 是特例——本地语音守护,不连网关。
L02

node 角色

什么叫 "node 角色"? Gateway(控制平面,Day 3/14)是大脑,但它跑在你的服务器/电脑上,够不到你手机的相机、你的位置、你的麦克风手机 App 以 "node"(节点)角色连上 Gateway,就相当于给大脑装了一只"手"——助理需要拍照/定位/读日历时,通过网关"调用"这个 node,node 在手机上执行并把结果传回。所以 App 不是"另一个聊天界面",而是"助理伸到你各个设备上的感官和手脚"。还有 "operator" 角色(控制/配置用)。这就是为什么一个个人助理需要原生 App——纯服务端摸不到物理设备能力。
📝 举个例子:一次"帮我拍张照"是怎么跨设备完成的 你对助理说"拍张照记下车位" → 大脑(Gateway)决定要用相机 → 它不自己拍(服务器没相机),而是给已连上的 iOS node 推一个 event:调用相机能力 → iOS App 的 Camera 能力处理器执行拍照、附上 GPS → 把照片和坐标作为结果传回大脑 → 大脑写进记忆、回复你"已记录,车在 XX"。→ 手机只是大脑伸出去的一只"眼睛",决策仍在大脑。
L03

线级协议

五个客户端共用一套 Gateway 线级协议:

  • 传输:单条 WebSocket,wss(TLS)或 ws,URL = scheme://host:port
  • 帧:JSON,type = req/res/event(请求/响应/事件)。
  • 分层认证:token/bootstrapToken/password/每设备 deviceToken + Ed25519 签名。
读法:就是一个基于 WebSocket 的 JSON-RPC:客户端发 req、服务端回 res、服务端主动推 event。wss(加密)用于非本地连接。这套协议在 Swift 和 Kotlin 里各实现一遍,靠跨语言测试保持字节级一致(L08)。
L04

签名握手

连接握手用 Ed25519 设备签名防冒充:

// 1. 客户端连上 → gateway 发 connect.challenge 事件带 nonce(一次性随机数)
// 2. 客户端回 connect 请求:client 信息 + 能力 + auth + 对 nonce 的 Ed25519 签名
// 3. gateway 校验签名 → 回快照,可下发持久 deviceToken

// 载荷规范化(Android: DeviceAuthPayload.kt:4 buildV3)用 | 拼:
// v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
// 与 Swift GatewayDeviceAuthPayload.buildV3 字节一致
为什么要"签名握手"? 助理能访问你的设备能力(相机、位置),要是随便谁都能冒充你的手机连上网关,就危险了。Ed25519 签名握手:每个设备有一对密钥,网关发一个一次性随机数(nonce),设备用私钥签名回去,网关用公钥验证——证明"你确实是那台注册过的设备",且这次连接不是重放的旧请求(nonce 一次性)。验证通过后网关发一个长期 deviceToken,下次免签快速连。这是设备身份认证的标准做法(类似 SSH 密钥、WebAuthn),比"共享一个密码"安全得多。
签名握手时序:对暗号防冒充(nonce 一次性防重放) 📱 设备(node) 🧠 Gateway ① WebSocket 连上 ② connect.challenge:发一次性 nonce 用私钥对 nonce 签名(buildV3) ③ connect:信息+能力+Ed25519 签名 用公钥验签→确认身份 ④ 回快照 + 下发长期 deviceToken Swift 与 Kotlin 两套实现,buildV3 拼串字节一致→暗号通用
图注:nonce 一次性 → 就算旧握手报文被截获也没法重放;公私钥 → 冒充者没私钥就签不出。

👶 小白:为什么不直接给每台设备发个固定密码,非要搞签名和 nonce?

👨‍🏫 老师:固定密码有两个致命伤。① 会泄露:密码要在每次连接时发出去,被抓包一次就永久作废。② 能重放:抓到一次登录报文,原样再发一遍就能冒充。签名握手把这两点都堵了:私钥永远不出设备(只发"用私钥算出的签名"),而 nonce 每次都不一样且只用一次——旧报文重放时 nonce 已失效,直接拒绝。这就是为什么 SSH、WebAuthn 都用这套,而不是共享密码。

L05

macOS / iOS(Swift)

真正的传输在共享包 apps/shared/OpenClawKit/GatewayChannel.swift

// public actor GatewayChannelActor(:165)用 URLSessionWebSocketTask
// sendConnect(:372)等 challenge nonce(connect.challenge 于 :640/:665 识别)
//   构 auth + 签名设备块(GatewayDeviceAuthPayload.buildV3, :444-462)
//   发 connect 帧(:465-471);含重连看门狗 + ping 保活
  • macOS:SwiftUI 菜单栏 AppMenuBar.swift:10),托管本地运行时;Sparkle 自动更新;SwiftPM 构建。
  • iOS:OpenClawApp.swift:499,node 角色暴露相机/定位/通讯录/日历/运动/语音/推送;含 Watch App、Share Extension、Live Activity;XcodeGen(无提交 .xcodeproj,从 project.yml 生成)。
读法:iOS/macOS 共用 OpenClawKit(一份 Swift 协议实现),各自套自己的 UI。actor 保证 WebSocket 状态的并发安全(Swift 并发特性)。非回环/.ts.net 地址强制 TLS(安全)。
L06

Android(Kotlin)

// gateway/GatewaySession.kt:82 —— OkHttp WebSocket 客户端
val scheme = if (tls != null) "wss" else "ws"
val url = "$scheme://${endpoint.host}:${endpoint.port}"
socket = client.newWebSocket(Request.Builder().url(url).build(), Listener())
// sendConnect(:389) → 签名 device 块(:513-521)→ 协议版本 minProtocol/maxProtocol
// DeviceIdentityStore.kt: BouncyCastle Ed25519(signPayload:44)
// SecurePrefs.kt:18: EncryptedSharedPreferences(AES256-GCM)存 deviceToken
读法:Android 用 OkHttp(WebSocket)+ BouncyCastle(Ed25519 签名)+ EncryptedSharedPreferences(加密存 token),实现和 Swift 完全一致的握手协议。能力处理器 Camera/Location/Sms/Contacts/Calendar/Motion 各自处理网关下发的调用。Gradle Kotlin DSL 构建(minSdk 31)。
L07

Swabble 唤醒词

/Swabble(~25 Swift 文件)不是网关客户端,而是 macOS 本地纯离线的唤醒词/语音守护——让 App 在把语音发出去前先过唤醒词门(默认 clawd,别名 claude)。

// SpeechPipeline.swift: AVAudioEngine → SpeechAnalyzer/SpeechTranscriber
// WakeWordGate.swift: 唤醒词门(macOS App 依赖此 product)
// 触发后运行 <command> <args…> "<prefix><text>",带 SWABBLE_TEXT/SWABBLE_PREFIX 环境变量
为什么唤醒词要"本地离线"? 你不希望麦克风一直把所有声音传上网——既费流量又侵犯隐私。Swabble 在本地监听,只有听到唤醒词"clawd"才激活,把后续语音转成文字。唤醒词检测、语音转录全在你的 Mac 上离线完成(用系统的 SpeechAnalyzer),零网络。这是"隐私优先"的设计——助理默认"不听",你叫它才"醒"。它通过运行一个命令(hook)把识别的文字交给 OpenClaw,而非直连网关。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 五个原生目标各是什么?
  • "node 角色"是什么意思?为什么个人助理需要原生 App?
  • 线级协议的传输/帧/认证?
  • Ed25519 签名握手防什么?nonce 起什么作用?
  • Swabble 为什么要本地离线?

✋ 动手

cd /Users/bitmart/work/codes/github/openclaw
grep -n 'GatewayChannelActor\|sendConnect' apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift | head
grep -n 'newWebSocket\|buildV3' apps/android/app/src/main/java/ai/openclaw/app/gateway/*.kt 2>/dev/null | head
sed -n '1,6p' Swabble/README.md
明天预告 · Day 19构建系统与插件 SDK——pnpm workspace、tsdown 多入口打包、插件 SDK 的 .d.ts 生成、以及分层测试策略(unit/channels/gateway/e2e/live)。
← Day 17 安全 Day 19 · 构建系统与插件 SDK →