给 DSH 桌面版主进程打补丁的一键工具 + 补丁定义库。
起因:DSH 的「一切皆插件」覆盖的是应用层(Host 服务 / 客户端 UI / Agent 工具),
而 Electron 外壳(主进程 + preload)是插件的运行环境,不开放给插件 ——
will-attach-webview 那道闸门挡住了渲染进程通往 nodeIntegration 的路,
这是故意的安全边界。所以想让侧边栏浏览器「有身份、功能完整、agent 能操控」,
只能动主进程源码。官方升级一次就得重打一次,于是有了这个包。
cd <这个仓库>
node apply.mjs --status # 看一眼:目标在哪、补丁是 pristine / applied / partial
node apply.mjs # 默认 dry-run:逐条校验锚点,不写盘
node apply.mjs --apply # 真的打进去(会先备份)
node apply.mjs --revert # 用备份回滚参数:--asar <path> 换目标、--patch <file> 换补丁、--backup-dir <dir> 换备份位置。
打完必须重启 DSH —— 补丁改的是主进程的 lib/main.js,进程已经把它读进内存了。
这个目录本身就是一个 DSH bundle 插件。装进 profile:
dsh plugin --profile desktop add <这个目录的绝对路径>装完后它提供三样东西。
别的插件(尤其是那些同样需要动源码的)只要声明,就由本插件统一管账:
export const inject = ['sourcePatch']
export function apply(ctx) {
ctx.effect(() => ctx.sourcePatch.declare({
id: 'my-plugin',
patches: [new URL('./my.patch.json', import.meta.url)], // 绝对路径 / URL / 内联定义都行
}))
}接口:declare · definitions · inspect(id) · plan(id) · apply(id, confirmation) · revert(id, confirmation)。
同一个补丁 id 重复声明会被当场拒绝并记为冲突,不会静默覆盖。
| 工具 | 作用 |
|---|---|
source_patch_status |
只读。列出补丁 + 状态(pristine / applied / partial)+ app.asar 位置;带 patchId 时附上逐条锚点 dry-run。 |
source_patch_apply |
应用。要求 confirm 等于补丁 id(不是 boolean),误触发不了。 |
source_patch_revert |
从备份回滚。同样要求 confirm 等于补丁 id。 |
两个写工具都不会重启 DSH —— 只告诉你"重启才生效"。
就是下面那个 sidebar-browser-durable-identity。
装好后侧边栏会多一个 补丁管理 面板(「开始」菜单里也有入口),里面能:
- 看到每个补丁的 状态徽章(未应用 / 已应用 / 半应用 / 不可用)、来源、编辑数、体积
- 应用 / 回滚 —— 两步确认:第一次点"应用"只是武装,按钮变成"确认应用?",再点一次才真的执行
- 锚点校验 —— 就地展开逐条锚点的 dry-run 结果(✔/✘ + 字节数),不写盘
- GitHub 区块 —— gh 装没装 / 登录没登录 / 私有仓库在不在;远端有哪些补丁、装没装、有没有更新;一键「发布全部」「安装 / 更新 / 卸载」
- 任何失败(锚点对不上、DSH 正在跑导致 EPERM)都原样显示在面板上,不吞错
客户端半边是手写的 lib/client.js,不是构建产物 —— 它就是一个符合 DSH 客户端模块格式的文件:
window.__ModuleLoader__.load({
id: "dsh-source-patch",
factory: (require) => { /* … */ return module.exports },
})里面只 require("react"),其余全靠宿主注入。所以改 UI 不需要任何构建工具链,改完直接能读。
它通过宿主半边的 POST /source-patch/api/<method> 说话,那条路由带 信任围栏(防 DNS rebinding / 跨站)——
这条路由能改主进程,围栏不是可选项。
node .test/plugin.spec.mjs它会:在只读模式下校验真实安装版 → 把改动打在 .test/app.asar 副本上跑完整应用/回滚流程 →
验证幂等、验证 confirm 守卫、验证别的插件 declare/withdraw、验证工具本身。
副本会临时占 121MB,跑完可以删。
.test/需要@deepseek-ai/dsh-tools才能解析index.js。真实 profile 里本来就有; 在工作区里则靠../node_modules/@deepseek-ai/dsh-tools那个仅测试用的 shim。 这个 shim 刻意放在包外 —— 放在包内会遮蔽 profile 里真正的dsh-tools。
插件可以把补丁包放到你 GitHub 上的一个 私有仓库 里。换台机器装同一个插件,就能把补丁拉回来。
它只调用官方 GitHub CLI(gh),token 存在操作系统的凭据库里(Windows 是凭据管理器)。
-
没装 gh → 提示你去 https://cli.github.com/ 装一个
-
装了但没登录 → 提示你运行一次:
gh auth login --hostname github.com --git-protocol https --web
这条命令走设备码流程:终端给你一个一次性代码,同时在浏览器里打开 GitHub, 你确认一次就完事。之后本插件直接复用这份登录态 —— 不需要你到任何地方粘贴 PAT。
默认在 <你的账号>/dsh-patches,私有,首次 publish 时自动创建。里面就是可读可 diff 的纯 JSON:
registry.json # 索引:id / version / title / sha256 / 编辑数 / 体积
patches/<id>.json # 完整的补丁定义
对 agent 说一句就行(工具 source_patch_remote):
| action | 干什么 | 要 confirm 吗 |
|---|---|---|
status |
gh 装没装、登录没登录、私有仓库在不在 | 不用 |
available |
远端有哪些补丁、本地装没装、有没有新版本 | 不用 |
installed |
本地 store 里有哪些 | 不用 |
publish |
把本地补丁推上去(缺省全推,可传 id 过滤) | 需要 confirm="publish" |
install |
下载某个补丁到本地 store(带 sha256 校验) | 需要 confirm 等于补丁 id |
uninstall |
从本地 store 删掉 | 需要 confirm 等于补丁 id |
也可以在别的插件里直接用服务:
const remote = ctx.get('sourcePatch').remote
await remote.ensureRepo()
await remote.publish(['sidebar-browser-durable-identity'])
await remote.install('sidebar-browser-durable-identity')~/.dsh/dsh-source-patch/repo/ # 私有仓库的缓存 clone
~/.dsh/dsh-source-patch/patches/ # 从远端装下来的补丁定义
patches/ 里的定义会被 ctx.sourcePatch.definitions() 当作 store 来源一起列出来 ——
装下来就能直接用,和内置补丁一视同仁。
publish 和 install 只搬 JSON。它们不会应用补丁;应用仍然要单独过一次
source_patch_apply(还得 confirm 等于补丁 id),而且任何一步都不会替你重启 DSH。
目标:安装版 resources/app.asar 里的 lib/main.js(打包后的主进程,约 480KB)。
补丁 sidebar-browser-durable-identity 共 7 处编辑,净增 1515 字节:
| # | 锚点 | 改动 |
|---|---|---|
| 1 | durable-partition |
`dsh-sidebar-browser-${randomUUID()}` → `persist:dsh-sidebar-browser-${sha256(workspace)}`。原来是无 persist: 的内存分区,退出即失;现在落到磁盘,按 workspace 稳定分键。 |
| 2 | policy-header |
注释从 "fixed isolation policy" 改成 "durable, workspace-scoped";注入 GRANTED_PERMISSIONS 白名单与 configuredExtensions()(读 DSH_DESKTOP_BROWSER_EXTENSIONS)。 |
| 3 | plugins-enabled |
plugins: false → true:放开 Chromium 内嵌插件(PDF 阅读器等)。 |
| 4 | dialogs-enabled |
disableDialogs: true → false:放开 alert / confirm / prompt。 |
| 5 | extension-registry-field |
给 guest 拥有者加 extensionLoads = new WeakSet()。 |
| 6 | session-policy |
整个 configureSession 换掉:加扩展加载;权限请求/查询改走白名单;删掉 will-download 的 preventDefault(放行下载)。设备权限与屏幕共享维持拒绝。 |
| 7 | guest-cdp-endpoint |
新增 installGuestDebuggingEndpoint():读 DSH_DESKTOP_CDP_PORT,设了就开一个只绑 127.0.0.1 的 CDP 端点,供 agent 操控侧边栏里的 guest。默认关闭,非法值直接抛错。 |
放宽的权限白名单:
clipboard-read、clipboard-sanitized-write、fullscreen、geolocation、media、notifications、pointerLock。
- 锚点必须唯一。 每处编辑是一个正则,必须恰好命中 1 次,否则整包拒绝、一个字节都不写。 第一次试跑时 3 处锚点各命中 3/3/2 次 —— 工具立刻罢工而不是"尽量猜"。
- 先自我校验。 动手前把解析出来的 asar 头部 JSON 重新 pickle 回去, 必须和原文件里的头部字节逐字节相同。对不上就说明我们没看懂这个 asar,直接放弃。
- 只重写一个文件。 asar 是「头部 pickle + 连续数据区」。改一个文件的尺寸会让它之后的条目整体平移, 所以工具会重写头部里的 offset、把数据区其余部分原样搬运,而不重新打包整个包。
- 写前备份,写后回读。 备份只存原始的
lib/main.js(约 480KB),回滚够用; 写完立刻从磁盘重新读一遍并比对,不一致就报错让你回滚。 - 锁定检测。 打不开写句柄(DSH 正在跑)就明确报错,而不是写到一半。
官方升级会换掉整个 app.asar,补丁随之失效。这时:
node apply.mjs --status # 应该显示 pristine
node apply.mjs # dry-run 校验锚点- 全部命中 →
--apply直接重打。 - 有锚点没命中 → 说明目标代码变了。工具会列出是哪几处;
用
.test/verify.mjs那套办法把新版本的lib/main.js抽出来对照, 改patches/*.json里的find正则即可。不要去放宽"必须命中 1 次"的约束。
这个包的能力本质上等于「改写 DSH 主进程」。主进程有完整机器权限, 所以一旦被滥用,效果等同于在下次启动时执行任意代码。
因此本包刻意保持:
永远显示改了什么 · 永远先备份 · 永远要人工加 --apply · 锚点对不上就拒绝。
将来把它封装成插件时,这几条必须原样保留 —— 不能有"自动应用"的路径。
index.js host 半边:ctx.sourcePatch 服务 + 4 个 agent 工具
remote.js GitHub 私有补丁仓库(gh + git,不碰 token)
fence.js /source-patch/api 的浏览器信任围栏
lib/client.js 客户端半边:侧边栏「补丁管理」Tab(手写,无需构建)
cordis.patch.yml 插件行(dsh.bundle.patch 那一层)
package.json 插件清单(dsh.bundle.patch / dsh.client / manifestVersion)
apply.mjs 引擎 + CLI(index.js 就是 import 它)
patches/sidebar-browser-durable-identity.json 补丁定义(锚点正则 + 替换模板 + marker)
.test/plugin.spec.mjs 宿主半边自检(含 HTTP 路由)
.test/client.spec.mjs 客户端半边自检(假 loader + 假 React)
.test/remote-demo.mjs GitHub 链路真跑一遍(会建私有仓库)
.test/verify.mjs asar 读写引擎的端到端验证
apply.mjs 导出的可复用部分:
parseArchive / buildHeaderPickle / entryInfo / readEntry / rewriteEntry /
applyEdits / detectState / markerReport / defaultArchivePath。
对 121,348,951 字节的真实 app.asar 副本执行 --apply:
- 7/7 处锚点各命中 1 次
lib/main.js490,173 → 491,688 字节- 有且仅有
lib/main.js变化(全量比对 11,470 个打包条目) - 抽样条目(
package.json/lib/preload-*.cjs/lib/welcome/welcome.js/node_modules/electron-updater/out/main.js/dsh/package.json…)逐字节不变 - 打补丁后的
lib/main.js通过node --check app.asar.unpacked/完全不碰
尚未验证:打完补丁的 App 真的能启动。这需要替换真实安装(得先关掉正在运行的 DSH), 留待实际应用时确认。
插件的自检(node .test/plugin.spec.mjs)已验证:
- 挂载后发布
ctx.sourcePatch,注册 3 个工具,disposer 全部可回收 - 补丁库发现 1 个内置补丁、无定义冲突
- 只读地校验真实安装版:7/7 锚点命中、状态 pristine、所有 marker 在未打补丁时都不存在
- 在副本上跑完整生命周期:应用(+1515B)→ 状态 applied → 幂等(重复应用 changed=false)→ 回滚 → pristine
confirm不等于补丁 id(含传 boolean)时,应用与回滚都被拒绝- 另一个插件
declare后能被列出并标注来源,withdraw后消失;重复 id / 缺 id 会报错 - DSH 正在运行时应用会安全失败:报
EPERM并说明原因,且不留下半成品文件
最后一条是真跑出来的 —— 测试对着正在运行的安装版调用工具,rename 被系统拒绝, 工具清掉了 121MB 的临时文件再抛出可读错误。这正是设计要的结果。
%USERPROFILE%\.dsh\source-patch-backups\<patch-id>\
—— main.js.orig(原始字节)+ manifest.json(版本、字节数、时间)。