# 架构与关键取舍
面向要改这个插件的人。用户只需要看 [README](../README.md)。
## 分层
```
浏览器(右侧边栏 tab)
│ 只经 DSH 自己的 web 源,从不接触 mobilecli 的端口
▼
lib/client.js tab 类型 + 正文组件;两条渲染路径(H.264 / MJPEG)
│ ⚠️ 这是**构建产物**(源码在 src/client/,见「为什么客户端要构建」)
│ fetch POST /_dsh/dsh-sidebar-mobile/{grant,control,device-action,status}
│
GET …/stream/
▼
lib/stream/routes.js 路由分发、body 解析、错误收敛
├── lib/stream/access.js 签名令牌 + loopback 围栏
└── lib/stream/session.js 单流多播、空闲自停、崩溃重启
│
▼
lib/device/mobilecli.js 二进制解析、execFile 调用、spawn 流进程
├── lib/device/android-toolchain.js adb 体检
└── lib/device/daemon-rpc.js daemon socket 的 JSON-RPC(交互加速)
│
▼
mobilecli(Go 二进制)
│ adb / simctl / usbmux / on-device agent
▼
设备
```
`lib/` 按职责分目录,同类放在一起:
| 目录 | 放什么 |
|---|---|
| `lib/`(根) | 仅 `client.js` —— 唯一一个构建产物,刻意不放进子目录,让"它是生成的"一眼可见 |
| `lib/device/` | 怎么跟设备说话:二进制解析、CLI 调用、daemon RPC、adb 体检 |
| `lib/stream/` | 视频流的整条链路:令牌、会话池、HTTP 路由 |
| `lib/agent/` | 给模型的那一面:工具定义、skill 注册、宿主契约转发层 |
Host 侧的 agent 模块:
- `lib/agent/tools.js` —— 11 个模型工具,全部经 `defineTool` 注册
- `lib/agent/skills.js` —— 把打包的 mobilecli playbook 注册给模型
`index.js` 负责接线,并用 `ctx.effect` 保证卸载时全部注销。
## 关键取舍
### 为什么用 CLI 而不是 mobilecli 的 server 模式
mobilecli 有 `server start` + JSON-RPC,返回 `sessionUrl` 供二次连接。本插件没用它,理由:
- server 模式要多管一个常驻 HTTP 服务(端口占用、会话过期、二次清理)
- `screencapture` 写 stdout 的路径已被实测验证(Android 104fps / iOS 131fps)
- 少一层进程就少一类失败
代价是流格式无法从 API 声明得知,只能从首块字节嗅探——但这本来就是必需的(见下)。
### 为什么交互指令走 daemon socket 而不是 spawn CLI
上面的「不用 server 模式」只管**取流**(`screencapture` 走 stdout 是对的)。
但**交互指令**另有一条路,而且实测差距很大:
| | tap | swipe(300ms) |
|---|---|---|
| spawn CLI | 104.2 ms | 411 ms(固定开销 111ms)|
| daemon socket | **7.3 ms** | 314.9 ms(固定开销 14ms)|
差在**每条命令新起一个 node 进程**(mobilecli 是 `#!/usr/bin/env node`),实测约 66ms。
关键是:**这条路不需要 server 模式**。mobilecli 的 daemon 本来就在运行(CLI 每条
命令都会把它拉起来,空闲 30 分钟自退),而它的 unix socket 直接吃 JSON-RPC。
所以「不用 server 模式」的三条理由在这里都不成立 —— 不新增进程、不占端口、
没有会话。
协议细节(都是实测出来的,写错任何一条都会静默变慢或失败):
- 传输是 **newline-delimited JSON**(写一行 + `\n`),**不是 HTTP**。
用 `curl --unix-socket` 会被拒(`Received HTTP/0.9 when not allowed`)。
- **一请求一连接**:daemon 回完响应就**主动关闭** socket。连发 3 个请求只回 1 个。
所以每个请求新建连接 —— 约 7ms,仍远快于 CLI。不要试图复用连接(会 EPIPE)。
- 参数形状与 CLI **不同**:CLI 是逗号串(`540,1800,540,900`),
RPC 是独立字段(`x1/y1/x2/y2`)。传逗号串报 `'x1' is required`。
- socket 路径 = `/daemon.sock`。**必须与 CLI 用同一个 home**
(插件里抽成 `mobilecliHome` 一个变量),否则两边连到不同 daemon。
- 权限天然收窄:socket 是 `srw-------`,只有本用户可连。
⚠️ **`rotate` 没有 RPC 实现**:daemon 不暴露 orientation(实测
`device.orientation(.get/.set)`、`device.rotate`、`device.setRotation` 等
全部 `method not found`),所以 `rpcForDeviceAction` 对它返回 null,
调用方回退 CLI。这是唯一一个仍走 CLI 的面板动作。
**CLI 始终是兜底**:daemon 没起(ENOENT)、版本不支持、某动作无 RPC 实现时
都回退,功能不丢。`DaemonRpc` 在**连接层**失败后会标记不可用,避免之后每次
交互都白等一次连接超时;但**业务失败**(设备不存在、手势被拒)不标记 ——
否则一次设备错误就会让整条快速通道永久降级。
配置 `useDaemonRpc: false` 可强制全部走 CLI(排查用)。
### 为什么从字节嗅探流格式,而不相信请求参数
实测:**iOS 模拟器上 `--format avc` 会静默降级成 MJPEG**,不报错、不警告。如果相信请求参数,就会给前端发 `content-type: video/h264`,而前端拿 H.264 解码器去解 JPEG——必然黑屏,且原因极难定位。
`sniffStreamFormat()` 因此只看字节:
- `00 00 01` / `00 00 00 01` 开头 → H.264 Annex B
- `--` 开头 → MJPEG,boundary 从首行提取
### 为什么是单流多播
面板、截图卡片、模型工具会同时想要画面。各起一个 `screencapture` 进程会:
- 让设备端反复经历 agent 启停
- 在 USB 真机上争抢带宽(实测多流会显著降低各自帧率)
所以 `StreamSessionPool` 按 deviceId 复用会话,消费者订阅同一份字节。空闲 5 分钟自停——面板关掉后不该让设备一直编码。
**主动停止不被重启逻辑对抗**:`stop()` 先置 `stopping` 再杀进程,否则退出事件会把刚停掉的流又拉起来。
### 为什么 MJPEG 用 `
` 而 H.264 用 WebCodecs
`multipart/x-mixed-replace` 是**浏览器原生支持**的格式,`
` 直接渲染:零解码代码、自动分帧、自动重连。自己解 multipart 只会更慢更脆。
H.264 没有这种待遇,必须自己解。几个必要处理:
- **按起始码切访问单元,跨 chunk 的半个 NAL 必须留住**。TCP 分片不会体贴地在 NAL 边界断开;丢掉残段会导致持续花屏或解码器卡死。
- **`optimizeForLatency: true`**:实时画面要的是延迟,不是压缩率。不开这个解码器会缓冲若干帧再吐。
#### codec 字符串必须跳过起始码(真机踩过)
`splitAnnexB` **有意保留起始码**(annexb 模式要的就是带起始码的完整 NAL),所以从 SPS 推 `avc1.PPCCLL` 时必须先跳过它:
```
00 00 00 01 | 67 | 64 00 32
└─起始码──┘ │ └─PPCCLL─┘ → avc1.640032
└─ NAL 头(低 5 位 = 7 = SPS)
```
写成 `nal[1..3]` 会读到**起始码本身**,得到 `avc1.000001`。Chrome 判为
`Unknown or ambiguous codec name.`,并**异步**把解码器置为 `closed`
(`configure()` 不抛同步异常,所以 try/catch 抓不到),之后每次 `decode()`
都抛 `Cannot call 'decode' on a closed codec` —— 表现为**画面全黑**。
#### 关键帧必须自带 SPS+PPS
实测(真实 Android 流,331 个 NAL,逐个策略跑 WebCodecs):
| 喂法 | 解码结果 |
|---|---|
| SPS/PPS 当独立 chunk 喂 | **0 帧** |
| 完全跳过 SPS/PPS | **0 帧** |
| **SPS+PPS 拼在关键帧前面** | **1338 帧,零报错** ✅ |
解码器要的是"一个自带参数集的关键帧访问单元"。参数集单独成 chunk 时会被丢弃或让
解码器停在等待关键帧的状态,**整段流一帧都出不来,而且不报错** —— 比报错更难查。
所以缓存 SPS/PPS,只在关键帧处 `concatBytes(sps, pps, unit)`。
#### 崩了要靠缓存的 codec 重建,不能等下一个 SPS
实测 SPS 在整个流里**只出现一次**(Android 8 秒流)。解码器一旦 `closed`,
靠"等下一个 SPS 再配置"永远等不到,画面永久卡死。正确做法是记住
`activeCodec`,一发现 `decoder.state === "closed"` 就立刻重建,
并置 `needsKeyframe` 等下一个 IDR(实测 IDR 间隔 ~0.8s,肉眼几乎无感)。
### 为什么坐标用 getBoundingClientRect
`object-fit: contain` 在画面比例与容器不一致时会留黑边。按 CSS 声明尺寸换算出的坐标会**系统性偏移**——偏移量随容器尺寸变化,表现为"点 A 却点到 B",很难复现。
正确做法是算出画面在容器内的实际显示矩形(`naturalWidth` × 缩放 + 居中偏移),并且**落在黑边上的指针事件直接忽略**(那不是对设备的操作)。
滑动与点击的区分:位移超过屏幕宽的 2% 判为滑动,否则为点击。阈值太小会把手指抖动误判成滑动。
### canvas 尺寸:必须由「帧的宽高比」决定(真机踩过)
曾经把 `canvas` 的尺寸写成「读它自己的 `getBoundingClientRect()` × DPR」,
结果**画面被压成一条横向小条**(用户截图确认)。原因是循环依赖:
```