给 DSH 打补丁:补丁管理插件的新增方式与写法
DSH 桌面版的功能都在 resources/app.asar 里。想加一个小功能——比如托盘右键菜单里多一项「重启 DeepSeek Harness」——最直接的办法就是改这个包。问题是 DSH 每次升级都会整包替换,手改的改动随之蒸发:这次托盘补丁就是这么丢的,截图里只剩「打开」和「退出」两项。
补丁管理插件解决的就是这件事:把改动登记成一份 JSON,升级后在设置页点一下重打,不用记脚本路径,也不用手动关程序。这篇讲怎么给它加一个新补丁,以及写这类补丁时真正会出错的地方。
一个补丁长什么样
补丁放在插件目录的 patches/ 下,一个 JSON 一个补丁,文件名随意,id 才是标识。
{
"version": 1,
"id": "tray-restart",
"name": "托盘菜单:重启 DeepSeek Harness",
"description": "在托盘右键菜单里加一项重启",
"files": [
{
"file": "lib/main.js",
"root": "archive",
"pristineSha": "F05DD366…97E3BA93",
"patchedSha": "BBB12E19…1238661C",
"markers": [
"relaunchApplication: \"Restart DeepSeek Harness\"",
"relaunchApplication: \"重启 DeepSeek Harness\"",
"label: messages.relaunchApplication,",
"this.options.restart();",
"desktop tray: restart confirmation failed"
],
"hunks": [
{ "find": "唯一的原文件片段", "replace": "替换成的内容" }
]
}
]
}
| 字段 | 作用 |
|---|---|
file | 要改的文件,相对哪个根由 root 决定 |
root | "scope"(默认)或 "archive",见下节 |
pristineSha | 原文件 SHA256(大写),用来判断「这份补丁是否对得上当前版本」 |
patchedSha | 打完后的 SHA256,打完逐字节比对,对不上就不安装 |
markers | 打完才出现的特征串,用于判断「已经打过」 |
hunks | 按顺序重放的 find → replace |
一个补丁可以带多个文件,所以「一次改动牵涉几个包」也能登记在同一条里。
root:到底改哪一层
这是最容易认错的地方,认错了就在设置页显示「目标缺失」。
| root | file 从哪算起 | 适合改什么 |
|---|---|---|
"scope"(默认) | dsh/node_modules/@deepseek-ai/ | 插件包,例如 dsh-some-package/lib/index.js |
"archive" | asar 根 | 桌面外壳自己:lib/main.js(托盘、菜单、退出流程)、lib/preload-*.cjs |
root 是这次为了托盘补丁才加进规范的。之前它只认 @deepseek-ai/ 作用域,而托盘代码在 asar 根的 lib/main.js 里,不在任何包里,没这个字段就根本表达不了目标。实现上加一个 archivePathOf(entry) 就够了:root === "archive" 时前缀为空,否则拼上 SCOPE_PREFIX。
不确定目标在哪一层时,别猜——用插件自己的 lib/asar.mjs 把条目列出来:openAsar + walkEntries,看目录结构再决定。dsh/node_modules 下有一万七千多个条目,桌面外壳只有根目录那几十个文件,一眼就能分清。
锚点怎么写才经得住升级
find 必须在原文件里恰好出现一次,插件不猜位置;上游把这段重写了就明确报「哪个文件、第几个 hunk、哪一行」然后停下。
所以锚点要满足两条:足够长到唯一,并且是语义完整的一整段。反面教材是拿裸标识符当锚点——messages.openApplication 在文件里出现好几次,插件会直接拒绝。正确做法是拿整段兄弟项:
{
label: messages.openApplication,
click: () => {
this.options.open();
}
},
这样插进去的新项和邻居风格一致,find 也天然唯一。生成脚本里先断言「每个锚点在原文件命中次数 === 1」,不满足就停下,别等打包完才发现。
新文案要新开一个键。语言包里已经有 restartApplication: "重启",但那是崩溃恢复对话框的按钮文案,拿来复用会让两处互相牵制。这次的补丁新开 relaunchApplication,中英两套各插一条,都紧邻 restartApplication——中英是两个独立的大对象(const en = { / const zh = {),插错对象得到的就是空标签。核对办法:该键在原文件里出现 0 次,再加打印插入点前后几十个字符确认落在语言包对象内。
插入的代码要核对作用域。回调里引用的每个标识符都得能在同一模块作用域里找到声明行——quitting、shuttingDown、skipQuitConfirmation、quitWithoutConfirmation、quitConfirmation,把 lib/main.js 抽成文件后逐个 grep 声明位置,别凭印象。const 声明在构造点之后没关系,前提是回调只在点击时才执行——没有顶层求值,就没有 TDZ 问题。
行为设计:别制造意外重启
托盘「重启」会打断正在跑的会话,和「退出」同性质,所以它会先弹一次应用自己的确认框(quitConfirmation.confirm()),用户取消就什么也不做。
这里有个容易写错的地方:不要图省事写成
app.relaunch();
app.quit();
因为 app.relaunch() 只是排一次队,如果随后的退出被用户取消,那个排队项还留着——下次正常退出时会被意外重启。正确顺序是先确认、再排 relaunch:
restart: () => {
if (quitting || shuttingDown) return;
const relaunch = () => { app.relaunch(); quitWithoutConfirmation(); };
if (skipQuitConfirmation || sessionEnding) { relaunch(); return; }
quitConfirmation.confirm().then((approved) => {
if (approved && !quitting) relaunch();
}).catch((error) => {
console.error("desktop tray: restart confirmation failed", error);
});
},
顺带一提,应用菜单里其实早就有「重启应用与 Host」,但被 ...development ? [...] : [] 包着,只在开发构建出现;渲染进程侧也没有暴露 quit/relaunch 的接口,所以托盘是唯一自然的位置。
验证:语法过了不代表对
node --check 只能证明语法合法,它查不出「菜单标签渲染成空串」「变量其实在别的作用域」。可用的验证链是这样的:
- 锚点命中次数各为 1;
- 产物
node --check通过; - 调插件自己的
inspect()看状态是否变成pending; - 调
plan()重放一遍,输出 sha 必须等于patchedSha; - 调
packAsar()预打包,条目数要和原包一致(这次是 18188 条); - 把产物单独断言一遍:每个 marker 命中次数、调用点唯一、键在中英两个包里,最好把前后文件都抽出来 diff 一眼。
sha 不要手填。脚本从真实 asar 读原文 → 按锚点插入 → 算出两个 sha → 语法校验 → 再走上面那条链自验。手抄 sha 是纯给自己挖坑。
先在临时目录装一遍。把真实的 app.asar 复制到临时 resources/,用 DSH_PM_JOB 环境变量直接跑 lib/apply-job.mjs,确认打包、替换、校验、重启整条链都通,最后才去点真机上的那个按钮。
安装器那一侧:四个真正咬人的坑
补丁写得再对,装不上去也白搭。这次从「打补丁报错」到「装成功」之间踩的坑,都在执行器 lib/apply-job.mjs 里:
| 现象 | 真因 | 修法 |
|---|---|---|
| 对话框「应用无法启动或已意外停止」,任务卡在 closing | Host 自己 process.exit(0) 时,shell 把它当崩溃处理——弹框但不退出,asar 仍被锁着 |
不再让 Host 自杀;任务按 pid 精确停掉 shell(process.ppid),再停 Host |
「已释放」判断通过,替换却 EBUSY |
Windows 上 Node 打开文件默认共享读写,DSH 跑着时写打开也成功;真正需要的是 rename | 探测改成 rename 往返(replaceable()),失败才算没释放 |
| 任务把自己杀掉,停在 closing、asar 没换 | 任务进程本身就是用同一个 DeepSeek Harness.exe 以 Node 模式起的,按映像名批量 kill 会连自己一起杀 |
批量 kill 必须带 /FI "PID ne <自身 pid>" |
| 补丁装好了,应用没自己打开 | Host 用 ELECTRON_RUN_AS_NODE=1 启动任务,环境变量被继承,「启动 DSH」变成「无脚本的 node 立刻退出」(实测 56ms、退出码 0) |
spawn 新实例前剔除该变量与任务变量 |
另外一条不是坑但值得记:关掉 DSH 之后如果最终没换成,执行器要把 DSH 重新拉起来再报失败,别把人晾在关机状态。
实例:托盘「重启」这一条补丁
| 项 | 值 |
|---|---|
| 目标文件 | lib/main.js(root: "archive") |
| hunks | 4 个:英文文案、中文文案、菜单项、构造点回调 |
| 体积变化 | 源文件 513441 → 514086 字符(+645);asar 182440116 → 182440665 字节 |
| 菜单顺序 | 打开 → 重启 → 分隔线 → 退出 |
| markers | 5 条,覆盖中英文案、菜单标签、调用点、错误日志 |
主逻辑就四行——菜单项和回调:
{ label: messages.relaunchApplication, click: () => { this.options.restart(); } }
restart: () => { if (quitting || shuttingDown) return; /* …确认后再 relaunch… */ }
装好之后,设置页里它会从「待重打」变成「已就位」;DSH 升级把它冲掉后又会变回「待重打」(上游改写了锚点则是「上游已变」),这时点「应用此补丁」或右上角「全部应用并重启」即可,重启一次约几秒,会中断当前对话。
加一条新补丁的完整清单
- 用
walkEntries找到目标文件,确定root; - 抽出原文,选语义完整且唯一的锚点,断言命中次数 === 1;
- 写 hunk;新增文案新开键,中英都加;核对闭包作用域;
- 破坏性动作复用应用已有的确认流程,注意 relaunch 的排队语义;
- 脚本算 sha、语法校验、跑
inspect/plan/packAsar; - 临时
resources/里用DSH_PM_JOB跑一遍完整 job; - 放进
patches/,回设置页点应用。
回滚很简单:把 resources/app.asar.orig 改名回 app.asar(DSH 关闭状态下操作)。每个备份都是整包约 180MB,只留这一个就够,其余 app.asar.bak-*、app.asar.prev-*、app.asar.new 都可以删。