# 架构与关键取舍 面向要改这个插件的人。用户只需要看 [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」, 结果**画面被压成一条横向小条**(用户截图确认)。原因是循环依赖: ``` 没设 CSS 尺寸时默认 300×150(宽高比 2.0) ↓ .mbm-media 只有 max-width/max-height → 元素按 300×150 排版 ↓ 读到 300×150 → 后备存储设成 600×300 → 显示仍是 300×150(max-* 之下不变) ↓ 自洽地卡在 2.0 —— 与设备的 1080×2376(宽高比 0.45)毫无关系 ``` 实测:连续 6 帧都停在 330×165(2.0),而正确值应是 ~318×700。 所以尺寸必须由**帧的真实宽高比** + **父容器的可用空间**推出,再把结果 **同时**写回 CSS 尺寸与后备存储: - CSS 尺寸(`style.width/height`)决定显示多大 —— contain 缩放,不变形、不溢出 - 后备存储(`canvas.width/height`)= CSS × DPR —— 1:1 映射物理像素,最清晰且不浪费 配套两点:`.mbm-media` 必须 `flex: none`(否则 flex 容器会拉伸它,宽高比又丢); 坐标换算仍用 `frameSizeRef`(真实帧尺寸),不是 `canvas.width`。 修好之后 `contain` 退化为恒等映射(画面与容器同比例、已居中),坐标换算里的 偏移量恒为 0 —— 但那段代码保留着,因为容器比例仍可能与设备不同。 ### 为什么客户端要构建(以及为什么产物进仓库) **先摆硬约束**:DSH 把 client bundle 当**经典脚本**求值 —— 它只做 `window.__ModuleLoader__` 的注册,不做任何转译。运行时解析器只有四条分支 (DSH 源码原文): ``` seed word → memoized record → graph row → registered factory; anything else → throw (loud — the runtime mirror of the build-time bundle purity gate) ``` 所以**不编译就既不能写 JSX,也不能拆文件** —— 相对 `require` 只对打包器产出的 chunk 有效,手写的兄弟文件解析不到。DSH 自己的客户端插件同样是打包出来的 (`packages/client/tsdown.client.ts`)。 早先的做法是接受这个约束、手写 `h(...)` 单文件。后来改了,因为: - `h(...)` 嵌套到第四层就读不动了,54 处调用全是括号 - CSS 与布局挤在同一个文件里,改样式要在 1800 行里翻 **现在的做法**:源码是 `src/client/index.jsx`(JSX)+ `src/client/styles.css` (样式),`npm run build` 经 esbuild 生成 `lib/client.js`。 三个刻意的决定: 1. **只转译、不打包**(用 esbuild 的 `transform`,不是 `build`)。 产物必须保留 `window.__ModuleLoader__.load({ factory(require) {...} })` 外壳,`require("react")` 也得留着 —— react 由宿主注入,打进产物就违反 purity,还会和宿主的 React 变成两份实例。 2. **`jsxFactory: "h"`**:JSX 编译成 `h(...)`,与改造前同构。好处是产物仍是 一堆 `h(...)` 调用,既有的源码断言测试继续有效(改成自动运行时 `_jsx(...)` 会让十几处断言全部重写)。 3. **产物提交进仓库**。本插件以 **git 依赖**安装 (profile 里写的是 `github:j62268781-alt/dsh-sidebar-mobile`),提交产物能让 安装方**完全不需要工具链** —— esbuild 只是 devDependency。 产物进仓库的代价是**会漂移**(改了 src 忘了构建,安装方拿到的还是旧文件, 这种 bug 极难察觉)。所以 `npm run verify` 里有一道 `build:check`:重新构建 一遍并与提交的产物逐字节比对,不一致就直接失败。 另外两个构建细节,都是踩过的坑: - **`charset: "utf8"` 必须显式设**。esbuild 默认 `ascii`,会把每个非 ASCII 字符转义成 `\uXXXX` —— 对本项目(字符串与注释几乎全中文)会让产物变成 一屏 `\u771F\u673A`,没法读也没法 review。实测确认过这个默认值。 - 构建时剥掉 esbuild 给每个 JSX 元素加的 `/* @__PURE__ */` 标注:那是给 下游打包器摇树用的,本插件不打包产物,留着只会弄脏提交的文件。 CSS 走占位符 `__CLIENT_CSS__` **内联**成字符串常量,而不是让客户端去 fetch: DSH 把插件注入的 `