我给一支一线客服团队做了一个浏览器扩展。它长在一个定制版 Udesk 工作台上,把日常最费手的操作——录工单、回消息、标会话状态、查知识库、看监控——压成几个快捷键。

这个扩展有两个先天限制:

  • 不能上架应用商店:它深度绑定一个特定租户的页面结构和内部接口,对商店里的任何人都毫无意义,也过不了审。
  • 不能开源:同上,代码里全是业务语义。

于是所有商店免费提供的东西,都得自己长出来。写功能反而是这件事里最轻松的部分。

一年多下来是 196 个 TypeScript 文件、一万两千多行项目文档,当前版本 1.18.4。这个系列就是把其中与业务无关的那部分拆出来——MV3 工程、操控没有源码的 React 应用、LLM tool 编排、PocketBase 当零成本后端、PowerShell 一条命令分发——写成一篇篇可以直接抄走的文章。

这篇是地图。

商店免费给的,你得自己做

环节上架能白拿我实际做了什么
分发一键安装irm | iex 一条命令 → load-unpacked 到用户目录
更新静默自动更新自托管 updates.xml + 重跑同一条命令
权限无(商店不管谁能用哪个功能)云端按工号裁决 + 本地签名名册兜底
公告商店页描述云端下发 → 宿主页顶栏消息条
更新日志商店页版本记录手写 JSON → 推云端 → 博客实时拉取
统计安装量面板安装脚本匿名上报一条记录
配置无参数按域拆 key 放云端,改参数不发版
排障商店评论区带 scope 的日志层 + 日志落盘 + 用户能自己导出

下面每一条都值一篇文章。先讲清楚每条链路的关键取舍,文章陆续补上,链接回填在文末目录。

一、分发:一条命令,和「老用户绝不搬家」

用户是一线员工,Windows 机器,没有管理员权限,很多人不用命令行。所以分发方案的硬约束是:免提权、一条命令、装完能看见结果。

最终形态是一条 PowerShell:

终端窗口
irm https://example.com/install.ps1 | iex

脚本读安装目录里的 manifest.json 判断首次还是更新,两条路体验完全不同:首次要开扩展管理页、弹出资源管理器并选中文件夹、提示拖拽;更新只开扩展页提示点「重新加载」,不动文件夹也不动剪贴板。

这里最贵的一条教训是「老用户绝不搬家」。 某个版本我把安装目录往下挪了一层(为了 explorer /select 能高亮选中文件夹让用户直接拖),如果更新脚本顺手把老用户也迁过去,他们的扩展会当场失效——浏览器认的是加载时那个绝对路径,目录一搬就断。所以脚本发现旧布局里有 manifest.json 就继续用旧目录,什么都不动。用户之间路径不一致是预期行为,不是 bug。

另一条是顺序:explorer 会抢前台焦点,而「打开扩展管理页」靠的是抢焦点 + Ctrl+L 粘贴地址导航(chrome:// 开头的页面没法用命令行参数打开)。所以必须先开扩展页再弹文件夹,反过来按键会全部打到资源管理器窗口上。

📄 对应文章:E1 一条命令装扩展、E2 PowerShell 编码地狱 + .cmd 引导器三铁律

二、权限:fail-open,但不能让人自己提权

不是所有功能都该给所有人。监控看板和 AI 助手需要授权,基础功能装了就能用。

裁决链路是:扩展读到当前登录者的工号 → 查云端记录 → 按记录里的布尔位开关功能。

两个设计决定值得单独说:

① 服务器挂了要 fail-open,不是 fail-closed。 云端不可达时退回「基础功能普惠、高级功能关闭」,而不是全锁死。一个内部工具因为我的服务器抽风就让所有人干不了活,这个代价比「某人多用了两天监控面板」大得多。

② 但本地兜底名册必须签名。 兜底的前提是客户端本地有一份名册,而明文名册等于零鉴权——用户翻开扩展目录改一行就给自己开了所有功能。所以名册用 Ed25519 签名,公钥内置在扩展里,私钥永远不进仓库。改任何一个字节,整份名册验签失败、直接丢弃。

这里还埋着一个 TypeScript 的坑:新版 TS 把 Uint8Array 泛型化成 Uint8Array<ArrayBufferLike>,和 WebCrypto 要的 BufferSource 不兼容,验签代码会红。解法是让 base64 解码函数直接返回 ArrayBuffer。

📄 对应文章:D2 Ed25519 离线兜底 + 示范项目 pb-remote-config + 工具页签名信封

三、更新日志与公告:把「漏写会静默」变成闸门

更新日志有三个落点:安装脚本跑完打印本次更新内容、博客的产品页展示历史、群通知里带一句摘要。三个落点、一份真源。

真源是一个手写的 changelog.json,发版时推到云端的一张通用 changelogs 表,用 product 字段区分不同项目——多个产品共用一张表,加新项目不用建新集合。

这里的关键设计不是数据结构,是闸门:

npm run package
→ build
→ 检查 changelog.json 里有没有 package.json 当前版本的条目
没有 → 直接打包失败

因为漏写更新日志从来不会报错。不写,打包照过、安装照跑,只是用户装完看不到「这次改了什么」——而这恰恰是他最该看见的一句话。版本号本身已经不用手写了(README 里写占位符,构建时从 package.json 注入),但「这版改了什么」只能人写,所以用闸门兜住。

同一个思路也用在公告上:公告是纯云端资产,管理员改一条记录,所有人下次同步就看到顶栏消息条,不用发版。

📄 对应文章:D3 一张表服务多个产品(含 H4 文档即真源)、D4 全员公告

四、统计:这是安装事件流,不是在用版本分布

安装脚本在部署完成后匿名上报一条记录:版本、升级前版本、是否首装、渠道(公网 / 内网)、系统 build。整个上报是 fire-and-forget——5 秒超时,异常全吞,统计失败绝不影响安装。

不含姓名、工号、主机名、用户名,定位不到具体个人。来源 IP 也刻意不由脚本上报:请求到达服务器时公网 IP 本来就在代理头里,让客户端再去问第三方查 IP 纯属绕远路加一个失败点。

两条教训:

① PocketBase 静默丢弃 schema 里没有的字段。 POST 返回 200,值不落库,毫无报错。我有一列代码一直在写、schema 里根本没建,空转了几个月才发现。顺序永远是:先建列,再发新脚本。 反过来做,你会看到「上报成功但查不到数据」且无从排查。

② 它回答不了「现在全员在用什么版本」。 装完再也不升级的人,永远停在他那条旧记录上。安装事件流和在用版本分布是两个问题,后者得由扩展自身上报。

五、配置:改一个参数不该发一次版

提示词、阈值、模板文案、分类规则——这些东西改动频率远高于代码。全写死在代码里意味着改一个词就要走一遍完整发版流程,然后等所有人重跑安装命令。

所以它们走云端参数。有两个设计点:

按域拆 key,不要一条记录装全部。 最初是一条 global 记录装所有参数,改一处要动整块 JSON,冲突风险和误删风险都高。后来按功能域拆成多条记录,各取各的。

两种下发模型要分清:一种是「用户也能改、云端只给默认值」(逐项合并进本地配置),另一种是「纯云端资产、本地不产生也不该编辑」(写进独立的只读键,不进配置结构、设置页不可编辑、不随个人偏好导出)。混用这两种模型会得到很难查的 bug——用户改了一个值,下次同步被云端覆盖回去。

📄 对应文章:D1 PocketBase 远程配置、C8 prompt 放云端(并入 D1)

六、排障:你看不到用户的 Console

这是内部工具最难受的地方。用户说「不好使」,你没有 Sentry、没有会话回放,甚至没法远程连他的机器。

做了三件事:

  1. 一个带 scope 的日志层,替掉全项目三百多处裸 console.*。error 级恒开不受开关约束,其余默认关——判级别时只问一句:「开关关着的时候,用户还需不需要看见它?」
  2. 日志落盘 + 设置页能一键导出,用户把文件发我就行。
  3. 两套作者后门:内容脚本里拨一个 localStorage 开关,Service Worker 里拨一个会话存储开关(SW 没有 localStorage)。后者浏览器一关自动清——调试后门本来就该是临时的。

还有一条是方法论层面的,吃了好几次亏:扩展的日志打在隔离世界,而 DevTools 的 Console 默认只显示主世界。 症状极具误导性——功能明明已经生效,Console 里一条日志都看不到,于是误判成「代码没生效」。所以给别人写验证步骤时,别写「看日志里有没有 X」,要写「跑这条命令,看返回里有没有 X」。日志是过程的旁证,产物才是结果本身。

📄 对应文章:A6 带 scope 的日志层、H2 排查方法论

技术上真正难的部分

上面是「产品链路」。纯技术那一侧,这个项目里最值得写的是这几个:

三个世界。 内容脚本跑在隔离世界,读不到页面 JS 对象——React fiber、页面全局变量全都碰不到。而你在 DevTools Console 里测会成功(它默认在主世界),于是极易写出「实验通过、上线无效」的代码。解法是往主世界注入一个脚本当桥。

一个页面五个实例。 内容脚本配了 all_frames: true,每个 iframe 都跑一份。模块级变量锁不住任何东西,会跑流程的 UI 必须收敛到顶层帧,而键位监听必须全帧注册(焦点可能在任意 iframe 里)。

Service Worker 不肯活着。 MV3 的 SW 三十秒无事就被回收。定时器不可靠、闹钟有频率下限、长连接才是保命绳,跨冻结的状态得往会话存储里放快照。

操控一个没有源码的 React 应用。 直接给 input.value 赋值,React 根本不认——它有自己的值追踪器。要绕过它得拿原型链上的 setter 写,再手动派发事件。下拉框用 click() 打不开,得用 mousedown。字段不能按 ID 找(自动生成的会变),得按中文 label 找。

富文本有两层皮。 显示层写成功不等于提交源写成功——提交读的是隐藏的 textarea,而存草稿读的是 React state。只写其中一层,你会得到「提交正常、草稿丢内容」这种最难复现的 bug。

给 AI 的 tool 要分级。 所有能力包成统一的 tool 注册表,按副作用分 read / write / send 三级。send(对客户发消息)永远不给 LLM 自主调用——注册表在自主语境下直接拒绝。「录单可以自主提交」指的是一条写死顺序的固定流程,和「LLM 自己决定调什么」是完全不同性质的事。

系列目录

文章陆续发布,发一篇在这里补一条链接。

A · Chrome MV3 扩展工程 A1 工程骨架五个入口 · A2 内容脚本的三个世界 · A3 all_frames 的代价 · A4 Service Worker 保活 · A5 一个 <base href> 引发的 CORS · A6 带 scope 的日志层 · A7 配置存储与迁移 · A8 全局键位管理器

B · 操控没有源码的第三方 React 应用 B1 让受控组件看见你写的值 · B2 从 DOM 摸到 fiber 再摸到表单实例 · B3 富文本的三个源 · B4 注入 UI 的三条纪律 · B5 兜底被上游 return 跳过(并入 H2)

C · LLM 与 agent 工程 C1 tool 的副作用分级 · C2 固定流程 + AI 判断节点 · C3 原生 Side Panel · C4 接 OpenAI 兼容网关的实战清单 · C5 让模型查树而不是背树 · C6 两段式分类 · C7 多模态输入 · C8 prompt 放云端(并入 D1) · C9 采集纠偏样本

D · 零成本后端:PocketBase D1 远程配置按域拆 key · D2 Ed25519 离线兜底 · D3 一张表服务多个产品 · D4 全员公告

E · Windows 分发与运维 E1 一条命令装扩展 · E2 PowerShell 5.1 编码地狱 · E3 .cmd 引导器三铁律(并入 E2) · E4 一条命令发版 · E5 打包与图标 · E6 Native Messaging 跑本地语音转写

F · 数据与监控 F1 用 SW 替掉一台 Puppeteer 机器人 · F2 服务端粗筛 + 本地精排 · F3 事后抽检而不是提交前拦人 · F4 一个判据的四代演进史

G · 注入式 UI G1 shadow DOM + 设计 token · G2 用框架就用它的组件类 · G3 入口放哪

H · 方法论与复盘 H1 本文 · H2 排查方法论 · H3 和 AI 结对一年 · H4 文档即真源(并入 D3)

示范代码:主世界桥 · React DOM 驱动器 · 分级 tool 注册表 · 远程配置 + 签名兜底 · PowerShell 安装器模板 · 工具页:React 受控表单靶页 · Ed25519 签名信封 · 分类树 Diff


回头看,这一年多真正消耗时间的,几乎全在「商店本来会替我做的那些事」上:怎么让一个不会用命令行的人装上、怎么在我睡觉的时候服务器挂了也不影响他干活、怎么在看不到他 Console 的情况下判断到底哪一步断了。

功能是最容易的部分。难的是让功能真的到达用户,并且你知道它到达了。