# 本地桥协议(插件 ↔ Chrome 扩展) > 这是本项目**第二条跨进程契约**(第一条是 [sidecar 协议](protocol.md))。 > 一端是 DSH 插件里的一个极小 HTTP 服务,另一端是 `extension/` 里的 Chrome 扩展。 > 读懂这一页,就能在不碰插件代码的前提下替换扩展(或者反过来)。 ## 它解决什么 弹幕由**你自己的浏览器页面**发出去,而不是插件自己拼 HTTP 请求。好处有三条: 1. 插件里**不存任何凭证** —— 没有 cookie 可泄露; 2. 签名(wbi、csrftoken)由 B 站前端自己算,**永远是对的** —— 插件不需要跟进它的算法; 3. 发没发出去,直播间页面上直接看得见。 代价是插件必须能和那个页面说上话,这就是桥。 ## 为什么是 HTTP 轮询,而不是 WebSocket 说实话,WebSocket 更好:零延迟、事件驱动。**没选它是因为一条会被静默违反的前提**。 MV3 的 service worker 空闲 30 秒就被回收。要让 WebSocket 活着,就得每 20 秒发一次 ping 把它吊住(Chrome 116 才允许 WebSocket 活动延长 SW 寿命,官方为此专门写了一篇 [教程](https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets))。 一条"必须一直有心跳"的链路,断了以后的表现是**按了没反应**,而且没有任何提示。 轮询把这条前提整个删掉了: ``` 内容脚本(每 800ms) → runtime.sendMessage → service worker → HTTP → 桥 ``` 不要求 service worker 活着,只要求它**被消息叫醒时能干一次活** —— 而消息唤醒是 可靠的。最坏情况只是冷启动多花几十毫秒。代价是每次发送最多 800ms 延迟,对一个 "按一下键、等着弹幕出现在屏幕上"的用途完全无感。 **推论:不要为了"省那 800 毫秒"改成 WebSocket。** 那会把这条已经删掉的依赖加回来。 ## 端点 全部只监听 `127.0.0.1`。**所有**请求都必须带 `?token=<口令>`,不匹配一律 403。 | 方法与路径 | 谁调用 | 作用 | |---|---|---| | `GET /poll?token&tab&href` | 内容脚本(经 SW) | 领一条待发弹幕。没有任务时返回 `{}` | | `POST /result?token` | 内容脚本(经 SW) | 交回执:`{id, ok, code?, message?}` | | `POST /page?token&tab` | 内容脚本(经 SW) | 上报页面状态:`{href, title, hasInput, hasButton, hookReady, error}` | | `GET /status?token` | 扩展弹窗 | 读桥的状态快照(端口、扩展多久没来、页面状态) | `tab` 是内容脚本每次加载时随机生成的一个 id。**它让桥能区分标签页** —— 没有它, 两个直播间会被当成同一个,"只发给正确的那个"就无从谈起。 ### `GET /poll` 的回应 ```jsonc // 没有任务 {} // 有任务(已指定发给谁) { "id": "9f3c1a2b4d5e", "text": "识别出来的那句话" } ``` `href` 参数不是装饰:桥据此判断**要不要把任务派给这个标签页**。 任务在**入队时**就已经指定了目标标签页,领任务时只做一件事:不是给你的就不给。 所以开着好几个直播间也不会出现"谁先轮询谁抢走"—— 那会把弹幕发到别人房间去。 ### 多直播间:三种情况(用户定的规则) | 打开的直播间 | 配了房间号 | 行为 | |---|---|---| | 只有一个 | 无所谓 | **直接发** —— 没有歧义,填没填房间号都不该拦 | | 多个 | 配了 | 发给**匹配的那个**,并告诉用户发到了哪里 | | 多个 | 没配 | **不发**,告诉用户去关掉多余的 | | 多个 | 配了,但有两个标签页都是它 | **不发**,让用户只留一个 | 第二、三行的区别是刻意的:房间号在"只有一个直播间"时只是顺手记一下的字段, 不该因为它和地址对不上就拒绝发送;而"多个直播间又没配房间号"时我们**确实无法知道** 该发到哪一个 —— 猜错就是把弹幕发进了别人的房间。 **拒绝发生在入队之前**,所以用户是立刻看到原因,而不是等 15 秒超时。 "同一个直播间开了两个标签页"也拒绝,是因为发哪个其实都一样 —— 但让用户自己 留一个更省心,也免得他以为弹幕发到别处去了。 ### `POST /result` 的回应 `code` 是 **B 站服务端的返回码**(扩展从页面自己的请求里 hook 回来的),不是桥的。 所以插件可以直接用 `src/channels/bilibili-codes.ts` 把它翻译成人话 —— 页面之后 面对的是同一个服务端,拒绝码当然是同一套。 **没有 `code` 的失败**要当成"无法确认"而不是"确定失败":它意味着扩展点了发送但没抓到 回执(B 站换了请求方式),这条弹幕**可能已经发出去了**。插件的错误文案必须提醒用户 **自己去看一眼直播间**,并且**不提供重发按钮** —— 结果未知时把判断权交回用户, 比替他猜一个"再发一次"要安全得多。 ## 安全模型 威胁模型是"本机上的其它程序,以及你访问的网页" —— 不是互联网(桥根本不对外监听)。 | 措施 | 挡住什么 | |---|---| | 只绑 `127.0.0.1` | 同一局域网里的任何人 | | 口令(16 字节随机,`timingSafeEqual` 比对) | 本机上不知道口令的程序 | | **不发任何 CORS 头** | 网页里的 JS —— 它就算猜到了口令,浏览器也不让它读响应 | | 扩展权限只有 `storage` + `http://127.0.0.1/*` | 扩展本身被怀疑"能读你所有网站" | 口令为什么**不是** secret 字段:用户必须能看见它、把它复制到扩展弹窗里。 它的敏感度也远低于登录凭证 —— 泄露它的前提是攻击者已经能在你机器上执行代码。 ## 生命周期 桥只在**需要它的时候**开着 —— 不占端口,也少一个本机能连进来的入口。 现在只有一条通道(`provider: page`),所以桥跟着它起停;口令为空时宿主先生成一个 写回设置。 端口变了会重开(桥一旦启动就固定在那个端口上)。设置连续变动时这些操作用一个 Promise 队列串行化:并发的两次"启动"会看到同一个"没在监听"的状态,然后撞在同一个 端口上。 ## 状态:四段链路压缩成一个值 页面通道有四段可能断掉的链路,而它们在用户眼里是**同一个现象**:"按了没反应"。 ``` 桥没起 → 扩展没连 → 页面没就绪 → 桥和扩展的口令不一致 ``` 宿主每 5 秒看一眼,把结论写进 `channel.page.status`,设置页据此显示一句能照做的 指引: | 值 | 含义 | |---|---| | `off` | 当前不是页面通道(界面不显示) | | `down` | 桥没在监听(端口被占?) | | `waiting` | 桥在等,扩展没来(没装?没开直播间页面?口令不一致?) | | `not-ready` | 扩展来了,但那个页面里没找到输入框/按钮 | | `page-error` | 扩展连上了,但在页面上操作时报了错 | | `ready` | 就绪,可以发了 | **这个字段必须定期算,不能事件驱动**:扩展随时可能关标签页、禁用自己、退出浏览器, 而这些都不会给插件发任何消息。 ## 调试 ```sh npm run test:extension # 扩展静态检查(权限、语法、manifest 引用完整性) npm run test:unit # 桥与通道的单元测试(含真桥联调) ``` 手工看桥在不在: ```sh # 在插件设置页把通道切到 page、保存,然后: curl "http://127.0.0.1:39217/status?token=<你的口令>" ``` 返回里 `lastSeenAt` 是扩展最近一次来轮询的时间戳(0 表示从没来过)。