Browse Source

Merge remote-tracking branch 'origin/master' into worktree/code-diff-card

creatixchu 3 days ago
parent
commit
8f0cdc9a98
100 changed files with 3515 additions and 1248 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 8 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 14 8
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  5. 7 14
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  6. 24 32
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  8. 17 15
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  9. 17 15
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  11. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  12. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  14. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  15. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  16. 6 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  17. 45 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  18. 45 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  19. 6 0
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.i18n.yaml
  20. 31 0
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md
  21. 31 0
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md
  22. 6 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml
  23. 27 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
  24. 27 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md
  25. 6 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.i18n.yaml
  26. 29 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.md
  27. 29 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.zh.md
  28. 6 0
      .agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.i18n.yaml
  29. 21 0
      .agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.md
  30. 21 0
      .agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.zh.md
  31. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.i18n.yaml
  32. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md
  33. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.zh.md
  34. 6 0
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml
  35. 49 0
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
  36. 49 0
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md
  37. 6 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
  38. 31 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
  39. 31 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
  40. 4 1
      .github/workflows/ci.yml
  41. 2 0
      .gitignore
  42. 4 2
      AGENTS.md
  43. 1 0
      THIRD_PARTY_NOTICES.md
  44. 2 2
      apps/cli/README.i18n.yaml
  45. 2 0
      apps/cli/README.md
  46. 2 0
      apps/cli/README.zh.md
  47. 10 1
      apps/cli/package.json
  48. 11 69
      apps/cli/src/plugin.ts
  49. 140 111
      apps/cli/src/profile-boot.ts
  50. 44 0
      apps/cli/tests/built-bin.e2e.ts
  51. 66 0
      apps/cli/tests/desktop-host.e2e.ts
  52. 164 0
      apps/cli/tests/resolved-profile-boot.spec.ts
  53. 2 4
      apps/cli/tsdown.config.ts
  54. 0 34
      apps/desktop-host/config/desktop.cordis.patch.yml
  55. 3 10
      apps/desktop-host/package.json
  56. 37 594
      apps/desktop-host/src/index.ts
  57. 112 0
      apps/desktop-host/src/primary-runtime.ts
  58. 0 184
      apps/desktop-host/src/wire.ts
  59. 53 0
      apps/desktop-host/src/workspace-dependencies.ts
  60. 130 0
      apps/desktop-host/tests/primary-runtime.spec.ts
  61. 17 10
      apps/desktop-host/tsconfig.json
  62. 29 0
      apps/desktop/.env.macos.example
  63. 17 0
      apps/desktop/.env.windows.example
  64. 2 2
      apps/desktop/README.i18n.yaml
  65. 75 50
      apps/desktop/README.md
  66. 75 50
      apps/desktop/README.zh.md
  67. 6 0
      apps/desktop/electron-builder.config.d.mts
  68. 29 2
      apps/desktop/electron-builder.config.mjs
  69. BIN
      apps/desktop/installer/assets/brand-2x.png
  70. BIN
      apps/desktop/installer/assets/brand-dark-2x.png
  71. BIN
      apps/desktop/installer/assets/brand-dark.png
  72. BIN
      apps/desktop/installer/assets/brand.png
  73. BIN
      apps/desktop/installer/assets/uninstaller-sidebar.png
  74. 120 0
      apps/desktop/installer/drawing.nsh
  75. 26 0
      apps/desktop/installer/extract-progress.h
  76. 95 0
      apps/desktop/installer/extract.h
  77. 89 0
      apps/desktop/installer/lifecycle.nsh
  78. 372 0
      apps/desktop/installer/pages.nsh
  79. 168 0
      apps/desktop/installer/path.nsh
  80. 61 0
      apps/desktop/installer/progress.h
  81. 41 0
      apps/desktop/installer/strings.nsh
  82. 91 0
      apps/desktop/installer/theme.nsh
  83. 244 0
      apps/desktop/installer/window-frame.cpp
  84. 5 0
      apps/desktop/package.json
  85. 8 0
      apps/desktop/renderer/plugin-manager.html
  86. 25 3
      apps/desktop/renderer/plugin-manager.js
  87. BIN
      apps/desktop/resources/icon-macos.png
  88. 6 0
      apps/desktop/resources/icon-macos.svg
  89. BIN
      apps/desktop/resources/icon-windows.png
  90. 6 0
      apps/desktop/resources/icon-windows.svg
  91. BIN
      apps/desktop/resources/icon.png
  92. 6 0
      apps/desktop/resources/icon.svg
  93. 1 1
      apps/desktop/scripts/desktop-build-paths.d.mts
  94. 2 2
      apps/desktop/scripts/desktop-build-paths.mjs
  95. 27 0
      apps/desktop/scripts/desktop-package-environment.d.mts
  96. 100 0
      apps/desktop/scripts/desktop-package-environment.mjs
  97. 5 3
      apps/desktop/scripts/dev.ts
  98. 18 3
      apps/desktop/scripts/development-project.ts
  99. 131 0
      apps/desktop/scripts/installer-directories.nsh
  100. 156 2
      apps/desktop/scripts/installer.nsh

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: d0f9b20f0adabda6cc7132e5411bcadd60c9e108
-2026-07-23-client-plugin-loading-model.zh.md: 27d5026ed03509d6408a16389246cb1321a37959
+2026-07-23-client-plugin-loading-model.md: e9dc734c05eb5a73a7c8ef531bd3042548e0f06e
+2026-07-23-client-plugin-loading-model.zh.md: f4df6c0a108bd2b99756764df089fd3c7f58839f

File diff suppressed because it is too large
+ 8 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md


+ 14 - 8
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -40,11 +40,11 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 ### Combo 外部脚本到达与源码映射
 
-Host 会快照每个已构建插件产物,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
 共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。启动 combo revision 覆盖合并脚本输入与 indexed map。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle,并发布所得 revision。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -68,25 +68,31 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
 
+### 动态图对账
+
+modules 控制器只持有从启动清单创建的 Loader 条目。Host 的完整快照更新模块描述并对账这些条目;其他 Loader 贡献方保留自身所有权。新增模块通过单资源 URL 到达,因为重放启动 batch 可能重复注册现有 factory。移除使用 Loader 删除语义,随后等待已捕获 fiber 清理完毕,再移除未使用的模块与样式。已声明及已观察到的传递依赖使共享模块保持存活。Factory revision 的跟踪独立于条目激活,因为下载或物化完成后仍可能没有创建条目。图更新会在任何消费者导入依赖前,使未归属受管条目的陈旧 factory 和失败的到达目标失效,并清除其样式;重建帧也会对账因导入失败而缺失的条目。
+
+Host SSE 适配器转发现有图变化通知,并在连接时发送当前完整图。图描述浏览器的目标条目,不代表 Host 清理完成:Host 生命周期顺序属于其 Loader,每个浏览器则等待自身被移除 fiber 的清理。图对账与代码重建共用一个页面队列。本地代际阻止过期下载挂载;不透明 revision 只比较相等。失败页面报告本地错误,并可重试同一张图而不改变 Host 启用状态。这保留了无关页面状态,也无需重启应用或引入第二套插件执行器。Electron 的独立安装流程不属于此机制。
+
 ### 热重载:一个驱动插件,自行监视的 bundle
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它只哈希可执行 bundle 字节,所以仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
-浏览器侧,驱动插件每帧重载一个插件,串行执行
+浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器
 
 1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的单资源 combo URL。Factory 还活着会让下一步变成 no-op。
 2. `prefetch`——加载该单资源外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始多资源脚本不会再次执行。
 3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
 4. 排空旧 fiber 的各 disposer。
 5. 移除名下的 `<style data-plugin>` 标签。
-6. `entry.refresh()`——重新 import,物化新工厂。CSS 在这里重新注入,沿用同一批稳定标签 id
+6. 通过模块系统物化新导出,再调用 `entry.refresh()` 由 Loader 挂载。CSS 在旧 disposer 清理完成后重新注入;显式物化使导入错误能够被捕获,而不只留下 Loader 的控制台日志
 7. `fiber.await()`——让失败大声重抛。
 
-每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
+Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始导出,重新挂载旧代码会重置消费者,却无法应用请求的 revision。页面会报告需要刷新,并保留 bootstrap fiber。所有非 bootstrap 插件都共享同一套替换语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
 
-支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑 factory」相冲突,属刻意不做。静态装配包与外壳内核不是 entry:改动它们意味着外壳重建加整页刷新。重载不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈
+重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠
 
 ## 包归属
 
@@ -96,7 +102,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
 
-接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成
+接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件
 
 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: 4a4dfe8910905b3c35fbdfdcaedd34a556b532f3
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 69877127578ae4d61643653403b736dbb5e7b1e6
+2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8

File diff suppressed because it is too large
+ 7 - 14
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md


+ 24 - 32
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md

@@ -6,6 +6,8 @@ Status: implemented
 
 profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
+[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
+
 ## 问题
 
 DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。
@@ -16,7 +18,7 @@ DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用
 
 ## 决策
 
-交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留
+交付小型 Electron 壳和固定版本 pnpm;[运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)持有可执行文件选择。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责 Host 启动与传输:私有 Host 运行共享 Web profile runner,Electron 加载其认证 HTTP URL,子进程 IPC 承载生命周期消息
 
 Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源存储、外部插件依赖、共享包链接和 profile 协调。私有 Desktop Host 保持独立于公共 CLI 包,且不会发布到 npm。
 
@@ -28,8 +30,8 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 | Owner | 职责 |
 |---|---|
-| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调 |
-| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 |
+| Electron 壳 | 窗口与子进程生命周期、本地壳页面、保留 desktop profile、插件 GUI、更新协调 |
+| Electron RunAsNode 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 |
 | Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
 | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
 | 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 |
@@ -42,26 +44,18 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 ```text
 ~/.dsh/
-  desktop/
-    pnpm/
-      store/
-      cache/
-      state/
-      config/
   profiles/
     desktop/
       package.json
       pnpm-lock.yaml
       lock
-      desktop-packages-pending
       pnpm-workspace.yaml
-      desktop-runtime-state.json
       node_modules/
   sessions/
   storages/
 ```
 
-`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。插件包内容使用 `.dsh/desktop/pnpm/store`
+`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。pnpm 根据正常配置选择 store 和缓存
 
 ## 安装与解析
 
@@ -81,42 +75,40 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
 
 ## 安全与发布策略
 
-核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限
+核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle
 
-Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 
-Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。
 
 本地 Windows 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有 EV Token 时诊断安装问题,也能防止本地测试产物通过发布上传校验。
 
-NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录。Finish 启动应用后,默认退出清理可能与后端的文件读取重叠。[安装器 hook](../../../../apps/desktop/scripts/installer.nsh) 在 `customInstall` 阶段仅删除该解压目录,早于交互和静默启动分支。它保留包归档、插件 DLL、回滚目录、寄存器和错误状态;[原生清理 smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) 检查这些约束。把清理移入安装阶段并不会减少文件系统工作,因此必须分别测量安装总耗时与点击 Finish 到窗口出现的耗时。
-
-安装器不开启直接向应用目录执行 `Nsis7z::Extract`。原生[文件占用探针](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi)会在未报错的情况下留下被占用的旧文件和新资源;暂存后执行的 `CopyFiles` 在相同替换失败时会设置错误标志。Windows 上同一份 737,557,488 字节载荷经过解压、复制和清理耗时 172.625 秒,直接解压耗时 28.031 秒,但每条路径的单次样本不足以支持放弃失败检测。计时不包括注册表修改、旧版删除及解压后的验证,也没有清空系统缓存。桌面专用载荷过滤减少需要复制的文件,同时保留安装器的替换错误处理。这种处理并不承诺完整的安装回滚。
+Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-installation.zh.md):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。
 
-打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。
+打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 pnpm 入口、dsh 资源 或活跃项目。
 
-在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量
+分架构构建报告实际组件级压缩体积和安装体积
 
 ## 实现
 
 | 表面 | 实现 |
 |---|---|
 | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
-| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 |
-| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
+| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 调用共享 profile runner,并向 Electron 报告认证 Web URL。 |
+| 包状态 | Electron RunAsNode 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
 | 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
 
-`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
+`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。开发运行时为独立的插件 profile 提供工作区链接;插件管理和恢复使用与打包应用相同的流程。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
 
 ## 考虑过的替代方案
 
 **使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。
 
-**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** JSON IPC 可以只保留一种消息机制,但会膨胀每个请求与响应消息体、在两个进程中构造大字符串、在分派前缓冲完整请求,还会再次编码 RPC JSON 中已经表示为 Base64 的图片字节。原始分帧管道保留明确的带版本协议,同时不要求 Electron 与上游 Node.js 共享 V8 序列化行为
+**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** 这会膨胀请求与响应消息体、在两个进程中构造大字符串,并在分派前缓冲请求。原始分帧管道避免 Base64 膨胀,但仍需维护第二套传输;[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)选择已有的 Web HTTP 传输
 
 **把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。
 
@@ -124,13 +116,13 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 
 **让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。
 
-**分离核心与插件解析,却不明确共享包归属。** 这会允许宿主模块重复以及不可控的 peer 回退。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)为分离的资源和 profile 目录提供明确链接及依赖验证
+**分离核心与插件解析,却不提供共享包链接。** pnpm 未安装宿主 peer 包时,插件需要访问内置运行时。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)通过明确链接提供 profile 缺失的包,同时保留 pnpm 安装的包
 
 **从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 运行时空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。
 
 **把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。
 
-**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收
+**把凭据写进已跟踪脚本或系统环境。** 本地平台文件把配置限制在单个 checkout,并使打包输入明确。代价是凭据以明文落盘:构建账号需要限制文件访问权限,CI 必须清理临时配置,Git 与发布文件映射都必须排除真实配置。已提交的模板不含凭据;Windows CMD 只包含变量引用,签名串行执行并在首次失败后停止,文件格式不会免除 Token 的错误 PIN 计数
 
 **让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。
 
@@ -139,7 +131,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 - 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。
 - 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。
 - `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。
-- 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`
+- 每次 Desktop 包操作都使用内置 pnpm,并遵循用户的正常环境与 profile 配置
 - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
 - 后端与浏览器应用不能修改桌面包。
 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
@@ -147,7 +139,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 - 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。
 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
-- 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。
+- Web 应用负责 HTTP 认证与服务;沙箱渲染进程不能访问任意文件系统或 Electron API。
 - Workspace 开发无需下载发布资源即可运行当前已构建代码,未封装安装器的应用验证仍保留生产安装路径。
 - Windows 发布打包要求已验证的 SignTool、EV Token、匹配的公开叶证书、Token Password 和明确的密钥容器,绝不会回退到未签名产物或可导出的密钥文件。
 - 目标更新只有在已完成签名的构建及其引用的每个产物通过发布校验后才能暴露新频道元数据;保留的历史产物继续供差分更新使用。
@@ -166,16 +158,16 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 
 ## 风险
 
-插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审
+插件生命周期脚本会执行第三方代码。profile 的 `allowBuilds` 配置决定哪些构建获准执行
 
-更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。校准会验证变化的依赖并重建原生包;失败后需要通过恢复 UI 显式修复。
+更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。已安装插件文件保留原位;加载失败需要通过恢复 UI 显式修复。
 
 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
 
-中断的包操作保留未完成标记。已安装产物测试必须验证后续启动会重试锁定依赖的安装和获准的原生构建
+包操作中断会保留部分变更。恢复操作重试实际 Host,或允许显式修复插件,不会自动重新安装包
 
 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
 
 ## 相关提案
 
-[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)取代本记录的离线 运行时准备与单项目包归属决策。发布身份、签名、无端口传输、进程归属及仅限 Electron 的包授权仍由本记录负责。
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)取代无端口传输与私有后端组合。发布身份、签名、进程归属及仅限 Electron 的包授权仍由本记录负责。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
-2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 581a4ad31121bfda651cb99e550487a0a712e943
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: fbbb25a4a3446d52ac56dc35bb176c0b706d7cb4
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: b49ae76bfd8c468e9c971f803cf714755978e5b4
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 58dd72124c0fdeb53652a600d0d3c143feb1ff3d

+ 17 - 15
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md

@@ -6,6 +6,8 @@ English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
 
 Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
 
+The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
+
 ## Problem
 
 Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades.
@@ -14,48 +16,48 @@ Separate package directories can load duplicate Cordis or service modules. Retai
 
 ## Decision
 
-[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. A bundled upstream Node process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`.
+[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. An Electron RunAsNode process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`.
 
-Desktop has not been released. This is its first installation format; there are no readers or migrations for the unpublished seed-based profile. This note supersedes core seed installation and single-project dependency ownership in the [Desktop packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That note continues to own release identity, signing, portless transport, process ownership, and Electron-only plugin authorization. No existing note is fully superseded or archived.
+This note owns core resource storage and external plugin dependencies. The [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) retains release identity, signing, process ownership, and Electron-only plugin authorization. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared Web boot and HTTP transport.
 
 ## Package ownership
 
 The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization.
 
-The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, clears inherited `NODE_OPTIONS`, and does not enable source mapping. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
+The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts. The Host inherits the user environment. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
 
-Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
+Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
 
-Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
+The shared profile runner projects missing installation and selected-bundle dependencies within the Desktop profile. pnpm-installed packages take precedence. Links resolve to real package directories under normal Node resolution, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
 
-External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient.
+External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. Shared fallback links supply missing packages, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI.
 
-The profile manifest records exact installed plugin dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates and validates them.
+The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) assigns initialization, bundle reconciliation, and module links to shared app-boot helpers; Desktop holds no separate link ledger or runtime-state identity.
 
 ## Transactions and upgrades
 
-First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links and validate enabled peers in place. Node version, platform, or architecture changes reinstall the locked plugin graph and run approved native builds.
+First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links in place. Node version, platform, or architecture changes preserve installed plugins; pnpm and the loader report installation and compatibility failures.
 
 Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
 
-Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction.
+Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime.
 
-Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and persistent retry state. Recorded host links identify owned directories independently of package-operation completion.
+Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery.
 
-The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Each backend launch requires the current runtime identity.
+The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded.
 
 ## Alternatives considered
 
-Full runtime verification belongs to packaging. Startup reads the descriptor, checks shared package records and required Host entries, and uses the recorded runtime identity for profile reuse. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory.
+Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory.
 
 - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
-- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions.
+- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Shared fallback supplies missing packages while pnpm-owned entries retain independent versions.
 - **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target.
 - **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable.
-- **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths.
+- **Keep core packages in ASAR.** Ordinary `extraResources` preserves native loading and subprocess paths. ASAR requires separate package-resolution qualification.
 
 ## Consequences
 
 Core package installation is absent from first launch and compatible upgrades. Metadata checks and backend loading still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails.
 
-The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them.
+The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, runtime changes without automatic reinstalls, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them.

+ 17 - 15
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md

@@ -6,6 +6,8 @@ Status: implemented
 
 profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
+[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
+
 ## 问题
 
 Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。
@@ -14,48 +16,48 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成
 
 ## 决策
 
-[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。内置上游 Node 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。
+[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。Electron RunAsNode 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。
 
-Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 seed profile 的读取器或迁移。本记录取代 [Desktop 打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的核心 seed 安装和单项目依赖归属部分。该记录继续负责发布身份、签名、无端口传输、进程归属和仅限 Electron 的插件授权。没有现有记录被完全取代或归档
+本记录负责核心资源存储与外部插件依赖。[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)保留发布身份、签名、进程归属和仅限 Electron 的插件授权。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 启动与 HTTP 传输
 
 ## 包归属
 
 资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。
 
-[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,清除继承的 `NODE_OPTIONS`,且不开启源码映射。经过审核的插件生命周期构建面向原生依赖,不执行任意 TypeScript 编译。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
+[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物。Host 继承用户环境。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
 
-包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
+包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
 
-dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。
+共享 profile runner 在 Desktop profile 内补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。链接通过正常 Node 解析指向真实包目录,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的双重实现。
 
-外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够
+外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或上级目录依赖解析。这些检查重复包管理器及加载器的职责,并拒绝 pnpm 支持的安装来源。共享补全链接提供缺失的包,但插件可以解析到另一份已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立壳 UI 恢复
 
-profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建并验证它们
+profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。禁用插件保留其包、锁文件条目及用户配置。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)将初始化、bundle 协调和模块链接交给共享 app-boot helper;Desktop 不保存独立链接账本或运行时状态身份
 
 ## 事务与升级
 
-首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接并验证已启用的 peer。Node 版本、平台或架构变化时,会重新安装锁定的插件依赖图并运行获准的原生构建
+首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接。Node 版本、平台或架构变化时保留已安装插件;安装和兼容性错误由 pnpm 与加载器报告
 
 共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
 
-依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败
+依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供
 
-Desktop 在包修改前停止 Host,并等待 pnpm 退出后再重启它。[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)规定部分失败和持久重试状态的处理方式。记录的宿主链接用于识别自有目录,与包操作是否完成相互独立
+Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复
 
-[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。每次后端启动都要求当前运行时标识。
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。
 
 ## 考虑过的替代方案
 
-完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。
+完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。
 
 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
-- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。
+- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。共享模块补全提供缺失的包,pnpm 拥有的条目保留独立版本。
 - **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
 - **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
-- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。
+- **把核心包留在 ASAR。** 普通 `extraResources` 保留原生加载和子进程路径。ASAR 需要单独验证包解析。
 
 ## 影响
 
 首次启动和兼容升级不安装核心包。元数据检查和后端加载仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。
 
-[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。
+[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、运行时变化时保留插件、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
-2026-09-09-desktop-immediate-window-and-direct-start.md: 1c2209c044495de1fb9d3410e5ac777759e8fa6b
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 49b08e4eeea2703c9bcf868d7b290d56eba79542
+2026-09-09-desktop-immediate-window-and-direct-start.md: 461f82719fabba67bd93c4c1f2a37d02fac1c06b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: efbeeb20993fdc6baeb11ca1ea1d1af5501567dc

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md

@@ -12,11 +12,11 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 ## Decision
 
-Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
+Electron creates the main window with the packaged Web loading page before profile reconciliation or Host startup. The Web entry draws its boot page before awaiting Host readiness. The owned preload delivers structured boot injections, and the existing document activates its client plugins after they are applied; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
 
-The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available only in a packaged application with loaded runtime metadata and available resources. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again.
+The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available with loaded runtime metadata and available resources, including in development mode. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again.
 
-Desktop starts the actual Host after preparing the profile in place, without booting a separate health-check backend. Package mutations retain dependency validation, approved lifecycle builds, runtime identity checks, and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
+Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
 
 This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md

@@ -12,11 +12,11 @@ profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in
 
 ## 决策
 
-Electron 在 profile 校准或 Host 启动前创建带本地加载页的主窗口。该页面仅依赖已打包的壳资源,并通过自有 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
+Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主窗口。Web 入口先显示启动页,再等待 Host 就绪。自有 preload 交付结构化启动注入,现有文档应用注入后激活客户端插件;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
 
-主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。只有已打包应用加载了运行时元数据且资源可用时,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
+主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。加载了运行时元数据且资源可用时,包括开发模式,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
 
-Desktop 直接准备 profile 后启动实际 Host,不另行启动健康检查后端。包修改保留依赖验证、获准生命周期构建、运行时标识检查和锁。失败后保留部分修改,供显式修复;不自动回滚 profile。
+Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供认证 Host URL 与启动注入。壳使用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为归属的应用 origin 认证直接 WebSocket 请求。这一载体适配保留 Web 路由与流语义,同时允许静态 HTML 在 Host 之前显示。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。
 
 本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
-2026-09-09-desktop-in-place-profile.md: 9a221bb334983a18366baeec00a179646dc76385
-2026-09-09-desktop-in-place-profile.zh.md: 53a744ae9ea284c32c3807f67ad365d270217287
+2026-09-09-desktop-in-place-profile.md: 425145ad89f9697c2420544b0ce2e70759313ffc
+2026-09-09-desktop-in-place-profile.zh.md: 8b98ec1c2e426b0019115a9a19eb84db4b9caef8

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md

@@ -10,13 +10,13 @@ Staging preserves an old plugin installation but adds profile copying, directory
 
 ## Decision
 
-Desktop stops the Host and modifies the current profile directly. Shared host links are detached for package changes and restored when the operation settles. Package locking, dependency validation, and approved native builds remain. Compatible upgrades refresh links without copying plugin files.
+Desktop stops the Host and modifies the current profile directly. Shared app-boot cleanup detaches its own fallback links before package changes; the Host’s shared profile runner supplies required links on startup. Package locking and configured lifecycle scripts remain. Upgrades refresh module links without copying plugin files.
 
 Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted.
 
-This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active.
+This supersedes staging and rollback in [the packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md), [the bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), and [the immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md). Host boot follows the [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md); release, module ownership, and window lifecycle remain separate decisions.
 
-A persistent `desktop-packages-pending` marker precedes package writes or native-runtime rebuilding and is removed only after installation, approved builds, and validation succeed. A later launch with that marker reinstalls the locked graph and retries pending builds even when recorded runtime metadata already matches. Ordinary unchanged startups reuse the profile without scanning the plugin dependency graph; package mutations and runtime reconciliation retain validation.
+Desktop delegates installation and lifecycle scripts to pnpm, without a pending-operation startup gate, frozen-lockfile reinstall, or automatic rebuild. Failed package operations preserve partial changes and leave disable, remove, reset, and startup retry available. The Host inherits the user environment, and profiles may use directory links. An unchanged legacy Desktop-generated pnpm configuration is replaced with the Web defaults; customized configuration remains user-owned.
 
 ## Alternatives considered
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md

@@ -10,13 +10,13 @@ staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢
 
 ## 决策
 
-Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共享链接,操作结束后恢复链接。保留包锁、依赖验证和已批准的原生构建。兼容升级只刷新链接,不复制插件文件。
+Desktop 停止 Host 后直接修改当前 profile。共享 app-boot 清理在包变更前分离其拥有的模块补全链接;Host 的共享 profile runner 在启动时补全所需链接。包锁及配置允许的生命周期脚本保留。升级刷新模块链接,不复制插件文件。
 
 包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。
 
-本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效
+本记录取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)、[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)及[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的暂存与回滚。Host 启动遵循[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md);发布、模块归属与窗口生命周期仍由各自决策负责
 
-持久的 `desktop-packages-pending` 标记先于包写入或原生运行时重建,仅在安装、获准构建和验证成功后删除。后续启动发现该标记时,会重新安装锁定的依赖图并重试待执行构建,即使记录的运行时元数据已经匹配。普通未变化的启动复用 profile,不扫描插件依赖图;包修改和运行时校准保留验证
+Desktop 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除、重置和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理
 
 ## 考虑过的替代方案
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
+2026-09-10-desktop-web-wrapper.md: 5b8df22e520af752ac1b8fefbf2cf2442ce17eca
+2026-09-10-desktop-web-wrapper.zh.md: 44c79d8c914f0b2bdeb69dbeb09f22ce84efe9c8

+ 45 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -0,0 +1,45 @@
+# Agent Note: Run Desktop through the shared Web application
+
+Status: implemented
+
+English | [中文](2026-09-10-desktop-web-wrapper.zh.md)
+
+The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
+
+## Problem
+
+Separate Desktop composition and request transport require their own configuration, module loading, streaming, and asset-serving behavior. Those implementations can omit Web features even when the renderer is shared. Desktop needs its own installation and native controls without maintaining a second application backend.
+
+## Decision
+
+The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
+
+The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and patch-reload policy and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
+
+The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
+
+Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web.
+
+App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared.
+
+Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
+
+This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
+
+## Alternatives considered
+
+**Maintain a second backend composition and carrier.** This permits a portless application, but every Web route, reload behavior, authentication change, and stream capability needs a Desktop implementation or explicit omission. Reintroduction requires a desktop product requirement that cannot use the Web implementation and justifies that continuing cost.
+
+**Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.
+
+**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
+
+**Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation.
+
+## Consequences
+
+Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; independent recovery resources remain available when startup fails.
+
+User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the independent recovery UI rather than triggering stricter admission checks or automatic rollback.
+
+Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement.

+ 45 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: 通过共享 Web 应用运行 Desktop
+
+Status: implemented
+
+[English](2026-09-10-desktop-web-wrapper.md) | 中文
+
+[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
+
+## Problem
+
+独立的 Desktop 组合与请求传输需要分别维护配置、模块加载、流式响应与资源服务行为。即使共享渲染界面,这些实现也可能遗漏 Web 功能。Desktop 需要独立安装与原生控件,但不需要第二套应用后端。
+
+## Decision
+
+私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
+
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表和 patch 重载策略初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
+
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
+
+独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。
+
+App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。
+
+共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
+
+本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
+
+## Alternatives considered
+
+**维护第二套后端组合与传输。** 这允许应用不监听端口,但每项 Web 路由、重载行为、认证变化和流式能力都需要 Desktop 实现或明确省略。只有无法使用 Web 实现、且足以承担持续维护成本的桌面产品需求,才支持重新引入这种方案。
+
+**合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。
+
+**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
+
+**固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。
+
+## Consequences
+
+Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;独立恢复资源在启动失败时仍可用。
+
+用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
+
+验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md
+2026-09-10-windows-native-installer-pages.md: 93aa4839620207447ad85b1027a68fcfdc5d3e2c
+2026-09-10-windows-native-installer-pages.zh.md: da28e22f82c6e403696904362bcbe6c8b96881b8

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md

@@ -0,0 +1,31 @@
+# Agent Note: Native Windows installer pages
+
+Status: implemented
+
+English | [中文](2026-09-10-windows-native-installer-pages.zh.md)
+
+## Problem
+
+The Windows installation interface needs branded light and dark pages without introducing another application runtime or replacing the release mechanisms that extract, register, upgrade, and uninstall Desktop.
+
+## Decision
+
+The installer adds custom welcome, progress, and completion pages through electron-builder's NSIS include. The stock script remains responsible for installation and uninstaller generation. A custom full script bypasses electron-builder's separate uninstaller signing path and is therefore unsuitable for this interface change. The [Desktop release decision](2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity, update distribution, and signing requirements.
+
+NSIS native controls preserve directory editing, folder selection, checkbox state, and keyboard interaction. An x86 Win32/GDI+ helper retains DWM shadows and draws installation progress on the UI thread while the stock installation worker runs. Stock page visibility is suppressed even when NSIS shows the page after MUI's callback. Windows 11 supplies the outer corner radius; Windows 10 retains its supported frame appearance.
+
+Installation is per-user. Welcome-page leave validation reads the current edit control for mouse and keyboard navigation; the debounced inline hint is not an installation authority. Running-process checks match the affected executable path, leaving other installations independent. Completion-page leave honors the launch checkbox for both mouse and keyboard navigation. Electron-builder resolves the registered directory before custom initialization, so silent updates without `/D=` retain that directory. Directory staging and promotion retain the release installer’s rollback behavior; first-launch profile preparation remains outside the installer.
+
+## Alternatives considered
+
+**An Electron installer interface** adds a runtime before the application exists and requires a separate installation bridge. Native controls provide the required interaction within the existing installer.
+
+**Replacing the full NSIS script with the lightweight prototype** also replaces upgrade, registry, uninstaller, and signing behavior. The prototype's transaction mechanism requires separate release qualification and is excluded from the UI integration.
+
+**A window region for rounded corners** disables the DWM frame shadow. The system frame preserves the shadow while accepting the platform's corner radius.
+
+## Consequences
+
+Windows packaging additionally requires the x86 Visual C++ compiler and Windows SDK. The helper is signed before embedding by the same signer as other Windows artifacts. The preparation hook returns true on every platform so electron-builder collects production dependencies. The directory installer owns staging, promotion, registration, and recovery. Its extraction hook invokes the pinned 7-Zip executable with a dedicated progress pipe and a separate diagnostic file. The child inherits only its standard streams and joins a kill-on-close job at creation, so installer termination also stops extraction. Only a zero exit code permits promotion. The helper parses percentages across pipe-read boundaries; it does not implement archive extraction or recursive copying. Preparation, promotion, registration, and cleanup retain bounded estimates. Stage weights express completed work, not remaining time. Displayed progress never regresses, and captions follow the displayed stage. NSIS success authorizes a 600 ms fill animation and a brief 100% frame, with a 750 ms transition target; timers cannot authorize success. The frame stays hidden until branded controls are ready and is initialized once; page transitions preserve its position. Finish hides the window before creating the installed process directly under the current user. An explicitly elevated installer retains shell-mediated launch. Launch failure restores the finish page. Application startup time is independent of installer dismissal.
+
+The native installer regression builds English-only and Chinese-only variants and identifies their language from the visible welcome button; the Windows installation language does not determine test labels. Sequential runs use a unique product identity and private installation directories to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, first-show readiness, and uninstall. Registered paths with trailing separators remain upgrade destinations, while drive roots remain invalid. Deterministic native progress tests cover fast and slow extraction, stalls, source resets, fragmented progress tokens, short cleanup, and success between UI ticks. Directory tests exercise extraction through the helper, long paths, new installation, replacement, locked-file recovery, missing staged directories, broken archives, and cancellation. Screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 原生 Windows 安装页面
+
+Status: implemented
+
+[English](2026-09-10-windows-native-installer-pages.md) | 中文
+
+## Problem
+
+Windows 安装界面需要符合品牌设计的亮暗页面,同时避免引入额外的应用运行时,也不能替换 Desktop 的解压、注册、升级和卸载发布机制。
+
+## Decision
+
+安装程序通过 electron-builder 的 NSIS include 接入自定义欢迎页、进度页和完成页。原生脚本继续负责安装和卸载程序生成。完整自定义脚本会绕过 electron-builder 单独生成并签名卸载程序的流程,因此不适合这次界面改动。[Desktop 发布决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)继续负责发布身份、更新分发和签名要求。
+
+NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交互。x86 Win32/GDI+ 辅助库保留 DWM 阴影,并在原生安装工作线程运行期间,由界面线程绘制安装进度。即使 NSIS 在 MUI 回调之后重新显示页面,原生页面仍保持隐藏。Windows 11 提供外框圆角半径;Windows 10 保留其支持的窗口外观。
+
+安装面向当前用户。欢迎页离开校验在鼠标和键盘导航时均读取当前编辑框;防抖显示的行内提示不决定实际安装路径。进程检查匹配受影响的可执行文件路径,使其他安装保持独立。完成页离开回调在鼠标和键盘导航时均遵循启动复选框。electron-builder 在自定义初始化之前解析已登记目录,因此不带 `/D=` 的静默更新会保留该目录。目录暂存和替换保留发布安装器的回滚行为;首次启动的配置档案准备仍不属于安装程序。
+
+## Alternatives considered
+
+**Electron 安装界面**会在应用安装前引入运行时,并要求额外的安装通信机制。原生控件能在现有安装程序内提供所需交互。
+
+**使用轻量原型替换完整 NSIS 脚本**还会替换升级、注册表、卸载和签名行为。原型的事务机制需要单独进行发布验证,不纳入界面接入。
+
+**使用窗口区域裁剪圆角**会禁用 DWM 窗口阴影。系统窗口框架保留阴影,同时接受平台提供的圆角半径。
+
+## Consequences
+
+Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库在嵌入前使用与其他 Windows 产物相同的签名器签名。准备钩子在所有平台返回 true,使 electron-builder 收集生产依赖。目录安装器负责暂存、替换、注册和恢复。其解压钩子通过独立进度管道和单独的诊断文件调用锁定版本的 7-Zip 可执行文件。子进程仅继承标准流句柄,并在创建时加入关闭即终止的 Job,因此终止安装程序也会停止解压。只有退出码为零才允许替换目录。辅助库跨管道读取边界解析百分比,不自行实现压缩包解压或递归复制。准备、替换、注册和清理仍使用有界估算。阶段权重表示已完成工作量,而非剩余时间。显示进度不回退,文案跟随显示阶段。NSIS 成功信号允许执行 600 毫秒补满动画并短暂显示 100%,切换目标时长为 750 毫秒;计时器不能宣告成功。窗口框架在品牌控件准备完成前保持隐藏,且只初始化一次,页面切换保留其位置。点击完成后先隐藏窗口,再以当前用户直接创建已安装应用的进程;显式提权的安装程序保留通过用户桌面启动的方式。启动失败会恢复完成页。应用启动耗时与安装窗口关闭分别处理。
+
+原生安装回归构建仅英文和仅中文的变体,并从可见的欢迎页按钮识别语言;Windows 安装语言不决定测试文案。顺序执行的测试使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。末尾带分隔符的已登记路径仍可用于升级,磁盘根目录仍然无效。确定性的原生进度测试覆盖快速和慢速解压、停滞、进度来源重置、分片进度文本、短暂清理,以及两次界面刷新之间成功的情况。目录测试通过辅助库执行解压,覆盖长路径、新装、替换、文件占用恢复、暂存目录缺失、损坏压缩包和取消。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
+2026-09-11-desktop-electron-node-runtime.md: 6743bbfcc9acffb04d28ef9f1540d516b10dd2fc
+2026-09-11-desktop-electron-node-runtime.zh.md: 73622e66183eb7ed94f654e861cb061e02be7a3d

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md

@@ -0,0 +1,27 @@
+# Agent Note: Use Electron as the Desktop Node runtime
+
+Status: implemented
+
+English | [中文](2026-09-11-desktop-electron-node-runtime.zh.md)
+
+## Problem
+
+Shipping an upstream Node executable alongside Electron duplicates the JavaScript runtime. Desktop needs one runtime for its Host and package scripts without requiring users to install Node.
+
+## Decision
+
+Desktop runs the shared Web Host and bundled pnpm through its own Electron executable with `ELECTRON_RUN_AS_NODE=1`. It ships no separate upstream Node executable. The target Electron distribution supplies both the packaging input and the runtime used to prepare and verify production dependencies; release metadata records its actual Node version. Development uses the installed Electron distribution.
+
+This supersedes the separate-Node choice in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Their independent plugin storage and ordinary resource-directory layout remain applicable. The [Web wrapper](2026-09-10-desktop-web-wrapper.md) retains the shared profile runner and HTTP transport.
+
+## Consequences
+
+Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled.
+
+Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
+
+Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN.
+
+## Alternatives considered
+
+A separate Node executable decouples the Host from Electron's runtime but adds another binary, download, signature, and version selection. Electron RunAsNode removes that duplication. Moving production packages into ASAR is a separate change involving native modules, package resolution, and subprocess paths; the Host continues to load ordinary resource files.

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 使用 Electron 作为 Desktop 的 Node 运行时
+
+Status: implemented
+
+[English](2026-09-11-desktop-electron-node-runtime.md) | 中文
+
+## 问题
+
+在 Electron 之外携带上游 Node 可执行文件会重复分发 JavaScript 运行时。Desktop 需要让 Host 和包脚本共用一个运行时,无需用户安装 Node。
+
+## 决策
+
+Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置 pnpm,并设置 `ELECTRON_RUN_AS_NODE=1`。应用不携带独立的上游 Node 可执行文件。目标 Electron 分发包同时作为打包输入,以及准备和验证生产依赖的运行时;发布元数据记录其实际 Node 版本。开发模式使用已安装的 Electron 分发包。
+
+此决策替代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的独立 Node 选择。独立插件存储和普通资源目录布局仍然适用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)保留共享 profile runner 和 HTTP 传输。
+
+## 后果
+
+Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。
+
+包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
+
+Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。
+
+## 考虑过的替代方案
+
+独立 Node 可执行文件可以让 Host 与 Electron 运行时分离,但会增加另一份二进制文件、下载、签名和版本选择。Electron RunAsNode 消除这一重复。将生产包移入 ASAR 是另一项涉及原生模块、包解析和子进程路径的改动;Host 继续加载普通资源文件。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.md
+2026-09-11-windows-directory-installation.md: 8208c6cc4b28de8d1ab947cb8e6afba7bfa4f673
+2026-09-11-windows-directory-installation.zh.md: d337397e8ea654d14cccd0839460763b66a19457

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.md

@@ -0,0 +1,29 @@
+# Agent Note: Replace Windows application directories after staging
+
+Status: implemented
+
+English | [中文](2026-09-11-windows-directory-installation.zh.md)
+
+## Problem
+
+The Desktop distribution contains thousands of small files. Extracting to the temporary directory, copying every file to the installation, and deleting the temporary tree repeats filesystem work. A 616,701,792-byte, 11,735-file payload took 101.235, 79.203, and 114.391 seconds through those three phases on Windows; copying accounted for 70.296, 56.688, and 85.281 seconds. These component measurements exclude old-version removal, registration, startup, and cache clearing.
+
+## Decision
+
+The [NSIS adapter](../../../../apps/desktop/scripts/windows-directory-installer.mjs) retains electron-builder's installer, signed uninstaller generation, registration, shortcuts, and updater cache. It stages the complete new application beside the destination before stopping the old application. The [directory transaction](../../../../apps/desktop/scripts/installer-directories.nsh) renames the old directory to a unique backup, renames the new directory to the destination, and removes the backup before launch. Both renames stay on the destination volume. This replaces the copy-based installation decision in the [packaging note](2026-08-25-electron-desktop-packaging-and-updates.md).
+
+The installer embeds its pinned 7-Zip command-line tool, signs its private copy for signed releases, and includes its license texts. Windows records the copied runtime inventory after its executable resources have been signed. A nonzero extraction exit leaves the old application intact. Explicit cleanup before upstream installer exits also covers silent cancellation. Same-path upgrades bypass the old uninstaller so it cannot delete the rollback copy or registration prematurely. Different-path and installation-scope migrations retain electron-builder's old-uninstaller behavior; the directory rollback does not undo those uninstall operations.
+
+## Consequences
+
+Directory cleanup, including uninstallation, uses extended-length Windows paths for deeply nested dependencies and backup suffixes. Failed promotion attempts restore the renamed old directory. If another process prevents restoration, the complete backup remains available. Abrupt process termination or power loss can leave staging or backup directories; the installer does not claim crash-atomic replacement across two renames. The transaction changes installation files, not the external Desktop plugin profile or product data.
+
+## Alternatives considered
+
+- **Direct extraction over the running installation.** The existing locked-file probe demonstrates that `Nsis7z::Extract` can retain an old file without setting the NSIS error flag. Staging through a tool with an exit status avoids accepting a mixed installation.
+- **Delete the old version before extraction.** A corrupt archive or failed write would remove the only usable version before replacement is ready.
+- **Put the Host in ASAR.** Native modules, package resolution, and subprocess paths require separate qualification; directory replacement preserves the resource layout.
+
+## Verification
+
+The [native directory smoke](../../../../apps/desktop/scripts/smoke-installer-directories.ps1) calls production macros against private directories. It covers fresh installation, obsolete-file removal on upgrade, locked-directory failure, restoration after the second rename fails, corrupt archives, and cancellation cleanup. Template tests pin staging before shutdown and promotion before registration. The signed 0.1.5-rc.2 installer passed fresh installation, same-path upgrade with obsolete-file removal, locked-file failure preserving the old installation, installation-location migration, and complete uninstallation on Windows. Each installation check compared all 11,736 payload files against the packaged tree and verified executable/uninstaller signatures and registration. The installed runtime passed native dependency, Web frontend, external plugin route, and actual Electron window startup/exit checks. The final installer sample took 38.0 seconds for fresh installation and 27.5 seconds for same-path upgrade, excluding verification and launch; caches were not cleared. These are full-installer observations, separate from the component baseline above. Hosted updater download and upgrades from historical published releases remain release qualification.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 暂存完成后替换 Windows 应用目录
+
+Status: implemented
+
+[English](2026-09-11-windows-directory-installation.md) | 中文
+
+## 问题
+
+Desktop 分发包包含数千个小文件。先解压到临时目录,再将每个文件复制到安装位置,最后删除临时树,会重复执行文件系统操作。Windows 上一份 616,701,792 字节、11,735 个文件的载荷经过这三个阶段分别耗时 101.235、79.203 和 114.391 秒,其中复制占 70.296、56.688 和 85.281 秒。这些组件测量不包括旧版卸载、注册、启动,也没有清空缓存。
+
+## 决策
+
+[NSIS 适配器](../../../../apps/desktop/scripts/windows-directory-installer.mjs)保留 electron-builder 的安装器、签名卸载器生成、注册、快捷方式和更新缓存。它在停止旧应用前,将完整新应用暂存到目标目录旁。[目录事务](../../../../apps/desktop/scripts/installer-directories.nsh)把旧目录改名为唯一备份,将新目录改名为正式目标,并在启动前删除备份。两次改名都位于目标卷。这替代了[打包记录](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中基于复制的安装决策。
+
+安装器嵌入固定版本的 7-Zip 命令行工具,为签名发布签署其私有副本,并携带许可证文本。Windows 在复制后的运行时可执行资源签名完成后记录文件清单。解压退出码非零时,旧应用保持完整。上游安装器退出前显式清理目录,也覆盖静默取消。同路径升级跳过旧卸载器,避免其提前删除回滚副本或注册信息。不同路径和安装范围迁移保留 electron-builder 的旧卸载器行为;目录回滚不会撤销这些卸载操作。
+
+## 影响
+
+目录清理(包括卸载)使用 Windows 扩展长度路径,覆盖深层依赖与备份后缀。新目录替换失败时,安装器尝试恢复已改名的旧目录。如果另一个进程阻止恢复,完整备份仍保留。进程被强制结束或断电可能留下暂存或备份目录;安装器不承诺跨两次改名的崩溃原子性。事务只改变安装文件,不改变外部 Desktop 插件 profile 或产品数据。
+
+## 考虑过的替代方案
+
+- **直接覆盖运行中的安装目录。** 现有文件占用探针证明,`Nsis7z::Extract` 可能保留旧文件而不设置 NSIS 错误标志。使用具有退出状态的工具进行暂存,可以避免接受混合版本安装。
+- **解压前删除旧版本。** 压缩包损坏或写入失败时,会在替换就绪前移除唯一可用版本。
+- **把 Host 放入 ASAR。** 原生模块、包解析和子进程路径需要单独验证;目录替换保留资源布局。
+
+## 验证
+
+[原生目录 smoke](../../../../apps/desktop/scripts/smoke-installer-directories.ps1)在私有目录中调用生产宏。它覆盖首次安装、升级时删除过时文件、目录占用失败、第二次改名失败后的恢复,损坏归档,以及取消清理。模板测试固定暂存早于关闭应用、正式替换早于注册。签名的 0.1.5-rc.2 安装器在 Windows 上通过了首次安装、同路径升级并移除过时文件、文件占用失败后保留旧安装、变更安装位置及完整卸载。每次安装检查都将全部 11,736 个载荷文件与打包目录比对,并验证程序、卸载器签名及注册信息。已安装运行时通过了原生依赖、Web 前端、外部插件路由及实际 Electron 窗口启动和退出检查。最终安装器的一次样本中,首次安装耗时 38.0 秒,同路径升级耗时 27.5 秒,不包含校验和启动,未清空缓存。这些完整安装器观测与上面的组件基线分别记录。线上更新下载和从历史已发布版本升级仍属于发布验收。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.md
+2026-09-14-windows-lock-release-probe.md: 76a21f655fc8c1b299e3507beffabfacde3ac0d9
+2026-09-14-windows-lock-release-probe.zh.md: 292203d3931225f0000d56cd5cb5d700ecb0bcc7

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.md

@@ -0,0 +1,21 @@
+# Agent Note: Windows lock release before contention probing
+
+Status: implemented
+
+English | [中文](2026-09-14-windows-lock-release-probe.zh.md)
+
+## Problem
+
+Windows exclusive file creation can report `EPERM` while another writer owns the lock. The holder can remove the lock before the contender's `lstat`, so absence at that later observation does not prove the create failed for a persistent permission restriction. The profile module-fallback contention test exposed this race.
+
+## Decision
+
+`withFileLock` permits one unconfirmed `EPERM` retry per Windows acquisition, using the existing backoff and deadline. Confirmed contention still waits normally. A second unconfirmed permission failure is rethrown, and the protected operation runs only after exclusive creation succeeds.
+
+## Alternatives considered
+
+Retrying every permission error until timeout would obscure persistent permission failures. Requiring the lock to exist during the later probe rejects a valid release race. Delaying the test's holder release changes timing without repairing the writer protocol.
+
+## Consequences
+
+A persistent Windows permission failure can incur one backoff before failing. POSIX behavior is unchanged. The atomic-write tests force release before the probe and verify both successful acquisition and persistent permission rejection; the profile tests exercise real competing writers. Existing credential-registration notes retain ownership of write serialization and are not superseded by this acquisition rule.

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.zh.md

@@ -0,0 +1,21 @@
+# Agent Note: Windows 锁在竞争探测前释放
+
+Status: implemented
+
+[English](2026-09-14-windows-lock-release-probe.md) | 中文
+
+## 问题
+
+Windows 独占创建文件时,若另一写入方持有锁,可能报告 `EPERM`。持锁方可能在竞争者执行 `lstat` 前删除锁,因此之后观察到锁不存在,并不能证明创建失败源于持续的权限限制。profile 模块回退的竞争测试暴露了这一竞态。
+
+## 决策
+
+`withFileLock` 在每次 Windows 获取锁的过程中允许对无法确认锁存在的 `EPERM` 重试一次,沿用现有退避与截止时间。确认存在的竞争仍正常等待。第二次无法确认锁存在的权限失败会重新抛出,且只有独占创建成功后才会运行受保护操作。
+
+## 考虑过的替代方案
+
+对所有权限错误一直重试到超时会掩盖持续的权限失败。要求锁在之后探测时仍存在会拒绝合法的释放竞态。延迟测试中持锁方的释放只改变时序,无法修复写入协议。
+
+## 后果
+
+Windows 持续的权限失败可能在一次退避后才报告。POSIX 行为不变。atomic-write 测试强制在探测前释放锁,并验证成功获取锁和持续权限失败;profile 测试覆盖真实的竞争写入方。现有凭据注册笔记仍负责写入串行化决策,不会被此获取锁规则取代。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md
+2026-09-15-linear-session-list-cache.md: fb09950f77c224d2b85bb76792bdf8c3c87e33f5
+2026-09-15-linear-session-list-cache.zh.md: b877f0659fff4e1b3d48aa4250e634120ddc365f

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md

@@ -0,0 +1,25 @@
+# Agent Note: Linear Session list cache reconciliation
+
+Status: implemented
+
+English | [中文](2026-09-15-linear-session-list-cache.zh.md)
+
+## Problem
+
+The Client Session list retains row objects for React reference stability. Scanning the entire new list for every cached ID makes snapshot rebuilds quadratic, including first hydration because new rows enter the cache before cleanup. Thousands of Sessions can occupy the browser thread during list updates.
+
+## Decision
+
+SessionManager builds one ID set from its reconciled rows and uses it for cache eviction and selected-row membership. Field comparisons, row identities, array reuse, lineage order, and retained subagent addresses keep their existing semantics.
+
+## Alternatives considered
+
+**Replace the row cache on every rebuild.** A new Map can also remove missing entries linearly, but requires changing the row-reuse path. A temporary membership set confines the change to membership checks.
+
+**Enforce unit-test wall-clock limits.** Shared CI load makes tight time budgets unreliable. An instance-local ID accessor counts membership reads during repeated refreshes; the original implementation exceeds the linear bound without depending on machine speed.
+
+## Consequences
+
+Reconciliation uses O(n + c) time and O(n) temporary membership storage for n current rows and c cached rows. Missing rows lose cached identity; unchanged rows and their array retain identity, and an off-list selection candidate can become current again when its row returns.
+
+Local macOS arm64 Node 26 measurements compile the production manager to JavaScript and time subscribed refreshes through the resulting list snapshot, with fresh synthetic Remote response objects. After three warmups, nine samples give median refresh times of 0.67, 1.40, 2.78, and 5.46 ms for 1,000, 2,600, 5,000, and 10,000 rows. The corresponding original medians are 4.86, 29.01, 30.62, and 459.89 ms. These measurements include list reconstruction and exclude server scanning, transport, DOM rendering, browser input, and memory measurement; they do not establish end-to-end reconnect latency. The focused manager tests cover eviction, empty lists, selection recovery, identity reuse, and the linear read bound.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Session 列表缓存的线性对账
+
+Status: implemented
+
+[English](2026-09-15-linear-session-list-cache.md) | 中文
+
+## 问题
+
+Client Session 列表保留行对象,以维持 React 引用稳定性。为每个缓存 ID 扫描整个新列表,会使快照重建成本按平方增长;首次加载也受影响,因为新行在清理之前就已进入缓存。数千个 Session 可能在列表更新时占用浏览器线程。
+
+## 决策
+
+SessionManager 从对账后的行构建一个 ID 集合,用于缓存淘汰和选中行的成员检查。字段比较、行对象标识、数组复用、派生顺序和保留的子智能体地址维持现有语义。
+
+## 考虑过的替代方案
+
+**每次重建都替换行缓存。** 新 Map 同样可以在线性时间内移除缺失条目,但需要修改行复用路径。临时成员集合把改动限定在成员检查。
+
+**在单元测试中限制实际耗时。** 共享 CI 负载会使严格时间预算不可靠。实例局部的 ID 访问器统计重复刷新时的成员读取次数;原实现超过线性上限,无需依赖机器速度。
+
+## 影响
+
+对于 n 个当前行和 c 个缓存行,对账使用 O(n + c) 时间和 O(n) 临时成员存储。缺失行失去缓存标识;未变化的行及其数组保留标识,离开列表的候选选中项在行返回后可以再次成为当前项。
+
+本地 macOS arm64 Node 26 测量将生产 manager 编译为 JavaScript,并在存在订阅时,从刷新开始计时到结果列表快照可读,Remote 响应使用新建的合成对象。预热三次后采集九个样本,1,000、2,600、5,000 和 10,000 行的刷新耗时中位数分别为 0.67、1.40、2.78 和 5.46 ms。原实现对应中位数为 4.86、29.01、30.62 和 459.89 ms。测量包含列表重建,排除服务端扫描、传输、DOM 渲染、浏览器输入和内存测量,因此不能证明端到端重连延迟。聚焦 manager 的测试覆盖淘汰、空列表、选择恢复、标识复用和线性读取上限。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
+2026-09-13-macos-hidden-titlebar-vibrancy.md: b6b2f249a1843f937cf73d4eb193d47e1b9e8ad5
+2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 8d4a4d17bda2e8c7845b66443cc883c6177b7343

+ 49 - 0
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md

@@ -0,0 +1,49 @@
+# Agent Note: macOS hidden titlebar with vibrancy sidebar
+
+Status: implemented
+
+English | [中文](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)
+
+## Problem
+
+The desktop app drew the stock macOS titlebar: an opaque bar above the web UI that repeats chrome the page already has, spends vertical space, and keeps the sidebar from reaching the window's top edge. The window looked like a browser tab rather than a macOS application, and no mechanism existed for platform-specific presentation — every pixel was identical on macOS, Windows, and the plain web.
+
+## Decision
+
+The Electron main process opens the main window on darwin with `titleBarStyle: 'hiddenInset'`, `trafficLightPosition: { x: 16, y: 18 }`, `vibrancy: 'sidebar'`, `visualEffectState: 'active'`, and a transparent `backgroundColor`. `'active'` keeps the material stable behind an unfocused window; `'followWindow'` washed the sidebar out on blur.
+
+Every web-side adjustment keys off `html[data-platform]`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). The plain web and non-darwin desktop render exactly as before.
+
+**Transparency chain.** Vibrancy shows only through transparent pixels: on darwin `html`/`body` (ui-web base.css) and the AppFrame are transparent, the center column paints `--dsw-alias-bg-base` opaque, and the sidebar column paints a translucent `color-mix` tint of the sidebar fill so the material reads through it. SidebarRoot's own opaque fill moves to the frame column for the same reason.
+
+**Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`.
+
+**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen.
+
+**Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs.
+
+**Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth.
+
+## Alternatives considered
+
+**`titleBarStyle: 'hidden'` with custom window controls.** Rebuilding the traffic lights forfeits native behavior (hover glyphs, fullscreen transitions) for no gain; `hiddenInset` keeps them native and only asks the page to route around them.
+
+**Keeping the 56px rail on darwin.** The rail under floating traffic lights doubled the chrome in the window's corner and wasted the width the collapse exists to reclaim; full hide with header-hosted reopen controls matches macOS sidebar conventions.
+
+**Mirroring the resolved theme instead of the preference.** Forwarding `light`/`dark` while the user preference is `system` would freeze the window material at the value resolved at send time; forwarding `system` lets macOS keep following the OS appearance natively.
+
+**Handling traffic-light clearance inside ui-dockkit.** The kit is host-agnostic and cannot know which host corner touches window chrome; publishing an inset variable keeps the policy in the host that owns the placement (ui-sidebar-right) and costs the kit one custom property.
+
+**A collapse-state prop pipe into the header controls.** The AppFrame already publishes `data-sidebar-collapsed`; CSS visibility against it avoids a second state path that could disagree with the frame's transition timeline.
+
+## Consequences
+
+- The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets.
+- The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering.
+- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
+- Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable.
+- The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash.
+
+## Testing
+
+ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet.

+ 49 - 0
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md

@@ -0,0 +1,49 @@
+# Agent Note: macOS hidden titlebar with vibrancy sidebar
+
+Status: implemented
+
+[English](2026-09-13-macos-hidden-titlebar-vibrancy.md) | 中文
+
+## Problem
+
+桌面应用此前使用 macOS 原生标题栏:Web UI 上方一条不透明的横条,重复页面已有的窗口装饰、占用纵向空间,并让侧边栏无法触及窗口顶边。窗口看起来像浏览器标签页而非 macOS 应用,且不存在任何按平台差异化呈现的机制——macOS、Windows 与纯 Web 上每个像素都完全一致。
+
+## Decision
+
+Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLightPosition: { x: 16, y: 18 }`、`vibrancy: 'sidebar'`、`visualEffectState: 'active'` 与透明 `backgroundColor` 打开主窗口。`'active'` 让窗口失焦时材质保持稳定;`'followWindow'` 会在失焦时把侧边栏冲淡。
+
+所有 Web 侧调整均以 `html[data-platform]` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。纯 Web 与非 darwin 桌面的渲染与之前完全一致。
+
+**透明链。** 毛玻璃只透过透明像素显现:darwin 上 `html`/`body`(ui-web base.css)与 AppFrame 透明,中间列铺不透明的 `--dsw-alias-bg-base`,侧边栏列铺侧边栏底色的半透明 `color-mix`,让材质透出。SidebarRoot 自身的不透明底色出于同一原因移到框架列。
+
+**原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。
+
+**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
+
+**拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。
+
+**全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。
+
+## Alternatives considered
+
+**`titleBarStyle: 'hidden'` + 自绘窗口控件。** 重造红绿灯会失去原生行为(悬停图形、全屏过渡)且无收益;`hiddenInset` 保留原生控件,只要求页面绕行。
+
+**darwin 上保留 56px rail。** 浮动红绿灯下的 rail 让窗口角落装饰翻倍,也浪费了收起本要回收的宽度;完全隐藏 + 头部承载重开控件符合 macOS 侧边栏惯例。
+
+**同步解析后的主题而非偏好。** 偏好为 `system` 时转发 `light`/`dark` 会把窗口材质冻结在发送时刻的解析值;转发 `system` 让 macOS 原生持续跟随系统外观。
+
+**在 ui-dockkit 内部处理红绿灯避让。** kit 与宿主无关,不可能知道哪个宿主角落贴着窗口装饰;发布内边距变量把策略留在拥有布局位置的宿主(ui-sidebar-right),kit 只付出一个自定义属性。
+
+**向头部控件加收起状态 prop 管道。** AppFrame 已发布 `data-sidebar-collapsed`;用 CSS 对其判断显隐,避免了可能与框架过渡时间线不一致的第二条状态路径。
+
+## Consequences
+
+- macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。
+- 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。
+- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
+- 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。
+- 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。
+
+## Testing
+
+ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
+2026-09-14-desktop-primary-runtime.md: 54dfba5d786b0e557f3e52b104a89e53fb091ecb
+2026-09-14-desktop-primary-runtime.zh.md: f57f2dbf5efdda9c09aefa3fd78cb9f0f2a7b8ea

+ 31 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md

@@ -0,0 +1,31 @@
+# Agent Note: Desktop primary runtime
+
+Status: implemented
+
+English | [中文](2026-09-14-desktop-primary-runtime.zh.md)
+
+## Problem
+
+Desktop agents need predictable Python data-processing libraries and an independent Node interpreter on machines without development environments. System interpreter selection must remain under user control.
+
+## Decision
+
+Desktop ships Python, Node.js, pnpm, numpy and pandas as one release-bound payload. The path-query tool installs the payload from application resources into the fixed Harness-home directory and returns absolute paths. It does not change PATH, environment variables or package-manager configuration. pnpm uses its native global-install rules.
+
+The application version and component versions live in `runtime.json`, not the directory name. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching releases reuse installed files; upgrades replace user-added Python dependencies inside the managed tree. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests.
+
+Node downloads and hash-verifies the complete locked wheel set and unpacks these library-only archives into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Wheels with `.data` installation directories are rejected; command-line entry-point wrappers are outside this library payload. Native smoke executes the final payload after temporary files are removed, so interpreter links must survive relocation.
+
+macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
+
+## Alternatives considered
+
+**System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas.
+
+**PATH injection and dedicated pnpm global directories.** They change command selection or require pnpm's global command directory to be on PATH. Absolute interpreter paths and native pnpm behavior satisfy the requested scope without those changes.
+
+**Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable.
+
+## Consequences
+
+The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Desktop 第一方 Runtime
+
+Status: implemented
+
+[English](2026-09-14-desktop-primary-runtime.md) | 中文
+
+## Problem
+
+Desktop 代理需要在没有开发环境的机器上获得确定的 Python 数据处理库和独立 Node 解释器。系统解释器的选择必须继续由用户控制。
+
+## Decision
+
+Desktop 将 Python、Node.js、pnpm、numpy 和 pandas 作为绑定应用版本的产物交付。路径查询工具从应用资源将产物安装到 Harness home 下的固定目录,并返回绝对路径。它不修改 PATH、环境变量或包管理器配置。pnpm 使用原生全局安装规则。
+
+应用版本和组件版本记录在 `runtime.json` 中,不放在目录名里。安装发布完整的暂存副本,并在替换成功前保留之前的目录。同版本复用已安装文件;升级替换受管目录内用户添加的 Python 依赖。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。
+
+Node 下载并校验完整锁定 wheel 集的哈希,将这些仅含库的压缩包解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。含 `.data` 安装目录的 wheel 会被拒绝;命令行入口包装器不属于该库产物。本机 smoke 在临时文件删除后执行最终产物,因此解释器链接必须在迁移后仍有效。
+
+macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
+
+## Alternatives considered
+
+**只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。
+
+**注入 PATH 并指定 pnpm 全局目录。** 这些方式会改变命令选择,或要求 pnpm 全局命令目录已在 PATH 中。绝对解释器路径和 pnpm 原生行为无需这些改动即可满足请求范围。
+
+**独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。
+
+## Consequences
+
+应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。

+ 4 - 1
.github/workflows/ci.yml

@@ -346,7 +346,10 @@ jobs:
 
       - name: Install Playwright Chromium and hosted dependencies
         if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
-        run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium
+        # Ephemeral runner images can carry Ubuntu mirrors whose HTTP endpoints are unreachable.
+        run: |
+          sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) -exec sed -i -E 's@http://([a-z]{2}\.)?archive\.ubuntu\.com/ubuntu@https://archive.ubuntu.com/ubuntu@g; s@http://security\.ubuntu\.com/ubuntu@https://security.ubuntu.com/ubuntu@g' {} +
+          pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium
 
       # The persistent VM image owns Playwright's Linux system packages; do
       # not mutate the shared host with apt on every failover run.

+ 2 - 0
.gitignore

@@ -1,5 +1,7 @@
 CLAUDE.local.md
 .env
+apps/desktop/.env.windows
+apps/desktop/.env.macos
 node_modules/
 lib/
 *.tsbuildinfo

+ 4 - 2
AGENTS.md

@@ -109,7 +109,7 @@ If a required `gh`, `pnpm`, build, test, or generator command fails because the
 
 ### Run relevant checks locally
 
-Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass.
+Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass.
 
 - Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers.
 - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
@@ -117,7 +117,9 @@ Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-c
 
 ## Secrets / .env
 
-Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
+Windows packaging/signing: [required reading](apps/desktop/README.md#windows-ev-signing).
+
+Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
 
 ## Conventions
 

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -152,6 +152,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | --- | --- |
 | [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3) | Apache-2.0 |
 | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
+| [`@electron/get`](https://github.com/electron/get) | MIT |
 | [`@electron/notarize`](https://github.com/electron/notarize) | MIT |
 | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT |
 | [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: cfab7a4983c31a21b68157c9cefb38c1654571ff
-README.zh.md: 67d07fba1fc27bca9c2a696daddde88aac3aa39c
+README.md: f2131121b46acc41e6f9a6db9751a8362a9e174a
+README.zh.md: 0fccc161a8bd0a6eaf07ef01fa7741dc15f88081

+ 2 - 0
apps/cli/README.md

@@ -55,4 +55,6 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
 
 Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
 
+The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
+
 The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.

+ 2 - 0
apps/cli/README.zh.md

@@ -55,4 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 
 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
 
+`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
+
 [Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。

+ 10 - 1
apps/cli/package.json

@@ -15,7 +15,8 @@
     "dsh": "lib/bin.js"
   },
   "files": [
-    "lib/*.js"
+    "lib/*.js",
+    "lib/types/*.d.ts"
   ],
   "dsh": {
     "configTrees": [
@@ -148,5 +149,13 @@
     "@types/ws": "8.18.1",
     "execa": "^10.0.0",
     "ws": "8.21.0"
+  },
+  "exports": {
+    "./profile-boot": {
+      "types": "./lib/types/profile-boot.d.ts",
+      "default": "./lib/profile-boot.js"
+    },
+    "./lib/*": "./lib/*",
+    "./package.json": "./package.json"
   }
 }

+ 11 - 69
apps/cli/src/plugin.ts

@@ -17,79 +17,14 @@ import {
   DEFAULT_PROFILE_BUNDLES,
   initProfile,
   PROFILE_TEMPLATES,
-  readProfileManifest,
-  resolveBundleDir,
+  readProfilePlugins,
+  reconcileProfilePlugins,
   resolveProfileDir,
-  writeProfileManifest,
-  type ProfileManifest,
 } from '@deepseek-ai/dsh-app-boot'
 import { INSTALL_ANCHOR } from './profile-boot.ts'
 
 const NAME = 'dsh'
 
-/**
- * Whether a resolved dependency exports a profile patch, i.e. is a bundle.
- * @param packageName - the dependency's package name.
- * @param profileDir - the profile directory (resolution anchor).
- * @returns true when the package manifest declares `dsh.bundle`.
- */
-function exportsPatch(packageName: string, profileDir: string): boolean {
-  let dir: string
-  try {
-    dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir)
-  } catch {
-    return false // pnpm reported success yet the package is unresolvable — treat as plain
-  }
-  const manifest = readProfileManifest(NAME, dir)
-  return manifest.dsh?.bundle?.patch !== undefined
-}
-
-/**
- * Reconcile `dsh.profile.bundles` against the installed state: pnpm has
- * already written the real installed names (so a git/path/tarball/alias spec
- * on the command line reconciles by its true package name) and materialized
- * the packages. A dependency that resolves to a `dsh.bundle`-declaring
- * package joins the layer stack (appended in dependency order); a
- * dependency-listed name that no longer does — removed, or the installed
- * version dropped the declaration — leaves it. In-box bundles from the
- * profile template are not dependencies and are never touched. Warns once
- * per newly-added bundle-less dependency (a plain library is fine; the
- * warning is orientation).
- */
-function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
-  const after = readProfileManifest(NAME, profileDir)
-  const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
-  const dependencies = Object.keys(after.dependencies ?? {})
-  const plugins = after.dsh?.profile?.bundles ?? []
-  let changed = false
-  for (const packageName of dependencies) {
-    const isBundle = exportsPatch(packageName, profileDir)
-    if (isBundle && !plugins.includes(packageName)) {
-      plugins.push(packageName)
-      changed = true
-    } else if (!isBundle && !beforeDeps.has(packageName)) {
-      process.stderr.write(
-        `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
-        + '(a later update that gains one activates it automatically)\n',
-      )
-    }
-  }
-  const dependencySet = new Set(dependencies)
-  for (const packageName of [...plugins]) {
-    // Only dependency-managed entries are subject to removal; template
-    // bundles (dsh-base and friends) are not dependencies.
-    const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
-    const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir)
-    if (wasDependency && !stillBundle) {
-      plugins.splice(plugins.indexOf(packageName), 1)
-      changed = true
-    }
-  }
-  if (!changed) return
-  after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
-  writeProfileManifest(profileDir, after)
-}
-
 /**
  * Rewrite relative filesystem specs against the user's invoking directory.
  * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
@@ -128,7 +63,8 @@ export function runPlugin(profile: string, args: readonly string[]): number {
     )
     process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
   }
-  const before = readProfileManifest(NAME, dir)
+  const location = { binName: NAME, profileDir: dir, installAnchor: INSTALL_ANCHOR }
+  const before = readProfilePlugins(location)
   // Windows resolves pnpm through its .cmd shim, which spawn() refuses
   // without a shell since the CVE-2024-27980 hardening.
   const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
@@ -146,7 +82,13 @@ export function runPlugin(profile: string, args: readonly string[]): number {
   }
   const exitCode = result.status ?? 1
   if (exitCode === 0) {
-    reconcilePlugins(before, dir)
+    const result = reconcileProfilePlugins({ ...location, before, preserveDisabled: false })
+    for (const packageName of result.addedPlainDependencies) {
+      process.stderr.write(
+        `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
+        + '(a later update that gains one activates it automatically)\n',
+      )
+    }
   } else {
     // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
     // one; the profile owns it, and the commonest failure here is pnpm ≥10

+ 140 - 111
apps/cli/src/profile-boot.ts

@@ -19,22 +19,23 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot,
-  composeEntries,
+  type ProfileResolutionMode,
+  type ProfileResolutionGeneration,
+  PluginPackages,
   createProfileResolutionGeneration,
+  composeEntries,
   healProfilesModuleFallback,
+  healIsolatedProfileModuleFallback,
   initProfile,
   installFailLoud,
   loadOptionalPatches,
   loadOverlayPatches,
   loadProfile,
-  PluginPackages,
   PROFILE_PATCH_FILENAME,
   PROFILE_TEMPLATES,
   resolveProfileDir,
   watchUserPatches,
   type Profile,
-  type ProfileResolutionGeneration,
-  type ProfileResolutionMode,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
@@ -197,9 +198,8 @@ export function prepareProfile(name: string, userLayer = true, fromDefaultProfil
 
 /** One profile's patch layers, in application order. */
 interface ComposedProfile {
-  profile: Profile
-  /** Immutable package fallback selected before any plugin imports. */
   resolution: ProfileResolutionGeneration
+  profile: Profile
   /** Bundle layers concatenated — the part below the user layers on a live reload. */
   bundlePatches: PatchOptions[]
   /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
@@ -227,6 +227,8 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
  * then the telemetry switch.
  * @param name - the profile name.
  * @param patchFiles - `--patch` overlay paths, in argv order.
+ * @param fromDefaultProfile - shipped template for a missing named profile.
+ * @param resolvedProfile - application-owned profile and installation; bypasses named discovery.
  * @returns the profile and its patch layers.
  */
 async function composeProfile(
@@ -234,10 +236,13 @@ async function composeProfile(
   patchFiles: readonly string[],
   resolutionMode: ProfileResolutionMode,
   fromDefaultProfile?: string,
+  resolvedProfile?: ResolvedProfileRuntime,
 ): Promise<ComposedProfile> {
-  const profile = prepareProfile(name, true, fromDefaultProfile)
-  const resolutionOptions = { installAnchor: INSTALL_ANCHOR, profile }
-  const resolution = resolutionMode === 'runtime'
+  const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
+  if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
+  const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
+  if (resolvedProfile !== undefined && resolutionMode !== 'runtime') healIsolatedProfileModuleFallback(resolvedProfile)
+  const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
     ? await createProfileResolutionGeneration(resolutionOptions)
     : await healProfilesModuleFallback(resolutionOptions)
   const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
@@ -253,20 +258,30 @@ async function composeProfile(
   return { profile, resolution, bundlePatches, homePatches, overlays: composedOverlays }
 }
 
+/** An application-owned profile and its independent installation fallback. */
+export interface ResolvedProfileRuntime {
+  /** Profile already loaded from the application's own directory. */
+  profile: Profile
+  /** Absolute package.json path of the application's dsh installation. */
+  installAnchor: string
+}
+
 /** Options for {@link runProfile}. */
 export interface RunProfileOptions {
+  /** Package lookup strategy; packaged executables always use runtime resolution. */
+  resolutionMode?: ProfileResolutionMode
   /** This run's frozen environment snapshot, provided before any entry mounts. */
   environment: LaunchEnvironmentSnapshot
   /** The profile name to boot. */
   profile: string
+  /** Loaded application profile; bypasses named profile initialization when supplied. */
+  resolvedProfile?: ResolvedProfileRuntime | undefined
   /** Shipped template used once to initialize a missing profile. */
   fromDefaultProfile?: string | undefined
   /** `--patch` overlay paths, in argv order. */
   patchFiles: readonly string[]
   /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
   args: readonly string[]
-  /** Module fallback backend; pkg executables always use runtime resolution. */
-  resolutionMode?: ProfileResolutionMode
 }
 
 /**
@@ -290,6 +305,7 @@ function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown
  * mounted plugins (or to a one-shot runner the composition mounts).
  * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
  * @returns the settled root context and the shutdown controller.
+ * @throws after disposing startup resources; a cleanup failure retains both errors in an AggregateError.
  */
 export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
   // Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the
@@ -303,111 +319,124 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
 
   const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
   const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link'
-  const composed = await composeProfile(
-    options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile,
-  )
   const app: { current?: Context } = {}
-  const appReady = createAppReady()
-  const shutdown = createProcessShutdown(async () => {
-    await app.current?.fiber.dispose()
-    await disposeProxy()
-  })
-  const signalShutdown = new AbortController()
-  const interrupt = (code: number): void => {
-    signalShutdown.abort()
-    shutdown.interrupt(code)
-  }
-  // Signals own teardown throughout the startup window, not only after boot()
-  // settles: an inserted provider can publish before sibling rows finish mounting.
-  // SIGTERM is a supervisor's ordinary stop request and exits 0 on every
-  // surface — the launcher does not know whether the app considered its work
-  // complete; SIGINT is a user interrupt and reports 130.
-  process.on('SIGTERM', () => { interrupt(0) })
-  process.on('SIGINT', () => { interrupt(130) })
-  installFailLoud(NAME, process, async () => {
-    await app.current?.fiber.dispose()
-  })
-
-  const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
-  // Recomposition for the live user layers: bundle layers below, overlays
-  // above, so a user edit can never displace them. Parsed app arguments are
-  // not in here at all — they live in app-provided services that survive a
-  // recomposition. BOTH
-  // user files are re-read per generation (the HMR watcher hands us only the
-  // changed file's patches, which one of the reads duplicates — fresh reads
-  // keep the two watchers from stitching in each other's stale copy).
-  // Fresh clones per generation: the include pushes `insert` rows into the
-  // mounted tree BY REFERENCE and later id-targeted patches mutate those
-  // objects in place. Reusing one parsed patch object across applications
-  // would bake a user override into the bundle's in-memory insert row, so
-  // removing the override could never revert the row to the bundle default.
-  const composeLive = (): PatchOptions[] => structuredClone([
-    ...composed.bundlePatches,
-    ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
-    ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
-    ...composed.overlays,
-  ])
-  // Cloned for the same insert-aliasing reason as composeLive: the boot
-  // application must not mutate the objects later reloads recompose from.
-  const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
-    app.current = hostCtx
-    // Before any config-tree entry mounts, so plugins resolve all launch-time
-    // environment values from the same immutable launch snapshot.
-    hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
-    await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
-      generation: composed.resolution,
-      behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
+  let disposal: Promise<void> | undefined
+  const dispose = (): Promise<void> => disposal ??= (async () => {
+    const failures: unknown[] = []
+    for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) {
+      try { await release() } catch (error) { failures.push(error) }
+    }
+    if (failures.length === 1) throw failures[0]
+    if (failures.length > 1) throw new AggregateError(failures, 'dsh: profile cleanup failed')
+  })()
+  try {
+    const composed = await composeProfile(
+      options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile,
+    )
+    const appReady = createAppReady()
+    const shutdown = createProcessShutdown(dispose)
+    const signalShutdown = new AbortController()
+    const interrupt = (code: number): void => {
+      signalShutdown.abort()
+      shutdown.interrupt(code)
+    }
+    // Signals own teardown throughout the startup window, not only after boot()
+    // settles: an inserted provider can publish before sibling rows finish mounting.
+    // SIGTERM is a supervisor's ordinary stop request and exits 0 on every
+    // surface — the launcher does not know whether the app considered its work
+    // complete; SIGINT is a user interrupt and reports 130.
+    process.on('SIGTERM', () => { interrupt(0) })
+    process.on('SIGINT', () => { interrupt(130) })
+    installFailLoud(NAME, process, async () => {
+      await app.current?.fiber.dispose()
     })
-    // The command line and bounded exit request are launcher facts available
-    // to every app plugin that injects the argument snapshot.
-    provideCmdline(hostCtx, {
-      args: options.args,
-      exit: code => void shutdown.shutdown(code),
-      ready: appReady.service,
+
+    const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
+    // Recomposition for the live user layers: bundle layers below, overlays
+    // above, so a user edit can never displace them. Parsed app arguments are
+    // not in here at all — they live in app-provided services that survive a
+    // recomposition. BOTH
+    // user files are re-read per generation (the HMR watcher hands us only the
+    // changed file's patches, which one of the reads duplicates — fresh reads
+    // keep the two watchers from stitching in each other's stale copy).
+    // Fresh clones per generation: the include pushes `insert` rows into the
+    // mounted tree BY REFERENCE and later id-targeted patches mutate those
+    // objects in place. Reusing one parsed patch object across applications
+    // would bake a user override into the bundle's in-memory insert row, so
+    // removing the override could never revert the row to the bundle default.
+    const composeLive = (): PatchOptions[] => structuredClone([
+      ...composed.bundlePatches,
+      ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
+      ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
+      ...composed.overlays,
+    ])
+    // Cloned for the same insert-aliasing reason as composeLive: the boot
+    // application must not mutate the objects later reloads recompose from.
+    const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
+      app.current = hostCtx
+      // Before any config-tree entry mounts, so plugins resolve all launch-time
+      // environment values from the same immutable launch snapshot.
+      hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
+      await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
+        generation: composed.resolution,
+        behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
+      })
+      // The command line and bounded exit request are launcher facts available
+      // to every app plugin that injects the argument snapshot.
+      provideCmdline(hostCtx, {
+        args: options.args,
+        exit: code => void shutdown.shutdown(code),
+        ready: appReady.service,
+      })
     })
-  })
-  app.current = ctx
-  // A live-reload profile can dispose the whole tree while post-boot watcher
-  // setup is in flight — a signal or appExit. Loader presence and fiber state
-  // own liveness; the initial check skips a tree that already exited, and the
-  // catch below re-checks for an exit that landed mid-setup. Startup-frozen
-  // profiles apply every user layer above but install no HMR fallback or watcher.
-  if (composed.profile.patchReload === 'live'
-    && !signalShutdown.signal.aborted
-    && ctx.fiber.state === FiberState.ACTIVE
-    && ctx.get('loader') !== undefined) {
-    try {
-      // Config-only HMR for the live profile patch layer: dsh-base disables
-      // module reload by default, so when no profile explicitly enabled that
-      // service, mount a watch-only instance with no module roots —
-      // cordis.patch.yml edits stay live without replacing source modules. A
-      // silent skip would break the documented reload contract. HMR injects
-      // the timer service, which a bare custom profile may not mount either.
-      if (ctx.get('hmr') === undefined) {
-        if (ctx.get('timer') === undefined) {
-          await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
+    app.current = ctx
+    // A live-reload profile can dispose the whole tree while post-boot watcher
+    // setup is in flight — a signal or appExit. Loader presence and fiber state
+    // own liveness; the initial check skips a tree that already exited, and the
+    // catch below re-checks for an exit that landed mid-setup. Startup-frozen
+    // profiles apply every user layer above but install no HMR fallback or watcher.
+    if (composed.profile.patchReload === 'live'
+      && !signalShutdown.signal.aborted
+      && ctx.fiber.state === FiberState.ACTIVE
+      && ctx.get('loader') !== undefined) {
+      try {
+        // Config-only HMR for the live profile patch layer: dsh-base disables
+        // module reload by default, so when no profile explicitly enabled that
+        // service, mount a watch-only instance with no module roots —
+        // cordis.patch.yml edits stay live without replacing source modules. A
+        // silent skip would break the documented reload contract. HMR injects
+        // the timer service, which a bare custom profile may not mount either.
+        if (ctx.get('hmr') === undefined) {
+          if (ctx.get('timer') === undefined) {
+            await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
+          }
+          await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
+          await ctx.loader.await()
         }
-        await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
-        await ctx.loader.await()
+        await watchUserPatches(ctx, {
+          binName: NAME,
+          filename: composed.profile.patchPath,
+          compose: composeLive,
+        })
+        await watchUserPatches(ctx, {
+          binName: NAME,
+          filename: homePatchPath(),
+          compose: composeLive,
+        })
+      } catch (error) {
+        suppressShutdownError(ctx, signalShutdown.signal, error)
       }
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: composed.profile.patchPath,
-        compose: composeLive,
-      })
-      await watchUserPatches(ctx, {
-        binName: NAME,
-        filename: homePatchPath(),
-        compose: composeLive,
-      })
-    } catch (error) {
-      suppressShutdownError(ctx, signalShutdown.signal, error)
     }
+    if (!signalShutdown.signal.aborted
+      && ctx.fiber.state === FiberState.ACTIVE
+      && ctx.get('loader') !== undefined) {
+      appReady.commit()
+    }
+    return { ctx, shutdown }
+  } catch (error) {
+    try { await dispose() } catch (cleanupError) {
+      throw new AggregateError([error, cleanupError], 'dsh: profile startup and cleanup failed')
+    }
+    throw error
   }
-  if (!signalShutdown.signal.aborted
-    && ctx.fiber.state === FiberState.ACTIVE
-    && ctx.get('loader') !== undefined) {
-    appReady.commit()
-  }
-  return { ctx, shutdown }
 }

+ 44 - 0
apps/cli/tests/built-bin.e2e.ts

@@ -973,6 +973,50 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 2 + 30_000)
 
+  it('reconciles a real pnpm alias and keeps ordinary dependencies outside the bundle list', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-alias-'))
+    try {
+      const bundle = join(home, 'bundle-source')
+      const library = join(home, 'library-source')
+      mkdirSync(bundle)
+      mkdirSync(library)
+      writeFileSync(join(bundle, 'package.json'), JSON.stringify({
+        name: 'original-bundle-name', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
+      }))
+      writeFileSync(join(bundle, 'cordis.patch.yml'), '[]\n')
+      writeFileSync(join(library, 'package.json'), JSON.stringify({ name: 'ordinary-library', version: '1.0.0' }))
+      const added = await runBuiltBin([
+        'plugin', '--profile', 'alias', 'add', `bundle-alias@file:${bundle}`, `file:${library}`,
+      ], { DSH_HOME: home }, home)
+      expect(added.code).toBe(0)
+      expect(added.stderr).toContain('ordinary-library declares no dsh.bundle — installed as a plain dependency, not a profile layer')
+      const manifestPath = join(home, 'profiles', 'alias', 'package.json')
+      const installed = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
+        dependencies: Record<string, string>
+        dsh: { profile: { bundles: string[] } }
+      }
+      expect(Object.keys(installed.dependencies).sort()).toEqual(['bundle-alias', 'ordinary-library'])
+      expect(installed.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'bundle-alias'])
+      installed.dsh.profile.bundles = ['@deepseek-ai/dsh-base']
+      writeFileSync(manifestPath, JSON.stringify(installed))
+      const refreshed = await runBuiltBin(['plugin', '--profile', 'alias', 'root'], { DSH_HOME: home }, home)
+      expect(refreshed.code).toBe(0)
+      expect(refreshed.stderr).not.toContain('declares no dsh.bundle')
+      const active = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
+      expect(active.dsh.profile.bundles).toContain('bundle-alias')
+      const removed = await runBuiltBin(['plugin', '--profile', 'alias', 'remove', 'bundle-alias'], { DSH_HOME: home }, home)
+      expect(removed.code).toBe(0)
+      const remaining = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
+        dependencies: Record<string, string>
+        dsh: { profile: { bundles: string[] } }
+      }
+      expect(Object.keys(remaining.dependencies)).toEqual(['ordinary-library'])
+      expect(remaining.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
+    } finally {
+      rmSync(home, { recursive: true, force: true })
+    }
+  })
+
   it('activates a dependency that gained dsh.bundle in a later update', async () => {
     // Reconcile runs against the INSTALLED state on every successful pnpm
     // run, so `update` (not only `add`) activates a package whose newer

+ 66 - 0
apps/cli/tests/desktop-host.e2e.ts

@@ -0,0 +1,66 @@
+/** Built Desktop Host lifecycle with Electron disconnecting before profile startup settles. */
+
+import { fork } from 'node:child_process'
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { finished } from 'node:stream/promises'
+import { expect, it, onTestFinished } from 'vitest'
+
+it.each([false, true])('settles startup after parent IPC disconnect (boot failure: %s)', async (fail) => {
+  const root = mkdtempSync(join(tmpdir(), 'desktop-disconnect-'))
+  const modules = join(root, 'node_modules', '@deepseek-ai')
+  for (const name of ['dsh-app-boot', 'dsh', 'dsh-home-paths', 'dsh-tools']) mkdirSync(join(modules, name), { recursive: true })
+  for (const [name, source] of [
+    ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`],
+    ['dsh-tools', 'export const defineTool = value => value'],
+  ] as const) {
+    writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}')
+    writeFileSync(join(modules, name, 'index.js'), source)
+  }
+  writeFileSync(join(root, 'package.json'), '{"type":"module"}')
+  writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
+  writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({}); export const loadLayeredEnv = () => ({})')
+  writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
+  writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
+    import { writeFileSync } from 'node:fs';
+    export function runProfile() {
+      process.send({ type: 'booting' });
+      return new Promise((resolve, reject) => process.once('disconnect', () => {
+        if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
+        resolve({ ctx: { plugin: async () => {}, connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
+          shutdown: { shutdown: async () => writeFileSync(${JSON.stringify(join(root, 'stopped'))}, 'stopped') } });
+      }));
+    }
+  `)
+  const entry = join(root, 'index.js')
+  copyFileSync(fileURLToPath(new URL('../../desktop-host/lib/index.js', import.meta.url)), entry)
+  const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
+  let stderr = ''
+  child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
+  const exited = new Promise<number | null>(resolve => child.once('exit', resolve))
+  const drained = finished(child.stderr!, { cleanup: true })
+  onTestFinished(async () => {
+    if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
+    await Promise.all([exited, drained])
+    rmSync(root, { recursive: true, force: true })
+  })
+  try {
+    await new Promise<void>((resolve, reject) => {
+      child.once('message', () => { resolve() })
+      child.once('error', reject)
+      child.once('exit', (code) => { reject(new Error(`Host exited before booting: ${String(code)} ${stderr}`)) })
+    })
+    child.disconnect()
+    expect(await exited).toBe(fail ? 1 : 0)
+    await drained
+    expect(stderr).not.toContain('ERR_IPC_CHANNEL_CLOSED')
+    expect(stderr).not.toContain('Unhandled')
+    if (fail) expect(stderr).toContain('fixture boot failure')
+    else expect(readFileSync(join(root, 'stopped'), 'utf8')).toBe('stopped')
+  } finally {
+    if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
+    await Promise.all([exited, drained])
+  }
+})

+ 164 - 0
apps/cli/tests/resolved-profile-boot.spec.ts

@@ -0,0 +1,164 @@
+/** Application-owned profiles share the named profile launch lifecycle. */
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
+import { boot, composeEntries, healIsolatedProfileModuleFallback, watchUserPatches, type Profile } from '@deepseek-ai/dsh-app-boot'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { runProfile } from '../src/profile-boot.ts'
+
+vi.mock('@deepseek-ai/dsh-app-boot', async importOriginal => ({
+  ...await importOriginal<typeof import('@deepseek-ai/dsh-app-boot')>(),
+  boot: vi.fn(),
+  healIsolatedProfileModuleFallback: vi.fn(),
+  installFailLoud: vi.fn(),
+  watchUserPatches: vi.fn(),
+}))
+vi.mock('@deepseek-ai/dsh-http-proxy', () => ({ installProxyFromEnvironment: vi.fn() }))
+
+const homes: string[] = []
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.unstubAllEnvs()
+  vi.clearAllMocks()
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+describe('runProfile with an application-owned profile', () => {
+  it.each(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)('releases startup resources after a %s failure', async (stage) => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-'))
+    homes.push(home)
+    mkdirSync(join(home, 'runtime'))
+    writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
+    writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
+    vi.stubEnv('DSH_HOME', home)
+    vi.spyOn(process, 'on').mockReturnValue(process)
+    const ctx = new Context()
+    ctx.provide('loader', { create: vi.fn() })
+    ctx.provide('hmr', {})
+    const dispose = vi.spyOn(ctx.fiber, 'dispose')
+    const failure = new Error('startup failed')
+    const cleanupFailure = new Error('proxy cleanup failed')
+    const treeCleanupFailure = new Error('tree cleanup failed')
+    if (stage === 'tree-cleanup' || stage === 'both-cleanups') dispose.mockRejectedValueOnce(treeCleanupFailure)
+    const disposeProxy = vi.fn().mockImplementation(() => stage === 'cleanup' || stage === 'both-cleanups'
+      ? Promise.reject(cleanupFailure)
+      : Promise.resolve())
+    vi.mocked(installProxyFromEnvironment).mockResolvedValue(disposeProxy)
+    vi.mocked(boot).mockImplementation(async (_name, _root, _patches, setup) => {
+      await setup?.(ctx)
+      if (stage === 'boot') throw failure
+      return ctx
+    })
+    if (stage === 'composition') vi.mocked(healIsolatedProfileModuleFallback).mockImplementationOnce(() => { throw failure })
+    if (stage !== 'composition' && stage !== 'boot') vi.mocked(watchUserPatches).mockImplementationOnce(() => { throw failure })
+    const profile: Profile = {
+      name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
+      patchReload: 'live', patches: [], layers: [],
+    }
+    try {
+      const application = runProfile({
+        environment: createLaunchEnvironmentSnapshot([]), profile: 'desktop', patchFiles: [], args: ['--no-open'],
+        resolvedProfile: { profile, installAnchor: join(home, 'runtime/package.json') },
+      })
+      if (stage === 'both-cleanups') {
+        await expect(application).rejects.toMatchObject({ errors: [failure, { errors: [treeCleanupFailure, cleanupFailure] }] })
+      } else if (stage === 'tree-cleanup') {
+        await expect(application).rejects.toMatchObject({ errors: [failure, treeCleanupFailure] })
+      } else if (stage === 'cleanup') {
+        await expect(application).rejects.toMatchObject({ errors: [failure, cleanupFailure] })
+      } else {
+        await expect(application).rejects.toBe(failure)
+      }
+      expect(disposeProxy).toHaveBeenCalledOnce()
+      expect(dispose).toHaveBeenCalledTimes(stage === 'composition' ? 0 : 1)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it.each([['live', 'link'], ['startup', 'link'], ['startup', 'runtime']] as const)('uses shared layers, %s reload, %s resolution, and shutdown', async (patchReload, resolutionMode) => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-'))
+    homes.push(home)
+    mkdirSync(join(home, 'runtime'))
+    writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}')
+    writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}')
+    vi.stubEnv('DSH_HOME', home)
+    vi.stubEnv('DSH_TELEMETRY_DISABLED', '1')
+    vi.spyOn(process, 'on').mockReturnValue(process)
+    const oldExitCode = process.exitCode
+    const ctx = new Context()
+    // The real context supplies services; this test substitutes tree mounting and filesystem watchers.
+    ctx.provide('loader', { create: vi.fn() })
+    ctx.provide('hmr', {})
+    const dispose = vi.spyOn(ctx.fiber, 'dispose')
+    const disposeProxy = vi.fn().mockResolvedValue(undefined)
+    vi.mocked(installProxyFromEnvironment).mockResolvedValue(disposeProxy)
+    vi.mocked(boot).mockImplementation(async (_name, _root, _patches, setup) => {
+      await setup?.(ctx)
+      return ctx
+    })
+    const homePatch = join(home, 'cordis.patch.yml')
+    const profilePatch = join(home, 'profile.patch.yml')
+    const overlay = join(home, 'desktop.patch.yml')
+    writeFileSync(homePatch, '- id: target\n  config: { home: true, priority: home }\n')
+    writeFileSync(profilePatch, '- id: target\n  config: { profile: true, priority: profile }\n')
+    writeFileSync(overlay, '- id: target\n  config: { overlay: true, priority: overlay }\n')
+    writeFileSync(join(home, 'cordis.yml'), '- id: stale\n')
+    const profile: Profile = {
+      name: 'desktop', dir: home, patchPath: profilePatch, patchReload,
+      patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
+      layers: [{
+        packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'),
+        patches: [{ insert: [
+          { id: 'target', name: 'target', config: { bundle: true, priority: 'bundle' } },
+          { id: 'session-telemetry-otel', name: 'telemetry' },
+        ] }],
+      }],
+    }
+    const environment = createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTPS_PROXY: 'http://localhost:8080' } }])
+    const runtime = { profile, installAnchor: join(home, 'runtime/package.json') }
+    try {
+      const { shutdown } = await runProfile({
+        environment, profile: 'desktop', resolvedProfile: runtime, resolutionMode,
+        patchFiles: [overlay], args: ['--port', '0', '--no-open'],
+      })
+      expect(installProxyFromEnvironment).toHaveBeenCalledWith(environment, expect.any(Function))
+      if (resolutionMode === 'link') {
+        expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({ profile, installAnchor: runtime.installAnchor })
+      } else {
+        expect(healIsolatedProfileModuleFallback).not.toHaveBeenCalled()
+      }
+      expect(readFileSync(join(home, 'cordis.yml'), 'utf8')).not.toContain('stale')
+      expect(ctx.cmdlineArgs!.get()).toEqual(['--port', '0', '--no-open'])
+      const ready = vi.fn()
+      ctx.appReady!.onReady(ready)
+      expect(ready).toHaveBeenCalledOnce()
+      const patches = vi.mocked(boot).mock.calls[0]![2]!
+      const rows = composeEntries([patches])
+      expect(patches.slice(1, 4)).toEqual([
+        { id: 'target', config: { profile: true, priority: 'profile' } },
+        { id: 'target', config: { home: true, priority: 'home' } },
+        { id: 'target', config: { overlay: true, priority: 'overlay' } },
+      ])
+      expect(rows.find(row => row.id === 'target')?.config).toEqual({ overlay: true, priority: 'overlay' })
+      expect(rows.find(row => row.id === 'session-telemetry-otel')?.disabled).toBe(true)
+      if (patchReload === 'live') {
+        expect(vi.mocked(watchUserPatches).mock.calls.map(call => call[1].filename)).toEqual([profilePatch, homePatch])
+        writeFileSync(homePatch, '- id: target\n  config: { home: updated }\n')
+        const live = vi.mocked(watchUserPatches).mock.calls[0]![1].compose!
+        expect(live([])[2]).toMatchObject({ id: 'target', config: { home: 'updated' } })
+      } else {
+        expect(watchUserPatches).not.toHaveBeenCalled()
+      }
+      await shutdown.shutdown(0)
+      expect(dispose).toHaveBeenCalledOnce()
+      expect(disposeProxy).toHaveBeenCalledOnce()
+    } finally {
+      await ctx.fiber.dispose()
+      process.exitCode = oldExitCode
+    }
+  })
+})

+ 2 - 4
apps/cli/tsdown.config.ts

@@ -1,13 +1,11 @@
 import { defineConfig } from 'tsdown'
 
 /**
- * The dsh CLI ships one entry: the `bin` referenced by package.json `bin`.
- * The root tsdown builds only `lib/types/index.js`, so this override points at
- * `lib/types/bin.js` instead; its reachable mode modules bundle with it.
+ * The dsh CLI ships its command and the profile lifecycle shared with Desktop.
  * Declarations come from `tsc -b` (dts: false), matching every package.
  */
 export default defineConfig({
-  entry: ['lib/types/bin.js'],
+  entry: ['lib/types/bin.js', 'lib/types/profile-boot.js'],
   outDir: 'lib',
   format: ['esm'],
   platform: 'node',

+ 0 - 34
apps/desktop-host/config/desktop.cordis.patch.yml

@@ -1,34 +0,0 @@
-# Electron reuses the browser composition without its network and browser-launch rows.
-
-- id: web-startup
-  disabled: true
-
-- id: webserver
-  disabled: true
-
-- id: web-runtime
-  disabled: true
-
-- id: client-hmr
-  disabled: true
-
-- id: open-in-app
-  disabled: true
-
-- id: ui-open-in-app
-  disabled: true
-
-- id: directory-picker
-  disabled: true
-
-- id: connection
-  inject:
-    - credentials
-  config: {}
-
-- insert:
-    - id: directory-picker-native
-      name: '@deepseek-ai/dsh-host-directory-picker-native'
-
-    - id: ui-directory-picker-native
-      name: '@deepseek-ai/dsh-client-ui-directory-picker-native'

+ 3 - 10
apps/desktop-host/package.json

@@ -7,22 +7,15 @@
   "type": "module",
   "main": "lib/index.js",
   "files": [
-    "lib/index.js",
-    "config/desktop.cordis.patch.yml"
+    "lib/index.js"
   ],
   "dependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/dsh": "workspace:^",
-    "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
-    "@deepseek-ai/dsh-client-modules": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
-    "@deepseek-ai/dsh-cmdline": "workspace:^",
-    "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
+    "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
-    "@deepseek-ai/dsh-launch-environment": "workspace:^",
-    "@deepseek-ai/dsh-web-frontend": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^"
   }
 }

+ 37 - 594
apps/desktop-host/src/index.ts

@@ -1,608 +1,51 @@
-/**
- * Electron child-process entry: boots the desktop project without a listening
- * socket and carries API plus validated Web assets over framed byte pipes.
- * @module @deepseek-ai/dsh-desktop-host
- */
+/** Launch the Desktop profile through the Web application and report its URL to Electron. */
 
-import { createRequire } from 'node:module'
-import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
-import { once } from 'node:events'
-import { readFile } from 'node:fs/promises'
-import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import type { Context } from '@deepseek-ai/cordis'
-import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import {
-  boot,
-  composeEntries,
-  createProfileResolutionGeneration,
-  loadLayeredEnv,
-  loadProfileDirectory,
-  loadOverlayPatches,
-  PluginPackages,
-  type Profile,
-} from '@deepseek-ai/dsh-app-boot'
-import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
-import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
-import type {} from '@deepseek-ai/dsh-api-gateway'
-import type { ConnectionFetchHandler } from '@deepseek-ai/dsh-client-connection'
-import type {} from '@deepseek-ai/dsh-client-modules'
-import { renderIndexInjections, type IndexInjection } from '@deepseek-ai/dsh-host-webserver'
-import {
-  DESKTOP_HOST_PROTOCOL_VERSION,
-  DESKTOP_PIPE_CHUNK_BYTES,
-  DESKTOP_REQUEST_PIPE_FD,
-  DESKTOP_RESPONSE_PIPE_FD,
-  DesktopHostRequestDecoder,
-  encodeDesktopResponseData,
-  encodeDesktopResponseEnd,
-  encodeDesktopResponseError,
-  encodeDesktopResponseStart,
-  type DesktopHostRequestFrame,
-} from './wire.ts'
-
-export { DESKTOP_HOST_PROTOCOL_VERSION } from './wire.ts'
-
-/** One request forwarded from Electron's `dsh-app://` handler. */
-export interface DesktopHostFetchCommand {
-  readonly streamId: number
-  readonly request: {
-    readonly url: string
-    readonly method: string
-    readonly headers: readonly [string, string][]
-  }
-}
-
-/** Commands accepted by the desktop child process. */
-export type DesktopHostCommand = {
-  readonly type: 'shutdown'
-}
-
-/** Events emitted by the desktop child process. */
-export type DesktopHostEvent = {
-  readonly type: 'ready'
-  readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
-  readonly dshVersion: string
-} | {
-  readonly type: 'fatal'
-  readonly message: string
-}
-
-/** Controller returned to tests and the self-executing process entry. */
-export interface DesktopHostController {
-  /** Installed dsh version carried by this host. */
-  readonly dshVersion: string
-  /** Dispatch one custom-protocol request and stream its response to the response pipe. */
-  fetch(command: DesktopHostFetchCommand, body: ReadableStream<Uint8Array> | null): Promise<void>
-  /** Abort one in-flight request. */
-  cancel(streamId: number): void
-  /** Stop accepting messages and await complete host teardown. */
-  dispose(): Promise<void>
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === 'object' && value !== null
-}
-
-function isDesktopHostCommand(message: unknown): message is DesktopHostCommand {
-  return typeof message === 'object' && message !== null && 'type' in message
-    && (message as Record<string, unknown>).type === 'shutdown'
-}
-
-interface PackageManifest {
-  readonly name?: string
-  readonly version?: string
-}
-
-const DESKTOP_PATCH = fileURLToPath(new URL('../config/desktop.cordis.patch.yml', import.meta.url))
-const ROOT_CONFIG = '# Electron desktop composition root; package transactions own this file.\n[]\n'
-const ROOT_CONFIG_FILENAME = 'desktop.cordis.yml'
-const DESKTOP_STREAM_PATH = '/.dsh/remote-stream'
-
-const DESKTOP_TRANSPORT_SCRIPT = `globalThis.__DSH_TRANSPORT__={
-  ownsHost:true,
-  async *openStream(endpoint,payload,signal){
-    const response=await fetch(${JSON.stringify(DESKTOP_STREAM_PATH)},{
-      method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({endpoint,payload}),signal
-    })
-    if(!response.ok||response.body===null)throw new Error('desktop stream transport failed: HTTP '+response.status)
-    const reader=response.body.getReader(),decoder=new TextDecoder()
-    let pending=''
-    for(;;){
-      const {done,value}=await reader.read()
-      pending+=decoder.decode(value,{stream:!done})
-      let newline
-      while((newline=pending.indexOf('\\n'))!==-1){
-        const line=pending.slice(0,newline);pending=pending.slice(newline+1)
-        if(line!=='')yield JSON.parse(line)
-      }
-      if(done)break
-    }
-    if(pending!=='')yield JSON.parse(pending)
-  }
-}`
-
-const MIME: Readonly<Record<string, string>> = {
-  '.css': 'text/css; charset=utf-8',
-  '.html': 'text/html; charset=utf-8',
-  '.js': 'text/javascript; charset=utf-8',
-  '.json': 'application/json',
-  '.svg': 'image/svg+xml',
-  '.webmanifest': 'application/manifest+json',
-}
-
-function readManifest(path: string): PackageManifest {
-  const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
-  if (!isRecord(value)) throw new Error(`dsh desktop: ${path} must contain a package manifest`)
-  return {
-    ...(typeof value.name === 'string' ? { name: value.name } : {}),
-    ...(typeof value.version === 'string' ? { version: value.version } : {}),
-  }
-}
-
-function packageManifestPath(projectDir: string, packageName: string): string {
-  const path = join(projectDir, 'node_modules', ...packageName.split('/'), 'package.json')
-  if (!existsSync(path)) throw new Error(`dsh desktop: installed package ${JSON.stringify(packageName)} has no manifest`)
-  return path
-}
-
-function isProjectPath(projectDir: string, target: string): boolean {
-  const root = realpathSync(projectDir)
-  const path = realpathSync(target)
-  return path === root || path.startsWith(root + sep)
-}
-
-interface DesktopComposition {
-  readonly installAnchor: string
-  readonly profile: Profile
-  readonly patches: PatchOptions[]
-}
-
-function desktopComposition(
-  runtimeDir: string,
-  projectDir: string,
-  allowLinkedPackages: boolean,
-): DesktopComposition {
-  const installAnchor = packageManifestPath(runtimeDir, '@deepseek-ai/dsh')
-  const dshRoot = dirname(installAnchor)
-  const profile = loadProfileDirectory('dsh desktop', projectDir, installAnchor)
-  for (const layer of profile.layers) {
-    if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir) && !isProjectPath(runtimeDir, layer.packageDir)) {
-      throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the Desktop runtime and profile`)
-    }
-  }
-  const layers = [
-    ...profile.layers.map(layer => layer.patches),
-    profile.patches,
-    loadOverlayPatches('dsh desktop', DESKTOP_PATCH),
-  ]
-  const rows = new Map(composeEntries(layers).flatMap(row => typeof row.id === 'string' ? [[row.id, row] as const] : []))
-  const agentPresets = rows.get('agent-presets')
-  if (agentPresets !== undefined) {
-    layers.push([{
-      id: 'agent-presets',
-      config: {
-        ...(agentPresets.config ?? {}) as Record<string, unknown>,
-        roots: [{ path: join(dshRoot, 'config', 'agent-presets'), trust: 'system' }],
-      },
-    }])
-  }
-  return { installAnchor, profile, patches: layers.flat() }
-}
-
-function dshVersion(runtimeDir: string): string {
-  const manifest = readManifest(packageManifestPath(runtimeDir, '@deepseek-ai/dsh'))
-  if (typeof manifest.version !== 'string') throw new Error('dsh desktop: installed dsh manifest has no version')
-  return manifest.version
-}
-
-function assetHandler(ctx: Context, runtimeDir: string): ConnectionFetchHandler {
-  const require = createRequire(join(runtimeDir, 'package.json'))
-  const distIndex = require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')
-  const distRoot = realpathSync(dirname(distIndex))
-  const renderIndex = async (): Promise<Response> => {
-    const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: DESKTOP_TRANSPORT_SCRIPT }]
-    ctx.emit('webserver/index-inject', rows)
-    const body = renderIndexInjections(await readFile(distIndex, 'utf8'), rows)
-    return new Response(body, { headers: { 'content-type': MIME['.html'] ?? 'text/html; charset=utf-8' } })
-  }
-  return {
-    requestBodyMode: () => 'buffered',
-    async fetch(request): Promise<Response> {
-      if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
-      const url = new URL(request.url)
-      if (url.pathname.startsWith('/plugins/')) return ctx.clientModules.fetchBundle(request)
-      let pathname: string
-      try {
-        pathname = decodeURIComponent(url.pathname)
-      } catch {
-        return new Response(null, { status: 400 })
-      }
-      if (pathname === '/' || pathname === '/index.html') return renderIndex()
-      const target = resolve(normalize(join(distRoot, pathname)))
-      if (target !== distRoot && !target.startsWith(distRoot + sep)) return new Response(null, { status: 403 })
-      try {
-        const realTarget = realpathSync(target)
-        if (realTarget !== distRoot && !realTarget.startsWith(distRoot + sep)) return new Response(null, { status: 403 })
-        return new Response(request.method === 'HEAD' ? null : await readFile(realTarget), {
-          headers: { 'content-type': MIME[extname(realTarget)] ?? 'application/octet-stream' },
-        })
-      } catch {
-        return renderIndex()
-      }
-    },
-  }
-}
-
-function remoteStreamHandler(ctx: Context): ConnectionFetchHandler {
-  return {
-    requestBodyMode: () => 'buffered',
-    async fetch(request): Promise<Response> {
-      if (request.method !== 'POST') return new Response(null, { status: 405 })
-      const gateway = ctx.get('typertGateway')
-      if (gateway === undefined) return new Response('gateway unavailable', { status: 503 })
-      let body: unknown
-      try {
-        body = await request.json()
-      } catch {
-        return new Response('body is not JSON', { status: 400 })
-      }
-      if (!isRecord(body) || typeof body.endpoint !== 'string') {
-        return new Response('invalid stream request', { status: 400 })
-      }
-      const abort = new AbortController()
-      const cancel = (): void => { abort.abort(request.signal.reason) }
-      request.signal.addEventListener('abort', cancel, { once: true })
-      const encoder = new TextEncoder()
-      const stream = new ReadableStream<Uint8Array>({
-        async start(controller) {
-          try {
-            const values = await gateway.wireStream.open(body.endpoint as string, body.payload, abort.signal)
-            for await (const value of values) {
-              controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
-            }
-            controller.close()
-          } catch (error) {
-            controller.error(error)
-          } finally {
-            request.signal.removeEventListener('abort', cancel)
-          }
-        },
-        cancel(reason) {
-          abort.abort(reason)
-          request.signal.removeEventListener('abort', cancel)
-        },
-      })
-      return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } })
-    },
-  }
-}
-
-interface NodeRequestInit extends RequestInit {
-  readonly duplex?: 'half'
-}
-
-/**
- * Boot one installed desktop npm project.
- * @param runtimeDir - immutable dsh packages supplied by the Electron application.
- * @param projectDir - active or staged Electron-owned desktop profile.
- * @param writeResponse - serialized response-pipe writer that applies byte backpressure.
- * @param options - development-only allowance for workspace-linked bundle packages.
- * @returns controller after every Host and client-manifest row is active.
- */
-export async function runDesktopHost(
-  runtimeDir: string,
-  projectDir: string,
-  writeResponse: (frame: Buffer) => Promise<void>,
-  options: { allowLinkedPackages?: boolean } = {},
-): Promise<DesktopHostController> {
-  const absoluteRuntime = resolve(runtimeDir)
-  const absoluteProject = resolve(projectDir)
-  mkdirSync(absoluteProject, { recursive: true })
-  const rootConfig = join(absoluteProject, ROOT_CONFIG_FILENAME)
-  writeFileSync(rootConfig, ROOT_CONFIG)
-  const environment = loadLayeredEnv('dsh desktop')
-  const composition = desktopComposition(absoluteRuntime, absoluteProject, options.allowLinkedPackages === true)
-  const resolution = await createProfileResolutionGeneration({
-    installAnchor: composition.installAnchor,
-    profile: composition.profile,
-  })
-  let current: Context | undefined
-  const ctx = await boot('dsh desktop', rootConfig, structuredClone(composition.patches), async (hostCtx) => {
-    current = hostCtx
-    hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment)
-    await hostCtx.plugin(PluginPackages, { generation: resolution })
-    provideCmdline(hostCtx, { args: [], exit: () => {} })
-  })
-  current = ctx
-  const connection = ctx.get('connection')
-  const clientModules = ctx.get('clientModules')
-  const gateway = ctx.get('typertGateway')
-  if (connection === undefined || clientModules === undefined || gateway === undefined) {
-    await ctx.fiber.dispose()
-    throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules')
-  }
-  const api = connection.createSharedFetchHandler('/api')
-  const assets = assetHandler(ctx, absoluteRuntime)
-  const streams = remoteStreamHandler(ctx)
-  const requests = new Map<number, AbortController>()
-  let disposing: Promise<void> | undefined
-
-  const dispose = async (): Promise<void> => {
-    disposing ??= (async () => {
-      for (const controller of requests.values()) controller.abort()
-      requests.clear()
-      await current?.fiber.dispose()
-      current = undefined
-    })()
-    await disposing
-  }
-
-  return {
-    dshVersion: dshVersion(absoluteRuntime),
-    cancel(streamId) {
-      requests.get(streamId)?.abort()
-    },
-    async fetch(command, body) {
-      if (disposing !== undefined) throw new Error('dsh desktop: host is disposing')
-      const controller = new AbortController()
-      requests.set(command.streamId, controller)
-      try {
-        const url = new URL(command.request.url)
-        const init: NodeRequestInit = {
-          method: command.request.method,
-          headers: new Headers(command.request.headers.map(([name, value]) => [name, value] as [string, string])),
-          ...(body === null ? {} : { body, duplex: 'half' }),
-          signal: controller.signal,
-        }
-        const request = new Request(url, init)
-        const response = url.pathname === DESKTOP_STREAM_PATH
-          ? await streams.fetch(request)
-          : url.pathname.startsWith('/api/')
-            ? await api.fetch(request)
-            : await assets.fetch(request)
-        await writeResponse(encodeDesktopResponseStart(command.streamId, {
-          status: response.status,
-          headers: [...response.headers.entries()],
-          hasBody: response.body !== null,
-        }))
-        if (response.body !== null) {
-          for await (const chunk of response.body) {
-            const bytes = Buffer.from(chunk)
-            for (let offset = 0; offset < bytes.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) {
-              await writeResponse(encodeDesktopResponseData(
-                command.streamId,
-                bytes.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES),
-              ))
-            }
-          }
-        }
-        await writeResponse(encodeDesktopResponseEnd(command.streamId))
-      } catch (error) {
-        if (!controller.signal.aborted) {
-          await writeResponse(encodeDesktopResponseError(
-            command.streamId,
-            error instanceof Error ? error.message : String(error),
-          ))
-        }
-      } finally {
-        requests.delete(command.streamId)
-      }
-    },
-    dispose,
-  }
-}
+import { join } from 'node:path'
+import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
+import { runProfile } from '@deepseek-ai/dsh/profile-boot'
+import type {} from '@deepseek-ai/dsh-client-connection'
+import type {} from '@deepseek-ai/dsh-host-webserver'
+import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
+import * as workspaceDependencies from './workspace-dependencies.ts'
 
 async function main(): Promise<void> {
-  const runtimeDir = process.argv[2]
-  const projectDir = process.argv[3]
-  if (runtimeDir === undefined || projectDir === undefined || process.send === undefined) {
-    throw new Error('dsh desktop: expected runtime and profile directories, byte pipes, and a Node IPC channel')
-  }
-  const option = process.argv[4]
-  if (option !== undefined && option !== '--allow-linked-profile') {
-    throw new Error(`dsh desktop: unsupported internal option ${JSON.stringify(option)}`)
-  }
-  const requestPipe = createReadStream('', { fd: DESKTOP_REQUEST_PIPE_FD, autoClose: false })
-  const responsePipe = createWriteStream('', { fd: DESKTOP_RESPONSE_PIPE_FD, autoClose: false })
-  let responseWriteTail: Promise<void> = Promise.resolve()
-  const writeResponse = (frame: Buffer): Promise<void> => {
-    const write = responseWriteTail.then(async () => {
-      if (responsePipe.destroyed) throw new Error('dsh desktop: Electron response pipe is unavailable')
-      if (!responsePipe.write(frame)) await once(responsePipe, 'drain')
-    })
-    responseWriteTail = write.catch(() => undefined)
-    return write
-  }
-  const send = (event: DesktopHostEvent): void => {
-    if (process.send === undefined || !process.connected) return
-    try {
-      process.send(event)
-    } catch (error) {
-      // A concurrent parent disconnect owns teardown; only that closed-channel
-      // condition is safe to discard while streamed responses unwind.
-      if ((error as NodeJS.ErrnoException).code !== 'ERR_IPC_CHANNEL_CLOSED') throw error
-    }
-  }
-  const controller = await runDesktopHost(runtimeDir, projectDir, writeResponse, { allowLinkedPackages: option !== undefined })
-  send({
-    type: 'ready',
-    protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
-    dshVersion: controller.dshVersion,
-  })
-  const decoder = new DesktopHostRequestDecoder()
-  const requestBodies = new Map<number, ReadableStreamDefaultController<Uint8Array>>()
-  const blockedRequests = new Set<number>()
-  const discardedRequestBodies = new Set<number>()
-  const runs = new Set<Promise<void>>()
-  let lastStreamId = 0
-  let requestedExitCode = 0
-  let stopping: Promise<void> | undefined
-
-  const resumeRequestPipe = (): void => {
-    if (blockedRequests.size === 0) requestPipe.resume()
-  }
-
-  const stop = (exitCode = 0): Promise<void> => {
-    requestedExitCode = Math.max(requestedExitCode, exitCode)
-    stopping ??= (async () => {
-      requestPipe.pause()
-      requestPipe.removeAllListeners('data')
-      const stopped = new Error('dsh desktop: Host is stopping')
-      for (const body of requestBodies.values()) body.error(stopped)
-      requestBodies.clear()
-      blockedRequests.clear()
-      discardedRequestBodies.clear()
-      requestPipe.destroy()
-      closeSync(DESKTOP_REQUEST_PIPE_FD)
-      await controller.dispose()
-      await Promise.allSettled([...runs])
-      await responseWriteTail.catch(() => undefined)
-      if (!responsePipe.destroyed) {
-        await new Promise<void>((resolvePromise) => { responsePipe.end(resolvePromise) })
-        responsePipe.destroy()
-      }
-      closeSync(DESKTOP_RESPONSE_PIPE_FD)
-      if (process.connected) process.disconnect()
-      process.exitCode = requestedExitCode
-    })()
-    return stopping
-  }
-
-  const failTransport = (error: unknown): void => {
-    const message = error instanceof Error ? error.message : String(error)
-    send({ type: 'fatal', message })
-    void stop(1)
-  }
-
-  const beginRequest = (frame: Extract<DesktopHostRequestFrame, { type: 'start' }>): void => {
-    if (frame.streamId <= lastStreamId) {
-      throw new Error(`dsh desktop: Electron reused or reordered request stream ${String(frame.streamId)}`)
-    }
-    lastStreamId = frame.streamId
-    let body: ReadableStream<Uint8Array> | null = null
-    if (frame.hasBody) {
-      body = new ReadableStream<Uint8Array>({
-        start(controllerOfBody) {
-          requestBodies.set(frame.streamId, controllerOfBody)
-        },
-        pull() {
-          blockedRequests.delete(frame.streamId)
-          resumeRequestPipe()
-        },
-        cancel() {
-          requestBodies.delete(frame.streamId)
-          blockedRequests.delete(frame.streamId)
-          controller.cancel(frame.streamId)
-          resumeRequestPipe()
-        },
-      })
-    }
-    const run = controller.fetch({
-      streamId: frame.streamId,
-      request: {
-        url: frame.url,
-        method: frame.method,
-        headers: frame.headers,
-      },
-    }, body)
-    runs.add(run)
-    void run.catch(failTransport).finally(() => {
-      runs.delete(run)
-      const openBody = requestBodies.get(frame.streamId)
-      if (openBody === undefined) return
-      openBody.error(new Error('dsh desktop: response completed before the request body ended'))
-      requestBodies.delete(frame.streamId)
-      blockedRequests.delete(frame.streamId)
-      discardedRequestBodies.add(frame.streamId)
-      resumeRequestPipe()
-    })
-  }
-
-  const handleRequestFrame = (frame: DesktopHostRequestFrame): void => {
-    switch (frame.type) {
-      case 'start':
-        beginRequest(frame)
-        return
-      case 'data': {
-        const body = requestBodies.get(frame.streamId)
-        if (body === undefined) {
-          if (discardedRequestBodies.has(frame.streamId)) return
-          throw new Error(`dsh desktop: Electron sent body data for inactive stream ${String(frame.streamId)}`)
-        }
-        body.enqueue(frame.data)
-        if ((body.desiredSize ?? 0) <= 0) {
-          blockedRequests.add(frame.streamId)
-          requestPipe.pause()
-        }
-        return
-      }
-      case 'end': {
-        const body = requestBodies.get(frame.streamId)
-        if (body === undefined) {
-          if (discardedRequestBodies.delete(frame.streamId)) return
-          throw new Error(`dsh desktop: Electron ended inactive body stream ${String(frame.streamId)}`)
-        }
-        body.close()
-        requestBodies.delete(frame.streamId)
-        blockedRequests.delete(frame.streamId)
-        resumeRequestPipe()
-        return
-      }
-      case 'cancel': {
-        if (frame.streamId > lastStreamId) {
-          throw new Error(`dsh desktop: Electron canceled unknown stream ${String(frame.streamId)}`)
-        }
-        const body = requestBodies.get(frame.streamId)
-        body?.error(new Error('dsh desktop: Electron canceled the request'))
-        requestBodies.delete(frame.streamId)
-        blockedRequests.delete(frame.streamId)
-        discardedRequestBodies.delete(frame.streamId)
-        controller.cancel(frame.streamId)
-        resumeRequestPipe()
-        return
-      }
-      default:
-        frame satisfies never
-    }
-  }
-
-  requestPipe.on('data', (chunk: string | Buffer) => {
-    try {
-      for (const frame of decoder.push(Buffer.from(chunk))) handleRequestFrame(frame)
-    } catch (error) {
-      failTransport(error)
-    }
+  const runtimeDir = process.argv[2] as string
+  const projectDir = process.argv[3] as string
+  const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
+  const profile = loadProfileDirectory('dsh', projectDir, installAnchor)
+  const application = runProfile({
+    environment: loadLayeredEnv('dsh'),
+    profile: 'desktop',
+    resolutionMode: process.argv[5] === 'runtime' ? 'runtime' : 'link',
+    resolvedProfile: { profile, installAnchor },
+    patchFiles: [],
+    args: ['--no-open', '--port', '19387'],
   })
-  requestPipe.once('end', () => {
-    if (stopping !== undefined) return
-    try {
-      decoder.finish()
-      failTransport(new Error('dsh desktop: Electron request pipe ended'))
-    } catch (error) {
-      failTransport(error)
-    }
-  })
-  requestPipe.once('error', failTransport)
-  responsePipe.once('error', failTransport)
-  process.on('message', (message: unknown) => {
-    if (!isDesktopHostCommand(message)) {
-      send({ type: 'fatal', message: 'dsh desktop: invalid Electron IPC command' })
-      void stop(1)
-      return
-    }
-    void stop()
+  const stop = async (): Promise<void> => {
+    // Startup failure is reported by main; shutdown only owns a tree that booted.
+    const running = await application.catch(() => undefined)
+    await running?.shutdown.shutdown(0)
+    if (process.connected) process.disconnect()
+  }
+  process.on('message', (message: { type?: string } | null) => {
+    if (message?.type === 'shutdown') void stop()
   })
   process.once('disconnect', () => { void stop() })
-  process.once('SIGTERM', () => { void stop() })
-  process.once('SIGINT', () => { void stop() })
+  const { ctx } = await application
+  await ctx.plugin(workspaceDependencies, {
+    source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
+    root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
+  })
+  const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`)
+  if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
 }
 
 if (import.meta.main) {
   main().catch((error: unknown) => {
     const message = error instanceof Error ? error.message : String(error)
-    if (process.send !== undefined) process.send({ type: 'fatal', message } satisfies DesktopHostEvent)
-    else process.stderr.write(`dsh desktop: ${message}\n`)
+    if (process.connected) process.send?.({ type: 'fatal', message }, (error) => { if (error !== null) console.error(error) })
+    console.error(error)
     process.exitCode = 1
+    if (process.connected) process.disconnect()
   })
 }

+ 112 - 0
apps/desktop-host/src/primary-runtime.ts

@@ -0,0 +1,112 @@
+/** Offline installation and absolute paths for Desktop's bundled script dependencies. */
+
+import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+
+/** Versions recorded by the Desktop build, independent of user-installed packages. */
+export interface PrimaryRuntimeManifest {
+  readonly desktopVersion: string
+  readonly platform: string
+  readonly arch: string
+  readonly components: {
+    readonly python: string
+    readonly node: string
+    readonly pnpm: string
+    readonly numpy: string
+    readonly pandas: string
+  }
+}
+
+/** Absolute entry points; pnpm is a script executed with the returned Node executable. */
+export interface WorkspaceDependencies {
+  readonly python: string
+  readonly node: string
+  readonly pnpm: string
+  readonly pythonPackages: string
+  readonly nodePackages: string
+}
+
+/**
+ * Read and validate build metadata before selecting interpreter paths.
+ * @param root - Installed or bundled primary runtime directory.
+ * @returns Validated component versions and target identifiers.
+ */
+export async function readPrimaryRuntime(root: string): Promise<PrimaryRuntimeManifest> {
+  const value: unknown = JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8'))
+  if (typeof value !== 'object' || value === null) throw new Error('primary runtime: invalid metadata')
+  const record = value as Record<string, unknown>
+  const components = record.components
+  if (typeof record.desktopVersion !== 'string' || record.desktopVersion.length === 0
+    || !['win32', 'darwin'].includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch))
+    || typeof components !== 'object' || components === null
+    || !['python', 'node', 'pnpm', 'numpy', 'pandas'].every(key => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(String((components as Record<string, unknown>)[key])))) {
+    throw new Error('primary runtime: invalid metadata')
+  }
+  return value as PrimaryRuntimeManifest
+}
+
+/**
+ * Resolve platform-specific interpreter and library locations without changing the environment.
+ * @param root - Absolute installation directory.
+ * @param manifest - Validated runtime metadata.
+ * @returns Absolute paths for explicit script execution.
+ */
+export function workspaceDependencyPaths(root: string, manifest: PrimaryRuntimeManifest): WorkspaceDependencies {
+  const dependencies = join(root, 'dependencies')
+  const windows = manifest.platform === 'win32'
+  return {
+    python: join(dependencies, 'python', ...(windows ? ['python.exe'] : ['bin', 'python3'])),
+    node: join(dependencies, 'node', 'bin', windows ? 'node.exe' : 'node'),
+    pnpm: join(dependencies, 'pnpm', 'bin', 'pnpm.mjs'),
+    pythonPackages: join(dependencies, 'python', ...(windows ? ['Lib'] : ['lib', `python${manifest.components.python.split('.').slice(0, 2).join('.')}`]), 'site-packages'),
+    nodePackages: join(dependencies, 'node', 'node_modules'),
+  }
+}
+
+async function exists(path: string): Promise<boolean> {
+  try {
+    if ((await lstat(path)).isSymbolicLink()) throw new Error(`primary runtime: installation path is a filesystem link: ${path}`)
+    return true
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
+    throw error
+  }
+}
+
+/**
+ * Install the application-owned payload locally, retaining a complete previous tree on copy failure.
+ * @param source - Payload carried by the current Desktop installation.
+ * @param root - Fixed primary runtime directory under the Harness home.
+ * @returns Paths into the installed payload; no PATH or package-manager configuration is changed.
+ */
+export async function installPrimaryRuntime(source: string, root: string): Promise<WorkspaceDependencies> {
+  const manifest = await readPrimaryRuntime(source)
+  if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture')
+  await mkdir(dirname(root), { recursive: true })
+  const previous = `${root}.previous`
+  await exists(previous)
+  await exists(root)
+  if (!await exists(root) && await exists(previous)) await rename(previous, root)
+  if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) {
+    const paths = workspaceDependencyPaths(root, manifest)
+    for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path)
+    return paths
+  }
+  const staging = await mkdtemp(join(dirname(root), '.primary-runtime-'))
+  try {
+    await cp(source, staging, { recursive: true, dereference: true })
+    const paths = workspaceDependencyPaths(staging, manifest)
+    for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path)
+    await rm(previous, { recursive: true, force: true })
+    const replacing = await exists(root)
+    if (replacing) await rename(root, previous)
+    try { await rename(staging, root) } catch (error) {
+      if (replacing) await rename(previous, root)
+      throw error
+    }
+    await rm(previous, { recursive: true, force: true })
+  } finally {
+    await rm(staging, { recursive: true, force: true })
+  }
+  return workspaceDependencyPaths(root, manifest)
+}

+ 0 - 184
apps/desktop-host/src/wire.ts

@@ -1,184 +0,0 @@
-/** Framed request and response bytes for the Electron Desktop Host transport. */
-
-/** Protocol version shared with the Electron shell. */
-export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const
-
-/** Child descriptor that receives Electron request frames. */
-export const DESKTOP_REQUEST_PIPE_FD = 3
-
-/** Child descriptor that emits Host response frames. */
-export const DESKTOP_RESPONSE_PIPE_FD = 4
-
-/** Maximum raw body bytes carried by one data frame. */
-export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024
-
-const FRAME_MAGIC = 0x44534833
-const FRAME_HEADER_BYTES = 13
-const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024
-
-const REQUEST_FRAME_START = 1
-const REQUEST_FRAME_DATA = 2
-const REQUEST_FRAME_END = 3
-const REQUEST_FRAME_CANCEL = 4
-
-const RESPONSE_FRAME_START = 1
-const RESPONSE_FRAME_DATA = 2
-const RESPONSE_FRAME_END = 3
-const RESPONSE_FRAME_ERROR = 4
-type ResponseFrameType = typeof RESPONSE_FRAME_START | typeof RESPONSE_FRAME_DATA
-  | typeof RESPONSE_FRAME_END | typeof RESPONSE_FRAME_ERROR
-
-/** One validated request-pipe frame. */
-export type DesktopHostRequestFrame = {
-  readonly type: 'start'
-  readonly streamId: number
-  readonly url: string
-  readonly method: string
-  readonly headers: readonly [string, string][]
-  readonly hasBody: boolean
-} | {
-  readonly type: 'data'
-  readonly streamId: number
-  readonly data: Buffer
-} | {
-  readonly type: 'end' | 'cancel'
-  readonly streamId: number
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === 'object' && value !== null
-}
-
-function isHeaders(value: unknown): value is readonly [string, string][] {
-  return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2
-    && typeof header[0] === 'string' && typeof header[1] === 'string')
-}
-
-function assertStreamId(streamId: number): void {
-  if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) {
-    throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`)
-  }
-}
-
-function encodeFrame(type: ResponseFrameType, streamId: number, payload: Buffer): Buffer {
-  assertStreamId(streamId)
-  const limit = type === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
-  if (payload.byteLength > limit) {
-    throw new Error(`dsh desktop: response pipe frame exceeds the ${String(limit)}-byte limit`)
-  }
-  const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength)
-  frame.writeUInt32BE(FRAME_MAGIC, 0)
-  frame.writeUInt8(type, 4)
-  frame.writeUInt32BE(streamId, 5)
-  frame.writeUInt32BE(payload.byteLength, 9)
-  payload.copy(frame, FRAME_HEADER_BYTES)
-  return frame
-}
-
-function encodeJsonFrame(type: ResponseFrameType, streamId: number, value: unknown): Buffer {
-  return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8'))
-}
-
-/** Encode response metadata before any body frames. */
-export function encodeDesktopResponseStart(
-  streamId: number,
-  response: {
-    readonly status: number
-    readonly headers: readonly [string, string][]
-    readonly hasBody: boolean
-  },
-): Buffer {
-  return encodeJsonFrame(RESPONSE_FRAME_START, streamId, response)
-}
-
-/** Encode one bounded raw response-body chunk. */
-export function encodeDesktopResponseData(streamId: number, data: Uint8Array): Buffer {
-  return encodeFrame(RESPONSE_FRAME_DATA, streamId, Buffer.from(data))
-}
-
-/** Encode normal response completion. */
-export function encodeDesktopResponseEnd(streamId: number): Buffer {
-  return encodeFrame(RESPONSE_FRAME_END, streamId, Buffer.alloc(0))
-}
-
-/** Encode one response failure without exposing an Error object across processes. */
-export function encodeDesktopResponseError(streamId: number, message: string): Buffer {
-  return encodeJsonFrame(RESPONSE_FRAME_ERROR, streamId, { message })
-}
-
-/** Incrementally decode validated request frames from the Electron byte pipe. */
-export class DesktopHostRequestDecoder {
-  private buffer: Buffer = Buffer.alloc(0)
-
-  /**
-   * Append bytes and return every complete request frame.
-   * @param chunk - next bytes read from the Electron request pipe.
-   * @returns complete frames in pipe order.
-   */
-  push(chunk: Buffer): DesktopHostRequestFrame[] {
-    this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk])
-    const frames: DesktopHostRequestFrame[] = []
-    for (;;) {
-      const frame = this.next()
-      if (frame === undefined) return frames
-      frames.push(frame)
-    }
-  }
-
-  /** Reject EOF that splits a frame. */
-  finish(): void {
-    if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Electron request pipe ended inside a frame')
-  }
-
-  private next(): DesktopHostRequestFrame | undefined {
-    if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined
-    if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Electron request frame marker')
-    const rawType = this.buffer.readUInt8(4)
-    const streamId = this.buffer.readUInt32BE(5)
-    const payloadLength = this.buffer.readUInt32BE(9)
-    assertStreamId(streamId)
-    const limit = rawType === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
-    if (payloadLength > limit) {
-      throw new Error(`dsh desktop: Electron request frame exceeds the ${String(limit)}-byte limit`)
-    }
-    const frameLength = FRAME_HEADER_BYTES + payloadLength
-    if (this.buffer.byteLength < frameLength) return undefined
-    const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength)
-    this.buffer = this.buffer.subarray(frameLength)
-    switch (rawType) {
-      case REQUEST_FRAME_START:
-        return this.parseStart(streamId, payload)
-      case REQUEST_FRAME_DATA:
-        return { type: 'data', streamId, data: payload }
-      case REQUEST_FRAME_END:
-        if (payloadLength !== 0) throw new Error('dsh desktop: Electron request end frame carried a payload')
-        return { type: 'end', streamId }
-      case REQUEST_FRAME_CANCEL:
-        if (payloadLength !== 0) throw new Error('dsh desktop: Electron request cancel frame carried a payload')
-        return { type: 'cancel', streamId }
-      default:
-        throw new Error(`dsh desktop: unknown Electron request frame type ${String(rawType)}`)
-    }
-  }
-
-  private parseStart(streamId: number, payload: Buffer): DesktopHostRequestFrame {
-    let value: unknown
-    try {
-      value = JSON.parse(payload.toString('utf8')) as unknown
-    } catch (error) {
-      throw new Error(`dsh desktop: Electron request start payload is not JSON: ${error instanceof Error ? error.message : String(error)}`)
-    }
-    if (!isRecord(value) || typeof value.url !== 'string' || typeof value.method !== 'string'
-      || !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') {
-      throw new Error('dsh desktop: invalid Electron request start payload')
-    }
-    return {
-      type: 'start',
-      streamId,
-      url: value.url,
-      method: value.method,
-      headers: value.headers,
-      hasBody: value.hasBody,
-    }
-  }
-}

+ 53 - 0
apps/desktop-host/src/workspace-dependencies.ts

@@ -0,0 +1,53 @@
+/** Desktop tool exposing bundled interpreters without modifying command resolution. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import { installPrimaryRuntime, type WorkspaceDependencies } from './primary-runtime.ts'
+
+export const name = 'desktop-workspace-dependencies'
+export const inject = ['tools']
+
+/** Application-selected payload and installation directories. */
+export interface Config {
+  readonly source: string
+  readonly root: string
+}
+
+/**
+ * Register the read-only path query, preparing bundled files on its first invocation.
+ * @param ctx - Desktop tool registry owner.
+ * @param config - Application payload and fixed installation paths.
+ */
+export function apply(ctx: Context, config: Config): void {
+  let installation: Promise<WorkspaceDependencies> | undefined
+  ctx.effect(() => async () => {
+    // Tool execution reports installation failures; disposal only waits for filesystem work to settle.
+    await installation?.catch(() => undefined)
+  })
+  ctx.tools.register(defineTool({
+    name: 'load_workspace_dependencies',
+    description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories. Python includes numpy and pandas. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.',
+    parameters: {},
+    output: {
+      schema: {
+        type: 'object', additionalProperties: false,
+        properties: {
+          python: { type: 'string', required: true },
+          node: { type: 'string', required: true },
+          pnpm: { type: 'string', required: true },
+          pythonPackages: { type: 'string', required: true },
+          nodePackages: { type: 'string', required: true },
+        },
+      },
+      render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }],
+    },
+    execute: () => {
+      installation ??= installPrimaryRuntime(config.source, config.root).catch((error: unknown) => {
+        installation = undefined
+        throw error
+      })
+      return installation
+    },
+    presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }),
+  }))
+}

+ 130 - 0
apps/desktop-host/tests/primary-runtime.spec.ts

@@ -0,0 +1,130 @@
+import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { installPrimaryRuntime, readPrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/primary-runtime.ts'
+import * as workspaceDependencies from '../src/workspace-dependencies.ts'
+
+const roots: string[] = []
+afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
+
+async function fixture() {
+  const directory = await mkdtemp(join(tmpdir(), 'dsh-primary-runtime-'))
+  roots.push(directory)
+  const source = join(directory, 'resources')
+  const root = join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime')
+  const manifest: PrimaryRuntimeManifest = {
+    desktopVersion: '1.0.0', platform: process.platform === 'win32' ? 'win32' : 'darwin', arch: process.arch,
+    components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' },
+  }
+  const paths = workspaceDependencyPaths(source, manifest)
+  for (const path of [paths.python, paths.node, paths.pnpm]) {
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, 'interpreter')
+  }
+  await mkdir(paths.pythonPackages, { recursive: true })
+  await mkdir(paths.nodePackages, { recursive: true })
+  await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest))
+  return { source, root, manifest, directory }
+}
+
+it.each(['win32', 'darwin'])('returns %s interpreter and package paths', (platform) => {
+  const manifest: PrimaryRuntimeManifest = { desktopVersion: '1', platform, arch: 'x64', components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' } }
+  const paths = workspaceDependencyPaths('/runtime', manifest)
+  expect(paths.python).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['python.exe'] : ['bin', 'python3'])))
+  expect(paths.pythonPackages).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages'))
+})
+
+it.skipIf(process.platform === 'linux')('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => {
+  const { source, root } = await fixture()
+  const environment = { ...process.env }
+  const installed = await installPrimaryRuntime(source, root)
+  await writeFile(join(installed.pythonPackages, 'user-package.py'), 'user content')
+  expect(await installPrimaryRuntime(source, root)).toEqual(installed)
+  expect(await readFile(join(installed.pythonPackages, 'user-package.py'), 'utf8')).toBe('user content')
+  expect(process.env).toEqual(environment)
+})
+
+it.skipIf(process.platform === 'linux')('replaces release components and recovers an interrupted directory swap', async () => {
+  const { source, root, manifest } = await fixture()
+  await installPrimaryRuntime(source, root)
+  await rename(root, `${root}.previous`)
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' }))
+  await installPrimaryRuntime(source, root)
+  expect((await readPrimaryRuntime(root)).desktopVersion).toBe('2.0.0')
+})
+
+it.skipIf(process.platform === 'linux')('keeps the installed release when the replacement payload is incomplete', async () => {
+  const { source, root, manifest } = await fixture()
+  await installPrimaryRuntime(source, root)
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' }))
+  await rm(workspaceDependencyPaths(source, manifest).python)
+  await expect(installPrimaryRuntime(source, root)).rejects.toThrow()
+  expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0')
+})
+
+it.skipIf(process.platform === 'linux')('refuses linked installation directories without modifying their targets', async () => {
+  const { source, root, directory } = await fixture()
+  const outside = join(directory, 'outside')
+  await mkdir(outside)
+  await writeFile(join(outside, 'keep'), 'untouched')
+  await mkdir(dirname(root), { recursive: true })
+  await symlink(outside, root, process.platform === 'win32' ? 'junction' : 'dir')
+  await expect(installPrimaryRuntime(source, root)).rejects.toThrow('filesystem link')
+  expect(await readFile(join(outside, 'keep'), 'utf8')).toBe('untouched')
+})
+
+it('rejects malformed metadata and incompatible targets', async () => {
+  const { source, root, manifest } = await fixture()
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, arch: process.arch === 'x64' ? 'arm64' : 'x64' }))
+  await expect(installPrimaryRuntime(source, root)).rejects.toThrow('incompatible')
+  await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, python: '../escape' } }))
+  await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata')
+})
+
+it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exposes installed paths, and unregisters on disposal', async () => {
+  const { source, root, manifest, directory } = await fixture()
+  const ctx = new Context()
+  try {
+    ctx.baseUrl = pathToFileURL(directory).href + '/'
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.include = Include
+    const modules = new Map<string, unknown>([
+      ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['dependencies', workspaceDependencies],
+    ])
+    ctx.loader.internal = {
+      version: 'v2',
+      async import(specifier: string) {
+        if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`)
+        return modules.get(specifier)
+      },
+    } as unknown as NonNullable<typeof ctx.loader.internal>
+    const config = join(directory, 'cordis.yml')
+    await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n  config:\n    source: ${JSON.stringify(source)}\n    root: ${JSON.stringify(root)}\n`)
+    await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } })
+    await ctx.loader.await()
+    for (const entry of ctx.loader.entries()) await entry.fiber?.await()
+    const environment = { ...process.env }
+    const request = { signal: new AbortController().signal, name: 'load_workspace_dependencies', arguments: {} }
+    const results = await Promise.all(['first', 'second'].map(id => ctx.tools.execute({ ...request, callId: ToolCallId(id) })))
+    for (const result of results) {
+      expect(result.isError).toBe(false)
+      expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(root, manifest), undefined, 2) }])
+    }
+    expect(process.env).toEqual(environment)
+    const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'dependencies')
+    expect(entry).toBeDefined()
+    await entry?.fiber?.dispose()
+    expect(ctx.tools.schemas().some(tool => tool.name === 'load_workspace_dependencies')).toBe(false)
+  } finally {
+    await ctx.fiber.dispose()
+  }
+})

+ 17 - 10
apps/desktop-host/tsconfig.json

@@ -4,16 +4,23 @@
     "rootDir": "src",
     "outDir": "lib/types"
   },
-  "include": ["src"],
+  "include": [
+    "src"
+  ],
   "references": [
-    { "path": "../../vendor/cordis" },
-    { "path": "../../vendor/include" },
-    { "path": "../../packages/api/gateway/tsconfig.host.json" },
-    { "path": "../../packages/boot/app-boot" },
-    { "path": "../../packages/boot/cmdline" },
-    { "path": "../../packages/client/connection/tsconfig.host.json" },
-    { "path": "../../packages/client/modules" },
-    { "path": "../../packages/host/webserver" },
-    { "path": "../../packages/util/launch-environment" }
+    { "path": "../../packages/util/home-paths" },
+    { "path": "../../packages/core/tools" },
+    {
+      "path": "../cli"
+    },
+    {
+      "path": "../../packages/boot/app-boot"
+    },
+    {
+      "path": "../../packages/client/connection/tsconfig.host.json"
+    },
+    {
+      "path": "../../packages/host/webserver"
+    }
   ]
 }

+ 29 - 0
apps/desktop/.env.macos.example

@@ -0,0 +1,29 @@
+# Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
+# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
+DSH_DESKTOP_APP_ID=com.deepseek.harness
+DSH_DESKTOP_AUTO_UPDATE_ENV=test
+DOWNLOAD_TEST_ORIGIN=
+DSH_DESKTOP_MACOS_SIGNING_IDENTITY=
+DSH_DESKTOP_MACOS_TEAM_ID=
+
+# Choose exactly one notarization strategy; leave the others commented out.
+APPLE_KEYCHAIN_PROFILE=
+# APPLE_KEYCHAIN=
+# APPLE_API_KEY=
+# APPLE_API_KEY_ID=
+# APPLE_API_ISSUER=
+# APPLE_ID=
+# APPLE_APP_SPECIFIC_PASSWORD=
+# APPLE_TEAM_ID=
+
+# Optional electron-builder certificate import instead of an existing keychain identity.
+# CSC_LINK=
+# CSC_KEY_PASSWORD=
+
+# Optional upload configuration; packaging does not require these credentials.
+# DOWNLOAD_TEST_COS_BUCKET=
+# DOWNLOAD_TEST_COS_SECRET_ID=
+# DOWNLOAD_TEST_COS_SECRET_KEY=
+# DOWNLOAD_PROD_COS_BUCKET=
+# DOWNLOAD_PROD_COS_SECRET_ID=
+# DOWNLOAD_PROD_COS_SECRET_KEY=

+ 17 - 0
apps/desktop/.env.windows.example

@@ -0,0 +1,17 @@
+# Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
+# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
+DSH_DESKTOP_APP_ID=com.deepseek.harness
+DSH_DESKTOP_AUTO_UPDATE_ENV=test
+DOWNLOAD_TEST_ORIGIN=
+DSH_DESKTOP_WINDOWS_CER_FILE=
+DSH_DESKTOP_WINDOWS_SIGNTOOL=
+DSH_DESKTOP_WINDOWS_KEY_CONTAINER=
+DSH_DESKTOP_WINDOWS_TOKEN_PIN=
+
+# Optional upload configuration; packaging does not require these credentials.
+# DOWNLOAD_TEST_COS_BUCKET=
+# DOWNLOAD_TEST_COS_SECRET_ID=
+# DOWNLOAD_TEST_COS_SECRET_KEY=
+# DOWNLOAD_PROD_COS_BUCKET=
+# DOWNLOAD_PROD_COS_SECRET_ID=
+# DOWNLOAD_PROD_COS_SECRET_KEY=

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: c053e4894714d10cba2a9d9c1ebed71b53457c37
-README.zh.md: e27554fe8a91ee8a918380ef5a860b0838351aa5
+README.md: 2793dae3a6bd0ca9ce5b4a7c13e539a861741994
+README.zh.md: 2beb07eb7580154a3aafb6fbea37157529e8d4f8

+ 75 - 50
apps/desktop/README.md

@@ -2,50 +2,74 @@
 
 English | [中文](README.zh.md)
 
-The desktop application is an Electron shell around the dsh Web UI. It opens no listening port: a bundled upstream Node.js child boots the installed dsh project, versioned framed byte pipes carry Fetch requests and streaming responses without an outer Base64 envelope, Node IPC carries lifecycle control, and `dsh-app://` serves the matching client assets.
+The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
 
 ## Key technical decisions
 
+The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
+
+The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release.
+
+### Bundled workspace dependencies
+
+The current Windows Python payload contains unsigned native extensions. Smart App Control blocked `_decimal`, `pyexpat`, `_lzma` and `_uuid` during local validation; XML and LZMA operations fail on that host. Successful numpy/pandas smoke checks do not establish compatibility for every extension.
+
+Desktop carries independent Python, Node.js and pnpm distributions, with numpy and pandas in Python's `site-packages`. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
+
+The payload follows the Desktop release. `runtime.json` records the Desktop version, target and component versions; a matching installation is reused, and a different release replaces the directory after a complete staged copy. Python packages added to that directory are retained within the same release and replaced with the application baseline on upgrade. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running.
+
+This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
+
+Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. The supported library wheels unpack directly into site-packages; wheels requiring other installation directories are rejected, and package command-line wrappers are not generated. Native-target checks execute the bundled interpreters and numpy/pandas operations after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies.
+
 | Decision | Why | Direct consequence |
 |---|---|---|
 | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
-| Runtime | Electron's Node.js carries Electron patches, fuses, ABI, and lifecycle constraints, while system runtimes and package-manager state are uncontrolled. | dsh runs under the bundled upstream Node.js and every package operation uses the bundled pnpm. Electron's Node.js, system Node.js, system pnpm, and user package-manager configuration are outside the execution path. |
-| Package sources | Core installation at startup adds work even when offline. | `extraResources/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
-| Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. |
+| Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. |
+| Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
+| Shared modules | Host APIs can depend on module identity. | The shared profile runner projects missing installation and bundle dependencies inside the Desktop profile; pnpm-managed packages take precedence. |
 | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
-| Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. |
+| Transport | Reusing Web serving and authentication keeps application behavior in one implementation. | Electron loads the Host’s authenticated HTTP URL directly; child IPC carries lifecycle messages, and the local shell protocol serves startup and management pages. |
 | Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. |
-| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
+| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
 
-The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions.
+The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md) owns shared Web behavior and Desktop adapters. The [Electron packaging and update decision](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns release identity, signing, and update qualification.
 
 ## Installation ownership
 
-Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
+Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
+
+The local startup page exposes startup status and available recovery actions. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
-The local startup page exposes startup status and available recovery actions; the loaded dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
+The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles and patch-reload policy.
 
 Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
 
+Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
+
 ### Runtime and plugin activation
 
-The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
+The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
+
+1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. The actual Host starts once and supplies missing module links through the shared profile runner.
+2. On application upgrades, the shared profile runner refreshes its owned module links without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
+3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
+4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
+5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
+
+CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links while preserving unrelated files, then starts the actual backend once. Unchanged startups reuse the profile without scanning installed plugin manifests.
-2. A compatible application upgrade refreshes shared links in the current profile and checks enabled plugins’ peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
-3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again.
-4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile.
-5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Unfinished package operations retain a marker so the next launch retries the locked installation and pending builds. Desktop creates no staging directories, activation journals, or rollback copies.
+The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.
 
-The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered only when packaged application resources support profile recovery; development and early initialization failures expose restart alone. The plugin manager remains available through the application menu. Runtime identity is checked before any backend starts; plugin changes have no automatic rollback.
+Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
 Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
 
-Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Reset preserves the directory and its lock until initialization and Host startup finish. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
+Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; the Host recreates needed links on startup. Reset preserves the profile directory and its lock until initialization and Host startup finish. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 
 ## Develop
 
-`dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without downloading the packaged Node.js runtime or resolving dsh from npm:
+`dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without resolving dsh from npm:
 
 ```sh
 pnpm run dev:desktop
@@ -59,19 +83,16 @@ After an explicit build, `start:desktop` reconstructs the disposable project and
 pnpm run start:desktop
 ```
 
-Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
+Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. Development and packaged profiles both use normal bundle resolution, including linked packages. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
 
 ## Package
 
-The normal packaging path is one complete command. It performs release preparation before creating the host platform's installers and update metadata. Every target requires a reverse-DNS `DSH_DESKTOP_APP_ID`. macOS targets additionally require the electron-builder certificate qualifier in `DSH_DESKTOP_MACOS_SIGNING_IDENTITY`, its 10-character Apple Team ID in `DSH_DESKTOP_MACOS_TEAM_ID`, and one complete notarytool credential strategy. The App Store Connect API-key strategy uses these variables:
+Packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging.
+
+Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading. macOS checks the identity, Team ID, one complete notarization strategy, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Run the same checks separately:
 
 ```sh
-export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
-export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
-export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
-export APPLE_API_KEY='<absolute path to the .p8 file>'
-export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
-export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
+pnpm --dir apps/desktop run check:package
 ```
 
 `prepare:desktop` is not a prerequisite:
@@ -90,20 +111,22 @@ pnpm run package:desktop:win:x64
 
 The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target.
 
-Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets/<target>/`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state.
+Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets/<target>/`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state.
 
 ### Runtime file selection
 
-Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
+Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
 
-The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under the bundled Node before the Host smoke and final inventory verification.
+The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
 
-Windows release qualification also runs [native cleanup and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Electron` to the prepared Electron executable and `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies Electron junction cleanup, installer scratch cleanup, and both locked-file replacement modes; it is not part of the unit-test lane.
+Windows release qualification also runs [native cleanup and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Electron` to the prepared Electron executable and `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies Electron junction cleanup, directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
 
 ```powershell
 pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
+The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
+
 ### Upload updates
 
 `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`, while the production origin remains `https://download.deepseek.com`. Upload additionally requires the selected deployment's COS bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. The target path is `_/harness/desktop/stable/<target>/`, where `target` is `mac-arm64`, `mac-x64`, or `win-x64`.
@@ -115,19 +138,14 @@ The update destination and upload credentials follow the selected deployment:
 | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` |
 | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` |
 
-Package and upload one target under the same environment. For example, the default test deployment uses:
+Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target:
 
 ```sh
-export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
 pnpm run package:desktop:mac:arm64
-
-export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
-export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
-export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
 pnpm run upload:mac:arm64
 ```
 
-Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
+Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` in the target dotenv file before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
 
 The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
 
@@ -145,25 +163,33 @@ pnpm run package:desktop:win:x64:unsigned
 
 The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements.
 
+### Windows installer interface
+
+The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; new destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running.
+
+The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the installation flow described above. First-launch profile preparation remains a separate Desktop operation.
+
+Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. The [installer decision](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md) records the NSIS integration and release checks.
+
+Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed.
+
 ### Windows EV signing
 
+For this project's SafeNet token, `SignTool Error: No private key is available.` indicates an incorrect PIN. Stop all signing attempts immediately and wait for the user to correct the PIN before continuing. Five incorrect PIN attempts lock the token. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure.
+
 Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
 
 NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
 
-Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target:
+Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git.
 
-```powershell
-$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
-$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
-$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
-$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
+```sh
 pnpm run package:desktop:win:x64
 ```
 
 Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
 
-The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable.
+The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
 
 Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
 
@@ -180,9 +206,9 @@ pnpm run prepare:desktop
 
 This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
 
-Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares target-specific Node and pnpm executables. `prepare:dsh` installs the production graph once at build time, copies materialized packages into `extraResources/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the copied inventory is checked before signing and again after signing. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
+Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
 
-An unpacked artifact contains Electron, the materialized dsh production tree, upstream Node.js and pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
+An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
 
 ## Updates
 
@@ -192,11 +218,10 @@ Signed packaging emits generic-provider channel metadata for the deployment sele
 
 ## Low-level development overrides
 
-An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
+An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
 
 ## Known limitations
 
-- The Web "Open In..." action is disabled in Desktop because its host plugin requires HTTP routes; Desktop does not provide a `webServer`.
 - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
-- Desktop plugins with dependency lifecycle scripts are rejected unless their package appears in the desktop project's reviewed `allowBuilds` policy.
-- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, lockfiles, and package-manager state remain separate.
+- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
+- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.

+ 75 - 50
apps/desktop/README.zh.md

@@ -2,50 +2,74 @@
 
 [English](README.md) | 中文
 
-桌面应用是包裹 dsh Web UI 的 Electron 壳。它不打开监听端口:内置的上游 Node.js 子进程启动已安装的 dsh 项目,带版本的分帧字节管道在没有外层 Base64 信封的情况下承载 Fetch 请求与流式响应,Node IPC 承载生命周期控制,`dsh-app://` 则提供与后端版本匹配的客户端资源
+桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖
 
 ## 关键技术决策
 
+设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
+
+macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。
+
+### 内置工作区依赖
+
+当前 Windows Python 产物包含未签名的原生扩展。本机验证中,Smart App Control 阻止了 `_decimal`、`pyexpat`、`_lzma` 和 `_uuid`;该主机上的 XML 和 LZMA 操作失败。numpy/pandas 冒烟检查通过,不代表所有扩展都兼容。
+
+Desktop 携带独立的 Python、Node.js 和 pnpm 分发包,并在 Python 的 `site-packages` 中预装 numpy 和 pandas。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
+
+该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台和组件版本;匹配的安装会被复用,版本不同时在完整暂存副本完成后替换目录。添加到该目录的 Python 包在同一版本内保留,升级时随应用基线一起替换。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
+
+该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
+
+Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。支持的库 wheel 直接解压到 site-packages;需要其他安装目录的 wheel 会被拒绝,不生成包的命令行包装器。本机目标检查在清理暂存目录后以及 macOS 签名后执行内置解释器及 numpy/pandas 运算。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。
+
 | 决策 | 原因 | 直接结果 |
 |---|---|---|
 | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
-| 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。Electron 的 Node.js、系统 Node.js、系统 pnpm 与用户的包管理器配置都不进入执行路径。 |
-| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
-| 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 |
+| 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 |
+| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
+| 共享模块 | Host API 可能依赖模块身份。 | 共享 profile runner 在 Desktop profile 内补全安装包与 bundle 缺失的依赖;pnpm 管理的包优先。 |
 | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
-| 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 |
+| 传输 | 复用 Web 服务与认证,让应用行为由同一份实现负责。 | Electron 直接加载 Host 的认证 HTTP URL;子进程 IPC 承载生命周期消息,本地壳协议提供启动和管理页面。 |
 | 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
-| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
+| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
-[Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。
+[薄壳决策](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 行为与 Desktop 适配。[Electron 打包与更新决策](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责发布身份、签名及更新验收
 
 ## 安装归属
 
-Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
+Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
+
+本地启动页面展示启动状态及可用恢复操作。产品渲染进程使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两个渲染进程都不会获得文件系统、原始 Electron IPC、shell 或任意 pnpm 参数访问权。
 
-本地启动页提供启动状态和可用恢复操作;加载后的 dsh 渲染进程仅接收桌面协议标记。独立插件窗口接收结构化的列表、安装、删除、更新和更新检查操作;两个渲染进程都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
+产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表和 patch 重载策略初始化新 profile
 
 Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
 
+Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
+
 ### 运行时与插件激活
 
-签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
+签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
+
+1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接。
+2. 应用升级时,共享 profile runner 刷新其拥有的模块链接,不检查插件 peer 要求。插件文件、配置、版本与锁文件保留原位;不运行 pnpm。
+3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
+4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
+5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
+
+CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-1. 主窗口在 profile 准备或后端启动前显示本地加载页。新 profile 创建清单和共享包链接,保留无关文件,然后启动一次实际后端。未变化的启动复用 profile,不扫描已安装插件的清单。
-2. 兼容的应用升级在当前 profile 中刷新共享链接,并检查已启用插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。
-3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。
-4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。
-5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。未完成的包操作保留标记,使下次启动重试锁定依赖的安装和待执行构建。Desktop 不创建 staging 目录、激活日志或回滚副本。
+加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚。
 
-加载页不依赖 Host。错误页提供重启和重装指导。只有已打包应用的资源支持 profile 恢复时,才提供禁用插件和重置 Desktop;开发模式和早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。每次后端启动前都会检查运行时标识;插件修改不自动回滚。
+Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
 重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload。
 
-包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败
+包事务独占 `$DSH_HOME/profiles/desktop/lock` 直到 pnpm 进程退出。pnpm 运行前,共享模块补全 helper 仅移除其拥有的链接,并保留 pnpm 管理的目录;Host 在启动时重新创建所需链接。重置保留 profile 目录与锁,直到初始化和 Host 启动结束。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备负责独立的构建时许可列表
 
 ## 开发
 
-`dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不下载安装包内的 Node.js,也不从 npm 解析 dsh:
+`dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不从 npm 解析 dsh:
 
 ```sh
 pnpm run dev:desktop
@@ -59,19 +83,16 @@ pnpm run dev:desktop
 pnpm run start:desktop
 ```
 
-Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
+Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。开发与打包 profile 都使用正常的 bundle 解析,包括链接包。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
 
 ## 打包
 
-正常打包只需执行一条完整命令。该命令会先准备发布资源,再生成宿主平台的安装包与更新元数据。所有目标都要求通过 `DSH_DESKTOP_APP_ID` 提供反向域名形式的应用 ID。macOS 目标还要求通过 `DSH_DESKTOP_MACOS_SIGNING_IDENTITY` 提供 electron-builder 证书限定名,通过 `DSH_DESKTOP_MACOS_TEAM_ID` 提供对应的 10 字符 Apple Team ID,并提供一套完整的 notarytool 凭据方案。App Store Connect API Key 方式使用以下变量:
+打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。
+
+每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置。macOS 检查身份、Team ID、一套完整公证凭据以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。单独运行相同检查:
 
 ```sh
-export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
-export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
-export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
-export APPLE_API_KEY='<absolute path to the .p8 file>'
-export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
-export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
+pnpm --dir apps/desktop run check:package
 ```
 
 无需提前执行 `prepare:desktop`:
@@ -90,20 +111,22 @@ pnpm run package:desktop:win:x64
 
 macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。
 
-每个目标都在 `apps/desktop/.desktop-build/targets/<target>/` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。
+每个目标都在 `apps/desktop/.desktop-build/targets/<target>/` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。
 
 ### 运行时文件筛选
 
-生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。
+生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。
 
-打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用内置 Node 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
+打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
 
-Windows 发布验收还需在 Desktop 构建后手动运行[原生清理和替换检查](scripts/smoke-windows.ps1)。将 `$Electron` 设为已准备的 Electron 可执行文件,将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 Electron junction 清理、安装器临时目录清理和两种文件占用替换方式;不属于单元测试通道。
+Windows 发布验收还需在 Desktop 构建后手动运行[原生清理和替换检查](scripts/smoke-windows.ps1)。将 `$Electron` 设为已准备的 Electron 可执行文件,将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 Electron junction 清理、目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
 
 ```powershell
 pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
+Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
+
 ### 上传更新
 
 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包时写入的更新 URL 与后续 COS 上传目标,可取 `test` 或 `production`;未设置时使用 `test`。测试打包必须通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin,生产 origin 仍为 `https://download.deepseek.com`。上传还必须通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选环境的 COS bucket。目标路径为 `_/harness/desktop/stable/<target>/`,其中 `target` 为 `mac-arm64`、`mac-x64` 或 `win-x64`。
@@ -115,19 +138,14 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron
 | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` |
 | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` |
 
-同一目标必须在同一环境下完成打包与上传。例如,默认测试环境使用
+在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标
 
 ```sh
-export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
 pnpm run package:desktop:mac:arm64
-
-export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
-export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
-export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
 pnpm run upload:mac:arm64
 ```
 
-生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
+生产发布需在打包前在目标 `.env` 中设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
 
 macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
 
@@ -145,25 +163,33 @@ pnpm run package:desktop:win:x64:unsigned
 
 该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。
 
+### Windows 安装界面
+
+Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。
+
+主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循上文描述的安装流程。首次启动的配置档案准备仍属于独立的 Desktop 操作。
+
+Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。[安装界面决策](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md)记录 NSIS 接入方式和发布验证要求。
+
+在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。
+
 ### Windows EV 签名
 
+本项目使用的 SafeNet Token 出现 `SignTool Error: No private key is available.` 时,说明 PIN(密码)错误。立即停止所有签名尝试,等待用户处理 PIN 后再继续。PIN 输错达到五次会锁定 Token。遇到该错误后,不得重试打包或签名探针。签名器串行执行 Token 操作,首次失败后拒绝所有排队任务。
+
 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
 
 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
 
-Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入:
+在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。
 
-```powershell
-$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
-$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
-$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
-$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
+```sh
 pnpm run package:desktop:win:x64
 ```
 
 打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
 
-PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量
+PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务
 
 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
 
@@ -180,9 +206,9 @@ pnpm run prepare:desktop
 
 这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
 
-每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Node 与 pnpm 可执行文件。`prepare:dsh` 在构建时安装一次生产依赖图,把物化包复制到 `extraResources/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;复制后的清单在签名前及签名后分别验证。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
+每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
 
-未压缩产物包含 Electron、物化后的 dsh 生产依赖树、上游 Node.js 与 pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
+未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
 
 ## 更新
 
@@ -192,11 +218,10 @@ pnpm run prepare:desktop
 
 ## 底层开发覆盖项
 
-未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
+未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
 
 ## 已知限制
 
-- Desktop 禁用 Web 的「在本地应用中打开…」操作,因为其 Host 插件依赖 HTTP 路由,而 Desktop 不提供 `webServer`。
 - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
-- 依赖包含 lifecycle script 的桌面插件,只有其包名进入桌面项目经过评审的 `allowBuilds` 策略后才能安装
-- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活、锁文件与包管理器状态彼此隔离。
+- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框
+- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。

+ 6 - 0
apps/desktop/electron-builder.config.d.mts

@@ -26,7 +26,13 @@ export interface DesktopElectronBuilderConfig {
   }
   readonly nsis: {
     readonly include: string
+    readonly oneClick: false
+    readonly perMachine: false
+    readonly allowElevation: false
+    readonly allowToChangeInstallationDirectory: false
+    readonly installerLanguages: readonly ['en_US', 'zh_CN']
   }
+  readonly beforeBuild: () => Promise<boolean>
   readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
   readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
 }

+ 29 - 2
apps/desktop/electron-builder.config.mjs

@@ -1,5 +1,7 @@
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
 import {
   resolveDesktopAppId,
   resolveMacOSNotarizationEnvironment,
@@ -10,9 +12,11 @@ import { verifyMacOSSignatureAfterSign } from './scripts/verify-macos-signature.
 import {
   createWindowsTokenSigner,
   installWindowsNsisBootstrapSigner,
+  scrubWindowsSigningEnvironment,
 } from './scripts/windows-sign.mjs'
 import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs'
 import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs'
+import { installWindowsDirectoryInstaller } from './scripts/windows-directory-installer.mjs'
 
 /**
  * Create electron-builder configuration from one release environment.
@@ -37,6 +41,7 @@ export function createElectronBuilderConfig(
   if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows')
   const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin')
   const packagesWindows = targetPlatform === 'win32'
+  if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller()
   const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
   if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
   const windowsSigner = packagesWindows && !unsigned
@@ -58,6 +63,21 @@ export function createElectronBuilderConfig(
     artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
     directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts },
     asar: true,
+    electronDist: buildPaths.electron,
+    electronFuses: { runAsNode: true },
+    beforeBuild: async () => {
+      if (resolvedPlatform !== 'win32') return true
+      await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
+        fileURLToPath(new URL('./scripts/prepare-windows-installer.ps1', import.meta.url)),
+        '-OutputDirectory', join(buildPaths.root, 'installer-ui')], {
+        env: scrubWindowsSigningEnvironment(env), windowsHide: true,
+      })
+      if (windowsSigner !== undefined) {
+        await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
+      }
+      // A falsy result tells electron-builder to omit its production node_modules collection.
+      return true
+    },
     files: [
       'lib/*.js',
       'lib/*.cjs',
@@ -77,12 +97,13 @@ export function createElectronBuilderConfig(
       { from: buildPaths.runtime, to: 'runtime' },
     ],
     mac: {
+      icon: fileURLToPath(new URL('./resources/icon-macos.png', import.meta.url)),
       category: 'public.app-category.developer-tools',
       identity: macOSSigning?.signingIdentity,
       forceCodeSigning: true,
       hardenedRuntime: true,
       // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
-      signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '\\.pak$'],
+      signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
       notarize: true,
       target: ['dmg', 'zip'],
     },
@@ -103,6 +124,7 @@ export function createElectronBuilderConfig(
       )
     },
     win: {
+      icon: fileURLToPath(new URL('./resources/icon-windows.png', import.meta.url)),
       forceCodeSigning: !unsigned,
       signtoolOptions: {
         sign: windowsSigner,
@@ -115,9 +137,14 @@ export function createElectronBuilderConfig(
       target: ['AppImage'],
     },
     nsis: {
+      installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
+      uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
       include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)),
       oneClick: false,
-      allowToChangeInstallationDirectory: true,
+      perMachine: false,
+      allowElevation: false,
+      allowToChangeInstallationDirectory: false,
+      installerLanguages: ['en_US', 'zh_CN'],
       differentialPackage: true,
     },
     publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }],

BIN
apps/desktop/installer/assets/brand-2x.png


BIN
apps/desktop/installer/assets/brand-dark-2x.png


BIN
apps/desktop/installer/assets/brand-dark.png


BIN
apps/desktop/installer/assets/brand.png


BIN
apps/desktop/installer/assets/uninstaller-sidebar.png


+ 120 - 0
apps/desktop/installer/drawing.nsh

@@ -0,0 +1,120 @@
+; Windows GDI+ supplies antialiasing for the native controls.
+Var InstallerGdiToken
+Var InstallerEditFrameBitmap
+
+!macro InstallerPixelFont HANDLE SIZE WEIGHT
+    System::Call 'kernel32::MulDiv(i -${SIZE}, i $InstallerDpi, i 96) i.r0'
+    System::Call 'gdi32::CreateFontW(i r0, i 0, i 0, i 0, i ${WEIGHT}, i 0, i 0, i 0, i 1, i 0, i 0, i 5, i 0, w "${INSTALLER_FONT}") p.s'
+    Pop ${HANDLE}
+!macroend
+
+; Produces a closed rounded rectangle. Inputs and output must not use $0/$1.
+!macro InstallerRoundPath PATH WIDTH HEIGHT DIAMETER
+    System::Call 'gdiplus::GdipCreatePath(i 0, *p .s)'
+    Pop ${PATH}
+    IntOp $0 ${WIDTH} - ${DIAMETER}
+    IntOp $1 ${HEIGHT} - ${DIAMETER}
+    ; System.dll has no float argument type. The arc angles use IEEE-754 bits.
+    System::Call 'gdiplus::GdipAddPathArcI(p ${PATH}, i 0, i 0, i ${DIAMETER}, i ${DIAMETER}, i 0x43340000, i 0x42B40000)'
+    System::Call 'gdiplus::GdipAddPathArcI(p ${PATH}, i r0, i 0, i ${DIAMETER}, i ${DIAMETER}, i 0x43870000, i 0x42B40000)'
+    System::Call 'gdiplus::GdipAddPathArcI(p ${PATH}, i r0, i r1, i ${DIAMETER}, i ${DIAMETER}, i 0, i 0x42B40000)'
+    System::Call 'gdiplus::GdipAddPathArcI(p ${PATH}, i 0, i r1, i ${DIAMETER}, i ${DIAMETER}, i 0x42B40000, i 0x42B40000)'
+    System::Call 'gdiplus::GdipClosePathFigure(p ${PATH})'
+!macroend
+
+Function InstallerDrawEditFrame
+    System::Call 'kernel32::MulDiv(i 384, i $InstallerDpi, i 96) i.R7'
+    System::Call 'kernel32::MulDiv(i 34, i $InstallerDpi, i 96) i.R8'
+    System::Call 'kernel32::MulDiv(i 12, i $InstallerDpi, i 96) i.R3'
+    System::Call 'gdiplus::GdipCreateBitmapFromScan0(i R7, i R8, i 0, i 0x26200A, p 0, *p .R5)'
+    System::Call 'gdiplus::GdipGetImageGraphicsContext(p R5, *p .R4)'
+    System::Call 'gdiplus::GdipGraphicsClear(p R4, i $InstallerBgArgb)'
+    System::Call 'gdiplus::GdipSetSmoothingMode(p R4, i 4)'
+    IntOp $R7 $R7 - 1
+    IntOp $R8 $R8 - 1
+    !insertmacro InstallerRoundPath $R6 $R7 $R8 $R3
+    System::Call 'gdiplus::GdipCreatePen1(i $InstallerBorder, i 0x40000000, i 2, *p .R1)'
+    System::Call 'gdiplus::GdipDrawPath(p R4, p R1, p R6)'
+    System::Call 'gdiplus::GdipCreateHBITMAPFromBitmap(p R5, *p .s, i $InstallerBgArgb)'
+    Pop $InstallerEditFrameBitmap
+    SendMessage $InstallerEditFrame ${STM_SETIMAGE} ${IMAGE_BITMAP} $InstallerEditFrameBitmap
+    System::Call 'gdiplus::GdipDeletePen(p R1)'
+    System::Call 'gdiplus::GdipDeletePath(p R6)'
+    System::Call 'gdiplus::GdipDeleteGraphics(p R4)'
+    System::Call 'gdiplus::GdipDisposeImage(p R5)'
+FunctionEnd
+
+; BS_AUTOCHECKBOX retains its native state, text, keyboard and accessibility role.
+Function InstallerPaintCheckbox
+    Pop $R0
+    Pop $R1
+    Pop $R2
+    ${If} $R1 != -12
+        Return
+    ${EndIf}
+    System::Call '*$R2(p, p, i, i .R3, p .R4, i, i, i, i, p, i .R9)'
+    ${If} $R3 != 1
+        Return
+    ${EndIf}
+    System::Call 'gdi32::SaveDC(p R4)'
+    System::Alloc 16
+    Pop $R2
+    System::Call 'user32::GetClientRect(p R0, p R2)'
+    System::Call 'gdi32::CreateSolidBrush(i $InstallerBgColorref) p.R3'
+    System::Call 'user32::FillRect(p R4, p R2, p R3)'
+    System::Call 'gdi32::DeleteObject(p R3)'
+    System::Call 'kernel32::MulDiv(i 8, i $InstallerDpi, i 96) i.r2'
+    System::Call 'kernel32::MulDiv(i 7, i $InstallerDpi, i 96) i.r3'
+    System::Call 'gdi32::SetViewportOrgEx(p R4, i r2, i r3, p 0)'
+    System::Call 'gdiplus::GdipCreateFromHDC(p R4, *p .R5)'
+    System::Call 'gdiplus::GdipSetSmoothingMode(p R5, i 4)'
+    System::Call 'kernel32::MulDiv(i 18, i $InstallerDpi, i 96) i.R7'
+    StrCpy $R8 $R7
+    System::Call 'kernel32::MulDiv(i 6, i $InstallerDpi, i 96) i.R3'
+    !insertmacro InstallerRoundPath $R6 $R7 $R8 $R3
+    ${NSD_GetState} $R0 $R8
+    StrCpy $R3 $InstallerBgArgb
+    ${If} $R8 == ${BST_CHECKED}
+        StrCpy $R3 $InstallerPrimary
+    ${EndIf}
+    System::Call 'gdiplus::GdipCreateSolidFill(i R3, *p .R1)'
+    System::Call 'gdiplus::GdipFillPath(p R5, p R1, p R6)'
+    System::Call 'gdiplus::GdipDeleteBrush(p R1)'
+    ${If} $R8 != ${BST_CHECKED}
+        System::Call 'gdiplus::GdipCreatePen1(i $InstallerBorder, i 0x40000000, i 2, *p .R1)'
+        System::Call 'gdiplus::GdipDrawPath(p R5, p R1, p R6)'
+        System::Call 'gdiplus::GdipDeletePen(p R1)'
+    ${EndIf}
+    System::Call 'gdiplus::GdipDeletePath(p R6)'
+    System::Call 'gdiplus::GdipDeleteGraphics(p R5)'
+    ${If} $R8 == ${BST_CHECKED}
+        System::Call 'kernel32::MulDiv(i 2, i $InstallerDpi, i 96) i.r0'
+        System::Call 'gdi32::CreatePen(i 0, i r0, i $InstallerButtonText) p.R1'
+        System::Call 'gdi32::SelectObject(p R4, p R1) p.R3'
+        System::Call 'kernel32::MulDiv(i 4, i $InstallerDpi, i 96) i.r0'
+        System::Call 'kernel32::MulDiv(i 9, i $InstallerDpi, i 96) i.r1'
+        System::Call 'gdi32::MoveToEx(p R4, i r0, i r1, p 0)'
+        System::Call 'kernel32::MulDiv(i 8, i $InstallerDpi, i 96) i.r0'
+        System::Call 'kernel32::MulDiv(i 13, i $InstallerDpi, i 96) i.r1'
+        System::Call 'gdi32::LineTo(p R4, i r0, i r1)'
+        System::Call 'kernel32::MulDiv(i 14, i $InstallerDpi, i 96) i.r0'
+        System::Call 'kernel32::MulDiv(i 5, i $InstallerDpi, i 96) i.r1'
+        System::Call 'gdi32::LineTo(p R4, i r0, i r1)'
+        System::Call 'gdi32::SelectObject(p R4, p R3)'
+        System::Call 'gdi32::DeleteObject(p R1)'
+    ${EndIf}
+    System::Call 'gdi32::SetViewportOrgEx(p R4, i 0, i 0, p 0)'
+    System::Call 'kernel32::MulDiv(i 34, i $InstallerDpi, i 96) i.r0'
+    System::Call '*$R2(i r0)'
+    System::Call 'gdi32::SetBkMode(p R4, i 1)'
+    System::Call 'gdi32::SetTextColor(p R4, i $InstallerTextColorref)'
+    System::Call 'gdi32::SelectObject(p R4, p $InstallerSmallFont)'
+    System::Call 'user32::DrawTextW(p R4, w "$(INSTALLER_LAUNCH)", i -1, p R2, i 0x24)'
+    IntOp $R9 $R9 & 16
+    ${If} $R9 != 0
+        System::Call 'user32::DrawFocusRect(p R4, p R2)'
+    ${EndIf}
+    System::Call 'gdi32::RestoreDC(p R4, i -1)'
+    System::Free $R2
+    ${NSD_Return} 4
+FunctionEnd

+ 26 - 0
apps/desktop/installer/extract-progress.h

@@ -0,0 +1,26 @@
+// 7-Zip emits an ASCII percentage at the start of each refreshed progress line.
+#pragma once
+#include <cstddef>
+
+struct ExtractionProgress {
+    unsigned value = 0;
+    unsigned number = 0;
+    unsigned digits = 0;
+    bool start = true;
+
+    void Read(const char* data, std::size_t length) {
+        for (std::size_t i = 0; i < length; ++i) {
+            const char c = data[i];
+            if (c == '\r' || c == '\n' || c == '\b') {
+                start = true; number = 0; digits = 0;
+            } else if (start && c == ' ' && digits == 0) {
+                continue;
+            } else if (start && c >= '0' && c <= '9' && digits < 3) {
+                number = number * 10 + c - '0'; ++digits;
+            } else {
+                if (start && c == '%' && digits && number <= 100 && number > value) value = number;
+                start = false;
+            }
+        }
+    }
+};

+ 95 - 0
apps/desktop/installer/extract.h

@@ -0,0 +1,95 @@
+// The pinned 7-Zip executable owns extraction; this pipe reader publishes its work percentage.
+#pragma once
+#include <string>
+#include <vector>
+#include "extract-progress.h"
+
+struct InstallerHandle {
+    HANDLE value = nullptr;
+    InstallerHandle() = default;
+    explicit InstallerHandle(HANDLE handle) : value(handle) {}
+    InstallerHandle(const InstallerHandle&) = delete;
+    InstallerHandle& operator=(const InstallerHandle&) = delete;
+    ~InstallerHandle() { Close(); }
+    void Close() {
+        if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value);
+        value = nullptr;
+    }
+};
+
+struct InstallerAttributes {
+    std::vector<unsigned char> storage;
+    LPPROC_THREAD_ATTRIBUTE_LIST list = nullptr;
+    ~InstallerAttributes() { if (list) DeleteProcThreadAttributeList(list); }
+    bool Initialize() {
+        SIZE_T size = 0;
+        InitializeProcThreadAttributeList(nullptr, 2, 0, &size);
+        storage.resize(size);
+        auto* candidate = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
+        if (!InitializeProcThreadAttributeList(candidate, 2, 0, &size)) return false;
+        list = candidate;
+        return true;
+    }
+};
+
+static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
+    SECURITY_ATTRIBUTES security = {sizeof(security), nullptr, TRUE};
+    InstallerHandle input, output;
+    if (!CreatePipe(&input.value, &output.value, &security, 0)) return GetLastError();
+    if (!SetHandleInformation(input.value, HANDLE_FLAG_INHERIT, 0)) return GetLastError();
+    InstallerHandle errors(CreateFileW(log, GENERIC_WRITE, FILE_SHARE_READ, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr));
+    if (errors.value == INVALID_HANDLE_VALUE) return GetLastError();
+    InstallerHandle nullInput(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &security, OPEN_EXISTING, 0, nullptr));
+    if (nullInput.value == INVALID_HANDLE_VALUE) return GetLastError();
+    InstallerHandle job(CreateJobObjectW(nullptr, nullptr));
+    if (!job.value) return GetLastError();
+    JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
+    limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+    if (!SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) return GetLastError();
+    InstallerAttributes attributes;
+    if (!attributes.Initialize()) return GetLastError();
+    HANDLE inherited[] = {nullInput.value, output.value, errors.value};
+    if (!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) ||
+        !UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job.value, sizeof(job.value), nullptr, nullptr)) return GetLastError();
+    STARTUPINFOEXW startup = {};
+    startup.StartupInfo.cb = sizeof(startup);
+    startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
+    startup.StartupInfo.hStdInput = nullInput.value;
+    startup.StartupInfo.hStdOutput = output.value;
+    startup.StartupInfo.hStdError = errors.value;
+    startup.lpAttributeList = attributes.list;
+    // A final dot prevents a destination's trailing backslash from escaping its closing quote.
+    std::wstring command = L"\"" + std::wstring(tool) + L"\" x -y -bso0 -bse2 -bsp1 -bb0 \"-o" + destination + L"\\.\" \"" + archive + L"\"";
+    PROCESS_INFORMATION child = {};
+    if (!CreateProcessW(tool, &command[0], nullptr, nullptr, TRUE,
+        EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, nullptr, &startup.StartupInfo, &child)) return GetLastError();
+    InstallerHandle process(child.hProcess), thread(child.hThread);
+    output.Close();
+    errors.Close();
+    ExtractionProgress progress;
+    char buffer[4096];
+    DWORD count = 0;
+    DWORD readError = ERROR_SUCCESS;
+    while (true) {
+        if (!ReadFile(input.value, buffer, sizeof(buffer), &count, nullptr)) { readError = GetLastError(); break; }
+        if (!count) break;
+        progress.Read(buffer, count);
+        if (parent) SetPropW(parent, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(static_cast<UINT_PTR>(progress.value)));
+    }
+    if (readError != ERROR_BROKEN_PIPE && readError != ERROR_SUCCESS) {
+        TerminateJobObject(job.value, readError);
+        WaitForSingleObject(process.value, INFINITE);
+        return readError;
+    }
+    if (WaitForSingleObject(process.value, INFINITE) != WAIT_OBJECT_0) return GetLastError();
+    DWORD result;
+    if (!GetExitCodeProcess(process.value, &result)) return GetLastError();
+    if (result == 0 && parent) SetPropW(parent, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(100));
+    return result;
+}
+
+// Runs on the NSIS worker; only an exit code of zero permits directory promotion.
+extern "C" __declspec(dllexport) DWORD __cdecl InstallerExtract(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
+    try { return ExtractApplication(parent, tool, archive, destination, log); }
+    catch (const std::bad_alloc&) { return ERROR_NOT_ENOUGH_MEMORY; }
+}

+ 89 - 0
apps/desktop/installer/lifecycle.nsh

@@ -0,0 +1,89 @@
+Var InstallerProgressWindow
+
+Function InstallerGuiInit
+    HideWindow
+    System::Call 'user32::SetWindowLongW(p $HWNDPARENT, i -16, i 0x800A0000)'
+    System::Call 'user32::GetDC(p $HWNDPARENT) p.r0'
+    System::Call 'gdi32::GetDeviceCaps(p r0, i 88) i.s'
+    Pop $InstallerDpi
+    System::Call 'user32::ReleaseDC(p $HWNDPARENT, p r0)'
+    System::Call 'kernel32::MulDiv(i 600, i $InstallerDpi, i 96) i.s'
+    Pop $InstallerSize
+    System::Call 'user32::GetSystemMetrics(i 0) i.r0'
+    System::Call 'user32::GetSystemMetrics(i 1) i.r1'
+    IntOp $0 $0 - $InstallerSize
+    IntOp $0 $0 / 2
+    IntOp $1 $1 - $InstallerSize
+    IntOp $1 $1 / 2
+    System::Call 'user32::SetWindowPos(p $HWNDPARENT, p 0, i r0, i r1, i $InstallerSize, i $InstallerSize, i 0x34)'
+    System::Call '$PLUGINSDIR\window-frame.dll::InstallerApplyFrame(p $HWNDPARENT) i.r0 ?c'
+    ${If} $0 < 0
+        MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)"
+        SetErrorLevel 2
+        Quit
+    ${EndIf}
+    GetDlgItem $0 $HWNDPARENT 1
+    ShowWindow $0 0
+    GetDlgItem $0 $HWNDPARENT 2
+    ShowWindow $0 0
+    GetDlgItem $0 $HWNDPARENT 3
+    ShowWindow $0 0
+    GetDlgItem $0 $HWNDPARENT 1028
+    ShowWindow $0 0
+    GetDlgItem $0 $HWNDPARENT 1256
+    ShowWindow $0 0
+FunctionEnd
+
+Function InstallerWelcome
+    ${If} ${isUpdated}
+        Abort
+    ${EndIf}
+    StrCpy $InstallerPhase "welcome"
+    Call InstallerCreate
+FunctionEnd
+
+Function InstallerBeforeInstall
+    SetAutoClose true
+    Call InstallerPreflight
+    ${If} $InstallerError != ""
+        MessageBox MB_OK|MB_ICONEXCLAMATION "$InstallerError" /SD IDOK
+        SetErrorLevel 2
+        Quit
+    ${EndIf}
+FunctionEnd
+
+Function InstallerProgressShow
+    ; Only the stock worker executes installation; this overlay runs on the UI thread.
+    ShowWindow $mui.InstFilesPage 0
+    StrCpy $0 0
+    StrCpy $1 "$PLUGINSDIR\brand-2x.bmp"
+    ${If} $InstallerTheme == "dark"
+        StrCpy $0 1
+        StrCpy $1 "$PLUGINSDIR\brand-dark-2x.bmp"
+    ${EndIf}
+    System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c'
+    Pop $InstallerProgressWindow
+    ${If} $InstallerProgressWindow == 0
+        MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)"
+        SetErrorLevel 2
+        Quit
+    ${EndIf}
+    System::Call 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Ready", p 1)'
+    ShowWindow $HWNDPARENT 5
+FunctionEnd
+
+Function .onInstFailed
+    MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_FAILED)" /SD IDOK
+    SetErrorLevel 2
+    Quit
+FunctionEnd
+
+Function InstallerFinish
+    System::Call '$PLUGINSDIR\window-frame.dll::InstallerFinishProgress(p $InstallerProgressWindow) i.r0 ?c'
+    ${If} $0 == 0
+        Quit
+    ${EndIf}
+    System::Call 'user32::DestroyWindow(p $InstallerProgressWindow)'
+    StrCpy $InstallerPhase "success"
+    Call InstallerCreate
+FunctionEnd

+ 372 - 0
apps/desktop/installer/pages.nsh

@@ -0,0 +1,372 @@
+; Native welcome and finish pages for the electron-builder installer.
+!ifndef HARNESS_INSTALLER_UI
+!define HARNESS_INSTALLER_UI
+!define WM_NOTIFY_OUTER_NEXT 0x408
+
+Var InstallerDialog
+Var InstallerPhase
+Var InstallerDpi
+Var InstallerSize
+Var InstallerImage
+Var InstallerButton
+Var InstallerStatus
+Var InstallerFont
+Var InstallerSmallFont
+Var InstallerChoose
+Var InstallerEdit
+Var InstallerEditFrame
+Var InstallerBrowse
+Var InstallerLaunch
+Var InstallerExpanded
+!include "${__FILEDIR__}\path.nsh"
+!include "${__FILEDIR__}\drawing.nsh"
+
+; All layout values are 96-DPI logical pixels.
+!macro InstallerPlace HWND X Y W H
+    System::Call 'kernel32::MulDiv(i ${X}, i $InstallerDpi, i 96) i.r0'
+    System::Call 'kernel32::MulDiv(i ${Y}, i $InstallerDpi, i 96) i.r1'
+    System::Call 'kernel32::MulDiv(i ${W}, i $InstallerDpi, i 96) i.r2'
+    System::Call 'kernel32::MulDiv(i ${H}, i $InstallerDpi, i 96) i.r3'
+    System::Call 'user32::MoveWindow(p ${HWND}, i r0, i r1, i r2, i r3, i 1)'
+!macroend
+
+Function InstallerCreate
+    nsDialogs::Create 1018
+    Pop $InstallerDialog
+    ${If} $InstallerDialog == error
+        Abort
+    ${EndIf}
+    !insertmacro InstallerPlace $InstallerDialog 0 0 ${INSTALLER_WINDOW_SIZE} ${INSTALLER_WINDOW_SIZE}
+    !insertmacro InstallerControlColors $HWNDPARENT
+    !insertmacro InstallerControlColors $InstallerDialog
+    !insertmacro InstallerPixelFont $InstallerFont ${INSTALLER_BUTTON_FONT_SIZE} 500
+    !insertmacro InstallerPixelFont $InstallerSmallFont ${INSTALLER_STATUS_FONT_SIZE} 400
+    System::Call '*(i 1, p 0, i 0, i 0) p.r0'
+    System::Call 'gdiplus::GdiplusStartup(*p .r1, p r0, p 0) i.r2'
+    StrCpy $InstallerGdiToken $1
+    System::Free $0
+    ${If} $2 != 0
+        MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)"
+        SetErrorLevel 2
+        Quit
+    ${EndIf}
+
+    ${NSD_CreateLabel} 0 0 0 0 ""
+    Pop $4
+    !insertmacro InstallerPlace $4 0 0 504 48
+    ${NSD_AddStyle} $4 ${SS_NOTIFY}
+    ${NSD_OnClick} $4 InstallerDrag
+    !insertmacro InstallerControlColors $4
+    ${NSD_CreateButton} 0 0 0 0 "−"
+    Pop $4
+    !insertmacro InstallerPlace $4 504 8 40 32
+    ${NSD_OnClick} $4 InstallerMinimize
+    ${NSD_OnNotify} $4 InstallerPaintButton
+    ${NSD_CreateButton} 0 0 0 0 "×"
+    Pop $4
+    !insertmacro InstallerPlace $4 548 8 40 32
+    ${NSD_OnClick} $4 InstallerClose
+    ${NSD_OnNotify} $4 InstallerPaintButton
+
+    ${NSD_CreateBitmap} 0 0 0 0 ""
+    Pop $4
+    !insertmacro InstallerPlace $4 0 ${INSTALLER_BRAND_Y} ${INSTALLER_WINDOW_SIZE} ${INSTALLER_BRAND_HEIGHT}
+    StrCpy $5 "brand"
+    ${If} $InstallerTheme == "dark"
+        StrCpy $5 "brand-dark"
+    ${EndIf}
+    ${If} $InstallerDpi <= 96
+        ${NSD_SetStretchedImage} $4 "$PLUGINSDIR\$5.bmp" $InstallerImage
+    ${Else}
+        ${NSD_SetStretchedImage} $4 "$PLUGINSDIR\$5-2x.bmp" $InstallerImage
+    ${EndIf}
+
+    ${NSD_CreateLabel} 0 0 0 0 ""
+    Pop $InstallerStatus
+    !insertmacro InstallerPlace $InstallerStatus 48 ${INSTALLER_STATUS_Y} 504 ${INSTALLER_STATUS_HEIGHT}
+    ${NSD_AddStyle} $InstallerStatus ${SS_CENTER}|${SS_CENTERIMAGE}
+    SendMessage $InstallerStatus ${WM_SETFONT} $InstallerSmallFont 1
+    !insertmacro InstallerControlColors $InstallerStatus
+
+    ${NSD_CreateButton} 0 0 0 0 "$(INSTALLER_CHOOSE_PATH)"
+    Pop $InstallerChoose
+    !insertmacro InstallerPlace $InstallerChoose 232 438 136 28
+    ${NSD_OnClick} $InstallerChoose InstallerExpandPath
+    ${NSD_OnNotify} $InstallerChoose InstallerPaintButton
+    ${NSD_CreateBitmap} 0 0 0 0 ""
+    Pop $InstallerEditFrame
+    !insertmacro InstallerPlace $InstallerEditFrame 64 434 384 34
+    Call InstallerDrawEditFrame
+    ${NSD_CreateText} 0 0 0 0 "$InstallerPath"
+    Pop $InstallerEdit
+    System::Call 'user32::GetWindowLongW(p $InstallerEdit, i -16) i.r0'
+    IntOp $0 $0 & 0xFF7FFFFF
+    System::Call 'user32::SetWindowLongW(p $InstallerEdit, i -16, i r0)'
+    System::Call 'user32::GetWindowLongW(p $InstallerEdit, i -20) i.r0'
+    IntOp $0 $0 & 0xFFFFFDFF
+    System::Call 'user32::SetWindowLongW(p $InstallerEdit, i -20, i r0)'
+    SendMessage $InstallerEdit ${WM_SETFONT} $InstallerSmallFont 1
+    ; A borderless single-line edit sits inside a separate padded frame.
+    System::Call 'user32::GetDC(p $InstallerEdit) p.r4'
+    System::Call 'gdi32::SelectObject(p r4, p $InstallerSmallFont) p.r5'
+    System::Alloc 60
+    Pop $6
+    System::Call 'gdi32::GetTextMetricsW(p r4, p r6)'
+    System::Call '*$6(i .r7)'
+    System::Free $6
+    System::Call 'gdi32::SelectObject(p r4, p r5)'
+    System::Call 'user32::ReleaseDC(p $InstallerEdit, p r4)'
+    System::Call 'kernel32::MulDiv(i 76, i $InstallerDpi, i 96) i.r0'
+    System::Call 'kernel32::MulDiv(i 434, i $InstallerDpi, i 96) i.r1'
+    System::Call 'kernel32::MulDiv(i 360, i $InstallerDpi, i 96) i.r2'
+    System::Call 'kernel32::MulDiv(i 34, i $InstallerDpi, i 96) i.r3'
+    IntOp $3 $3 - $7
+    IntOp $3 $3 / 2
+    IntOp $1 $1 + $3
+    System::Call 'user32::MoveWindow(p $InstallerEdit, i r0, i r1, i r2, i r7, i 1)'
+    System::Call 'user32::SetWindowPos(p $InstallerEdit, p 0, i 0, i 0, i 0, i 0, i 0x33)'
+    SendMessage $InstallerEdit ${EM_SETLIMITTEXT} 180 0
+    !insertmacro InstallerControlColors $InstallerEdit
+    ${NSD_OnChange} $InstallerEdit InstallerPathChanged
+    ${NSD_CreateButton} 0 0 0 0 "$(INSTALLER_BROWSE)"
+    Pop $InstallerBrowse
+    !insertmacro InstallerPlace $InstallerBrowse 456 434 80 34
+    ${NSD_OnClick} $InstallerBrowse InstallerBrowsePath
+    ${NSD_OnNotify} $InstallerBrowse InstallerPaintButton
+    ${NSD_CreateCheckbox} 0 0 0 0 "$(INSTALLER_LAUNCH)"
+    Pop $InstallerLaunch
+    SendMessage $InstallerLaunch ${WM_SETFONT} $InstallerSmallFont 1
+    System::Call 'user32::GetDC(p $InstallerLaunch) p.r4'
+    System::Call 'gdi32::SelectObject(p r4, p $InstallerSmallFont) p.r5'
+    StrLen $0 "$(INSTALLER_LAUNCH)"
+    System::Alloc 8
+    Pop $6
+    System::Call 'gdi32::GetTextExtentPoint32W(p r4, w "$(INSTALLER_LAUNCH)", i r0, p r6)'
+    System::Call '*$6(i .r7)'
+    System::Free $6
+    System::Call 'gdi32::SelectObject(p r4, p r5)'
+    System::Call 'user32::ReleaseDC(p $InstallerLaunch, p r4)'
+    System::Call 'kernel32::MulDiv(i 42, i $InstallerDpi, i 96) i.r2'
+    IntOp $2 $2 + $7
+    IntOp $0 $InstallerSize - $2
+    IntOp $0 $0 / 2
+    System::Call 'kernel32::MulDiv(i 438, i $InstallerDpi, i 96) i.r1'
+    System::Call 'kernel32::MulDiv(i 32, i $InstallerDpi, i 96) i.r3'
+    System::Call 'user32::MoveWindow(p $InstallerLaunch, i r0, i r1, i r2, i r3, i 1)'
+    !insertmacro InstallerControlColors $InstallerLaunch
+    ${NSD_OnNotify} $InstallerLaunch InstallerPaintCheckbox
+    ${NSD_Check} $InstallerLaunch
+
+    ${NSD_CreateButton} 0 0 0 0 "$(INSTALLER_INSTALL)"
+    Pop $InstallerButton
+    !insertmacro InstallerPlace $InstallerButton ${INSTALLER_BUTTON_X} ${INSTALLER_BUTTON_Y} ${INSTALLER_BUTTON_WIDTH} ${INSTALLER_BUTTON_HEIGHT}
+    SendMessage $InstallerButton ${WM_SETFONT} $InstallerFont 1
+    ${NSD_OnClick} $InstallerButton InstallerStart
+    ${NSD_OnNotify} $InstallerButton InstallerPaintButton
+    Call InstallerRender
+    System::Call 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Ready", p 1)'
+    ShowWindow $InstallerDialog 5
+    ShowWindow $HWNDPARENT 5
+    nsDialogs::Show
+    ${NSD_KillTimer} InstallerValidateEditedPath
+    ${NSD_FreeImage} $InstallerImage
+    ${NSD_FreeImage} $InstallerEditFrameBitmap
+    System::Call 'gdiplus::GdiplusShutdown(p $InstallerGdiToken)'
+    System::Call 'gdi32::DeleteObject(p $InstallerFont)'
+    System::Call 'gdi32::DeleteObject(p $InstallerSmallFont)'
+FunctionEnd
+
+Function InstallerRender
+    ShowWindow $InstallerChoose 0
+    ShowWindow $InstallerEdit 0
+    ShowWindow $InstallerEditFrame 0
+    ShowWindow $InstallerBrowse 0
+    ShowWindow $InstallerLaunch 0
+    ShowWindow $InstallerStatus 0
+    ${If} $InstallerPhase == "success"
+        ${NSD_SetText} $InstallerButton "$(INSTALLER_FINISH)"
+        ShowWindow $InstallerLaunch 5
+    ${Else}
+        ${NSD_SetText} $InstallerButton "$(INSTALLER_INSTALL)"
+        ${If} $InstallerExpanded == 1
+            ShowWindow $InstallerEditFrame 5
+            ShowWindow $InstallerEdit 5
+            ShowWindow $InstallerBrowse 5
+        ${Else}
+            ShowWindow $InstallerChoose 5
+        ${EndIf}
+    ${EndIf}
+FunctionEnd
+
+Function InstallerStart
+    Pop $0
+    SendMessage $HWNDPARENT ${WM_NOTIFY_OUTER_NEXT} 1 0
+FunctionEnd
+
+; Page leave callbacks also run when Enter activates NSIS's hidden default button.
+Function InstallerWelcomeLeave
+    ${NSD_GetText} $InstallerEdit $InstallerPath
+    Call InstallerPreflight
+    ${If} $InstallerError != ""
+        MessageBox MB_OK|MB_ICONEXCLAMATION "$InstallerError"
+        Abort
+    ${EndIf}
+FunctionEnd
+
+Function InstallerFinishLeave
+    ${NSD_GetState} $InstallerLaunch $0
+    HideWindow
+    ${If} $0 == ${BST_CHECKED}
+        StrCpy $0 ""
+        ${If} ${isUpdated}
+            StrCpy $0 "--updated"
+        ${EndIf}
+        ClearErrors
+        ${If} ${UAC_IsAdmin}
+            ; An explicitly elevated installer must still launch through the user's shell.
+            ${StdUtils.ExecShellAsUser} $1 "$INSTDIR\${APP_EXECUTABLE_FILENAME}" "open" "$0"
+            ${If} $1 != "ok"
+            ${AndIf} $1 != "fallback"
+                SetErrors
+            ${EndIf}
+        ${Else}
+            ; Per-user installation can create the process without Explorer/shortcut dispatch.
+            Exec '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" $0'
+        ${EndIf}
+        ${If} ${Errors}
+            ShowWindow $HWNDPARENT 5
+            MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_LAUNCH_FAILED)"
+            Abort
+        ${EndIf}
+    ${EndIf}
+FunctionEnd
+
+Function InstallerExpandPath
+    Pop $0
+    StrCpy $InstallerExpanded 1
+    Call InstallerRender
+    SendMessage $InstallerEdit ${EM_SETSEL} 0 0
+    System::Call 'user32::SetFocus(p $InstallerEdit)'
+FunctionEnd
+
+Function InstallerPathChanged
+    Pop $0
+    ${NSD_CreateTimer} InstallerValidateEditedPath 450
+FunctionEnd
+
+Function InstallerValidateEditedPath
+    ${NSD_KillTimer} InstallerValidateEditedPath
+    ${If} $InstallerPhase != "welcome"
+        Return
+    ${EndIf}
+    ${NSD_GetText} $InstallerEdit $InstallerPath
+    Call InstallerValidatePath
+    ${NSD_SetText} $InstallerStatus "$InstallerError"
+    !insertmacro InstallerPlace $InstallerStatus 48 542 504 42
+    ShowWindow $InstallerStatus 5
+FunctionEnd
+
+Function InstallerBrowsePath
+    Pop $0
+    nsDialogs::SelectFolderDialog "$(INSTALLER_CHOOSE_PATH)" "$InstallerPath"
+    Pop $0
+    ${If} $0 != "error"
+        ${NSD_SetText} $InstallerEdit "$0"
+        Call InstallerValidateEditedPath
+    ${EndIf}
+FunctionEnd
+
+Function InstallerDrag
+    Pop $0
+    System::Call 'user32::ReleaseCapture()'
+    SendMessage $HWNDPARENT ${WM_NCLBUTTONDOWN} 2 0
+FunctionEnd
+
+; NM_CUSTOMDRAW keeps native button focus, keyboard input and accessible text.
+; This NMCUSTOMDRAW layout is for the x86 NSIS stub, including on x64 Windows.
+Function InstallerPaintButton
+    Pop $R0
+    Pop $R1
+    Pop $R2
+    ${If} $R1 != -12
+        Return
+    ${EndIf}
+    System::Call '*$R2(p, p, i, i .R3, p .R4, i .R5, i .R6, i .R7, i .R8, p, i .R9)'
+    ${If} $R3 != 1
+        Return
+    ${EndIf}
+    System::Call 'gdi32::SaveDC(p R4)'
+    System::Alloc 16
+    Pop $R2
+    System::Call 'user32::GetClientRect(p R0, p R2)'
+    System::Call 'gdi32::CreateSolidBrush(i $InstallerBgColorref) p.R3'
+    System::Call 'user32::FillRect(p R4, p R2, p R3)'
+    System::Call 'gdi32::DeleteObject(p R3)'
+    StrCpy $R3 $InstallerBgArgb
+    ${If} $R0 == $InstallerButton
+        StrCpy $R3 $InstallerPrimary
+        IntOp $R1 $R9 & 64
+        ${If} $R1 != 0
+            StrCpy $R3 $InstallerPrimaryHover
+        ${EndIf}
+        IntOp $R1 $R9 & 1
+        ${If} $R1 != 0
+            StrCpy $R3 $InstallerPrimaryPressed
+        ${EndIf}
+    ${Else}
+        IntOp $R1 $R9 & 65
+        ${If} $R1 != 0
+            StrCpy $R3 $InstallerControlHover
+        ${EndIf}
+    ${EndIf}
+    System::Call 'gdiplus::GdipCreateSolidFill(i R3, *p .R1)'
+    System::Call 'gdiplus::GdipCreateFromHDC(p R4, *p .R5)'
+    System::Call 'gdiplus::GdipSetSmoothingMode(p R5, i 4)'
+    System::Call 'gdiplus::GdipSetPixelOffsetMode(p R5, i 4)'
+    System::Call 'kernel32::MulDiv(i ${INSTALLER_BUTTON_DIAMETER}, i $InstallerDpi, i 96) i.R3'
+    ${If} $R0 == $InstallerBrowse
+        System::Call 'kernel32::MulDiv(i 12, i $InstallerDpi, i 96) i.R3'
+    ${EndIf}
+    !insertmacro InstallerRoundPath $R6 $R7 $R8 $R3
+    System::Call 'gdiplus::GdipFillPath(p R5, p R1, p R6)'
+    ${If} $R0 == $InstallerBrowse
+        System::Call 'gdiplus::GdipCreatePen1(i $InstallerBorder, i 0x40000000, i 2, *p .r2)'
+        System::Call 'gdiplus::GdipDrawPath(p R5, p r2, p R6)'
+        System::Call 'gdiplus::GdipDeletePen(p r2)'
+    ${EndIf}
+    System::Call 'gdiplus::GdipDeletePath(p R6)'
+    System::Call 'gdiplus::GdipDeleteGraphics(p R5)'
+    System::Call 'gdiplus::GdipDeleteBrush(p R1)'
+    System::Call 'gdi32::SetBkMode(p R4, i 1)'
+    ${If} $R0 == $InstallerButton
+        System::Call 'gdi32::SetTextColor(p R4, i $InstallerButtonText)'
+    ${Else}
+        System::Call 'gdi32::SetTextColor(p R4, i $InstallerTextColorref)'
+    ${EndIf}
+    System::Call 'gdi32::SelectObject(p R4, p $InstallerFont)'
+    ${If} $R0 == $InstallerChoose
+    ${OrIf} $R0 == $InstallerBrowse
+        System::Call 'gdi32::SelectObject(p R4, p $InstallerSmallFont)'
+    ${EndIf}
+    ${NSD_GetText} $R0 $R3
+    System::Call 'user32::DrawTextW(p R4, w R3, i -1, p R2, i 0x25)'
+    IntOp $R9 $R9 & 16
+    ${If} $R9 != 0
+        System::Call 'user32::InflateRect(p R2, i -4, i -4)'
+        System::Call 'user32::DrawFocusRect(p R4, p R2)'
+    ${EndIf}
+    System::Call 'gdi32::RestoreDC(p R4, i -1)'
+    System::Free $R2
+    ${NSD_Return} 4
+FunctionEnd
+
+Function InstallerMinimize
+    Pop $0
+    ShowWindow $HWNDPARENT 6
+FunctionEnd
+
+Function InstallerClose
+    Pop $0
+    System::Call 'user32::PostMessageW(p $HWNDPARENT, i ${WM_CLOSE}, p 0, p 0)'
+FunctionEnd
+!endif

+ 168 - 0
apps/desktop/installer/path.nsh

@@ -0,0 +1,168 @@
+Var InstallerPath
+Var InstallerError
+; Reject reparse points along the selected path before any write or cleanup.
+!macro InstallerValidatePathFunction PREFIX
+Function ${PREFIX}InstallerValidatePath
+    StrCpy $InstallerError "$(INSTALLER_PATH_INVALID)"
+    StrLen $0 $InstallerPath
+    ${If} $0 < 4
+    ${OrIf} $0 > 180
+        Return
+    ${EndIf}
+    StrCpy $0 $InstallerPath 2 1
+    ${If} $0 != ":\"
+        Return
+    ${EndIf}
+    StrCpy $0 $InstallerPath 3
+    System::Call 'kernel32::GetDriveTypeW(w r0) i.r1'
+    ${If} $1 != 3
+        Return
+    ${EndIf}
+    StrCpy $1 3
+    ${Do}
+        StrCpy $0 $InstallerPath 1 $1
+        ${If} $0 == ""
+            ${ExitDo}
+        ${EndIf}
+        ${If} $0 == ':'
+        ${OrIf} $0 == '*'
+        ${OrIf} $0 == '?'
+        ${OrIf} $0 == '"'
+        ${OrIf} $0 == '<'
+        ${OrIf} $0 == '>'
+        ${OrIf} $0 == '|'
+        ${OrIf} $0 == '/'
+        ${OrIf} $0 == '$\r'
+        ${OrIf} $0 == '$\n'
+        ${OrIf} $0 == '$\t'
+            Return
+        ${EndIf}
+        IntOp $1 $1 + 1
+    ${Loop}
+    StrCpy $2 $InstallerPath
+    ${Do}
+        StrLen $0 $2
+        ${If} $0 <= 3
+            ${ExitDo}
+        ${EndIf}
+        ${GetFileName} $2 $3
+        StrCpy $0 $3 1 -1
+        ${If} $3 == ""
+        ${OrIf} $0 == "."
+        ${OrIf} $0 == " "
+            Return
+        ${EndIf}
+        ; Windows reserves device names even when they have an extension.
+        StrCpy $5 ""
+        StrCpy $6 0
+        ${Do}
+            StrCpy $0 $3 1 $6
+            ${If} $0 == ""
+            ${OrIf} $0 == "."
+                ${ExitDo}
+            ${EndIf}
+            StrCpy $5 "$5$0"
+            IntOp $6 $6 + 1
+        ${Loop}
+        ${If} $5 == "CON"
+        ${OrIf} $5 == "PRN"
+        ${OrIf} $5 == "AUX"
+        ${OrIf} $5 == "NUL"
+            Return
+        ${EndIf}
+        StrCpy $0 $5 3
+        ${If} $0 == "COM"
+        ${OrIf} $0 == "LPT"
+            StrLen $0 $5
+            StrCpy $5 $5 1 3
+            ${If} $0 == 4
+            ${AndIf} $5 >= 1
+            ${AndIf} $5 <= 9
+                Return
+            ${EndIf}
+        ${EndIf}
+        System::Call 'kernel32::GetFileAttributesW(w r2) i.r0'
+        ${If} $0 != -1
+            IntOp $1 $0 & 0x400
+            IntOp $0 $0 & 0x10
+            ${If} $1 != 0
+            ${OrIf} $0 == 0
+                Return
+            ${EndIf}
+        ${EndIf}
+        ${If} $2 == $WINDIR
+        ${OrIf} $2 == $PROGRAMFILES32
+        ${OrIf} $2 == $PROGRAMFILES64
+        ${OrIf} $2 == $PROFILE
+        ${OrIf} $2 == $LOCALAPPDATA
+            ; Ancestors PROFILE and LOCALAPPDATA are allowed; the selected directory itself is not.
+            ${If} $2 == $WINDIR
+            ${OrIf} $2 == $PROGRAMFILES32
+            ${OrIf} $2 == $PROGRAMFILES64
+            ${OrIf} $2 == $InstallerPath
+                Return
+            ${EndIf}
+        ${EndIf}
+        ${GetParent} $2 $2
+    ${Loop}
+    System::Call 'kernel32::GetFullPathNameW(w "$InstallerPath", i ${NSIS_MAX_STRLEN}, w .r4, p 0) i.r0'
+    ${If} $0 == 0
+    ${OrIf} $0 >= ${NSIS_MAX_STRLEN}
+        Return
+    ${EndIf}
+    StrCpy $InstallerPath $4
+    StrCpy $InstallerError ""
+FunctionEnd
+!macroend
+!insertmacro InstallerValidatePathFunction ""
+
+; A new installation requires an empty directory; updates require the registered executable.
+Function InstallerPreflight
+    Call InstallerValidatePath
+    ${If} $InstallerError != ""
+        Return
+    ${EndIf}
+    StrCpy $INSTDIR $InstallerPath
+    ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation"
+    ${If} $0 != $INSTDIR
+    ${OrIfNot} ${FileExists} "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
+        FindFirst $0 $1 "$INSTDIR\*.*"
+        ${DoWhile} $1 != ""
+            ${If} $1 != "."
+            ${AndIf} $1 != ".."
+                FindClose $0
+                StrCpy $InstallerError "$(INSTALLER_PATH_OWNERSHIP)"
+                Return
+            ${EndIf}
+            FindNext $0 $1
+        ${Loop}
+        FindClose $0
+    ${EndIf}
+    StrCpy $2 $INSTDIR
+    ${Do}
+        System::Call 'kernel32::GetFileAttributesW(w r2) i.r0'
+        ${If} $0 != -1
+            ${ExitDo}
+        ${EndIf}
+        ${GetParent} $2 $2
+    ${Loop}
+    System::Call 'kernel32::GetTempFileNameW(w r2, w "HIL", i 0, w .r3) i.r0'
+    ${If} $0 == 0
+        StrCpy $InstallerError "$(INSTALLER_PATH_WRITABLE)"
+        Return
+    ${EndIf}
+    System::Call 'kernel32::DeleteFileW(w r3)'
+    System::Call 'kernel32::GetDiskFreeSpaceExW(w r2, *l .r0, p 0, p 0) i.r1'
+    ${If} $1 == 0
+        StrCpy $InstallerError "$(INSTALLER_PATH_WRITABLE)"
+        Return
+    ${EndIf}
+    IntOp $2 ${APP_64_UNPACKED_SIZE} + 65536
+    System::Int64Op $2 * 1024
+    Pop $2
+    System::Int64Op $0 < $2
+    Pop $0
+    ${If} $0 != 0
+        StrCpy $InstallerError "$(INSTALLER_DISK_SPACE)"
+    ${EndIf}
+FunctionEnd

+ 61 - 0
apps/desktop/installer/progress.h

@@ -0,0 +1,61 @@
+// 7-Zip work drives extraction; directory promotion, registration, and cleanup use bounded estimates.
+#pragma once
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+
+struct InstallProgress {
+    int stage = 0;
+    double value = 0;
+    double target = 0;
+    double from = 0;
+    std::uint64_t stageStarted;
+    std::uint64_t animationStarted;
+    bool succeeded = false;
+
+    explicit InstallProgress(std::uint64_t now) : stageStarted(now), animationStarted(now) {}
+
+    void Tick(std::uint64_t now) {
+        const double duration = succeeded ? 600.0 : 250.0;
+        const double fraction = std::min(1.0, (now - animationStarted) / duration);
+        value = from + (target - from) * fraction;
+    }
+
+    void Advance(int nextStage, double fraction, std::uint64_t now) {
+        Tick(now);
+        if (succeeded || nextStage < stage || nextStage > 4) return;
+        if (nextStage > stage) {
+            stage = nextStage;
+            stageStarted = now;
+        }
+        const double boundaries[] = {0, 2, 94, 96, 98, 99};
+        if (stage != 1) {
+            const double seconds = stage == 0 ? 3.0 : stage == 4 ? 11.0 : 1.0;
+            fraction = 1 - std::exp(-static_cast<double>(now - stageStarted) / (seconds * 1000));
+        }
+        const double next = boundaries[stage] + (boundaries[stage + 1] - boundaries[stage]) *
+            std::max(0.0, std::min(1.0, fraction));
+        if (next > target) {
+            from = value;
+            target = next;
+            animationStarted = now;
+        }
+    }
+
+    // Only the successful NSIS finish callback may authorize 100%.
+    void Complete(std::uint64_t now) {
+        if (succeeded) return;
+        Tick(now);
+        succeeded = true;
+        from = value;
+        target = 100;
+        animationStarted = now;
+    }
+
+    int CaptionStage() const {
+        const double boundaries[] = {0, 2, 94, 96, 98};
+        int caption = 0;
+        while (caption < stage && value >= boundaries[caption + 1]) ++caption;
+        return caption;
+    }
+};

+ 41 - 0
apps/desktop/installer/strings.nsh

@@ -0,0 +1,41 @@
+; NSIS owns installer localization independently of the Electron application locale.
+LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now"
+LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装"
+LangString INSTALLER_FINISH ${LANG_ENGLISH} "Finish"
+LangString INSTALLER_FINISH ${LANG_SIMPCHINESE} "完成"
+LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation… %d%%"
+LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "正在准备安装… %d%%"
+LangString INSTALLER_PROGRESS_EXTRACT ${LANG_ENGLISH} "Extracting files… %d%%"
+LangString INSTALLER_PROGRESS_EXTRACT ${LANG_SIMPCHINESE} "正在解压文件… %d%%"
+LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Installing files… %d%%"
+LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在安装文件… %d%%"
+LangString INSTALLER_PROGRESS_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%"
+LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%"
+LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%"
+LangString INSTALLER_PROGRESS_CLEAN ${LANG_SIMPCHINESE} "正在清理临时文件… %d%%"
+LangString INSTALLER_CHOOSE_PATH ${LANG_ENGLISH} "Choose location"
+LangString INSTALLER_CHOOSE_PATH ${LANG_SIMPCHINESE} "选择安装位置"
+LangString INSTALLER_BROWSE ${LANG_ENGLISH} "Browse"
+LangString INSTALLER_BROWSE ${LANG_SIMPCHINESE} "选择"
+LangString INSTALLER_LAUNCH ${LANG_ENGLISH} "Launch now"
+LangString INSTALLER_LAUNCH ${LANG_SIMPCHINESE} "立即启动"
+LangString INSTALLER_LAUNCH_FAILED ${LANG_ENGLISH} "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer."
+LangString INSTALLER_LAUNCH_FAILED ${LANG_SIMPCHINESE} "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。"
+LangString INSTALLER_UI_ERROR ${LANG_ENGLISH} "The installer could not initialize its window. Close it and try again."
+LangString INSTALLER_UI_ERROR ${LANG_SIMPCHINESE} "无法初始化安装窗口,请关闭后重试。"
+LangString INSTALLER_THEME_ERROR ${LANG_ENGLISH} "Use /THEME=auto, /THEME=light or /THEME=dark."
+LangString INSTALLER_THEME_ERROR ${LANG_SIMPCHINESE} "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。"
+LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again."
+LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。"
+LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Close the application, then run this installer again."
+LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行。请先关闭应用,再重新运行安装程序。"
+LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported."
+LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。"
+LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder."
+LangString INSTALLER_PATH_OWNERSHIP ${LANG_SIMPCHINESE} "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。"
+LangString INSTALLER_PATH_WRITABLE ${LANG_ENGLISH} "This location is not writable. Choose a folder available to the current user."
+LangString INSTALLER_PATH_WRITABLE ${LANG_SIMPCHINESE} "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。"
+LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk space. Choose another location."
+LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。"
+LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first."
+LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。"

+ 91 - 0
apps/desktop/installer/theme.nsh

@@ -0,0 +1,91 @@
+; Figma logical pixels, independent of the Windows display scale.
+!define INSTALLER_WINDOW_SIZE 600
+!define INSTALLER_BRAND_Y 174
+!define INSTALLER_BRAND_HEIGHT 196
+!define INSTALLER_BUTTON_X 240
+!define INSTALLER_BUTTON_Y 490
+!define INSTALLER_BUTTON_WIDTH 120
+!define INSTALLER_BUTTON_HEIGHT 44
+!define INSTALLER_BUTTON_DIAMETER 20
+!define INSTALLER_PROGRESS_X 64
+!define INSTALLER_PROGRESS_Y 482
+!define INSTALLER_PROGRESS_WIDTH 472
+!define INSTALLER_PROGRESS_HEIGHT 6
+!define INSTALLER_PROGRESS_DIAMETER 4
+!define INSTALLER_STATUS_Y 512
+!define INSTALLER_STATUS_HEIGHT 22
+!define INSTALLER_FONT "Microsoft YaHei UI"
+!define INSTALLER_BUTTON_FONT_SIZE 16
+!define INSTALLER_STATUS_FONT_SIZE 14
+; GDI+ ARGB values; GDI text uses COLORREF below.
+!define INSTALLER_PRIMARY 0xFF0F1115
+!define INSTALLER_PRIMARY_HOVER 0xFF2D3135
+!define INSTALLER_PRIMARY_PRESSED 0xFF000000
+!define INSTALLER_CONTROL_HOVER 0xFFEEF0F2
+!define INSTALLER_TRACK_COLOR 0xFFE9ECF2
+!define INSTALLER_TEXT_COLORREF 0x15110F
+
+Var InstallerTheme
+Var InstallerBgHex
+Var InstallerTextHex
+Var InstallerBgArgb
+Var InstallerBgColorref
+Var InstallerTextColorref
+Var InstallerPrimary
+Var InstallerPrimaryHover
+Var InstallerPrimaryPressed
+Var InstallerControlHover
+Var InstallerTrack
+Var InstallerButtonText
+Var InstallerBorder
+
+; SetCtlColors accepts only compile-time colors; choose between the two native palettes.
+!macro InstallerControlColors HANDLE
+    ${If} $InstallerTheme == "dark"
+        SetCtlColors ${HANDLE} FFFFFF 151517
+    ${Else}
+        SetCtlColors ${HANDLE} 0F1115 FFFFFF
+    ${EndIf}
+!macroend
+
+Function InstallerResolveTheme
+    ${If} $InstallerTheme == "auto"
+        ClearErrors
+        ReadRegDWORD $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" "AppsUseLightTheme"
+        ${If} ${Errors}
+            StrCpy $0 1
+        ${EndIf}
+        ${If} $0 == 0
+            StrCpy $InstallerTheme "dark"
+        ${Else}
+            StrCpy $InstallerTheme "light"
+        ${EndIf}
+    ${EndIf}
+    ${If} $InstallerTheme == "dark"
+        StrCpy $InstallerBgHex "151517"
+        StrCpy $InstallerTextHex "FFFFFF"
+        StrCpy $InstallerBgArgb 0xFF151517
+        StrCpy $InstallerBgColorref 0x171515
+        StrCpy $InstallerTextColorref 0xFFFFFF
+        StrCpy $InstallerPrimary 0xFFF9FAFB
+        StrCpy $InstallerPrimaryHover 0xFFE9ECF2
+        StrCpy $InstallerPrimaryPressed 0xFFD4DBE9
+        StrCpy $InstallerControlHover 0xFF303034
+        StrCpy $InstallerTrack 0xFF61666B
+        StrCpy $InstallerButtonText 0x15110F
+        StrCpy $InstallerBorder 0xFF61666B
+    ${Else}
+        StrCpy $InstallerBgHex "FFFFFF"
+        StrCpy $InstallerTextHex "0F1115"
+        StrCpy $InstallerBgArgb 0xFFFFFFFF
+        StrCpy $InstallerBgColorref 0xFFFFFF
+        StrCpy $InstallerTextColorref ${INSTALLER_TEXT_COLORREF}
+        StrCpy $InstallerPrimary ${INSTALLER_PRIMARY}
+        StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER}
+        StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED}
+        StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER}
+        StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR}
+        StrCpy $InstallerButtonText 0xFFFFFF
+        StrCpy $InstallerBorder 0xFFBBC0C8
+    ${EndIf}
+FunctionEnd

+ 244 - 0
apps/desktop/installer/window-frame.cpp

@@ -0,0 +1,244 @@
+// Keeps the DWM frame and shadow while the NSIS page owns the entire client area.
+#define WIN32_LEAN_AND_MEAN
+#define NOMINMAX
+#define UNICODE
+#include <windows.h>
+#include <tlhelp32.h>
+#include <objidl.h>
+#include <commctrl.h>
+#include <dwmapi.h>
+#include <gdiplus.h>
+#include <new>
+#include <algorithm>
+#include "progress.h"
+#include "extract.h"
+
+using namespace Gdiplus;
+
+// Match the affected executable, not another user's or directory's same-named application.
+// Returns 0 while running, 1 when absent, and -1 if the process list cannot be read.
+extern "C" __declspec(dllexport) int __cdecl InstallerFindProcess(LPCWSTR executable) {
+    WCHAR target[32768];
+    DWORD length = GetLongPathNameW(executable, target, ARRAYSIZE(target));
+    LPCWSTR expected = length > 0 && length < ARRAYSIZE(target) ? target : executable;
+    LPCWSTR filename = wcsrchr(expected, L'\\');
+    filename = filename ? filename + 1 : expected;
+    HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
+    if (snapshot == INVALID_HANDLE_VALUE) return -1;
+    PROCESSENTRY32W entry = {};
+    entry.dwSize = sizeof(entry);
+    int result = 1;
+    BOOL present = Process32FirstW(snapshot, &entry);
+    while (present) {
+        if (_wcsicmp(entry.szExeFile, filename) == 0) {
+            HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID);
+            if (process) {
+                WCHAR path[32768];
+                DWORD count = ARRAYSIZE(path);
+                if (QueryFullProcessImageNameW(process, 0, path, &count) && _wcsicmp(path, expected) == 0) result = 0;
+                CloseHandle(process);
+                if (result == 0) break;
+            }
+        }
+        present = Process32NextW(snapshot, &entry);
+    }
+    if (!present && GetLastError() != ERROR_NO_MORE_FILES) result = -1;
+    CloseHandle(snapshot);
+    return result;
+}
+
+struct ProgressPage {
+    InstallProgress progress{GetTickCount64()};
+    bool dark;
+    UINT dpi;
+    ULONG_PTR gdiplus;
+    Image* brand;
+    WCHAR captions[5][128];
+};
+
+// NSIS shows its page after MUI's SHOW callback; keep its controls off screen.
+static LRESULT CALLBACK HiddenPageProc(HWND window, UINT message, WPARAM wparam,
+                                      LPARAM lparam, UINT_PTR id, DWORD_PTR) {
+    if (message == WM_WINDOWPOSCHANGING) {
+        auto* position = reinterpret_cast<WINDOWPOS*>(lparam);
+        position->flags = (position->flags & ~SWP_SHOWWINDOW) | SWP_HIDEWINDOW;
+    }
+    if (message == WM_NCDESTROY) RemoveWindowSubclass(window, HiddenPageProc, id);
+    return DefSubclassProc(window, message, wparam, lparam);
+}
+
+static void FillProgress(Graphics& graphics, Brush& brush, REAL width) {
+    if (width <= 0) return;
+    if (width < 4) { graphics.FillRectangle(&brush, 64.0f, 482.0f, width, 6.0f); return; }
+    GraphicsPath path;
+    path.AddArc(64.0f, 482.0f, 4.0f, 4.0f, 180.0f, 90.0f);
+    path.AddArc(64.0f + width - 4, 482.0f, 4.0f, 4.0f, 270.0f, 90.0f);
+    path.AddArc(64.0f + width - 4, 484.0f, 4.0f, 4.0f, 0.0f, 90.0f);
+    path.AddArc(64.0f, 484.0f, 4.0f, 4.0f, 90.0f, 90.0f);
+    path.CloseFigure();
+    graphics.FillPath(&brush, &path);
+}
+
+static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
+    auto* page = reinterpret_cast<ProgressPage*>(GetWindowLongPtrW(window, GWLP_USERDATA));
+    if (message == WM_CREATE) {
+        page = static_cast<ProgressPage*>(reinterpret_cast<CREATESTRUCTW*>(lparam)->lpCreateParams);
+        SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(page));
+        SetTimer(window, 1, 16, nullptr);
+    }
+    if (message == WM_ERASEBKGND) return 1;
+    if (message == WM_TIMER) { InvalidateRect(window, nullptr, FALSE); return 0; }
+    if (message == WM_LBUTTONDOWN && page) {
+        const int x = LOWORD(lparam) * 96 / page->dpi;
+        const int y = HIWORD(lparam) * 96 / page->dpi;
+        if (y < 48) {
+            HWND parent = GetParent(window);
+            if (x >= 548) PostMessageW(parent, WM_CLOSE, 0, 0);
+            else if (x >= 504) ShowWindow(parent, SW_MINIMIZE);
+            else { ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); }
+        }
+        return 0;
+    }
+    if (message == WM_PAINT && page) {
+        PAINTSTRUCT paint;
+        HDC dc = BeginPaint(window, &paint);
+        {
+            Bitmap buffer(MulDiv(600, page->dpi, 96), MulDiv(600, page->dpi, 96), PixelFormat32bppPARGB);
+            Graphics graphics(&buffer);
+            graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f);
+            graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
+            graphics.SetSmoothingMode(SmoothingModeAntiAlias);
+            graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
+            const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
+            const double fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0;
+            page->progress.Advance(stage, fraction, GetTickCount64());
+            const int percent = static_cast<int>(page->progress.value);
+            SolidBrush track(page->dark ? Color(255, 97, 102, 107) : Color(255, 233, 236, 242));
+            SolidBrush ink(page->dark ? Color(255, 255, 255, 255) : Color(255, 15, 17, 21));
+            FillProgress(graphics, track, 472.0f);
+            FillProgress(graphics, ink, 472.0f * static_cast<REAL>(page->progress.value) / 100);
+            FontFamily family(L"Microsoft YaHei UI");
+            Font font(&family, 14, FontStyleRegular, UnitPixel);
+            StringFormat centered;
+            centered.SetAlignment(StringAlignmentCenter);
+            centered.SetLineAlignment(StringAlignmentCenter);
+            WCHAR caption[160];
+            wsprintfW(caption, page->captions[page->progress.CaptionStage()], percent);
+            SetWindowTextW(window, caption);
+            graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), &centered, &ink);
+            Font controls(&family, 16, FontStyleRegular, UnitPixel);
+            graphics.DrawString(L"\x2212", -1, &controls, RectF(504, 8, 40, 32), &centered, &ink);
+            graphics.DrawString(L"\x00d7", -1, &controls, RectF(548, 8, 40, 32), &centered, &ink);
+            Graphics screen(dc);
+            screen.DrawImage(&buffer, 0, 0);
+        }
+        EndPaint(window, &paint);
+        if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast<HANDLE>(100));
+        return 0;
+    }
+    if (message == WM_NCDESTROY && page) {
+        KillTimer(window, 1);
+        delete page->brand;
+        GdiplusShutdown(page->gdiplus);
+        delete page;
+    }
+    return DefWindowProcW(window, message, wparam, lparam);
+}
+
+// Runs on the NSIS UI thread; the stock installation section runs on its worker.
+extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
+        BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
+        const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning) {
+    HINSTANCE module = GetModuleHandleW(nullptr);
+    WNDCLASSW type = {};
+    type.lpfnWndProc = ProgressProc;
+    type.hInstance = module;
+    type.lpszClassName = L"HarnessInstallerProgress";
+    type.hCursor = LoadCursorW(nullptr, IDC_ARROW);
+    RegisterClassW(&type);
+    auto* page = new (std::nothrow) ProgressPage{};
+    if (!page) return nullptr;
+    GdiplusStartupInput startup;
+    if (GdiplusStartup(&page->gdiplus, &startup, nullptr) != Ok) { delete page; return nullptr; }
+    HWND stockPage = GetParent(source);
+    if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
+        GdiplusShutdown(page->gdiplus); delete page; return nullptr;
+    }
+    ShowWindow(stockPage, SW_HIDE);
+    page->dark = dark != FALSE;
+    page->dpi = dpi;
+    const WCHAR* captions[] = {preparing, extracting, copying, registering, cleaning};
+    for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128);
+    page->brand = new Image(brand);
+    if (page->brand->GetLastStatus() != Ok) {
+        delete page->brand; GdiplusShutdown(page->gdiplus); delete page; return nullptr;
+    }
+    HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE,
+        0, 0, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), parent, nullptr, module, page);
+    if (!window) { delete page->brand; GdiplusShutdown(page->gdiplus); delete page; }
+    return window;
+}
+
+// NSIS has already reported success. Pump the UI for the bounded final animation,
+// including a painted 100% frame, before constructing the interactive finish page.
+extern "C" __declspec(dllexport) BOOL __cdecl InstallerFinishProgress(HWND window) {
+    auto* page = reinterpret_cast<ProgressPage*>(GetWindowLongPtrW(window, GWLP_USERDATA));
+    if (!page) return FALSE;
+    const ULONGLONG started = GetTickCount64();
+    SetPropW(GetParent(window), L"HarnessInstaller.Succeeded", reinterpret_cast<HANDLE>(1));
+    page->progress.Complete(started);
+    while (IsWindow(window) && GetTickCount64() - started < 750) {
+        MSG message;
+        if (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
+            if (message.message == WM_QUIT) { PostQuitMessage(static_cast<int>(message.wParam)); return FALSE; }
+            TranslateMessage(&message);
+            DispatchMessageW(&message);
+        } else {
+            MsgWaitForMultipleObjectsEx(0, nullptr, 16, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
+        }
+    }
+    if (!IsWindow(window)) return FALSE;
+    InvalidateRect(window, nullptr, FALSE);
+    UpdateWindow(window);
+    return TRUE;
+}
+
+static LRESULT CALLBACK FrameProc(HWND window, UINT message, WPARAM wparam,
+                                 LPARAM lparam, UINT_PTR id, DWORD_PTR) {
+    if (message == WM_NCCALCSIZE && wparam) return 0;
+    if (message == WM_NCHITTEST) {
+        const LRESULT hit = DefSubclassProc(window, message, wparam, lparam);
+        // The installer has a fixed size; its page provides the drag area.
+        return hit >= HTLEFT && hit <= HTBOTTOMRIGHT ? HTCLIENT : hit;
+    }
+    if (message == WM_NCDESTROY) RemoveWindowSubclass(window, FrameProc, id);
+    return DefSubclassProc(window, message, wparam, lparam);
+}
+
+extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window) {
+    // NSIS may release its DLL reference before the window receives WM_NCDESTROY.
+    HMODULE module = nullptr;
+    if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
+                           reinterpret_cast<LPCWSTR>(&FrameProc), &module)) return E_FAIL;
+    if (!SetWindowSubclass(window, FrameProc, 1, 0)) return E_FAIL;
+    const int stockControls[] = {1, 2, 3, 1028, 1256, 1034, 1035, 1036, 1037, 1038, 1039};
+    for (int id : stockControls) {
+        HWND child = GetDlgItem(window, id);
+        if (child) {
+            if (!SetWindowSubclass(child, HiddenPageProc, 1, 0)) return E_FAIL;
+            ShowWindow(child, SW_HIDE);
+        }
+    }
+    SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME);
+    const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
+    HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));
+    if (FAILED(result)) return result;
+    const DWORD rounded = 2;
+    // Windows 10 does not support the Windows 11 corner preference.
+    DwmSetWindowAttribute(window, 33, &rounded, sizeof(rounded));
+    const MARGINS margins = {1, 1, 1, 1};
+    result = DwmExtendFrameIntoClientArea(window, &margins);
+    SetWindowPos(window, nullptr, 0, 0, 0, 0,
+                 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
+    return result;
+}

+ 5 - 0
apps/desktop/package.json

@@ -7,15 +7,18 @@
   "type": "module",
   "main": "lib/main.js",
   "scripts": {
+    "test:installer": "node scripts/test-windows-installer.mjs",
     "build": "tsc -b && tsdown",
     "dev": "tsx scripts/dev.ts",
     "start": "tsx scripts/dev.ts --skip-build",
     "prepare:runtime": "tsx scripts/prepare-runtime.ts",
+    "prepare:primary-runtime": "tsx scripts/prepare-primary-runtime.ts",
     "prepare:packages": "tsx scripts/prepare-package-set.ts",
     "prepare:dsh": "tsx scripts/prepare-dsh.ts",
     "prepare:package": "tsx scripts/package-target.ts --prepare-only",
     "verify:mac-signature": "node scripts/verify-macos-signature.mjs",
     "package": "tsx scripts/package-target.ts",
+    "check:package": "tsx scripts/package-target.ts --check",
     "package:dir": "tsx scripts/package-target.ts --dir",
     "package:mac:arm64": "tsx scripts/package-target.ts mac-arm64",
     "package:mac:arm64:dir": "tsx scripts/package-target.ts mac-arm64 --dir",
@@ -34,7 +37,9 @@
   },
   "devDependencies": {
     "@aws-sdk/client-s3": "3.1067.0",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@electron/get": "^5.1.0",
     "@electron/notarize": "2.5.0",
     "@types/js-yaml": "^4.0.9",
     "@types/node": "^22.20.0",

+ 8 - 0
apps/desktop/renderer/plugin-manager.html

@@ -29,6 +29,14 @@
           <button id="install" type="submit"></button>
         </div>
       </form>
+      <form id="update-form" hidden aria-labelledby="update-label">
+        <label id="update-label" for="update-version"></label>
+        <div class="install-row">
+          <input id="update-version" name="update-version" autocomplete="off" required>
+          <button id="update-submit" type="submit"></button>
+          <button id="update-cancel" class="quiet" type="button"></button>
+        </div>
+      </form>
       <p id="status" role="status" aria-live="polite"></p>
       <section aria-labelledby="installed-heading">
         <h2 id="installed-heading"></h2>

+ 25 - 3
apps/desktop/renderer/plugin-manager.js

@@ -24,6 +24,12 @@ async function main() {
   const form = document.querySelector('#install-form')
   const input = document.querySelector('#package-spec')
   const refresh = document.querySelector('#refresh')
+  const updateForm = document.querySelector('#update-form')
+  const updateVersion = document.querySelector('#update-version')
+  let updateName = ''
+  let updateTrigger
+  document.querySelector('#update-submit').textContent = messages.update
+  document.querySelector('#update-cancel').textContent = messages.cancel
 
   function setBusy(busy, statusMessage = '') {
     for (const control of document.querySelectorAll('button, input')) control.disabled = busy
@@ -53,9 +59,13 @@ async function main() {
       update.type = 'button'
       update.textContent = messages.update
       update.addEventListener('click', () => {
-        const next = window.prompt(message('targetVersion', { name: plugin.name }), plugin.version)?.trim()
-        if (next === undefined || next === '' || next === plugin.version) return
-        void run(() => api.plugins.update(plugin.name, next), message('updating', { name: plugin.name }))
+        updateName = plugin.name
+        updateTrigger = update
+        document.querySelector('#update-label').textContent = message('targetVersion', { name: plugin.name })
+        updateVersion.value = plugin.version
+        updateForm.hidden = false
+        updateVersion.focus()
+        updateVersion.select()
       })
       const actions = document.createElement('span')
       actions.className = 'package-actions'
@@ -106,6 +116,18 @@ async function main() {
       input.value = ''
     }, message('installing', { spec }))
   })
+  updateForm.addEventListener('submit', (event) => {
+    event.preventDefault()
+    const version = updateVersion.value.trim()
+    if (version === '') return
+    const name = updateName
+    updateForm.hidden = true
+    void run(() => api.plugins.update(name, version), message('updating', { name }))
+  })
+  document.querySelector('#update-cancel').addEventListener('click', () => {
+    updateForm.hidden = true
+    updateTrigger.focus()
+  })
   document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
   document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
   refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))

BIN
apps/desktop/resources/icon-macos.png


File diff suppressed because it is too large
+ 6 - 0
apps/desktop/resources/icon-macos.svg


BIN
apps/desktop/resources/icon-windows.png


File diff suppressed because it is too large
+ 6 - 0
apps/desktop/resources/icon-windows.svg


BIN
apps/desktop/resources/icon.png


File diff suppressed because it is too large
+ 6 - 0
apps/desktop/resources/icon.svg


+ 1 - 1
apps/desktop/scripts/desktop-build-paths.d.mts

@@ -8,7 +8,7 @@ export interface DesktopTargetBuildPaths {
   readonly packageSet: string
   readonly dsh: string
   readonly dshPnpm: string
-  readonly nodeExtract: string
+  readonly electron: string
   readonly packedDsh: string
   readonly packedVendor: string
   readonly packedLandlock: string

+ 2 - 2
apps/desktop/scripts/desktop-build-paths.mjs

@@ -31,7 +31,7 @@ export function resolveDesktopBuildTarget(
 /**
  * Return the mutable preparation and artifact directories owned by one release target.
  * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
- * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
+ * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
  */
 export function desktopTargetBuildPaths(target) {
   if (!SUPPORTED_TARGETS.has(target)) {
@@ -46,7 +46,7 @@ export function desktopTargetBuildPaths(target) {
     packageSet: join(root, 'package-set'),
     dsh: join(root, 'dsh'),
     dshPnpm: join(root, 'dsh-pnpm'),
-    nodeExtract: join(root, 'node-extract'),
+    electron: join(root, 'electron'),
     packedDsh: join(packed, 'dsh'),
     packedVendor: join(packed, 'vendor'),
     packedLandlock: join(packed, 'landlock'),

+ 27 - 0
apps/desktop/scripts/desktop-package-environment.d.mts

@@ -0,0 +1,27 @@
+/** Load platform-local release settings without changing the caller's process environment. */
+
+/**
+ * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
+ * @param platform Target platform.
+ * @param environment Parent environment, retained only for unrelated build tools.
+ * @param appRoot Desktop application directory; relative credential paths resolve here.
+ * @returns Isolated environment with file-owned release settings.
+ */
+export function loadDesktopPackageEnvironment(
+  platform: 'win32' | 'darwin',
+  environment?: NodeJS.ProcessEnv,
+  appRoot?: string,
+): NodeJS.ProcessEnv
+
+/**
+ * Validate release configuration before preparation without invoking a token or Apple's services.
+ * @param environment File-owned release settings.
+ * @param target Selected release target.
+ * @param options Explicit packaging mode.
+ * @returns Nothing.
+ */
+export function validateDesktopPackageEnvironment(
+  environment: NodeJS.ProcessEnv,
+  target: { platform: 'win32' | 'darwin', arch: string },
+  options?: { unsigned?: boolean, prepareOnly?: boolean },
+): void

+ 100 - 0
apps/desktop/scripts/desktop-package-environment.mjs

@@ -0,0 +1,100 @@
+/** Load platform-local release settings without changing the caller's process environment. */
+
+import { accessSync, constants, readFileSync, statSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { parseEnv } from 'node:util'
+import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
+import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
+import { createWindowsTokenSigner } from './windows-sign.mjs'
+
+const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
+const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV)|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
+const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u
+const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
+const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
+const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN']
+
+/**
+ * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
+ * @param {'win32' | 'darwin'} platform Target platform.
+ * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
+ * @param {string} appRoot Desktop application directory; relative credential paths resolve here.
+ * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
+ */
+export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) {
+  const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
+  let contents
+  try {
+    contents = readFileSync(path, 'utf8')
+  }
+  catch {
+    throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`)
+  }
+  let settings
+  try {
+    settings = parseEnv(contents.replace(/^\uFEFF/u, ''))
+  }
+  catch {
+    // Parser diagnostics can contain credential-bearing input.
+    throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
+  }
+  const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
+  for (const name of Object.keys(settings)) {
+    if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) {
+      throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
+    }
+    if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
+  }
+  for (const name of FILE_SETTINGS) {
+    if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
+  }
+  return {
+    ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
+    ...settings,
+  }
+}
+
+function requireReadableFile(environment, name) {
+  try {
+    if (!statSync(environment[name]).isFile()) throw new Error('not a file')
+    accessSync(environment[name], constants.R_OK)
+  }
+  catch {
+    throw new Error(`desktop package: ${name} must identify a readable local file`)
+  }
+}
+
+/**
+ * Validate release configuration before preparation without invoking a token or Apple's services.
+ * @param {NodeJS.ProcessEnv} environment File-owned release settings.
+ * @param {{ platform: 'win32' | 'darwin', arch: string }} target Selected release target.
+ * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Explicit packaging mode.
+ * @returns {void}
+ */
+export function validateDesktopPackageEnvironment(environment, target, options = {}) {
+  resolveDesktopAppId(environment)
+  if (options.unsigned) return
+  if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
+  if (target.platform === 'win32') {
+    if (!options.prepareOnly) createWindowsTokenSigner({
+      certificateFile: environment.DSH_DESKTOP_WINDOWS_CER_FILE,
+      signTool: environment.DSH_DESKTOP_WINDOWS_SIGNTOOL,
+      tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
+      keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
+    })
+  } else {
+    resolveMacOSSigningEnvironment(environment)
+    const strategies = [
+      ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'],
+      ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'],
+      ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'],
+    ]
+    if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) {
+      throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies')
+    }
+    const credentials = resolveMacOSNotarizationEnvironment(environment)
+    if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY')
+    if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN')
+  }
+}

+ 5 - 3
apps/desktop/scripts/dev.ts

@@ -1,6 +1,6 @@
 /** Build and launch the unpackaged Electron shell against the current workspace. */
 
-import { spawn } from 'node:child_process'
+import { spawn, execFileSync } from 'node:child_process'
 import { existsSync, readFileSync } from 'node:fs'
 import { createRequire } from 'node:module'
 import { join, resolve } from 'node:path'
@@ -8,6 +8,7 @@ import { parseArgs } from 'node:util'
 import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
 import type { DesktopRelease } from '../src/release.ts'
 import { prepareDevelopmentProject } from './development-project.ts'
+import { preparePrimaryRuntime } from './prepare-primary-runtime.ts'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
@@ -66,7 +67,6 @@ async function launchElectron(): Promise<void> {
     ...process.env,
     DSH_HOME: home,
     DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort),
-    DSH_DESKTOP_NODE_BINARY: process.execPath,
     DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1',
     ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
   }
@@ -98,7 +98,8 @@ async function main(): Promise<void> {
     schemaVersion: 1,
     version,
     hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
-    nodeVersion: process.versions.node,
+    nodeVersion: execFileSync(createRequire(import.meta.url)('electron') as string, ['-p', 'process.versions.node'],
+      { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }).trim(),
     pnpmVersion,
   }
   prepareDevelopmentProject({
@@ -108,6 +109,7 @@ async function main(): Promise<void> {
     dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'),
     release,
   })
+  await preparePrimaryRuntime()
   await launchElectron()
 }
 

+ 18 - 3
apps/desktop/scripts/development-project.ts

@@ -10,10 +10,12 @@ import {
   rmSync,
   symlinkSync,
   unlinkSync,
+  writeFileSync,
 } from 'node:fs'
 import { dirname, join } from 'node:path'
 import { createDevelopmentProjectMetadata } from '../src/project-manager.ts'
 import type { DesktopRelease } from '../src/release.ts'
+import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
 
 interface PackageManifest {
   readonly name?: string
@@ -62,7 +64,8 @@ function linkDirectory(source: string, destination: string): void {
   symlinkSync(realpathSync(source), destination, process.platform === 'win32' ? 'junction' : 'dir')
 }
 
-function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): void {
+function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): string[] {
+  const names: string[] = []
   for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
     if (entry.name === '.bin') continue
     const source = join(sourceRoot, entry.name)
@@ -71,11 +74,16 @@ function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): voi
       for (const scoped of readdirSync(source, { withFileTypes: true })) {
         if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue
         linkDirectory(join(source, scoped.name), join(destinationRoot, entry.name, scoped.name))
+        names.push(`${entry.name}/${scoped.name}`)
       }
       continue
     }
-    if (entry.isDirectory() || entry.isSymbolicLink()) linkDirectory(source, join(destinationRoot, entry.name))
+    if (entry.isDirectory() || entry.isSymbolicLink()) {
+      linkDirectory(source, join(destinationRoot, entry.name))
+      names.push(entry.name)
+    }
   }
+  return names
 }
 
 /**
@@ -109,12 +117,19 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s
   createDevelopmentProjectMetadata(options.projectDir, options.release)
   const destinationModules = join(options.projectDir, 'node_modules')
   mkdirSync(destinationModules, { recursive: true })
-  mirrorDependencyLinks(options.dependencyDir, destinationModules)
+  const names = mirrorDependencyLinks(options.dependencyDir, destinationModules)
   const dshLink = join(destinationModules, '@deepseek-ai', 'dsh')
   removeOwnedPath(dshLink)
   linkDirectory(options.cliDir, dshLink)
   const hostLink = join(destinationModules, '@deepseek-ai', 'dsh-desktop-host')
   removeOwnedPath(hostLink)
   linkDirectory(options.hostDir, hostLink)
+  const sharedPackages = [...new Set([...names, '@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host'])].flatMap((name) => {
+    const manifest = readManifest(join(destinationModules, name, 'package.json'))
+    return typeof manifest.version === 'string' ? [{ name, version: manifest.version, path: `node_modules/${name}` }] : []
+  })
+  const runtime: DesktopRuntimeDescriptor = { schemaVersion: 1, release: options.release,
+    platform: process.platform, arch: process.arch, sharedPackages, files: [] }
+  writeFileSync(join(options.projectDir, DESKTOP_RUNTIME_FILE), `${JSON.stringify(runtime, undefined, 2)}\n`)
   return options.projectDir
 }

+ 131 - 0
apps/desktop/scripts/installer-directories.nsh

@@ -0,0 +1,131 @@
+!include "LogicLib.nsh"
+
+Var dshFinalDirectory
+Var dshNewDirectory
+Var dshOldDirectory
+Var dshOldMoved
+Var dshNewMoved
+
+!macro dshExtractPayload FILE
+  !ifmacrodef customInstallerExtract
+    !insertmacro customInstallerExtract "${FILE}"
+  !else
+    nsExec::ExecToStack '"$PLUGINSDIR\dsh-7za.exe" x -y -bd -bb0 "-o$INSTDIR" "${FILE}"'
+    Pop $R0
+    Pop $R1
+  !endif
+  ${If} $R0 != 0
+    DetailPrint $R1
+    Call dshRollbackDirectories
+    MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK
+    SetErrorLevel 2
+    Quit
+  ${EndIf}
+!macroend
+
+!macro dshStageApplication
+  StrCpy $dshFinalDirectory $INSTDIR
+  System::Call 'ole32::CoCreateGuid(g .r0) i .r1'
+  ${If} $1 != 0
+    SetErrorLevel 2
+    Quit
+  ${EndIf}
+  StrCpy $dshNewDirectory "$INSTDIR.new-$0"
+  StrCpy $dshOldDirectory "$INSTDIR.old-$0"
+  StrCpy $dshOldMoved ""
+  StrCpy $dshNewMoved ""
+  ClearErrors
+  CreateDirectory $dshNewDirectory
+  ${If} ${Errors}
+    SetErrorLevel 2
+    Quit
+  ${EndIf}
+  File /oname=$PLUGINSDIR\dsh-7za.exe "${DSH_SEVENZIP_PATH}"
+  StrCpy $INSTDIR $dshNewDirectory
+  SetOutPath $INSTDIR
+  !insertmacro installApplicationFiles
+  !ifdef DSH_SEVENZIP_LICENSE_DIR
+    File /oname=7zip-installer-LICENSE.txt "${DSH_SEVENZIP_LICENSE_DIR}\LICENSE.txt"
+    File /oname=7zip-installer-COPYING.txt "${DSH_SEVENZIP_LICENSE_DIR}\COPYING"
+  !endif
+  !ifdef UNINSTALLER_ICON
+    File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}"
+  !endif
+  StrCpy $INSTDIR $dshFinalDirectory
+  SetOutPath $PLUGINSDIR
+!macroend
+
+Function .onGUIEnd
+  Call dshCleanupDirectories
+FunctionEnd
+
+Function dshCleanupDirectories
+  ${If} $dshFinalDirectory != ""
+    Call dshRollbackDirectories
+  ${EndIf}
+FunctionEnd
+
+; Only directories created or renamed by this installer are removed during rollback.
+Function dshRollbackDirectories
+  SetOutPath $PLUGINSDIR
+  ${If} $dshNewMoved == "1"
+    RMDir /r "\\?\$dshFinalDirectory"
+    StrCpy $dshNewMoved ""
+  ${EndIf}
+  ${If} $dshOldMoved == "1"
+    ClearErrors
+    Rename $dshOldDirectory $dshFinalDirectory
+    ${If} ${Errors}
+      ; Leave the complete backup in place if another process prevents restoration.
+      DetailPrint $dshOldDirectory
+      Return
+    ${EndIf}
+    StrCpy $dshOldMoved ""
+  ${EndIf}
+  ${If} $dshNewDirectory != ""
+    RMDir /r "\\?\$dshNewDirectory"
+  ${EndIf}
+  StrCpy $INSTDIR $dshFinalDirectory
+FunctionEnd
+
+Function dshPromoteDirectories
+  !ifmacrodef InstallerPublishStage
+    !insertmacro InstallerPublishStage 2
+  !endif
+  ; SetOutPath opens a directory handle; release it before either rename.
+  SetOutPath $PLUGINSDIR
+  ClearErrors
+  ${If} ${FileExists} "$dshFinalDirectory\*.*"
+    Rename $dshFinalDirectory $dshOldDirectory
+    ${If} ${Errors}
+      Call dshRollbackDirectories
+      SetErrors
+      Return
+    ${EndIf}
+    StrCpy $dshOldMoved "1"
+  ${Else}
+    ; NSIS can create the destination before the install section starts.
+    RMDir $dshFinalDirectory
+  ${EndIf}
+  ClearErrors
+  Rename $dshNewDirectory $dshFinalDirectory
+  ${If} ${Errors}
+    Call dshRollbackDirectories
+    SetErrors
+    Return
+  ${EndIf}
+  StrCpy $dshNewMoved "1"
+  SetOutPath $dshFinalDirectory
+  !ifmacrodef InstallerPublishStage
+    !insertmacro InstallerPublishStage 3
+  !endif
+  ClearErrors
+FunctionEnd
+
+!macro dshFinishDirectories
+  StrCpy $dshNewMoved ""
+  ${If} $dshOldMoved == "1"
+    RMDir /r "\\?\$dshOldDirectory"
+    StrCpy $dshOldMoved ""
+  ${EndIf}
+!macroend

+ 156 - 2
apps/desktop/scripts/installer.nsh

@@ -1,4 +1,158 @@
 !include "LogicLib.nsh"
+!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
+!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
+
+!ifndef BUILD_UNINSTALLER
+  ManifestDPIAware true
+  !define MUI_CUSTOMFUNCTION_GUIINIT InstallerGuiInit
+!endif
+
+!macro customHeader
+  !include "${INSTALLER_SOURCE_DIR}\strings.nsh"
+  !ifndef BUILD_UNINSTALLER
+    !include "${INSTALLER_SOURCE_DIR}\theme.nsh"
+    !include "${INSTALLER_SOURCE_DIR}\pages.nsh"
+    !include "${INSTALLER_SOURCE_DIR}\lifecycle.nsh"
+  !endif
+!macroend
+
+!macro customInit
+  ${If} ${isForAllUsers}
+    MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_PER_USER)" /SD IDOK
+    SetErrorLevel 2
+    Quit
+  ${EndIf}
+  ReadRegStr $0 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation
+  ${If} $0 != ""
+    MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_PER_USER)" /SD IDOK
+    SetErrorLevel 2
+    Quit
+  ${EndIf}
+  !insertmacro setInstallModePerUser
+  StrCpy $hasPerMachineInstallation 0
+  StrCpy $hasPerUserInstallation 1
+  StrCpy $InstallerPath $INSTDIR
+  StrCpy $InstallerTheme "auto"
+  ${GetParameters} $0
+  ${GetOptions} $0 "/THEME=" $1
+  ${IfNot} ${Errors}
+    ${If} $1 == "light"
+    ${OrIf} $1 == "dark"
+    ${OrIf} $1 == "auto"
+      StrCpy $InstallerTheme $1
+    ${Else}
+      MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_THEME_ERROR)" /SD IDOK
+      SetErrorLevel 2
+      Quit
+    ${EndIf}
+  ${EndIf}
+  Call InstallerResolveTheme
+  InitPluginsDir
+  File "/oname=$PLUGINSDIR\brand.bmp" "${INSTALLER_BUILD_DIR}\brand.bmp"
+  File "/oname=$PLUGINSDIR\brand-2x.bmp" "${INSTALLER_BUILD_DIR}\brand-2x.bmp"
+  File "/oname=$PLUGINSDIR\brand-dark.bmp" "${INSTALLER_BUILD_DIR}\brand-dark.bmp"
+  File "/oname=$PLUGINSDIR\brand-dark-2x.bmp" "${INSTALLER_BUILD_DIR}\brand-dark-2x.bmp"
+  File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
+  ${If} ${Silent}
+    Call InstallerPreflight
+    ${If} $InstallerError != ""
+      SetErrorLevel 2
+      Quit
+    ${EndIf}
+  ${EndIf}
+!macroend
+
+!macro customInstallMode
+  ; Preserve the directory selected on the custom welcome page.
+  StrCpy $installMode CurrentUser
+  SetShellVarContext current
+  Abort
+!macroend
+
+!macro customWelcomePage
+  Page custom InstallerWelcome InstallerWelcomeLeave
+!macroend
+
+!macro customPageAfterChangeDir
+  !define MUI_PAGE_CUSTOMFUNCTION_PRE InstallerBeforeInstall
+  !define MUI_PAGE_CUSTOMFUNCTION_SHOW InstallerProgressShow
+!macroend
+
+!macro customFinishPage
+  Page custom InstallerFinish InstallerFinishLeave
+!macroend
+
+; Installation work publishes stage changes without disturbing the NSIS caller.
+!macro InstallerPublishStage Stage
+  ; Extraction owns the stack and error flag across these callbacks.
+  Push $0
+  StrCpy $0 0
+  ${If} ${Errors}
+    StrCpy $0 1
+  ${EndIf}
+  System::Store /NOUNLOAD "S"
+  System::Call /NOUNLOAD 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Stage", p ${Stage})'
+  System::Store "L"
+  ${If} $0 == 1
+    SetErrors
+  ${Else}
+    ClearErrors
+  ${EndIf}
+  Pop $0
+!macroend
+
+!macro customInstallerExtract Archive
+  !insertmacro InstallerPublishStage 1
+  System::Store /NOUNLOAD "S"
+  System::Call /NOUNLOAD '$PLUGINSDIR\window-frame.dll::InstallerExtract(p $HWNDPARENT, w "$PLUGINSDIR\dsh-7za.exe", w "${Archive}", w "$INSTDIR", w "$PLUGINSDIR\extract.log") i.s ?c'
+  System::Store "L"
+  Pop $R0
+  StrCpy $R1 "$R0"
+  ${If} $R0 != 0
+    Push $0
+    FileOpen $0 "$PLUGINSDIR\extract.log" r
+    ${IfNot} ${Errors}
+      FileRead $0 $R1
+      FileClose $0
+    ${EndIf}
+    Pop $0
+  ${EndIf}
+!macroend
+
+!macro customCheckAppRunning
+  !ifdef BUILD_UNINSTALLER
+    InitPluginsDir
+    File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
+  !endif
+  System::Call '$PLUGINSDIR\window-frame.dll::InstallerFindProcess(w "$INSTDIR\${APP_EXECUTABLE_FILENAME}") i.R0 ?c'
+  ${If} $R0 == 0
+    ${If} ${isUpdated}
+      StrCpy $R1 0
+      ${DoWhile} $R0 == 0
+        Sleep 250
+        System::Call '$PLUGINSDIR\window-frame.dll::InstallerFindProcess(w "$INSTDIR\${APP_EXECUTABLE_FILENAME}") i.R0 ?c'
+        IntOp $R1 $R1 + 1
+        ${If} $R1 >= 40
+          ${ExitDo}
+        ${EndIf}
+      ${Loop}
+    ${EndIf}
+    ${If} $R0 == 0
+      MessageBox MB_OK|MB_ICONINFORMATION "$(INSTALLER_RUNNING)" /SD IDOK
+      SetErrorLevel 2
+      Quit
+    ${EndIf}
+  ${EndIf}
+  ${If} $R0 < 0
+    MessageBox MB_OK|MB_ICONEXCLAMATION "$(INSTALLER_UI_ERROR)" /SD IDOK
+    SetErrorLevel 2
+    Quit
+  ${EndIf}
+!macroend
+
+!ifndef BUILD_UNINSTALLER
+  !include "${__FILEDIR__}\installer-directories.nsh"
+!endif
 
 !macro customInstall
   Push $0
@@ -6,8 +160,8 @@
   ${If} ${Errors}
     StrCpy $0 1
   ${EndIf}
-  ; Finish can launch the app while NSIS removes its remaining plugin directory.
-  RMDir /r "$PLUGINSDIR\7z-out"
+  !insertmacro InstallerPublishStage 4
+  !insertmacro dshFinishDirectories
   ${If} $0 == 1
     SetErrors
   ${Else}

Some files were not shown because too many files changed in this diff