← 报告站

给 DSH 打补丁:补丁管理插件的新增方式与写法

2026-10-29 · 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:到底改哪一层

这是最容易认错的地方,认错了就在设置页显示「目标缺失」。

rootfile 从哪算起适合改什么
"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")
hunks4 个:英文文案、中文文案、菜单项、构造点回调
体积变化源文件 513441 → 514086 字符(+645);asar 182440116 → 182440665 字节
菜单顺序打开 → 重启 → 分隔线 → 退出
markers5 条,覆盖中英文案、菜单标签、调用点、错误日志

主逻辑就四行——菜单项和回调:

{ label: messages.relaunchApplication, click: () => { this.options.restart(); } }

restart: () => { if (quitting || shuttingDown) return; /* …确认后再 relaunch… */ }

装好之后,设置页里它会从「待重打」变成「已就位」;DSH 升级把它冲掉后又会变回「待重打」(上游改写了锚点则是「上游已变」),这时点「应用此补丁」或右上角「全部应用并重启」即可,重启一次约几秒,会中断当前对话。

加一条新补丁的完整清单

  1. 用 walkEntries 找到目标文件,确定 root;
  2. 抽出原文,选语义完整且唯一的锚点,断言命中次数 === 1;
  3. 写 hunk;新增文案新开键,中英都加;核对闭包作用域;
  4. 破坏性动作复用应用已有的确认流程,注意 relaunch 的排队语义;
  5. 脚本算 sha、语法校验、跑 inspect/plan/packAsar;
  6. 临时 resources/ 里用 DSH_PM_JOB 跑一遍完整 job;
  7. 放进 patches/,回设置页点应用。

回滚很简单:把 resources/app.asar.orig 改名回 app.asar(DSH 关闭状态下操作)。每个备份都是整包约 180MB,只留这一个就够,其余 app.asar.bak-*、app.asar.prev-*、app.asar.new 都可以删。