# 打包与发版 > 便携版 = `dsh 本体(electron-builder 产物)` + `我们的桌面补丁` + `profile overlay`。 > 上游每次升级桌面壳都会让补丁失配,所以**这不是一条能无脑重跑的链**。 --- ## 1. 四个前置(缺一必挂) 1. **harness 侧 `pnpm install` + `pnpm build`**(打包脚本硬要 `lib/` 存在)。 2. **`git apply docs/harness-desktop-build.patch`**(当前 6 个目标 / 14 个 hunk)。 3. **`node scripts/patch-electron-builder.mjs `** —— 给 electron-builder 的 `extractArchive` 补 rename 重试,否则 `EPERM: rename '...win-unpacked.tmp' -> '...win-unpacked'`。 4. **`/apps/desktop/.env.windows` 必须存在**。这是 alpha.2 起的硬要求: 缺失直接抛错、一步都走不动;而且**环境变量里的 release 设置会被全部过滤掉**(`AMBIENT_RELEASE_SETTING`), `DSH_DESKTOP_APP_ID` **只能写进这个文件**,写 env 是死代码。CI 里已加专门一步生成它。 > `--unsigned` 只跳过签名与自动更新的凭据,`resolveDesktopPolicyEnvironment()` **照样要过**。 > 另外 `DSH_DESKTOP_AUTO_UPDATE_ENV` 的合法取值是 `test` / `production` —— **不是 `prod`**(示例文件的注释容易带偏)。 **补丁为什么总失配**:上游一次改桌面壳就是几百到 900 行。每个 hunk 的存在理由记在这里,下次失配按表定位, 别再从头读一遍 900 行: | 文件 | 改动 | 为什么需要 | |---|---|---| | `electron-builder-config.mjs` | `files` 里删两条 `{from: buildPaths.dsh, …}` | dsh 运行时进了 app 目录会被 electron-builder 当生产依赖剪裁(实测 239 个 `package.json` 字节被改小),`desktop-runtime.json` 的 files 清单随之对不上 → 完整性校验必挂 | | 同上 | `extraResources` 里加回同样两条 | 让运行时落在 `resources\dsh` **真目录**。与 `main.ts` 里 `resourcesPath` 那条**成对,改一条必须改两条** | | `prepare-dsh.ts` | `install --prod` 去掉 `--frozen-lockfile` / `--trust-lockfile` | 带这两个标志 pnpm 卡在「added 241/507」且 download 恒为 0(与 registry 无关,换源也不动),去掉后 21.5s 装完 | | `main.ts` | 顶层 4 条 env 注入(`DSH_APP_EXECUTABLE` / `DSH_BROWSER_PROVIDER` / `DSH_PTC_NODE` / 便携 `DSH_HOME`) | **全部要在 `resolveDesktopPaths()` 之前**:桌面 profile 由应用独占并每次重建,默认值写不进 profile,只能由 shell 在最早时刻落进环境 | | 同上 | `runtimeResources()` 打包态 → `join(process.resourcesPath, 'dsh')` | 与新 `extraResources` 配套(上游改成了 `join(app.getAppPath(),'dsh')`,那是 app.asar 内) | | 同上 | 尾段把 `claimDesktopSingleInstance` 包进 `DSH_BROWSER_ELECTRON_HOST` 分支 | 窗口宿主模式要拿主 exe 起第二个实例;不先接管,第二个实例会先抢单实例锁然后直接退出。**顺序约束就在这一处,重打时必须重新核** | | 同上 | `DSH_PTC_NODE` 指向包内真 node | 见 §6「包内 node 路径」 | | 同上 | `protocol.handle` 补一个 **`shell` host 分支**(服务 `app.getAppPath()/renderer`) | 上游只实现了 `app` host,`dsh-app://shell/*` 全部 404 → 更新确认页 / 策略登录页 / 强制更新页加载出**空文档**,却照样是「与主窗口同尺寸 + 抢焦点」的透明窗口,**吃光所有鼠标点击**(2026-09-19 真机事故,完整复盘见 `开发指南.md` §6)。**不能改用 `loadFile`**:preload 按 `location.href` 字面放行桥接、`update-dialog.ts` 的 `owned()` 也比同一个字面 | | `paths.ts` | 追加 `resolvePortableDshHome(executablePath)` | 便携版布局是 `\app\` + `\home`。有它「双击 exe」与「走 `启动.cmd`」才等价 | | `main-startup.spec.ts` | mock 补 `resolvePortableDshHome: () => undefined` | 上游的启动单测 mock 了 `./paths.ts`,新导出没补进去 → 单测直接挂 | | `sandbox-local/index.ts` | 新增 `runnerExecutable()`,`windowsAclRunnerInvocation()` 改用它 | 见 §6 | > **别在本地 harness 上直接 `git apply` 我们的补丁**:那 6 个文件的改动通常**已经在工作树里**(未提交)。 > 直接 apply 会报 `patch does not apply` —— 那不代表补丁坏,代表已经打过。 > 要证明补丁能在干净上游上应用,开一个干净工作树: > ```bash > cd D:/dev/cli/deepseek-harness && git worktree add ../dsh-clean-check > cd ../dsh-clean-check && git apply --check ../../cli/dsh-webops-plugin/docs/harness-desktop-build.patch # 期望 exit 0 > ``` > 反向核对(证明工作树 == 补丁):`git apply --check --reverse ` exit 0。 **两个 workflow 的 `HARNESS_REF` 必须跟补丁同源**:补丁的上下文行取自某个具体提交,pin 旧 ref 会直接 apply 失败。 bump ref 会让本体缓存 key 失效、CI 全量重建一次 —— 这是预期代价。 ## 2. 命令链 ```bash # 桌面端本体(在 harness 里) cd D:/dev/cli/deepseek-harness/apps/desktop export CODEBUDDY_SAFE_DELETE_ENABLED=0 # ⚠ 必须!见下面两条 export TAR_OPTIONS=--force-local # ⚠ 本机必须!见下面两条 pnpm run prepare:runtime && pnpm run prepare:packages && pnpm run prepare:dsh pnpm run package:win:x64:dir --unsigned # → win-unpacked(便携形态,不是 NSIS 安装器) # 打成便携 zip(在本仓库里) cd D:/dev/cli/dsh-webops-plugin CODEBUDDY_SAFE_DELETE_ENABLED=0 TAR_OPTIONS=--force-local DSH_DESKTOP_APP_ID=com.sdegongzuo.dshwebops \ node scripts/package-desktop-portable.mjs --app --version x.y.z # 构建完顺便把本体入库(下次就能省掉 --app) node scripts/package-desktop-portable.mjs --app --cache-base # 只想入库、不打 zip(CI 用) node scripts/package-desktop-portable.mjs --app --cache-base --cache-move --cache-only # 只问「缓存里有没有可用的本体」(印 CACHE_APP_DIR=<路径>,CI 用;没有就印诊断并 exit 0) node scripts/package-desktop-portable.mjs --list-cached ``` > ⚠️ **harness 侧那几步也要关垫片**(2026-09-19 实测踩到)。不关的话每一步都会以 > `[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED]` 挂掉,而且**看起来像各不相干的构建失败**: > `prepare:runtime` 删 `.desktop-build/targets/win-x64/electron`(77 条)、 > `prepare:dsh` 删同级的 `dsh`(10007 条)、`package:win:x64:dir` 内部要跑的 `build:official` > → `build:web` 删 `apps/web/dist/assets` 也被拦。**四个错一个都不是真错**,只有这一条根因。 > (CI 里没这个问题:那边没有这个垫片。) > ⚠️ **本机还得 `TAR_OPTIONS=--force-local`**(2026-09-19 实测踩到,**另一个同样不像真错的错**)。 > `prepare:packages` 用 `tar -xOzf <绝对路径> package/package.json` 读包内 manifest,而 MSYS 的 > **GNU tar 把 `D:` 当远程主机名**:`tar (child): Cannot connect to D: resolve failed`,exit 2。 > 报错点离真因很远(栈里只有 `packedManifest → capture`)。 > 判据:手敲 `tar -xOzf "D:/…/<某>.tgz" package/package.json` 会复现、加 `--force-local` 即通。 > 这条是**本机环境**问题(drive letter),CI 的 Linux tar 没有 —— 但本机不设就永远出不了包。 - 版本号来自 `--version`(缺省取 `package.json` 的 `version`)。 - `CODEBUDDY_SAFE_DELETE_ENABLED=0` 绕开 safe-delete shim。两个理由:① 它拦大目录删除 (`SAFE_DELETE_BULK_CONFIRM_REQUIRED`);② 它让**每个** `unlink` 慢到 450ms(实测,见 §2 末)。 - 下载 electron 之类的大件要走代理:`ELECTRON_GET_USE_PROXY=true HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=...` (本机有 7890 代理在监听,但**没有**设成环境变量)。 - **解压一律用 `python scripts/unzip-portable.py --zip … --dir …`**(绑了 `testzip()` + 前 32 字节 NUL 自检),见 §7。 ### 本体缓存:按 dsh 版本分槽 dsh 本体(`app/`,解压 1.2G)不必每次重编。缓存落在 **`.desktop-base//app`**, 一个版本一个槽;不带 `--app` 跑就自动挑版本最高的可用槽。CI 用 `actions/cache` 整个 `.desktop-base` (key 含 harness ref + patch hash),命中就跳过 electron-builder。 槽位是不是「可用」要同时满足四条,缺一条就报出来而不是拿去用: 1. 有 `<槽>/cache.json` **完成标记**(拷贝中断会留下一个「文件看着都在、其实少了几个」的目录, 而 `assertLooksLikeApp` 只看顶层那几项,拦不住这种); 2. 有主 exe + `resources/{app.asar,dsh,runtime}`; 3. `desktop-runtime.json` 读得出; 4. **槽位名 == 里面的 dsh 版本**。 > **为什么不「拷到 `.new` 再改名」**(2026-09-18 改掉):**IDE**(详见下面那条更正:不是杀软) > 会去解析工作区里新出现的 `resources\app.asar`,句柄只挡改名和删除,共享读与原地位写都通畅。 > 而 Windows **不允许改名一个内含 > 无 `FILE_SHARE_DELETE` 句柄文件的目录**,于是 `.new → app` 这一步恒 `EPERM`,而且连 > `.desktop-base` 本身都改不了名。现在入库全程不需要任何改名/删除: > `mkdir` → `cpSync` → 校验 → 写标记。代价是「同版本重装」必须先手工删掉那个槽 > (槽里的 `app.asar` 删不掉时也就删不掉整槽),但这个代价基本不出现:dsh 一升级就是新版本、自然落新槽。 > > **为什么锁的不是杀毒软件,是 IDE**(2026-09-19 实测纠正)。用 Windows Restart Manager > (`rstrtmgr.dll` 的 `RmRegisterResources` + `RmGetList`)点名持有者,拿到的是 > **`WorkBuddy.exe`(PID 22952)** 和 **`Qoder CN`(PID 19880)** —— 五个杀软进程 > (火绒 `HipsDaemon`、`qaxdefender`、`MBAAntiVirus`、`MsMpEng`、`trantorAgent`)**一个都没出现**。 > 对照实验(6 组文件 × 180 秒逐秒探测 + 全量扫描)给出的触发条件是: > **只有「工作区内」且「内容真能解析成 asar 归档」的 `.asar` 会被锁**。 > 工作区外的同内容 `.asar`/`.zip`/`.bin` 和全零文件 180 秒全程未锁、Restart Manager 也报无持有者; > 工作区内 `probe.asar` 第 2 秒起 `EBUSY`,而同内容的 `probe.zip`/`probe.bin`/`probe.asar.new` > 和 **`zeros.asar`(.asar 后缀但内容是 2.7MB 全零、解析不成归档)全部未锁**; > 全量扫描 40 个 `.asar` 锁住 39 个,唯一没锁的就是那个 `zeros.asar`。 > 机制:VS Code 系 IDE 把 asar 当**可解析的归档格式**去打开解析,句柄**不带 `FILE_SHARE_DELETE` > 且不释放**(与 IDE 进程同生共死)—— 这也解释了「截断成 0 字节也不释放」。 > > **推论与对策**:① 想绕开最可靠的是**把产物放工作区外** —— 已落地为环境变量 > `DSH_DESKTOP_BUILD_ROOT`(2026-09-19 端到端实测通过,见下面「产物放工作区外」); > ② 次选是把 `.desktop-*` 加进两个 IDE 的 `files.watcherExclude`/`search.exclude`/索引排除, > 但**没验过**;③ 不要为此改「不打包 asar」(那是上游 harness 的构建配置,代价远大于收益)。 > 残留目录只能**瘦身**: > `node scripts/clean-build-residue.mjs --yes` 会收回每棵残留里除那些被锁文件以外的一切 > (GB → MB;2026-09-19 实测 22 棵瘦身后按字节合计只剩 58.0MB);要彻底清掉只能在**重启 IDE 后立刻**跑。 > 注意每棵残留里有**两个**被锁文件:`resources/app.asar` 和 `resources/default_app.asar`。 > 该脚本对 `.desktop-base` 是**拆子项**处理的,并用「有 `cache.json` 或 `app/` 下有主 exe」 > 双判据保护本体缓存槽 —— 别把 `.desktop-base` 整个当成清理目标,那会连 1.2G 缓存一起收走。 > > **配套坑:安全删除垫片会把批量删除拖成「假卡死」。** `CODEBUDDY_SAFE_DELETE_ENABLED=1` 时 > 每个 `unlink` 要 **450ms**(同批对照:新建 0.5ms、读取 0.1ms、删除 452ms;置 `0` 后 0.4ms, > **差 1130 倍**)—— 只有删除慢,说明是删除专属钩子,不是磁盘/杀软。删两万个文件要几小时, > 中途文件数看着不动,极易误判成脚本卡死。垫片在 **require 期**读 env,进程内改无效, > 所以上面那条命令**不用手加前缀**:脚本在 `--yes` 下会自己带 `CODEBUDDY_SAFE_DELETE_ENABLED=0` > 重跑一个进程。 **但 zip 每次都要重打**(压 1.2G)+ 上传。真变化通常只有 overlay `home/` ≈ 407KB (插件 374K + profile package.json + pnpm-workspace.yaml + settings.yaml)—— 所以下面 §9 的增量包才是日常更新的正路。 ### 产物放工作区外(`DSH_DESKTOP_BUILD_ROOT`) 把两个大件(`.desktop-base` / `.desktop-stage`)挪出工作区,IDE 的 asar 句柄就**根本不会出现** —— 这是上面那串锁问题的根治手段,**2026-09-19 端到端实测通过**: **本机写在 `.env.local`**(模板 `.env.local.example`;该文件已进 `.gitignore`,不进库 —— 见 `AGENTS.md`): ```bash # 本机 .env.local 里已经有 DSH_DESKTOP_BUILD_ROOT=D:/dsh-build,所以平时直接跑即可 node scripts/package-desktop-portable.mjs --version 0.2.6 node scripts/clean-build-residue.mjs --yes # 清外面的 # 临时换一个根就带前缀(命令行前缀优先级高于文件) DSH_DESKTOP_BUILD_ROOT=<工作区外的目录> node scripts/package-desktop-portable.mjs --version 0.2.6 ``` - **只认一个根**:`package-desktop-portable.mjs`、`clean-build-residue.mjs`、 `probe-tool-concurrency.mjs` 都经 `scripts/local-env.mjs` 的 `buildRoot()` 取同一个根 (`DSH_DESKTOP_BUILD_ROOT` 或 `.env.local`,缺省 = 仓库根)。 **缺省必须留在仓库根** —— CI(`release-desktop.yml`)的 `actions:cache` 就是按相对路径 `.desktop-base` 缓存的,改默认会让缓存永远不命中。 - **别两种根混用**:留在工作区里一半、放外面一半时,两个脚本各只扫自己那个根, 会互相看不见对方的残留。 - **实测对照**(同一份内容,只差位置):工作区内的 `.desktop-base/0.1.6-alpha.2/app/resources/app.asar` 被 `22952|WorkBuddy` 持着; 挪到 `D:/dsh-build/` 后,Restart Manager 报 **(无持有者) 0/2 锁**。 - **打包后自检也放外面**:`.rel-verify-ext` 这类解压目录同理,`verify-portable.mjs --dir` 指向它即可 (实测五道全过:完整性 12951 文件、applyRelease、便携内容、真起宿主 59 行 boot graph、bundle 17095 字节)。 - ⚠️ `removeTree()` 的坑:垫片对 **>1G 的目录**会走回收站助手,5 秒超时后抛一个 **没有 `.code` 的裸 `Error`**(看着像「空报错」)。所以 `STAGE` 的删除不用 `rmSync`, 而是 `removeTree()` 起一个带 `CODEBUDDY_SAFE_DELETE_ENABLED=0` 的子进程去删。 ### 本地测试的固定目录(别再每轮解压一份) 每轮真机测试都 `unzip` 一份新整包(解压后 1.2G)、测完就扔,`D:/dsh-build` 下就堆出 `dsh-0.2.6-test2`、`.rel-verify-ext` 一串目录。而 **dsh 版本不变时,真实变化的只有插件那 ~160KB**。 固定目录 + 增量覆盖就是解法(2026-09-19 实测跑通): ```bash node scripts/portable-dev.mjs status # 先看:目录在不在、两个版本号、启动过没有 node scripts/portable-dev.mjs env # 看这些路径是从哪来的(环境变量 / .env.local / 没配) node scripts/portable-dev.mjs seed # 首次铺底(不传 --zip 就挑 dist 里最新的整包) node scripts/portable-dev.mjs refresh # 日常主循环:build → 增量包 → 覆盖(实测约 10s) node scripts/portable-dev.mjs set-app --from # 重编桌面端后:只换 app/ node scripts/portable-dev.mjs verify # 三轮起宿主自检(只读,不动目录) node scripts/portable-dev.mjs launch --cdp 9333 # 起 GUI(WMI,脱离 job,见 §5) node scripts/portable-dev.mjs clean --yes # 删掉整个固定目录 ``` 目录来源优先级:`--dir` > `DSH_PORTABLE_TEST_DIR`(本机写在 `.env.local`)。**脚本里没有写死的 兜底路径** —— 没配就报缺哪个键,不会静默指到某个 `D:` 盘目录。 npm 别名:`portable:status` / `portable:env` / `portable:seed` / `portable:refresh` / `portable:set-app` / `portable:verify` / `portable:launch`。 - **改了桌面壳(即 `harness-desktop-build.patch`)走 `set-app`,不用重下整包**:便携包里的 `app/` 就是 win-unpacked 的**逐文件拷贝**(`cpSync(appDir, STAGE/app)`),而插件、会话、凭据全在 `home/`。 所以换掉 `app/` 即可 —— `--from `、`--zip <整包 zip>`(只解 `app/` 前缀)、 或都不给(去 `/.desktop-base` 挑版本最高的可用槽)。`--move` 同盘改名,瞬时。 同时 `refresh` 继续管插件那一层,两条路互不干扰。 - **`refresh` 是先删插件目录再解压,不是就地覆盖**:tsdown 的 chunk 名带内容哈希,就地覆盖会一轮轮 攒下不再参与加载的旧 chunk。删的只是 `home/profiles/desktop/node_modules/<插件>` 这一层, 同级的 `package.json`(用户自己装过的其它插件登记)**不碰**。 - **「要不要先停实例」两条路不一样**(2026-09-19 实测):`refresh` **不用停** —— 跑着的实例 **不持有**插件任何 `lib/*.js`(`who-locks.ps1` 报全部 `(无持有者)`),实测连着两次不杀直接 refresh 都成功、PID 不变;`set-app` **必须停** —— 主 exe 与 `app.asar` 被占着,删旧 `app/` 报 `EBUSY`。 ⚠️ 但**不重启就不生效**:宿主在**启动时**把插件清单/模块读进内存了。实测往 `package.json` 的 `description` 塞探针、refresh 完不重启去读插件页 —— 显示的还是旧文案。 「不用停」只省一次操作,**不等于能热更新**。也别在旧实例里 Ctrl+R 重载渲染进程(客户端 bundle 可能与宿主里的旧插件版本对不上,未实测)。 - ✅ **这份目录可以喂 `verify:portable`,而且会全绿**(2026-09-19 实测;早先两句「必挂 / 会假红」都已证伪): ①「启动过的 profile 里躺着 241 条链接 → 建链报 `refusing to replace unowned package @deepseek-ai/cordis` + `Cannot find package 'js-yaml'`」—— 0.1.6-alpha.2 起上游把 link 模式 **整套退役**、宿主包由运行时目录直接供给(profile 里**不建链**),这条不会发生; ② 那条 `profile 里没有越权 overlay` 原先**只看文件在不在**,而宿主首次启动会在 profile 根写两个 **出厂空模板** `cordis.yml` / `cordis.patch.yml`(内容只有注释 + `[]`)→ 对复用过的目录**恒红**。 已改成**按内容判**(`isStockEmptyPatch`:空模板放行,**有内容的 patch 照样红**,反向验证过)。 → **结论:一个固定目录就够**,`set-app` / `refresh` / `verify:plugin-update` / `verify:portable` 四件事都能对着它跑,不必再解压第二份 1.2G。 - 目录必须在**工作区外**(同 `DSH_DESKTOP_BUILD_ROOT` 那条:工作区内的 `app.asar` 会被 IDE 持句柄)。 - `seed` 可以用现成目录**改名**过来(同卷 rename 是瞬时的),不必真解压一遍 —— 初始那份 `portable-test` 就是从 `dsh-0.2.6-test2` 改名来的。 ### 本机重编桌面端(`pnpm harness:build`) 改桌面壳(`docs/harness-desktop-build.patch` 那 6 个文件)之后要重编。**别直接跑 `pnpm run package:win:x64:dir`** —— 本机会**先跑 20 分钟再报错**,两处都是网络(2026-09-19 实测): | 坑 | 现象 | 处置 | | --- | --- | --- | | **registry 被上游写死成 npmjs.org** | `prepare:dsh` 里 pnpm 报 `[23] The operation was aborted due to timeout`。`apps/desktop/scripts/prepare-dsh.ts:77,89` 硬编码 `https://registry.npmjs.org/`,还剥掉子进程所有 `npm_*`/`pnpm_*` 变量、把 `--config.userconfig` 指向空文件 → **`~/.npmrc` 里配的镜像完全无效,环境变量也注不进去** | 临时换成 `DSH_NPM_REGISTRY`(默认 npmmirror)。直连 npmjs 实测仅 **11–31 KB/s**(`node-pty` 7.15MB 要 ~10 分钟),且该脚本每轮 `mkdtemp` 新建 BUILD_ROOT(store 在里面)→ **store 每轮冷** → 必撞 60s `fetch-timeout`;镜像实测 **1.8–2.5 MB/s** | | **Electron 二进制 157MB** | `prepare:runtime` 报 `TypeError: fetch failed` | 设 `ELECTRON_MIRROR`(`DSH_ELECTRON_MIRROR`,默认 npmmirror 的 electron 镜像,实测是真 zip) | `scripts/harness-build.mjs` 把两者都挡掉了;并且**临时替换 `prepare-dsh.ts` 后无条件还原** (它是本补丁的目标文件,替换残留会随补丁出货到 CI —— 还原带 sha256 自证,不一致就非 0 退出)。 产物完整性不受影响:pnpm 按 lockfile 校验 `integrity`,镜像有出入会直接失败而非静默换包。 ```bash pnpm harness:build # 全链:prepare:runtime → prepare:packages → prepare:dsh → package pnpm harness:build --skip-prepare # 前 3 步已过,只重打 package pnpm harness:build --no-mirror # 网络好时用 pnpm harness:build --dry-run # 只演练「替换 → 还原」,不跑构建 ``` 产物:`.desktop-build/targets/win-x64/unsigned-artifacts/win-unpacked`,接着 `node scripts/portable-dev.mjs set-app --from <它>`。 ⚠️ `set-app` 前先关掉固定目录里起着的桌面端(WMI 起的宿主**故意脱离 job 存活**),否则删旧 `app/` 报 `EBUSY: resource busy or locked, rmdir '…\portable-test\app'`;Windows 上用 `taskkill /PID <父PID> /T /F`(Git Bash 里可能要 `MSYS_NO_PATHCONV=1`)。 ## 3. profile 物化 打包态桌面端**没有 CLI 装插件的入口**(`plugin-add` 只收 npm registry 形态的 spec), 所以按官方 `createPluginProfile` 的模板**手工物化** `home/profiles/desktop/`,让桌面端启动时认为插件本来就是装好的。 `applyRelease()` 状态自洽时不跑包管理器(不重装、不联网);`cleanProfileCorePackages` 只删 app-owned core 包 (unrelated plugins survive);`initProfile()`「不存在才写」→ 预置插件不会被冲掉。 **这是「只换插件文件也能生效」的依据。** > ⚠️ **已退役**:alpha.2 **废掉了整套 profile 状态文件与 link 模式机制** > (`apps/desktop/src/profile-packages.ts` 现在只剩 `migrateDesktopProfileLinks()`,作用是**删除** `desktop-runtime-state.json`)。 > 旧笔记里「必须额外写 `desktop-runtime-state.json`,否则 `createPluginProfile()` 会把 `package.json` > 重写成 `dependencies:{}` + 插件被静默抹掉」那一套**已不适用**。别照着老写法去补那个文件。 > 相应地 `verify-portable` 的 `[1/5]` 现在改判「包里**没有遗留**的 `desktop-runtime-state.json`」, > `[2/5]` 改成在 profile **副本**上跑**真** `applyRelease(true)`,断言插件登记与 `node_modules` 里的文件都还在。 **出厂态 `home/profiles/desktop/node_modules` 只有 `dsh-webops-plugin` 一个是正常的** —— alpha.2 起宿主包**不再物化到 profile**:早先「由桌面端首次启动时 `linkDesktopHostPackages` 建 241 条 `@deepseek-ai/*` 链接」的那套已**随 link 模式一起退役**,现在它们**直接由运行时目录供给** (`app/resources/dsh/node_modules`,实测顶层 149 项、其中 `@deepseek-ai/*` 真目录 262 个), profile 里**不建链** —— 所以 zip 里本来就没有、也不该有它们(2026-09-19 实测复核)。 ### `$DSH_HOME` 的便携兜底 `resolveDshHome()` 的优先级是 **显式参数 → `$DSH_HOME` → `~/.dsh`**,**没有**便携兜底。 所以 harness 补丁在 `main.ts` **顶层**(早于任何 `resolveDesktopPaths()`)加了: ```ts if ((process.env.DSH_HOME ?? '').trim() === '') { const portableHome = resolvePortableDshHome(process.execPath) // \home if (portableHome !== undefined) process.env.DSH_HOME = portableHome } ``` 判定抽成 `paths.ts` 的导出函数 —— 唯一理由是**可被真跑**:`verify:portable` 直接 import 它, 在真解压目录上**正反两向**断言(命中 `\home` / 没有兄弟 `home\` 必须返回 `undefined`),两条都验过能转红。 - 兜底**只在 `$DSH_HOME` 为空时**生效;显式设置(`启动.cmd` / `dev-desktop` / CI)永远优先。 - 已知边界:Electron 自己的 `userData`(渲染缓存、Local Storage)仍在 `%APPDATA%\`,不随包走。 「整个目录拷 U 盘」的便携程度以 `home\` 为界。 ## 4. 四道自检 **别用 `pnpm run verify:xxx -- --dir ...`**:本机 pnpm 会把字面 `--` 透传下去,用 node 直跑。 ```bash NODE="C:/Users/yemaf/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" DIR="<刚解压、未启动过的目录>" $NODE scripts/verify-portable.mjs --dir "$DIR" $NODE scripts/verify-ptc.mjs --dir "$DIR" $NODE scripts/verify-settings.mjs --dir "$DIR" $NODE scripts/verify-browser-host.mjs --dir "$DIR" --out dist/verify-browser-host.png ``` | 自检 | 验什么 | `--dir` 约束 | |---|---|---| | `verify-portable` | 运行时树全量 sha256 vs `desktop-runtime.json` 清单;包里无遗留状态文件;跑真 `applyRelease(true)` 后插件登记与 `node_modules` 里的文件都还在;出货 patch 无 `llm/stream` 劫持;`__DSH_BOOT__` 里客户端行能 200 拉下来 | **必须刚解压、没启动过** | | `verify-plugin-update` | 增量包:三轮真起宿主正反两向(见 §9) | **必须已经启动过** | | `verify-ptc` | 用包内**真 patch 引擎**把 base 层 → 插件层 compose,断言 `ptc-runtime.nodeExecutable` 命中并求值正确 | 同上 | | `verify-settings` | 出厂模型配置(pi-ai 路由)能被真实接受 | 同上 | | `verify-browser-host` | spawn 包内主 exe(app 模式)→ 等端口 → 探针页真导航 → 快照 + 截图;provider 选择**正反两向**断言 | **可以是启动过的目录**(只读产物) | > `verify-portable` **不断言状态条出现在 DOM 里**:状态条挂在会话面的 `conversation.input.dock` 上, > 而 `ui-conversation` 只在会话存在时才渲染那个 slot —— 空 home 会停在「选择工作区」页,那一面根本没挂载。 > 这是**预期行为**,不是插件没加载。 **判据原则:宁可红,不要静默绿。** 两个具体例子: - `verify-ptc [1/4]` 原来是 grep 一个**已被上游删除**的文件里的字符串,现在改成用包内 `@deepseek-ai/cordis-plugin-include` + `js-yaml` 的 `entryListSchema` 把两层**真 compose** 一遍。 grep 证得了「文件里有这行字」,证不了「能命中」—— 而**「不命中」恰好是静默失败**。 - `verify-portable` 的 `detectResolutionMode()` 认不出就**抛错**,不做「认不出就当 dev」的兜底。 (曾有一支 `profileResolution: 'link'` 的死分支被删:`git log -S` 证明上游从没写过字面量 `'link'`。 **给「未来可能出现」的形态预留分支前,先 `git log -S` 查一遍它是否真的出现过。**) > **dsh 对「补丁的目标行不存在」只记一条 Loader 警告,不抛错**。名字写错、层级放错(比如误放进 `insert:`) > 都会静默变成 no-op,包照样打出来、自检照样绿。所以这条不能靠肉眼或 grep。 ## 5. 真机验证(自检过不了这一关的都不能算过) - 起包内主 exe 时**必须让它脱离工具会话的 job object**,否则会话一返回进程就被回收。两条都实测过: - `MSYS_NO_PATHCONV=1 /c/Windows/explorer.exe "<解压目录>/app/DeepSeek Harness.exe"`(explorer 当父进程) - WMI 创建(能拿到 PID):`([wmiclass]"Win32_Process").Create('"<目录>\app\DeepSeek Harness.exe"', '<目录>\app')` → 返 `ret=0 pid=…`;父进程是 WmiPrvSE,且继承的是**服务环境**,下面那条污染自动不存在。 `nohup … &` 和 PowerShell `Start-Process -PassThru` **都不行** —— 同一调用内 sleep 25s 还活着、 调用一返回就全没了。⚠️ **判「常驻」必须跨 tool 调用**,在同一调用里 sleep 完再查是**假阳性**。 - **硬证据(缺一不算通过)**:`<解压目录>/home/storages/workspace.json` 里 `initialized: true`, 且有 `.credentials.yaml` 授权记录 —— 其中 `client-connection/browser-session` 那条就是**本插件的 浏览器会话凭据**,比 boot graph 更贴本插件。 - 本机 shell 自带 `ELECTRON_RUN_AS_NODE=1`(WorkBuddy CLI 注入,注册表 HKCU/HKLM 里都没有), 起 GUI 前先 `env -u ELECTRON_RUN_AS_NODE -u NODE_OPTIONS`。中招特征三条: **exit 0 + stdout/stderr 全空 + 目标应用 userData 的 mtime 没动**(连 Chromium 都没到) —— 全中就是它,别去查文件锁 / 杀软 / 缺依赖。 - **判「窗口真开出来」用 `Get-Process` 的 `MainWindowTitle`**(非空才算真可见窗口); `tasklist /v` 在沙箱里读不到标题、恒给 `N/A`,**别拿它判**。 - ⚠️ **「标题非空」≠「点得动」**(2026-09-19 真机事故)。实测主窗口标题正常、`workspace.json` 也 `initialized: true`, 界面却完全点不动 —— 因为**另有一个空白、透明、与主窗口同尺寸的 `shell/update-dialog.html` 窗口压在上面, 还 `hasFocus: true`**,把鼠标事件全吃掉了。真凶是 **`dsh-app://shell/*` 全部 404**(`app` host 正常), 那个窗口加载出空文档(39 字节)却照样 visible。所以: - **验证时必须真的用鼠标点一下**(点「内测声明 → 继续」这种必经交互),光看文件和进程状态不算过; - 怀疑类此问题时,**给 exe 加 `--remote-debugging-port=`,用 CDP 列 target 逐个问 `document.hasFocus()` / `document.visibilityState` / `body.innerText.length`** —— `elementFromPoint` 之类的命中测试**只在同文档内做,跨窗口遮挡根本看不见**,DOM 检查注定全绿; - `dsh-app://shell/*` 挂了意味着 `mandatory-update.html`(强制更新页)、`policy-login-loading.html` 同样坏。 - 想看 `__DSH_BOOT__`(真验插件加载)需要**当次**的 token:它每次启动重新生成, 只在 stdout 的 `dsh web: http://127.0.0.1:19387/?token=…` 那行 —— 所以要用 `cmd /c "exe > log 2>&1"` 包一层再 WMI 起,否则拿不到。 - 火绒按路径放行:**换安装目录会出不去网**,看着像包坏(先跑出网自检再下结论)。 - 四道自检**只证**「包结构 + 生产链」,**证不了**「状态条看得见、模型回不回、真去调工具」。 **自检通过 ≠ 可以发版。** ## 6. 包内 node 路径(打包态专有坑,已修) 打包态 `process.execPath` 是 **Electron 主 exe,不是 node**;它靠宿主注入的 `ELECTRON_RUN_AS_NODE=1` 才以 node 模式运行。所以谁在这套环境里 spawn 子进程、并且**把环境清掉**,谁就会把「一个 node 脚本」交给 Electron 当 GUI 起。 这个错在**两个地方**,症状却完全不同: | 谁在用 `process.execPath` | 清环境的动作 | 症状 | |---|---|---| | `dsh-ptc-runtime-node` 的 `nodeExecutable` | 它自己 spawn 时清到只剩 6 个启动变量 | 子进程起成 GUI,stdin/stdout 控制协议建不起来 → `run_code` **挂到超时** | | `dsh-sandbox-local` 的 `windowsAclRunnerInvocation()` | 上一条清好的环境原样传给沙箱 runner | `runner.js` 被当 GUI 起,撞单实例锁 → **退出 0、零输出** → `worker-exit: Node process exited before completing (0)` | 第二层更阴:它只在会话权限是 **workspace-write**(桌面端默认)时才走沙箱那条链, 所以症状是「`run_code` 一律报 `worker-exit (0)`」,而**开发态永远复现不了**。 **alpha.2 把包内自带 node 搬了家**: | 路径 | alpha.2 实测 | |---|---| | `app/resources/runtime/node/node.exe` | ❌ **不存在**(旧笔记与早先补丁都指它) | | `app/resources/runtime/primary-runtime/dependencies/node/bin/node.exe` | ✅ 存在,v24.21.0(与 `primary-runtime/runtime.json` 的 `components.node` 一致) | 四处查找都已改走新路径,`existsSync` 失败回落 `process.execPath`: 补丁 `main.ts` 的 `DSH_PTC_NODE`、`sandbox-local` 的 `runnerExecutable()`、`verify-ptc.mjs [1/4]`、`bundle-patch.test.ts`。 > **不要改指 `resources/runtime/bin/node.cmd`**:shim 内容是 > `set ELECTRON_RUN_AS_NODE=1` + `"%DSH_DESKTOP_NODE_EXECUTABLE%" --expose-internals %*`,**依赖两个环境变量**; > 而 PTC 给子进程构造环境时白名单只有 `PATH/PATHEXT/SYSTEMROOT/WINDIR/TEMP/TMP`, > 且显式删掉 `ELECTRON_RUN_AS_NODE` —— shim 在 PTC 子进程里必然拿到空路径。 **为什么自检抓得到**:`verify-ptc` 的 `[1/4]` 会读包内 node 并断言字节数 > 0 → 路径错了**直接转红**。 反过来说:谁看到 `verify:ptc` 红在这一条,就是撞上了这件事,不是补丁坏了。 **另一处 alpha.2 静默缺口**:`@deepseek-ai/dsh-desktop-host` 不再导出 `runDesktopHost()` (`lib/index.js` 只剩 `export {}`,入口改成 `import.meta.main` + IPC `{type:'ready', url}`)。 三份自检原先 `import { runDesktopHost }` 会在最后一步 `is not a function`, 现改走 `scripts/run-packaged-host.mjs`,按 `host-process.ts` 的同一条 spawn。 注意 `authenticatedUrl` 的 `/?token=` 会 303 换 cookie,**直 GET `/index.html?token=` 是 401**。 ## 7. zip 与解压 **解压产物会被静默损坏**(曾出现 14071 个文件里 5160 个是 NUL 填充:大小对、内容全 `\x00`, 连 `使用说明.txt` 都中招,而 zip 自己的 CRC **11954 个条目全绿**)。 所以分诊顺序**不能反**: ```bash # ① 先验 zip(3 秒能跑完上万个条目),返回 None 就是全绿 python -c "import zipfile;print(zipfile.ZipFile(r'<路径>').testzip())" # ② 再验解压产物:读每个文件前 32 字节,全为 0 即中招(省时间,不必读全文) ``` 全绿就**别再怀疑打包逻辑**。换一种解压方式重来:Python 的 `zipfile.read()` 逐条写盘是可靠的 (11826 个文件 / 237MB,13.9s,零异常)。本仓固定用 `scripts/unzip-portable.py`。 **打包侧**:`Compress-Archive` 失败是 pwsh 的**非终止错误**(看着成功其实没压), 脚本已改成 `spawnSync` + `$ErrorActionPreference='Stop'` + 自断言;临时名必须以 `.zip` 结尾。 被火绒扫描句柄挡住时退化到 `python scripts/zip-stage.py`(共享读)。 > **不要用字节数判断包的好坏**:CI 构建与本地构建本就有差异。只认内容断言。 ## 8. 出货 patch 的红线 **出货的 `cordis.patch.yml` 绝不能含 fake-llm / llm-replay / mock-llm 之类的 `llm/stream` 劫持行。** 那不是无害的调试开关 —— `llm/stream` 在 dsh 里是 **waterfall**,监听器只要不调 `next()` 就短路掉真实模型, 装了这种包的用户**任何真实对话都会被换成脚本回放**。 `src/bundle-patch.test.ts` 把这条钉死在 `pnpm test` 里(按 YAML 有效行断言,注释里的解释不会误伤自己)。 **出货 patch 只留两条**:`install --prod` 去掉 `--frozen-lockfile`;跳过 `checkFsExt()`。 **`ptc-runtime` 的 `nodeExecutable` 覆盖收在我们自己出货的 `cordis.patch.yml` 里**(不在 harness 补丁里)。 上游删掉 `desktop-host/config/` 之后,桌面 profile 的「补丁挂载点」就没了;三条路里选这条的理由: profile 的 `dsh.profile.bundles` 里本来就有我们这层(排在 base 之后), 顶层 `- id: ptc-runtime` 的语义正好是「覆盖一个已存在行的 config」,顺带少维护一个补丁目标。 两个前提都实测过:能命中(用 harness 真代码 compose,0 条 skipped 警告,拿到 `!!js` 表达式节点); 未设 `DSH_PTC_NODE` 时表达式回落 `process.execPath`,与上游默认值等价。 ## 9. 插件增量更新包(不必重下 472MB) 整包 472MB 里 dsh 本体占 1.2G(解压后)且每次发版完全不变,**插件侧真实变化只有约 407KB**。 给已装旧包的用户重下整包是纯浪费,所以有增量包这条路。 ```bash node scripts/package-plugin-update.mjs [--version 0.2.8] [--out dist/xxx.zip] node scripts/verify-plugin-update.mjs --dir <已解压的便携版目录> [--in-place] ``` 产出(默认 `dist/dsh-webops-plugin-update-v.zip`,实测约 159KB)内部结构**对应便携包根目录**: ``` home\profiles\desktop\node_modules\dsh-webops-plugin\{lib\, package.json, cordis.patch.yml} 更新说明.txt ``` **两条设计约束**: 1. **不碰 `home\profiles\desktop\package.json`**。那份 manifest 是用户的, 可能登记了他自己在桌面端里装过的其它插件,整份覆盖会把它们抹掉。 实测(三轮真起宿主)**只换 `node_modules` 下这个插件目录就能生效** —— `applyRelease()` 不跑包管理器、不校验依赖版本;`cleanProfileCorePackages` 也只删 app-owned core 包。 而且不动它**没有任何代价**(2026-09-19 查证):`packages/boot/app-boot/src/profile-plugins.ts:64-77` 里 `version: installed?.version ?? spec`,`installed` 读的是 `profileDir/node_modules//package.json` —— 版本号与 bundle 判定都取自插件自身目录,跟着新包走;`enabled` 读的是 manifest 的 bundles 列表(我们不动那列表)。 (原先有过一个 `--with-manifest` 开关用于「同步 profile manifest」,已按这条查证砍掉 —— 没有存在价值,且它必然触发自检的越界断言。) 2. **产物必须是真实文件,不能把 symlink 压进去**(`validateDesktopPluginGraph` 见链就拒)。 **自检为什么必须真起宿主**:静态比对只能证明「zip 里的文件对」,证明不了「dsh 会去读它」。 三轮启动的正反两向 —— 出厂 lib 不该出现探针 / 覆盖后必须出现 / 还原后必须消失。 **上游哪天引入 bundle 缓存,第 2 轮会立刻转红**,本脚本就是「覆盖即生效」这条结论的护栏。 > ⚠️ **bundle 不是逐字节确定的**:末尾 `sourceMappingURL=...&rev=<每次启动现生成>` 每次都变 > (同一份 lib 连起两次也只有这一处不同)。**自检比字节前必须先剥 `&rev=`**,否则「不同」证明不了任何事。 > 另外 chunk 名带内容哈希 → 覆盖式更新只加新文件、不删旧 chunk(不参与加载,无害)。 > **CI 已挂上**(2026-09-19):`release.yml` 在「打便携版 zip」这一步同时产出插件便携包与增量包, > 两个附件都传 Release,notes 里给出「你现在的状态 → 下哪个」的对照表。 > > **但 CI 验不了它的生效性**:`verify:plugin-update` 需要一个**已解压的完整便携版目录**(472MB) > 才能三轮起宿主,而 `release.yml` 根本不构建桌面端。所以规矩是 > **推 tag 之前必须本地对一份真实便携版跑过 `verify:plugin-update`** —— CI 只保证「包打出来了、附件传对了」。 ## 10. 发版 ### 产物命名(三种 zip,别搞混) | 文件 | 谁产出 | 给谁 | 怎么用 | |---|---|---|---| | `dsh-webops-desktop-v-win-x64-portable.zip`(约 472MB) | `release-desktop.yml` | 全新用户 | 解压 → 双击 `启动.cmd` | | `dsh-webops-plugin-v-win-x64-portable.zip` | `release.yml` | 自己有一份 dsh 的人 | `dsh plugin --profile

add <解压目录>` | | `dsh-webops-plugin-update-v.zip`(约 159KB) | `release.yml` | **已在用便携版桌面端的人** | 解压覆盖到便携包根目录 → 重启 | - 版本号一律取自 tag(`v*` → `release.yml`,`desktop-v*` → `release-desktop.yml`), 并**显式传给打包脚本**,不靠 `package.json` 兜底。 - **两个 workflow 的第一步都会校验 tag 版本 == `package.json` 的 `version`**,不一致直接失败。 为什么必须拦:`--version` 同时决定 zip 名、profile 里登记的插件版本、以及随包插件 `package.json` 的 version;不一致不会报任何错,只会产出一个「自称新版本、里面是旧版本」的包,用户报的版本号从此不可信。 - 取产物文件**必须按确定的文件名取**,不要 `Get-ChildItem dist/*.zip | Select-Object -First 1` —— `dist/` 里同时躺着三种 zip(本机实测有 9 个),`-First 1` 取到哪个纯看目录枚举顺序, 于是 sha256 会贴到另一个文件上(而附件还是对的,很难发现)。 ### tag 与 workflow | tag | workflow | 产物 | |---|---|---| | `v*` | `release.yml` | 插件便携包 + 插件增量包,约 3 分钟 | | `desktop-v*` | `release-desktop.yml` | 整包,约 472MB | `release-desktop.yml` 内置「发版前自检」,跑在「打好 zip」之后、「发布 Release」之前, 直接验 `.desktop-stage`(zip 的内容源,不必先解压)—— **不过就不发 Release**,所以坏包不会再出现在 Release 上。 **重指 tag 的正确姿势**:删 release + 删 tag → 新提交重打 → push。不要就地覆盖资产。 ## 11. `verify:browser-host` 的三个实现坑 1. **包内产物不能直接 import**:`lib/browser-electron/index.js` 里 import 了 `@deepseek-ai/schemastery`, 而 profile 的 `node_modules` 只有插件自己(dsh 的包全在 `app/resources/dsh/node_modules`,由宿主的解析环境供给)。 所以临时目录里放插件产物的**拷贝** + 一条指向 runtime `@deepseek-ai` 的 junction。 2. **用拷贝而不用 junction 指回产物**:ESM 默认按 realpath 解析,junction 指回去等于没换地方,裸包名照样解析不到。 3. **探针页要有可交互元素**(否则断言不出「真导航 + 有 ref」)。