dsh-desktop
DeepSeek Harness 的 Linux Electron 桌面客户端,由claude opus生成, 打包后开箱即用, 已更新至0.1.1-rc.2, 插件安装方式与web端一致, download in Release Page.
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 20, 2026
- Updated
- Aug 22, 2026
Introduction
dsh-desktop
DeepSeek Harness 的 Linux Electron 桌面客户端,由claude opus生成。harness 全部从 npm registry 安装(当前 0.1.1-rc.2),本仓库只拥有桌面层:Electron 外壳、一层 cordis 补丁 overlay 和运行时胶水插件。没有 submodule,没有补丁,不改上游一行代码。
桌面端不拥有自己的 profile:它启动的就是 dsh --profile web 启动的那个 web profile,插件装一次两边都在。
布局
packages/desktop-app/ dsh-desktop-app —— 桌面差异层
cordis.patch.yml 叠在 web profile 之上的 overlay(两条 delta)
src/index.ts 播报回环端口、注册 desktop-surface 提示段
src/announce.ts 播报行的格式:写端与读端唯一的一份定义
src/approval.ts 订阅审批审计流,把待办的审批播报给外壳
apps/shell/ dsh-desktop-shell —— Electron 主进程
src/main.ts 起 harness 子进程、拿到 URL 后开窗
src/harness.ts spawn 子进程、从 stdout 读 URL 与审批播报、SIGTERM 收尾
src/node-runtime.ts 产品自带的那份 node 二进制在哪(harness 和装插件都跑在它上面)
src/profile.ts 启动哪个 profile + 让 overlay 包可被解析
src/pnpm.ts 产品自带的包管理器:pnpm/node 命令 + 注入用的 PATH
src/print-package-manager-path.ts 把那个 PATH 打到 stdout,给 bin/dsh 读
src/userdata.ts 浏览器状态封顶与超预算修剪
src/notify.ts 待办审批的系统通知:失焦才弹,回焦即撤
src/window.ts BrowserWindow + 关闭时持久化 bounds
src/window-state.ts window-state.json 读写(位置 + 尺寸)
src/boot-theme.ts 从 settings.yaml 读外观偏好,定开屏配色(只读)
src/boot-overlay.ts 开屏遮罩:独立 renderer 的建、掀、销毁
src/boot-loader.ts 遮罩上那段动画:鲸鱼跃出水面的 CSS 与标记
src/boot-whale.ts 从图标描摹出的鲸鱼矢量(生成物,勿手改)
src/preload.cts 页面侧的观察者:主题定色了就挂一个属性
scripts/ 打包
build-desktop.ts 流水线(构建 → 部署闭包 → 查 engines → 打包 → 校验 → 冒烟)
desktop-closure.ts 产品软链的悬空检查
node-engines.ts 闭包声明的 engines.node 对不对得上产品自带的那份 node
product-smoke.ts 用产品自己的二进制跑冒烟
trace-whale.ts 重新描摹 boot-whale.ts,只在图标换了时手动跑
harness 跑在子进程里
外壳不在自己进程里托管 harness:它 spawn 产品自带的那份 node 二进制(带 --expose-internals)跑 @deepseek-ai/dsh 的 bin.js,等它播报回环 URL,再把这个 URL 装进窗口。
三个事实逼出这个拆分,harness 侧没有责任:
- Electron 的 Linux 二进制跑不了图像管线,见下一节。这条同时决定了子进程用哪个二进制。
- cordis 的 loader 用 Node 内部 ESM loader 解析裸插件名。Electron 主进程不暴露它,替代方案那个 addon 又是按 Node ABI 编译的,于是每一个裸插件名都解析失败。
- 发布版
@deepseek-ai/dsh没有exports字段,包外够不到profile-boot,但够得到bin入口。
端口的传递因此也跨了进程:desktop-runtime 在 webServer 就绪后往 stdout 打一行 dsh desktop surface: http://127.0.0.1:<port>/,外壳在自己持有的管道上读到它。格式两端共用 packages/desktop-app/src/announce.ts 一份定义;stdout 管道顺带白送了时序,写端口文件则要轮询。
窗口关闭时外壳先 SIGTERM 子进程再 app.quit():harness 手里有子进程、文件监听和打开着的 session 日志,Electron 不管它没生过的进程。
产品带两份运行时:Electron 只留给窗口
产品闭包里除了 Electron,还有一份真正的 node 二进制(node-linux-x64,作为 apps/shell 的 optionalDependencies 装进来,位置由 apps/shell/src/node-runtime.ts 解析)。harness 子进程、bin/dsh、产品自带的 pnpm 和 lifecycle script 用的 node,全部跑在它上面;Electron 只负责开窗。
代价是产品体积多出 121MiB(node-linux-x64 全部就是那一个二进制,头文件和 man page 在打包时被 IGNORE_PATTERNS 排掉),产品目录从约 617MiB 变成 738MiB。买到的是图像管线:Electron 的 Linux 二进制动态链接系统全局 glib 并把符号泄漏进进程空间,与 sharp 自带 libvips 的 glib 冲突,一跑图像管线(连 metadata() 都算)就 SIGSEGV。sharp 官方文档对此不提供任何修复,只指向 electron#46323。这个冲突发生在本仓库任何代码跑起来之前,harness 侧和 sharp 侧都没有可配置的出口——唯一的解法就是换一个进程里只有一份 glib 的运行时。调用方是 @deepseek-ai/dsh-attachment-local(由 dsh-base 挂载),它在模块顶层 import sharp,只在图片路径上调用。
原生模块是按加载它的运行时编译的,所以装插件那条路径必须跟着搬过来:产品自带的 pnpm 跑在同一个二进制上,node-gyp 与 prebuild-install 就从它读出目标,命令里因此一个 ABI 变量都不写。已经装过的原生插件如果是旧版本按 Electron ABI 编出来的,升级后要重装一次。
sharp 在这份 runtime 下能解码是打包冒烟的断言之一,见打包。闭包里其余三个原生模块(node-pty、koffi、landlock addon)在两种运行时下都验过。
传输方式
窗口通过回环 HTTP 加载界面,和浏览器端完全同一条路:webserver 行就是 web profile 里那个 @deepseek-ai/dsh-host-webserver,绑在 127.0.0.1 上,127.0.0.1 保证局域网无法触达。外壳在 spawn 之前先探一次 3080:空闲就要它,被占用就传 0 让操作系统分配。首选固定端口是为了 origin 稳定——渲染进程的 origin 含端口,而每个 client 插件半边都按 origin 存 localStorage,端口每次变就等于每次启动丢掉「当前会话」和侧边栏布局;回落分支保证同机跑着 dsh --profile web 时窗口照样能起。
窗口的浏览器状态(缓存和各 client 插件的 localStorage)留在 Electron 默认的 userData 目录,即 ~/.config/dsh-desktop-shell/,随固定端口一起让「当前会话」和侧边栏布局跨启动保留。会话、设置、插件本身不在这里,它们在 DSH_HOME(默认 ~/.dsh),由子进程写。
这个目录有上限,由 apps/shell/src/userdata.ts 分两步守住:启动前告诉 Chromium HTTP 缓存最多 64MiB,再在拿到单实例锁之后量一次派生缓存的总量,超过 128MiB 就整批删掉重建。两步缺一不可——开关管得住 Cache/,管不住 V8 的 Code Cache/(每次重建 bundle 都写一批新条目)和崩溃转储。哪些目录算「派生」由 DERIVED_ENTRIES 列举,往里加一项等于决定丢掉它存的东西;Local Storage、Cookies、Preferences 不在其中。修剪只发生在持锁的那个实例里:抢锁失败的实例和赢家共用这个目录,而赢家正在打开里面的文件。
这是刻意放弃 Electron 自定义协议(零端口 carrier)的结果:carrier 提供的 req/res 不是真正的 node:http 对象,第三方插件只要碰 req.socket、res.setHeader、WebSocket upgrade 中的任何一个就会在桌面端失效,而在 web 端正常。
审批通知
session 里出现需要用户拍板的审批时,桌面端弹一条系统通知并让任务栏条目闪烁。这是外壳对窗口做的事,和记住窗口大小、画任务栏图标同类——不是交给页面的能力,浏览器端因此没有这一条。
信号和端口走同一根管子:packages/desktop-app/src/approval.ts 订阅 session/event,把 approval/asked 和 approval/decided 各播报成一行 dsh desktop approval: {…},外壳在自己持有的 stdout 管道上读到它,交给 apps/shell/src/notify.ts。行格式两端共用 packages/desktop-app/src/announce.ts 一份定义,和 surface 播报住在一起。reason 是提问方写的自由文本,播报前截到 200 字符:它会变成一行 stdout,而通知正文本来也显示不了那么多。
订阅的是审计流,不是审批链。 approval/request 是决定谁来回答的 waterfall,挂在上面的监听器离「替用户做决定」只差一个写错的返回值;session/event 是提交之后的 fire-and-forget 播报,影响不了任何结果,播错了最多打错一行。
只在窗口失焦时通知。 用户正看着的问题已经在屏幕上,为它再弹一条只是噪音。通知在两种情况下撤回:decided 到达(问题已经在窗口里回答完,留着的通知点开也没东西可看),以及窗口重新获得焦点(连带清掉任务栏的闪烁标志,那个标志是粘的)。
限制:没有桌面通知服务的 Linux 环境退化成只闪任务栏(Notification.isSupported() 为假);产品目前只打 Linux 包,Windows 上还需要设 AppUserModelID 才能正确显示。
开屏遮罩
窗口从打开到界面渲染出来,盖着一层不透明的遮罩,上面是那只跃出水面的鲸鱼。
配色跟着 harness 的外观设置走,读的是磁盘。 apps/shell/src/boot-theme.ts 从 $DSH_HOME/settings.yaml 里取 ui-theme.preference,BrowserWindow 的 backgroundColor 和遮罩文档的背景都用这一个值,所以浅色主题的用户不会先被一块深色糊住。文件没有、键没有、解析不了、写着 system,四种情况一律回落到 nativeTheme.shouldUseDarkColors。外壳只读不写,任何情况下都不创建 settings.yaml:那是设置插件的文件,从没打开过设置的用户本来就没有它,外壳替它建一个等于替 harness 播种状态。读页面而不是读磁盘是做不到的——遮罩要在被盖的页面存在之前就画出来。
遮罩是自己的 renderer,不是页面里的元素。 前端启动时把自己那个渲染进程占满(长任务、commit、首次 layout、图片解码),住在同一个进程里的动画就跟着一起掉帧,而那正好是遮罩在场的那两秒。apps/shell/src/boot-overlay.ts 因此把它做成一个 WebContentsView 盖在窗口上:自己的主线程、自己的出帧,前端怎么忙都不影响它。文档是主进程拼出来的 data: URL,不走文件也不走网络,所以它比被盖的页面先画出来;里面 javascript: false,动画全是 CSS。窗口也因此改在遮罩画好时就显示,不再等前端的第一帧——早约一秒,而且早出来的那一秒有东西可看。
被盖住的页面必须 backgroundThrottling: false。 一个铺满窗口的子 view 会让底下那个 webContents 被判定为不可见:实测 document.visibilityState 变成 hidden、requestAnimationFrame 一帧不发、setTimeout 掉到 1Hz——前端的启动会被这层遮罩拖慢,掀开的信号也永远发不出来。关掉节流后同一次启动里页面全程 visible、rAF 满帧。
掀开的判据是主题定下颜色,不是应用挂载完:挂载点拿到第一个子节点比主题样式表早几百毫秒,按 DOM 信号掀正好赶在闪白之前掀开。前端的 body 背景读 --dsw-alias-bg-base,而 @deepseek-ai/dsh-client-ui-theme 的 client 半边分两步定义这个变量:先随样式表给出浅色值,再往 body 上加 data-ds-dark-theme 属性选中深色值。两步之间文档是货真价实的浅色主题,掀在这中间就是那道白闪。判据因此读计算样式——变量已定义,且背景色连续稳定 SETTLED_MS——它不依赖前端的结构,也覆盖浅色主题用户(那个属性对他们永远不出现)。SETTLED_MS 是余量不是推导值:主题两步之间实测约 200ms,取 400ms 留给更慢的机器,多等的代价只是那只鲸鱼多跳一轮。超时兜底会无条件掀开:前端起不来是该看见的 bug,一块永不消失的色块看起来只是程序卡死。掀开就销毁那个 renderer,遮罩没有第二次登场。
信号是页面推、主进程拉,中间只有一个属性。 计算样式只有页面自己算得出,而按帧跨进程去问代价太大,所以 preload 在页面里按帧盯着自己的背景,定色后往 documentElement 上挂一次 data-dsh-boot-settled,主进程每 100ms 用 executeJavaScript 读一下这个属性。
preload 只观察、只公布,不搭桥。 它不 expose 任何 contextBridge,也不碰 ipcRenderer:页面通往 harness 的路仍然只有回环 HTTP,和浏览器标签页一样。从这里交出去的任何能力都是浏览器端没有的,而两条 surface 是同一个应用;一个页面自己也能写的属性不是能力,最坏也只是把遮罩早掀一会儿。sandbox 下 Electron 只加载 CommonJS preload,所以它是 .cts 而不是 .ts,window.ts 按构建产物路径 lib/preload.cjs 指向打包出来的那一份。它跑在渲染进程里,要 DOM 类型而外壳其余部分不要,所以单独有一份 apps/shell/tsconfig.preload.json,由 apps/shell/tsconfig.json 的 references 带着一起构建。
preload 交付的是一个文件。 sandbox 里的 require 是 Electron 自己那份,只认 electron 和少数内建模块,碰到相对路径抛 module not found,而 preload 抛错只写渲染进程控制台、页面照常加载——症状是遮罩一直挂到超时才掀。所以 apps/shell 的 build 在 tsc 之后跑一次 esbuild,把 src/preload.cts 连同它 import 的模块打成 lib/preload.cjs,tsc 那一侧只出声明(emitDeclarationOnly),源码怎么拆都不影响交付。
遮罩上的动画
apps/shell/src/boot-loader.ts 把动画产出成一段规则和一段标记,boot-overlay 拼成遮罩的文档:一条水平线横在正中,鲸鱼从线下跃出、带起水滴,在最高点略停后落回水面,溅起水花和涟漪,循环到遮罩掀开为止。鲸鱼和水滴装在一个下沿正好压在水平线上的裁切框里,「破水而出」和水花的收场因此都不用单独画。水和水花的颜色跟着遮罩配色走,鲸鱼保持图标自己的蓝。prefers-reduced-motion 下整套动画停住,鲸鱼半出水面停在那里——本来也没有进度可报。
鲸鱼是 apps/shell/src/boot-whale.ts 里的常量,由 scripts/trace-whale.ts 从 apps/shell/assets/icon.png 的 alpha 通道描摹:外圈是蓝色的身体,内圈(肚子、眼斑、瞳孔)填浅色再用 evenodd 打回去,得到图标本身的读法。尾巴要能单独摆动,所以脚本先清掉它与身体唯一相连的那一小段像素、flood fill 分出来,再往身体方向生长几个像素让关节在摆动中不裂开。描摹只在图标换了的时候手动跑一次 tsx scripts/trace-whale.ts,取舍写在脚本里。
首次搭建
git clone <this repo>
pnpm install
pnpm run build
需要 DEEPSEEK_API_KEY,从 ~/.dsh 的配置或环境读取。
日常开发
pnpm run build # 构建 packages/ 与 apps/
pnpm run dev # 启动 Electron
升级 harness
改 apps/shell/package.json 和 packages/desktop-app/package.json 里的 @deepseek-ai/dsh* 版本号,同步替换 pnpm-workspace.yaml 里 minimumReleaseAgeExclude 的整份清单,然后 pnpm install && pnpm run build && pnpm run package。版本必须写死:rc 版本靠精确 pin 才能和那份按 包名@版本 逐条列出的豁免清单对上,漏掉的条目会被 pnpm 的最小发布时长门禁挡在解析之外。清单不必手抄全,pnpm install 会把新增的包补进去。
外壳的依赖清单只有三条,因为插件闭包不是靠它逐条列出来的:pnpm deploy --config.node-linker=hoisted 把 @deepseek-ai/dsh 的整个传递依赖平铺进 resources/app/node_modules(198 个 @deepseek-ai/* 包),harness 按包名解析插件时看到的就是这一层。三条各有各的理由——@deepseek-ai/dsh 是这个闭包的根,harness.ts 还要按路径 resolve 它的 bin.js;@deepseek-ai/dsh-app-boot 被 profile.ts 和 pnpm.ts 直接 import 取 resolveProfileDir;pnpm 是产品自带的包管理器。判断一个包该不该加进来,标准是外壳自己够得着它,不是 profile 组合里用到它。
「插件行按包名解析,闭包里没有这个名字就整行静默消失、服务器照样 200」这条失效模式仍然成立,只是守住它的是 hoisted 布局和 product-smoke,不是这份清单——Service Definition 那类包(dsh-fs、dsh-workflow、dsh-shell 等)从来不是插件行,闭包里几十上百个包各自声明着它们,在外壳这里再写一遍不改变任何解析结果。
产品自带那份 node 什么时候要跟着升
它写死在 apps/shell/package.json 的 optionalDependencies 里(node-linux-x64,版本号与 node 自己的发行版一一对应),所以升级 harness 时它不会跟着动——而上游哪天开始要求更新的 node,症状是产品在用户机器上跑不起来,构建机上一切正常:构建机跑的是开发者自己的 node,产品跑的是这一行。
scripts/node-engines.ts 就是补这个洞的:pnpm deploy 之后、@electron/packager 之前,把闭包里每个包声明的 engines.node 拿出来,对着 node-linux-x64 的版本 semver 校验一遍,有一个对不上就中止打包并列出是谁。这是打包链路上唯一同时握着「闭包要什么」和「产品带什么」两个事实的地方;根 manifest 的 engines、pnpm 自己的预检、脚本跑在哪个版本上,说的全是构建机。修法只有一个,报错里也就直说:把那一行的版本号抬到满足所有范围的版本。
semver 解析不了的范围只报不拦——那是别人已发布 manifest 里的笔误,既不该由这次构建来失败,也不值得瞒着。门禁不替代冒烟:冒烟在打包出来的二进制上真起一次 harness,抓的是「装不上/加载不了」,门禁抓的是「跑得起来但已经跑在声明支持范围之外」。
profile:和 web 端共用一份
桌面端启动的是 ~/.dsh/profiles/web/——dsh --profile web 用的同一个目录,同一份 cordis.patch.yml,同一份 node_modules。装插件只有一条命令,也只有一个地方可查:
dsh plugin --profile web add @linxin666/dsh-pet@latest
装完重启桌面应用即可。外壳不创建这个 profile:harness 自带 web 模板,首次使用时自己初始化,外壳再抄一份只会和上游漂移。
桌面与浏览器的差异不走 profile,而走一层 --patch overlay(packages/desktop-app/cordis.patch.yml),外壳启动子进程时把它的绝对路径传进去。overlay 有两条 delta:
| delta | 为什么 |
|---|---|
web-runtime 的 openBrowser/printUrl/surfaceContext/trustedHosts 改成字面量 | 不开浏览器标签页、不打第二条人类可读的 URL 行、提示段换成桌面版、回环绑定没有 LAN 授权可言 |
插入 desktop-runtime | 播报机器可读的端口行、注册桌面 surface 提示段 |
overlay 应用在所有 bundle 层和用户层之后,所以这两条一定赢;而命令行从不加载它,所以共用 profile 不会把桌面行为推给终端。
端口不在 overlay 里,走 --port flag。patch 替换的是目标行整个 config,所以一条 webserver delta 必须连端口一起写死,而端口是外壳每次启动才定的(3080 空闲就要它,被占用就传 0)。host 同理留给 flag 派生,其默认值就是回环。
web-runtime 只能改配置,不能 disabled: true。 它提供 webRuntime,connection 行 inject 它——禁掉它会连带把浏览器传输一起弄死,而服务器照样 200,页面照样出来,只是什么都点不动。
用户自己的覆盖层仍然写在 ~/.dsh/profiles/web/cordis.patch.yml,热重载生效,桌面和终端都读得到。想让模型知道 harness 源码在哪(自我修改场景,需要本地另有一份 harness checkout),这条得写进 overlay 而不是用户层,因为 desktop-runtime 行只存在于 overlay 里:
- id: desktop-runtime
config:
surfaceContext: true
harnessSourceRoot: /absolute/path/to/deepseek-harness
本仓库根目录下的 deepseek-harness/ 就是这样一份本地 checkout:它被 .gitignore 忽略,构建和打包都不读它,删掉不影响任何流程。
外壳唯一往 ~/.dsh 写的东西是一条软链:profiles/node_modules/dsh-desktop-app 指向安装位置。overlay 里按包名引用这个插件,而 harness 是从自己的依赖闭包解析插件名的,闭包里不可能有依赖它的外壳。profiles/node_modules 是 harness 为这种情况留的扁平回退目录,pnpm 不管它,所以 profile 里跑 dsh plugin add 不会把这条链剪掉。
装插件:包管理器由产品自带
dsh plugin 是一层 pnpm 转发器,它按名字从子进程 PATH 上 spawn pnpm。开发机上找得到,用户机上找不到,而唯一的症状是有人点安装插件时冒出 pnpm not found on PATH——读起来像是用户机器的问题。
所以产品自带 pnpm。要用它的进程起来之前,先往 $DSH_HOME/desktop-bin/ 写两个文件,再把 desktop-bin/ 本身前置进那个进程的 PATH:
| 文件 | 作用 |
|---|---|
pnpm | 用产品自带的那份 node 跑闭包里那份 pnpm,机器上不需要有 node |
node-shim/node | 安装期 lifecycle script 从 PATH 找 node。没有它,任何带 postinstall 的包都装不上;机器上另有一个 node 的话,原生模块会按那一个来编译 |
两个命令指的都是同一个二进制,这就是原生模块能被加载的全部原因:addon 按加载它的运行时编译,而加载插件 addon 的正是 harness 子进程那个运行时。命令里没有任何一个变量在声明 ABI——pnpm 跑在 harness 跑的那个二进制上,node-gyp 与 prebuild-install 本来就从这里读目标。
这买不到编译工具链:某个包没发预编译产物的话,用户机器上仍然得有 python 和 C++ 编译器。
这条 PATH 走得比装插件远
harness 把自己的环境交给它替 agent 跑的每一个进程(dsh-subprocess 只滤掉匹配 KEY|PASSWORD|SECRET|TOKEN 和 DSH_ 前缀的键,PATH 原样透传)。所以 desktop-bin/ 上有什么,模型 bash 工具里就解析到什么——而一个在用户仓库里干活的 agent 必须看见用户自己的工具链。node 因此是收窄过的:
node写在node-shim/子目录里,这个目录不在交出去的 PATH 上。pnpm命令把它前置进自己的 PATH,所以 lifecycle script(pnpm 的子进程)照样找得到;agent shell 里的node还是用户那个,用户没装 node 就是not found——和没有这个产品时一样。
pnpm 本身躲不掉:dsh plugin 按名字找它,它就必须可见。收窄之后,交出去的 PATH 上只多了一个版本固定的普通 pnpm。
几条边界
- 命令写在 harness home 下,不在产品目录里。
harness.ts不能 importelectron(打包探针在纯 node 下加载它),所以这里够不着app.getPath('userData');何况只读安装的产品也没地方写。harness home 按定义可写,harness 自己就往里写 profile。 - 每次启动重新生成。命令里写死了本次安装那份 node 二进制的绝对路径,产品升级或换位置之后旧的就指向一个不存在的二进制。
- 前置而不是追加。机器上真有 pnpm 也走产品这份:版本,加上它跑在装出来的 addon 将被谁加载的那个二进制上,才是被测过的组合。
- 两条启动路径都注入:GUI 起的 harness 子进程,和
bin/dsh plugin。它们装进同一个 profile,ABI 装错了谁都加载不了。bin/dsh的其余子命令不注入,也就不用付那次多出来的进程启动。用户自己 shell 的 PATH 两条路径都不动。 - pnpm 默认拦 build script。带原生模块的插件装完会提示把它加进 profile 里
pnpm-workspace.yaml的allowBuilds,照提示加完重跑即可。这是 pnpm 的策略,不是桌面端加的门禁。
打包
pnpm run package # 完整构建 + 打包
pnpm run package -- --skip-build # 复用已有构建产物
pnpm run package -- --dry-run # 只打印将要执行的命令与文件改动
产出 dist/desktop/dsh-linux-x64/,VSCode 那种解压即用的目录(asar: false,全是明文文件):
dsh 重命名后的 Electron 二进制 —— 双击开 GUI
bin/dsh CLI 包装脚本 —— 跑在闭包里那份 node 上,不是这个二进制
resources/app/ 应用本体:外壳自己的生产闭包
package.json 外壳的 manifest,main 指向 lib/main.js
lib/main.js Electron 主进程
lib/print-package-manager-path.js bin/dsh plugin 读的那行 PATH
node_modules/ @deepseek-ai/dsh、桌面 overlay 包、整个插件闭包,以及
node-linux-x64/bin/node —— harness 跑的那份运行时
locales/、*.pak Electron 运行时
resources/app 就是 apps/shell 的 pnpm deploy 闭包。@deepseek-ai/dsh 的传递依赖在这里被平铺成一层,harness 子进程按包名解析插件时读的就是它,profile 目录里不需要另装一份。
两个不能改的细节:
--config.node-linker=hoisted是承重的,不是体积优化。默认的 isolated 布局只把直接依赖放进闭包的node_modules,传递依赖全在 store 里的隐藏目录,只有 store 内部的包能解析到。harness 按包名从自己安装位置的 manifest 解析插件,而它绝大多数插件包是传递依赖——默认布局下产品会用它能看见的那几个组出一个 profile,剩下的静默丢弃。--legacy必须带:新版 deploy 拒绝没有开inject-workspace-packages的 workspace,而开了它之后开发期每次重建 bundle 都要重装一次才生效。
闭包部署完、packager 开跑之前夹着一道 engines 门禁,理由见产品自带那份 node 什么时候要跟着升。
打包最后一步 product-smoke 用产品自己的二进制启动探针,探针再按外壳的同一条路 spawn harness 子进程,抓首页 HTML,断言 @deepseek-ai/dsh-web-app(web profile 自己的 bundle)patch 层声明的每个 client 插件都出现在 __DSH_BOOT__ 里。这一步不能省:插件解析失败时服务器照样返回 200,页面照样有 <div id="root">,只是白屏。绑定端口不参与断言——3080 和 OS 分配的端口都是正确结果,取到哪个取决于当时还有什么在跑。
同一个探针还查包管理器和图像管线,理由和查插件名册一样:三者都是产品能一边丢掉一边照常启动的能力。它按绝对路径跑产品自带的 pnpm --version 和 node-shim/node,断言前者报出闭包里声明的版本、后者是闭包里声明的那个 node 版本且不是 Electron 二进制;走绝对路径是为了不让构建机自己 PATH 上的 pnpm 替产品答题。图像那条要真解码:sharp 在跑不了的运行时上照样 require 得进来、照样报得出 versions,只有解码能分开两者,而失败是 SIGSEGV 不是异常,所以探针拿产品那份 node 去解一张内联的 1×1 PNG 并重新编码。
「不该够到的」和「该够到的」一起断言:探针先在 desktop-bin/ 里种一个 node(旧版本就是写在那里的),重跑一次生成再断言它没了——干净环境跑不出升级路径,而所有已经用过产品的人走的都是升级路径;bin/dsh 读的那个 PATH 入口必须只吐出以 desktop-bin 打头的一行;profile 里 pnpm exec node 拿到的必须是产品自己那份 node,否则装出来的 addon 归错运行时。这一条断言的是命令跑起来的行为而不是它的文本——一个前置错目录的 PATH 行和一个前置对了的,看源码是一样的。