Przeglądaj źródła

Use Electron Node runtime and replace Windows installations by directory

07akioni 1 tydzień temu
rodzic
commit
bebf88de4f
54 zmienionych plików z 818 dodań i 311 usunięć
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  2. 8 8
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  3. 8 8
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  5. 5 3
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  6. 5 3
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  8. 3 1
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  9. 3 1
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml
  11. 27 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
  12. 27 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.i18n.yaml
  14. 29 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.md
  15. 29 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.zh.md
  16. 4 2
      AGENTS.md
  17. 1 0
      THIRD_PARTY_NOTICES.md
  18. 2 2
      apps/desktop/README.i18n.yaml
  19. 18 14
      apps/desktop/README.md
  20. 18 14
      apps/desktop/README.zh.md
  21. 14 2
      apps/desktop/electron-builder.config.mjs
  22. 1 0
      apps/desktop/package.json
  23. 1 1
      apps/desktop/scripts/desktop-build-paths.d.mts
  24. 2 2
      apps/desktop/scripts/desktop-build-paths.mjs
  25. 3 3
      apps/desktop/scripts/dev.ts
  26. 121 0
      apps/desktop/scripts/installer-directories.nsh
  27. 5 13
      apps/desktop/scripts/installer.nsh
  28. 3 0
      apps/desktop/scripts/node-bin/node
  29. 3 0
      apps/desktop/scripts/node-bin/node.cmd
  30. 1 1
      apps/desktop/scripts/package-target.ts
  31. 8 5
      apps/desktop/scripts/prepare-dsh.ts
  32. 21 77
      apps/desktop/scripts/prepare-runtime.ts
  33. 1 1
      apps/desktop/scripts/runtime-file-policy.ts
  34. 89 0
      apps/desktop/scripts/smoke-installer-directories.ps1
  35. 1 1
      apps/desktop/scripts/smoke-runtime.ts
  36. 2 9
      apps/desktop/scripts/smoke-windows.ps1
  37. 18 0
      apps/desktop/scripts/windows-directory-installer.d.mts
  38. 86 0
      apps/desktop/scripts/windows-directory-installer.mjs
  39. 1 1
      apps/desktop/scripts/windows-sign.d.mts
  40. 38 34
      apps/desktop/scripts/windows-sign.mjs
  41. 6 4
      apps/desktop/src/host-process.ts
  42. 6 4
      apps/desktop/src/main.ts
  43. 19 0
      apps/desktop/src/node-environment.ts
  44. 6 4
      apps/desktop/src/project-manager.ts
  45. 1 1
      apps/desktop/tests/desktop-build-paths.spec.ts
  46. 0 70
      apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi
  47. 36 0
      apps/desktop/tests/fixtures/installer-directory-smoke.nsi
  48. 39 6
      apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
  49. 2 2
      apps/desktop/tests/host-process.spec.ts
  50. 1 1
      apps/desktop/tests/macos-signature.spec.ts
  51. 2 3
      apps/desktop/tests/main-startup.spec.ts
  52. 37 0
      apps/desktop/tests/windows-directory-installer.spec.ts
  53. 32 0
      apps/desktop/tests/windows-sign.spec.ts
  54. 7 4
      pnpm-lock.yaml

+ 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: 92dcd8b30ed43ed5a856e98649830402d7f0a5ff
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: b937baeb15e4058c65ca7d74dd02887a338491e0
+2026-08-25-electron-desktop-packaging-and-updates.md: f257a387cdd1fdfd770dd0bb91052aa75b95e424
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: a1f5f65c5ab2d7ac7fa7f9f3ccb090e703a12444

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

@@ -6,6 +6,8 @@ English | [中文](2026-08-25-electron-desktop-packaging-and-updates.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
 
 DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow.
@@ -16,7 +18,7 @@ The current GUI protocol binds the Web client and backend release. Independently
 
 ## Decision
 
-Ship a small Electron shell with a bundled upstream Node.js executable and pinned pnpm. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) owns Host boot and transport: the private Host runs the shared Web profile runner, Electron loads its authenticated HTTP URL, and child IPC carries lifecycle messages.
+Ship a small Electron shell and pinned pnpm; the [runtime decision](2026-09-11-desktop-electron-node-runtime.md) owns the executable choice. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) owns Host boot and transport: the private Host runs the shared Web profile runner, Electron loads its authenticated HTTP URL, and child IPC carries lifecycle messages.
 
 Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resource storage, external plugin dependencies, shared package links, and profile reconciliation. The private Desktop Host remains outside the public CLI package and is never published to npm.
 
@@ -29,7 +31,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p
 | Owner | Responsibility |
 |---|---|
 | Electron shell | Window and child lifecycle, local shell pages, reserved desktop profile, plugin GUI, update coordination |
-| Bundled Node.js and pnpm | Execute dsh and install desktop-project dependencies using pnpm’s normal configuration |
+| Electron RunAsNode and pnpm | Execute dsh and install desktop-project dependencies using pnpm’s normal configuration |
 | Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision |
 | Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication |
 | Installed dsh package | Backend, matching Web UI, boot manifest, client bundles, and product behavior |
@@ -85,13 +87,11 @@ Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled
 
 Local Windows installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without an EV token while preventing local test output from qualifying for release upload.
 
-NSIS extracts into its private `7z-out` directory before copying files into the application directory. Its default exit cleanup can overlap the backend's file reads after Finish launches the application. The [installer hook](../../../../apps/desktop/scripts/installer.nsh) removes only that extraction directory during `customInstall`, before interactive and silent launch branches. It preserves package archives, plugin DLLs, rollback directories, registers, and error status; the [native cleanup smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) checks these constraints. Moving cleanup into installation does not remove filesystem work, so total installation time and Finish-to-window time require separate measurements.
-
-Direct `Nsis7z::Extract` into the application directory is not enabled. A native [locked-file probe](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi) leaves an old locked file beside a new asset while reporting no error; the staged `CopyFiles` operation sets the error flag for the same failed replacement. An unchanged 737,557,488-byte payload on Windows took 172.625 seconds through extraction, copying, and cleanup versus 28.031 seconds through direct extraction, but that single sample per path does not justify losing failure detection. The clocks exclude registry changes, old-version removal, and post-extraction verification; system caches were not cleared. Desktop-specific payload filtering reduces the copied file set while retaining the installer's replacement-error handling. This handling is not a promise of complete installation rollback.
+Windows application replacement follows the [directory-installation decision](2026-09-11-windows-directory-installation.md): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder.
 
-Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project.
+Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the pnpm entry, dsh resources, or active project.
 
-The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the dsh production tree. Architecture-specific builds must report actual component-level size deltas.
+Architecture-specific builds report actual component-level compressed and installed sizes.
 
 ## Implementation
 
@@ -99,7 +99,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 |---|---|
 | Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. |
 | Installed runtime | Private `@deepseek-ai/dsh-desktop-host` invokes the shared profile runner and reports the authenticated Web URL to Electron. |
-| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. |
+| Package state | Electron RunAsNode executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. |
 | Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. |
 
 `dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. The development runtime supplies workspace links to a separate plugin profile; plugin management and recovery use the same flow as packaged applications. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, dsh preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation.

+ 8 - 8
.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。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责 Host 启动与传输:私有 Host 运行共享 Web profile runner,Electron 加载其认证 HTTP URL,子进程 IPC 承载生命周期消息。
+交付小型 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。
 
@@ -29,7 +31,7 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 | Owner | 职责 |
 |---|---|
 | Electron 壳 | 窗口与子进程生命周期、本地壳页面、保留 desktop profile、插件 GUI、更新协调 |
-| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 |
+| Electron RunAsNode 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 |
 | Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
 | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
 | 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 |
@@ -85,13 +87,11 @@ Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-
 
 本地 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 安装体积。分架构构建必须报告实际组件级体积增量
+分架构构建报告实际组件级压缩体积和安装体积
 
 ## 实现
 
@@ -99,7 +99,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 |---|---|
 | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
 | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 调用共享 profile runner,并向 Electron 报告认证 Web URL。 |
-| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
+| 包状态 | 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。开发运行时为独立的插件 profile 提供工作区链接;插件管理和恢复使用与打包应用相同的流程。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。

+ 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: fc0821d26de1b34d6aedfa6a69daa8fe5c3e6010
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: a1dec8fb5713d89bfe6e2e98e318ff2ffa9c4ebc
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: b49ae76bfd8c468e9c971f803cf714755978e5b4
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 58dd72124c0fdeb53652a600d0d3c143feb1ff3d

+ 5 - 3
.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,7 +16,7 @@ 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`.
 
 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.
 
@@ -24,7 +26,7 @@ The resource descriptor records the exact release, Node version, platform, archi
 
 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.
 
 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.
 
@@ -52,7 +54,7 @@ Full runtime verification belongs to packaging. Startup reads the resource descr
 - **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
 

+ 5 - 3
.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,7 +16,7 @@ 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` 加载已启用插件。
 
 本记录负责核心资源存储与外部插件依赖。[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)保留发布身份、签名、进程归属和仅限 Electron 的插件授权。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 启动与 HTTP 传输。
 
@@ -24,7 +26,7 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成
 
 [桌面文件规则](../../../../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。
 
 共享 profile runner 在 Desktop profile 内补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。链接通过正常 Node 解析指向真实包目录,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的双重实现。
 
@@ -52,7 +54,7 @@ Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共
 - **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。共享模块补全提供缺失的包,pnpm 拥有的条目保留独立版本。
 - **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
 - **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
-- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。
+- **把核心包留在 ASAR。** 普通 `extraResources` 保留原生加载和子进程路径。ASAR 需要单独验证包解析。
 
 ## 影响
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.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-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 063f6ae92f9e2aca6295fb1241de4bb1dac8f9a6
-2026-09-10-desktop-web-wrapper.zh.md: 9ba17a66f22741b8e548ccafbd4ba6a8af6887d0
+2026-09-10-desktop-web-wrapper.md: 98f77df4e4757edc48813f67794e49b4a9804bb9
+2026-09-10-desktop-web-wrapper.zh.md: c740ea56b2c79ca571eb29f934f9acfe7496509f

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -4,6 +4,8 @@ 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.
@@ -14,7 +16,7 @@ The private Desktop Host invokes the CLI's shared profile runner against the ind
 
 The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Web and Desktop share application mechanisms while owning their deployment defaults. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Native directory selection is a Desktop overlay with an observable UI purpose; 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 Node.js and 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.
+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.
 

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

@@ -4,6 +4,8 @@ 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 需要独立安装与原生控件,但不需要第二套应用后端。
@@ -14,7 +16,7 @@ Status: implemented
 
 共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Web 与 Desktop 共享应用机制,各自决定部署默认值。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。原生目录选择是具有可见 UI 用途的 Desktop overlay;壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
 
-[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 Node.js 和 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
+[内置运行时决策](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 专用的来源、环境及构建限制。
 

+ 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 秒,不包含校验和启动,未清空缓存。这些完整安装器观测与上面的组件基线分别记录。线上更新下载和从历史已发布版本升级仍属于发布验收。

+ 4 - 2
AGENTS.md

@@ -89,7 +89,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.
@@ -97,7 +97,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

@@ -144,6 +144,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/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |

+ 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: 5cf050d81bdcf9f7566b37c85eb2e1b414436176
-README.zh.md: d1cef944b3c20eadc2c151e1b37bf8294837033c
+README.md: 460a4be6054d13327e399790b6dccb2ff4402415
+README.zh.md: 600cf675ce4eabddf96bb89eeb644636b048346a

+ 18 - 14
apps/desktop/README.md

@@ -2,26 +2,26 @@
 
 English | [中文](README.zh.md)
 
-The desktop application is an Electron shell around the complete dsh Web application. A bundled upstream Node.js child starts the shared profile runner, and Electron loads its authenticated HTTP URL. Web owns client assets, API routes, and response streaming; Node IPC carries child readiness and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
+The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron loads its authenticated HTTP URL. Web owns client assets, API routes, and response streaming; Node IPC carries child readiness and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
 
 ## Key technical decisions
 
 | 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. Package-manager configuration and the Host environment follow the user's settings. |
+| 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. | `extraResources/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 | 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 [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 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/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/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same Electron Node-mode process, with normal realpath resolution; 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.
 
@@ -31,11 +31,11 @@ Electron chooses typed English or Chinese shell copy from its application locale
 
 ### 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/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 a local loading page 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 bundled Node, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
+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.
 
@@ -51,7 +51,7 @@ Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through
 
 ## 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
@@ -65,7 +65,7 @@ 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. 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 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
 
@@ -96,20 +96,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.
 
-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`.
@@ -153,6 +155,8 @@ The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, inc
 
 ### 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.
@@ -186,9 +190,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, 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.
 
-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
 
@@ -198,7 +202,7 @@ 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
 

+ 18 - 14
apps/desktop/README.zh.md

@@ -2,26 +2,26 @@
 
 [English](README.md) | 中文
 
-桌面应用是完整 dsh Web 应用外的一层 Electron 壳。内置上游 Node.js 子进程启动共享 profile runner,Electron 加载其带认证的 HTTP URL。Web 负责客户端资源、API 路由与响应流;Node IPC 承载子进程就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
+桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 加载其带认证的 HTTP URL。Web 负责客户端资源、API 路由与响应流;Node IPC 承载子进程就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
 
 ## 关键技术决策
 
 | 决策 | 原因 | 直接结果 |
 |---|---|---|
 | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
-| 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。 |
+| 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 |
 | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/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 服务与认证,让应用行为由同一份实现负责。 | Electron 直接加载 Host 的认证 HTTP URL;子进程 IPC 承载生命周期消息,本地壳协议提供启动和管理页面。 |
 | 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
-| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
+| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
 [薄壳决策](../../.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` 包含 pnpm 安装的包;`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/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个 Electron Node 模式进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
 本地启动页面展示启动状态及可用恢复操作。产品渲染进程使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两个渲染进程都不会获得文件系统、原始 Electron IPC、shell 或任意 pnpm 参数访问权。
 
@@ -31,11 +31,11 @@ Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,
 
 ### 运行时与插件激活
 
-签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
+签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
 1. 主窗口在 profile 准备或后端启动前显示本地加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接。
 2. 应用升级时,共享 profile runner 刷新其拥有的模块链接,不检查插件 peer 要求。插件文件、配置、版本与锁文件保留原位;不运行 pnpm。
-3. 内置 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 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 目录、激活日志或回滚副本。
 
@@ -51,7 +51,7 @@ Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输
 
 ## 开发
 
-`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
@@ -65,7 +65,7 @@ pnpm run dev:desktop
 pnpm run start:desktop
 ```
 
-Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。开发与打包 profile 都使用正常的 bundle 解析,包括链接包。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
+Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。开发与打包 profile 都使用正常的 bundle 解析,包括链接包。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
 
 ## 打包
 
@@ -96,20 +96,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、内置包管理器或用户安装的插件文件。
 
-打包应用运行编译后的 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`。
@@ -153,6 +155,8 @@ pnpm run package:desktop:win:x64:unsigned
 
 ### 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 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
@@ -186,9 +190,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` 在构建时安装一次生产依赖图,把物化包复制到 `extraResources/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;复制后的清单在签名前及签名后分别验证。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
 
-未压缩产物包含 Electron、物化后的 dsh 生产依赖树、上游 Node.js 与 pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
+未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
 
 ## 更新
 
@@ -198,7 +202,7 @@ 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。
 
 ## 已知限制
 

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

@@ -13,6 +13,7 @@ import {
 } 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 +38,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 +60,8 @@ 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 },
     files: [
       'lib/*.js',
       'lib/*.cjs',
@@ -85,8 +89,16 @@ export function createElectronBuilderConfig(
       writeUpdateInfo: false,
     },
     afterPack: async context => {
-      const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
-      await verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'),
+      const { verifyDesktopRuntime, writeDesktopRuntime } = await import('./lib/types/runtime-tree.js')
+      const runtimeRoot = join(context.packager.getResourcesDir(context.appOutDir), 'dsh')
+      if (resolvedPlatform === 'win32' && !unsigned) {
+        // Windows signs copied executable resources before afterPack runs.
+        const prepared = await verifyDesktopRuntime(buildPaths.dsh,
+          context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
+        writeDesktopRuntime(runtimeRoot, prepared.release, prepared.sharedPackages.map(entry => entry.name),
+          { platform: resolvedPlatform, arch: resolvedArch })
+      }
+      await verifyDesktopRuntime(runtimeRoot,
         context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
     },
     afterSign: async context => {

+ 1 - 0
apps/desktop/package.json

@@ -36,6 +36,7 @@
     "@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",

+ 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'),

+ 3 - 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'
@@ -66,7 +66,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 +97,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({

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

@@ -0,0 +1,121 @@
+!include "LogicLib.nsh"
+
+Var dshFinalDirectory
+Var dshNewDirectory
+Var dshOldDirectory
+Var dshOldMoved
+Var dshNewMoved
+
+!macro dshExtractPayload FILE
+  nsExec::ExecToStack '"$PLUGINSDIR\dsh-7za.exe" x -y -bd -bb0 "-o$INSTDIR" "${FILE}"'
+  Pop $R0
+  Pop $R1
+  ${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
+  ; 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
+  ClearErrors
+FunctionEnd
+
+!macro dshFinishDirectories
+  StrCpy $dshNewMoved ""
+  ${If} $dshOldMoved == "1"
+    RMDir /r "\\?\$dshOldDirectory"
+    StrCpy $dshOldMoved ""
+  ${EndIf}
+!macroend

+ 5 - 13
apps/desktop/scripts/installer.nsh

@@ -1,17 +1,9 @@
 !include "LogicLib.nsh"
 
+!ifndef BUILD_UNINSTALLER
+  !include "${__FILEDIR__}\installer-directories.nsh"
+!endif
+
 !macro customInstall
-  Push $0
-  StrCpy $0 0
-  ${If} ${Errors}
-    StrCpy $0 1
-  ${EndIf}
-  ; Finish can launch the app while NSIS removes its remaining plugin directory.
-  RMDir /r "$PLUGINSDIR\7z-out"
-  ${If} $0 == 1
-    SetErrors
-  ${Else}
-    ClearErrors
-  ${EndIf}
-  Pop $0
+  !insertmacro dshFinishDirectories
 !macroend

+ 3 - 0
apps/desktop/scripts/node-bin/node

@@ -0,0 +1,3 @@
+#!/bin/sh
+export ELECTRON_RUN_AS_NODE=1
+exec "$DSH_DESKTOP_NODE_EXECUTABLE" --expose-internals "$@"

+ 3 - 0
apps/desktop/scripts/node-bin/node.cmd

@@ -0,0 +1,3 @@
+@echo off
+set ELECTRON_RUN_AS_NODE=1
+"%DSH_DESKTOP_NODE_EXECUTABLE%" --expose-internals %*

+ 1 - 1
apps/desktop/scripts/package-target.ts

@@ -1,4 +1,4 @@
-/** Build one release target with matching Electron, Node.js, and dsh architecture. */
+/** Build one release target with matching Electron and dsh architecture. */
 
 import { spawn } from 'node:child_process'
 import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'

+ 8 - 5
apps/desktop/scripts/prepare-dsh.ts

@@ -3,7 +3,8 @@
 import { spawn, execFile } from 'node:child_process'
 import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { delimiter, dirname, join, relative, resolve } from 'node:path'
+import { delimiter, join, relative, resolve } from 'node:path'
+import { desktopNodeEnvironment } from '../src/node-environment.ts'
 import { createRuntimeProjectMetadata } from '../src/project-manager.ts'
 import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
 import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts'
@@ -35,7 +36,7 @@ const STORE_ROOT = join(BUILD_ROOT, 'store')
 const RUNTIME_ROOT = BUILD_PATHS.runtime
 const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm
 const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
-const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
+const NODE = join(BUILD_PATHS.electron, process.platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron')
 const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
 
 function manifestVersion(path: string, subject: string): string {
@@ -69,6 +70,7 @@ function runPnpm(args: readonly string[]): Promise<void> {
     mkdirSync(config, { recursive: true })
     writeFileSync(userConfig, '')
     const child = spawn(NODE, [
+      '--expose-internals',
       PNPM,
       '--config.registry=https://registry.npmjs.org/',
       `--config.store-dir=${STORE_ROOT}`,
@@ -85,7 +87,8 @@ function runPnpm(args: readonly string[]): Promise<void> {
         NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
         NPM_CONFIG_STORE_DIR: STORE_ROOT,
         NPM_CONFIG_USERCONFIG: userConfig,
-        PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`,
+        ...desktopNodeEnvironment(NODE, join(RUNTIME_ROOT, 'bin'), {}),
+        PATH: `${join(RUNTIME_ROOT, 'bin')}${delimiter}${process.env.PATH ?? ''}`,
         XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'),
         XDG_CONFIG_HOME: config,
         XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'),
@@ -139,8 +142,8 @@ async function main(): Promise<void> {
     writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
     const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
     await new Promise<void>((accept, reject) => {
-      execFile(NODE, [join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT],
-        { timeout: 120_000, env: { ...process.env, NODE_OPTIONS: '' } }, (error, stdout, stderr) => {
+      execFile(NODE, ['--expose-internals', join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT],
+        { timeout: 120_000, env: desktopNodeEnvironment(NODE, join(RUNTIME_ROOT, 'bin'), { ...process.env, NODE_OPTIONS: '' }) }, (error, stdout, stderr) => {
           if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error }))
           else { process.stdout.write(stdout); accept() }
         })

+ 21 - 77
apps/desktop/scripts/prepare-runtime.ts

@@ -1,83 +1,15 @@
-/** Download and verify the upstream Node.js runtime and copy the pinned pnpm CLI. */
+/** Prepare the target Electron distribution and pinned pnpm CLI. */
 
-import { createHash } from 'node:crypto'
-import { spawnSync } from 'node:child_process'
-import { cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
-import { chmod, readFile } from 'node:fs/promises'
+import { execFileSync } from 'node:child_process'
+import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { createRequire } from 'node:module'
 import { dirname, join } from 'node:path'
-import { pipeline } from 'node:stream/promises'
+import { downloadArtifact } from '@electron/get'
 import extractZip from 'extract-zip'
-import { extract } from 'tar'
-import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
+import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
 
-const NODE_VERSION = '24.17.0'
 const BUILD_PATHS = resolveDesktopTargetBuildPaths()
 const RUNTIME_ROOT = BUILD_PATHS.runtime
-const DOWNLOAD_ROOT = BUILD_PATHS.downloads
-
-type RuntimePlatform = 'darwin' | 'linux' | 'win'
-type RuntimeArch = 'arm64' | 'x64'
-
-function target(): { platform: RuntimePlatform; arch: RuntimeArch } {
-  const rawPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.env.npm_config_platform ?? process.platform
-  const rawArch = process.env.DSH_DESKTOP_TARGET_ARCH ?? process.env.npm_config_arch ?? process.arch
-  const platform = rawPlatform === 'win32' ? 'win' : rawPlatform
-  if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win') {
-    throw new Error(`desktop runtime: unsupported platform ${rawPlatform}`)
-  }
-  if (rawArch !== 'arm64' && rawArch !== 'x64') throw new Error(`desktop runtime: unsupported architecture ${rawArch}`)
-  return { platform, arch: rawArch }
-}
-
-async function download(url: string, path: string): Promise<void> {
-  const response = await fetch(url)
-  if (!response.ok) throw new Error(`desktop runtime: ${url} returned HTTP ${String(response.status)}`)
-  writeFileSync(path, new Uint8Array(await response.arrayBuffer()), { mode: 0o600 })
-}
-
-async function prepareNode(platform: RuntimePlatform, arch: RuntimeArch): Promise<void> {
-  const extension = platform === 'win' ? 'zip' : 'tar.gz'
-  const folder = `node-v${NODE_VERSION}-${platform}-${arch}`
-  const archiveName = `${folder}.${extension}`
-  const releaseRoot = `https://nodejs.org/download/release/v${NODE_VERSION}`
-  const archive = join(DOWNLOAD_ROOT, archiveName)
-  const sums = join(DOWNLOAD_ROOT, `node-v${NODE_VERSION}-SHASUMS256.txt`)
-  if (!existsSync(archive)) await download(`${releaseRoot}/${archiveName}`, archive)
-  if (!existsSync(sums)) await download(`${releaseRoot}/SHASUMS256.txt`, sums)
-  const line = (await readFile(sums, 'utf8')).split(/\r?\n/u)
-    .find(candidate => candidate.endsWith(`  ${archiveName}`))
-  if (line === undefined) throw new Error(`desktop runtime: ${archiveName} is absent from Node.js SHASUMS256.txt`)
-  const expected = line.split(/\s+/u)[0]
-  const actual = createHash('sha256').update(await readFile(archive)).digest('hex')
-  if (actual !== expected) throw new Error(`desktop runtime: checksum mismatch for ${archiveName}`)
-
-  const extraction = BUILD_PATHS.nodeExtract
-  rmSync(extraction, { recursive: true, force: true })
-  mkdirSync(extraction, { recursive: true })
-  if (platform === 'win') await extractZip(archive, { dir: extraction })
-  else await extract({ cwd: extraction, file: archive })
-  const source = join(extraction, folder, platform === 'win' ? 'node.exe' : 'bin/node')
-  const destinationRoot = join(RUNTIME_ROOT, 'node')
-  const destination = join(destinationRoot, platform === 'win' ? 'node.exe' : 'node')
-  rmSync(destinationRoot, { recursive: true, force: true })
-  mkdirSync(destinationRoot, { recursive: true })
-  // A fresh write prevents macOS from retaining invalid code-signature vnode state from a tar-extracted Mach-O clone.
-  await pipeline(createReadStream(source), createWriteStream(destination, { flags: 'wx' }))
-  if (platform !== 'win') await chmod(destination, 0o755)
-  const hostPlatform = process.platform === 'win32' ? 'win' : process.platform
-  const hostCanExecute = platform === hostPlatform
-    && (arch === process.arch || (platform === 'darwin' && arch === 'x64' && process.arch === 'arm64'))
-  if (hostCanExecute) {
-    const result = spawnSync(destination, ['--version'], { encoding: 'utf8' })
-    if (result.error !== undefined || result.status !== 0 || result.stdout.trim() !== `v${NODE_VERSION}`) {
-      const detail = result.error?.message ?? result.signal ?? result.stderr.trim()
-      const outcome = detail === '' ? `exit ${String(result.status)}` : detail
-      throw new Error(`desktop runtime: prepared Node.js ${NODE_VERSION} failed executable verification: ${outcome}`)
-    }
-  }
-  rmSync(extraction, { recursive: true, force: true })
-}
 
 function preparePnpm(): string {
   const require = createRequire(import.meta.url)
@@ -92,14 +24,26 @@ function preparePnpm(): string {
 }
 
 async function main(): Promise<void> {
-  const { platform, arch } = target()
-  mkdirSync(DOWNLOAD_ROOT, { recursive: true })
+  const target = resolveDesktopBuildTarget()
+  const platform = target.startsWith('mac-') ? 'darwin' : 'win32'
+  const arch = target.endsWith('arm64') ? 'arm64' : 'x64'
+  const require = createRequire(import.meta.url)
+  const { version } = require('electron/package.json') as { version: string }
+  const archive = await downloadArtifact({ version, platform, arch, artifactName: 'electron', cacheRoot: BUILD_PATHS.downloads })
+  rmSync(BUILD_PATHS.electron, { recursive: true, force: true })
+  await extractZip(archive, { dir: BUILD_PATHS.electron })
+  const executable = join(BUILD_PATHS.electron, platform === 'win32' ? 'electron.exe' : 'Electron.app/Contents/MacOS/Electron')
+  const nodeVersion = execFileSync(executable, ['-p', 'process.versions.node'], {
+    encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
+  }).trim()
+  rmSync(RUNTIME_ROOT, { recursive: true, force: true })
   mkdirSync(RUNTIME_ROOT, { recursive: true })
-  await prepareNode(platform, arch)
   const pnpmVersion = preparePnpm()
+  cpSync(join(import.meta.dirname, 'node-bin'), join(RUNTIME_ROOT, 'bin'), { recursive: true })
+  chmodSync(join(RUNTIME_ROOT, 'bin', 'node'), 0o755)
   writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({
     schemaVersion: 1,
-    node: NODE_VERSION,
+    node: nodeVersion,
     pnpm: pnpmVersion,
   }, undefined, 2)}\n`)
 }

+ 1 - 1
apps/desktop/scripts/runtime-file-policy.ts

@@ -5,7 +5,7 @@
  * Unrecognized assets and target runtime binaries are retained. Paths name the copied
  * node_modules tree, including nested package containers.
  * @param path - Path relative to the production node_modules directory.
- * @param target - Platform and architecture of the bundled Node executable.
+ * @param target - Platform and architecture of the Electron Node runtime.
  * @returns Omission reason, or undefined when the entry must be copied.
  */
 export function desktopRuntimeFileExclusion(

+ 89 - 0
apps/desktop/scripts/smoke-installer-directories.ps1

@@ -0,0 +1,89 @@
+# Exercise the production NSIS directory transaction with private installation directories.
+param(
+  [Parameter(Mandatory)][string]$Makensis,
+  [Parameter(Mandatory)][string]$SevenZip,
+  [scriptblock]$SignExecutable
+)
+$ErrorActionPreference = 'Stop'
+$Makensis = [System.IO.Path]::GetFullPath($Makensis)
+$SevenZip = [System.IO.Path]::GetFullPath($SevenZip)
+$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-directory-smoke-').FullName
+$fixture = Join-Path $PSScriptRoot '../tests/fixtures/installer-directory-smoke.nsi'
+
+function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
+  & $Executable @Arguments | Out-Host
+  if ($LASTEXITCODE -ne 0) { throw "$Executable exited with $LASTEXITCODE" }
+}
+
+try {
+  $payload = Join-Path $scratch 'payload'
+  New-Item -ItemType Directory -Path $payload | Out-Null
+  [System.IO.File]::WriteAllText((Join-Path $payload 'asset.txt'), 'new asset')
+  $deepPayload = Join-Path $payload (('nested-' * 20) + '\asset-' + ('x' * 60) + '.txt')
+  [IO.Directory]::CreateDirectory((Split-Path $deepPayload -Parent)) | Out-Null
+  [IO.File]::WriteAllText($deepPayload, 'long path asset')
+  $archive = Join-Path $scratch 'payload.7z'
+  Invoke-Checked $SevenZip @('a', '-bd', '-t7z', $archive, "$payload/*")
+  $brokenArchive = Join-Path $scratch 'broken.7z'
+  [System.IO.File]::WriteAllText($brokenArchive, 'invalid archive')
+
+  foreach ($mode in @('first', 'upgrade', 'locked', 'missing-stage', 'broken', 'cancelled')) {
+    $caseRoot = Join-Path $scratch $mode
+    $target = Join-Path $caseRoot 'Application'
+    New-Item -ItemType Directory -Path $caseRoot | Out-Null
+    if ($mode -ne 'first') {
+      New-Item -ItemType Directory -Path $target | Out-Null
+      [System.IO.File]::WriteAllText((Join-Path $target 'asset.txt'), 'old asset')
+      [System.IO.File]::WriteAllText((Join-Path $target 'obsolete.txt'), 'old only')
+      $oldDeep = Join-Path $target ([IO.Path]::GetRelativePath($payload, $deepPayload))
+      [IO.Directory]::CreateDirectory((Split-Path $oldDeep -Parent)) | Out-Null
+      [IO.File]::WriteAllText($oldDeep, 'old long path asset')
+    }
+    $probe = Join-Path $caseRoot 'probe.exe'
+    $caseArchive = if ($mode -eq 'broken') { $brokenArchive } else { $archive }
+    $compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip")
+    if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' }
+    if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' }
+    Invoke-Checked $Makensis ($compileArgs + $fixture)
+    if ($null -ne $SignExecutable) { & $SignExecutable $probe }
+    $handle = $null
+    $process = $null
+    try {
+      if ($mode -eq 'locked') {
+        $handle = [System.IO.File]::Open((Join-Path $target 'asset.txt'), 'Open', 'Read', 'Read')
+      }
+      $process = Start-Process -FilePath $probe -ArgumentList '/S' -WindowStyle Hidden -PassThru
+      if (-not $process.WaitForExit(60000)) {
+        $process.Kill($true)
+        $process.WaitForExit()
+        throw "Directory smoke $mode did not exit within 60 seconds"
+      }
+      $expectedExit = if ($mode -in @('first', 'upgrade')) { 0 } else { 2 }
+      if ($process.ExitCode -ne $expectedExit) {
+        throw "Directory smoke $mode exited with $($process.ExitCode), expected $expectedExit"
+      }
+    } finally {
+      if ($null -ne $handle) { $handle.Dispose() }
+      if ($null -ne $process) { $process.Dispose() }
+    }
+    $expectedAsset = if ($mode -in @('first', 'upgrade')) { 'new asset' } else { 'old asset' }
+    if ([System.IO.File]::ReadAllText((Join-Path $target 'asset.txt')) -ne $expectedAsset) {
+      throw "Directory smoke $mode changed the wrong version"
+    }
+    $expectObsolete = $mode -notin @('first', 'upgrade')
+    if ((Test-Path -LiteralPath (Join-Path $target 'obsolete.txt')) -ne $expectObsolete) {
+      throw "Directory smoke $mode did not preserve the expected old-only file state"
+    }
+    if (@(Get-ChildItem -LiteralPath $caseRoot -Directory | Where-Object Name -ne 'Application').Count -ne 0) {
+      throw "Directory smoke $mode left transaction directories behind"
+    }
+    Write-Output "Directory replacement smoke passed: $mode"
+  }
+} finally {
+  $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + '\'
+  $resolvedScratch = [System.IO.Path]::GetFullPath($scratch)
+  if (-not $resolvedScratch.StartsWith($tempRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
+    throw "Refusing cleanup outside the temporary root: $resolvedScratch"
+  }
+  Remove-Item -LiteralPath $resolvedScratch -Recurse -Force
+}

+ 1 - 1
apps/desktop/scripts/smoke-runtime.ts

@@ -10,7 +10,7 @@ import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
 /**
  * Prove the final resource tree boots and serves its matching Web frontend.
  * @param root - Materialized dsh resources.
- * @param node - Prepared target Node executable.
+ * @param node - Prepared target Electron executable.
  * @param runtime - Verified resource descriptor.
  */
 export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise<void> {

+ 2 - 9
apps/desktop/scripts/smoke-windows.ps1

@@ -16,20 +16,13 @@ function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
 }
 
 try {
+  & (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') -Makensis $Makensis -SevenZip $SevenZip
   $previousRunAsNode = $env:ELECTRON_RUN_AS_NODE
   try {
     $env:ELECTRON_RUN_AS_NODE = '1'
     Invoke-Checked $electron @((Join-Path $fixtureRoot 'owned-directory-smoke.mjs'))
   } finally { $env:ELECTRON_RUN_AS_NODE = $previousRunAsNode }
 
-  $cleanupExe = Join-Path $scratch 'cleanup.exe'
-  $cleanupResult = Join-Path $scratch 'cleanup.txt'
-  Invoke-Checked $Makensis @('/V2', "/DOUTPUT_FILE=$cleanupExe", "/DRESULT_FILE=$cleanupResult", (Join-Path $fixtureRoot 'installer-cleanup-smoke.nsi'))
-  Invoke-Checked $cleanupExe @('/S')
-  if ((Get-Content -LiteralPath $cleanupResult -Raw) -ne 'scratch removed; archive, plugin, rollback, registers and error flags preserved') {
-    throw 'NSIS cleanup did not preserve its sentinels'
-  }
-
   $payload = Join-Path $scratch 'payload'
   New-Item -ItemType Directory -Path $payload | Out-Null
   [System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime')
@@ -56,7 +49,7 @@ try {
       throw "Unexpected NSIS $mode replacement result"
     }
   }
-  Write-Output 'Electron cleanup and NSIS cleanup/replacement smokes passed.'
+  Write-Output 'Electron cleanup and NSIS directory/replacement smokes passed.'
 } finally {
   $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
   $resolvedScratch = [System.IO.Path]::GetFullPath($scratch)

+ 18 - 0
apps/desktop/scripts/windows-directory-installer.d.mts

@@ -0,0 +1,18 @@
+/** Types for the pinned NSIS directory installation adapter. */
+
+/**
+ * Replace payload installation while preserving registration and uninstall UI.
+ * @param source Pinned electron-builder installSection.nsh contents.
+ * @returns Section with staged directory replacement.
+ */
+export function directoryInstallSection(source: string): string
+
+/**
+ * Clean staged directories on upstream Quit paths, including silent installers.
+ * @param source Pinned NSIS helper source.
+ * @returns Helper with cleanup before installer exits.
+ */
+export function directoryInstallerExits(source: string): string
+
+/** Install the build-only adapter while retaining signed uninstaller generation. */
+export function installWindowsDirectoryInstaller(): void

+ 86 - 0
apps/desktop/scripts/windows-directory-installer.mjs

@@ -0,0 +1,86 @@
+/** Adapt the pinned NSIS template to stage and rename complete application directories. */
+import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+
+const require = createRequire(import.meta.url)
+const templates = join(dirname(require.resolve('app-builder-lib/package.json')), 'templates/nsis')
+const patched = Symbol.for('@deepseek-ai/dsh-desktop/directory-installer')
+
+function replaceOnce(source, before, after) {
+  if (source.split(before).length !== 2) throw new Error(`Desktop NSIS template changed: ${before}`)
+  return source.replace(before, after)
+}
+
+/**
+ * Preserve upstream registration and uninstall UI while replacing payload installation.
+ * @param {string} source - Pinned electron-builder installSection.nsh contents.
+ * @returns {string} Section with staging before shutdown and directory promotion before registration.
+ */
+export function directoryInstallSection(source) {
+  let result = source.replaceAll('\r\n', '\n')
+  result = replaceOnce(result, '!include installer.nsh', `!include installer.nsh
+!macroundef extractUsing7za
+!macro extractUsing7za FILE
+  !insertmacro dshExtractPayload "\${FILE}"
+!macroend
+!macroundef uninstallOldVersion
+!macro uninstallOldVersion ROOT_KEY
+  !insertmacro readReg $R4 "\${ROOT_KEY}" "\${INSTALL_REGISTRY_KEY}" InstallLocation
+  \${If} $R4 == $INSTDIR
+    StrCpy $R0 0
+    ClearErrors
+  \${Else}
+    Push "\${ROOT_KEY}"
+    Call uninstallOldVersion
+  \${EndIf}
+!macroend`)
+  result = replaceOnce(result, '!insertmacro setLinkVars', `!insertmacro setLinkVars
+!insertmacro dshStageApplication`)
+  result = replaceOnce(result, '!insertmacro installApplicationFiles', 'Call dshPromoteDirectories\nIfErrors 0 +4\n  SetErrorLevel 2\n  MessageBox MB_OK|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDOK\n  Quit')
+  result = replaceOnce(result, '!ifdef UNINSTALLER_ICON\n  File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}"\n!endif\n', '')
+  // The staging macro uses the upstream installer macro, including its signed uninstaller.
+  return result
+}
+
+/**
+ * Clean staged directories on upstream Quit paths, including silent installers.
+ * @param {string} source - Pinned NSIS helper source.
+ * @returns {string} Helper with cleanup before installer exits.
+ */
+export function directoryInstallerExits(source) {
+  return source.replaceAll(/^(\s*)Quit\s*$/gm, '$1!ifndef BUILD_UNINSTALLER\n$1Call dshCleanupDirectories\n$1!endif\n$1Quit')
+}
+
+/** Install the build-only NSIS adapter without replacing electron-builder's signed bootstrap path. */
+export function installWindowsDirectoryInstaller() {
+  // The package entry initializes the platform classes before their mutually dependent leaves.
+  require('app-builder-lib')
+  const { NsisTarget } = require('app-builder-lib/out/targets/nsis/NsisTarget.js')
+  const { getPath7za } = require('app-builder-lib/out/toolsets/7zip.js')
+  const prototype = NsisTarget.prototype
+  if (prototype[patched]) return
+  prototype[patched] = true
+  const compute = prototype.computeFinalScript
+  prototype.computeFinalScript = async function (source, ...args) {
+    const directory = join(this.outDir, '.nsis-directory-installer')
+    await mkdir(directory, { recursive: true })
+    const section = join(directory, 'installSection.nsh')
+    await writeFile(section, directoryInstallSection(await readFile(join(templates, 'installSection.nsh'), 'utf8')))
+    const sourceTool = await getPath7za()
+    const tool = join(directory, '7za.exe')
+    await copyFile(sourceTool, tool)
+    await this.packager.signIf(tool)
+    let adapted = replaceOnce(source, '!include "installSection.nsh"', `!include "${section}"`)
+    for (const helper of ['allowOnlyOneInstallerInstance.nsh', 'installUtil.nsh']) {
+      const path = join(directory, helper)
+      await writeFile(path, directoryInstallerExits(await readFile(join(templates, 'include', helper), 'utf8')))
+      adapted = replaceOnce(adapted, `!include "${helper}"`, `!include "${path}"`)
+    }
+    const uninstaller = join(directory, 'uninstaller.nsh')
+    await writeFile(uninstaller, replaceOnce(await readFile(join(templates, 'uninstaller.nsh'), 'utf8'),
+      'RMDir /r $INSTDIR', 'RMDir /r "\\\\?\\$INSTDIR"'))
+    adapted = replaceOnce(adapted, '!include "uninstaller.nsh"', `!include "${uninstaller}"`)
+    return `!define DSH_SEVENZIP_PATH "${tool}"\n!define DSH_SEVENZIP_LICENSE_DIR "${dirname(dirname(sourceTool))}"\n${await compute.call(this, adapted, ...args)}`
+  }
+}

+ 1 - 1
apps/desktop/scripts/windows-sign.d.mts

@@ -15,7 +15,7 @@ export function buildWindowsSigningEnvironment(environment: NodeJS.ProcessEnv, i
 }): NodeJS.ProcessEnv
 
 /**
- * Create the electron-builder hook for a hardware-backed Windows code-signing certificate.
+ * Serialize hardware-token signing and stop all queued tasks after the first failure.
  *
  * @param options Release signing configuration.
  * @returns The signing hook.

+ 38 - 34
apps/desktop/scripts/windows-sign.mjs

@@ -134,7 +134,7 @@ export function buildWindowsSigningEnvironment(environment, input) {
 }
 
 /**
- * Create the electron-builder hook for a SafeNet-backed Windows code-signing certificate.
+ * Serialize SafeNet signing and stop all queued tasks after the first failure.
  *
  * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration.
  * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>} The signing hook.
@@ -146,39 +146,43 @@ export function createWindowsTokenSigner(options) {
   const commandInterpreter = options.commandInterpreter
     ?? process.env.ComSpec
     ?? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
-  return async (configuration) => {
-    if (configuration.hash !== 'sha256') {
-      throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`)
-    }
-    await repairDanglingAuthenticodeDirectory(configuration.path)
-    const secrets = [tokenPin]
-    let result
-    try {
-      result = await execFileAsync(commandInterpreter, [
-        '/d',
-        '/v:off',
-        '/c',
-        WINDOWS_SIGN_SCRIPT,
-      ], {
-        cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY,
-        env: buildWindowsSigningEnvironment(process.env, {
-          certificateFile,
-          signTool,
-          path: configuration.path,
-          isNest: configuration.isNest,
-          tokenPin,
-          keyContainer,
-        }),
-        windowsHide: false,
-      })
-    }
-    catch (error) {
-      throw createRedactedWindowsSigningError(error, configuration.path, secrets)
-    }
-    const stdout = redactedSigningOutput(result.stdout, secrets)
-    const stderr = redactedSigningOutput(result.stderr, secrets)
-    if (stdout !== '') process.stdout.write(stdout)
-    if (stderr !== '') process.stderr.write(stderr)
+  let pending = Promise.resolve()
+  return (configuration) => {
+    pending = pending.then(async () => {
+      if (configuration.hash !== 'sha256') {
+        throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`)
+      }
+      await repairDanglingAuthenticodeDirectory(configuration.path)
+      const secrets = [tokenPin]
+      let result
+      try {
+        result = await execFileAsync(commandInterpreter, [
+          '/d',
+          '/v:off',
+          '/c',
+          WINDOWS_SIGN_SCRIPT,
+        ], {
+          cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY,
+          env: buildWindowsSigningEnvironment(process.env, {
+            certificateFile,
+            signTool,
+            path: configuration.path,
+            isNest: configuration.isNest,
+            tokenPin,
+            keyContainer,
+          }),
+          windowsHide: true,
+        })
+      }
+      catch (error) {
+        throw createRedactedWindowsSigningError(error, configuration.path, secrets)
+      }
+      const stdout = redactedSigningOutput(result.stdout, secrets)
+      const stderr = redactedSigningOutput(result.stderr, secrets)
+      if (stdout !== '') process.stdout.write(stdout)
+      if (stderr !== '') process.stderr.write(stderr)
+    })
+    return pending
   }
 }
 

+ 6 - 4
apps/desktop/src/host-process.ts

@@ -1,7 +1,8 @@
-/** Bundled Node child lifecycle for the shared Web application. */
+/** Electron Node-mode child lifecycle for the shared Web application. */
 
 import { spawn, type ChildProcess } from 'node:child_process'
 import { join } from 'node:path'
+import { desktopNodeEnvironment } from './node-environment.ts'
 
 interface ReadyEvent {
   readonly type: 'ready'
@@ -48,7 +49,7 @@ export interface DesktopHostReady {
   readonly url: string
 }
 
-/** One Web backend running under the bundled upstream Node.js executable. */
+/** One Web backend running under the Electron executable in Node mode. */
 export class DesktopHostProcess {
   private child: ChildProcess | undefined
   private readyResolve!: (ready: DesktopHostReady) => void
@@ -63,7 +64,7 @@ export class DesktopHostProcess {
   private stopping = false
 
   /**
-   * @param node - Absolute bundled upstream Node.js executable.
+   * @param node - Absolute Electron executable in Node mode.
    * @param runtimeDir - Immutable packages carried by the current application.
    * @param projectDir - Desktop plugin profile and child working directory.
    * @param inspectPort - Optional loopback inspector port for workspace development.
@@ -87,13 +88,14 @@ export class DesktopHostProcess {
     if (this.child !== undefined) return this.readyPromise
     const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
     const child = spawn(this.node, [
+      '--expose-internals',
       ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
       entry,
       this.runtimeDir,
       this.projectDir,
     ], {
       cwd: this.projectDir,
-      env: this.environment,
+      env: desktopNodeEnvironment(this.node, undefined, this.environment),
       stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
     })
     this.child = child

+ 6 - 4
apps/desktop/src/main.ts

@@ -16,6 +16,7 @@ import {
 import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
+import { desktopNodeEnvironment } from './node-environment.ts'
 import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts'
 import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
@@ -63,6 +64,7 @@ const MIME: Readonly<Record<string, string>> = {
 }
 
 interface RuntimeResources {
+  readonly nodeBin: string
   readonly node: string
   readonly pnpm: string
   readonly dsh: string
@@ -70,14 +72,14 @@ interface RuntimeResources {
 
 function runtimeResources(): RuntimeResources {
   const development = !app.isPackaged
-  const node = (development ? process.env.DSH_DESKTOP_NODE_BINARY : undefined)
-    ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node')
+  const node = process.execPath
+  const nodeBin = development ? join(app.getAppPath(), 'scripts', 'node-bin') : join(process.resourcesPath, 'runtime', 'bin')
   const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined)
     ?? (development ? join(app.getAppPath(), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs')
       : join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
   const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined)
     ?? (development ? join(app.getAppPath(), '.desktop-build', 'development', 'project') : join(process.resourcesPath, 'dsh'))
-  return { node, pnpm, dsh }
+  return { node, nodeBin, pnpm, dsh }
 }
 
 function developmentHostInspectPort(enabled: boolean): number | undefined {
@@ -215,7 +217,7 @@ async function main(): Promise<void> {
   const backend = new DesktopBackendController((onFailure) => {
     const hostInspectPort = developmentHostInspectPort(development)
     const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
-      hostInspectPort, process.env, onFailure)
+      hostInspectPort, desktopNodeEnvironment(resources.node, resources.nodeBin, process.env), onFailure)
     return {
       start: async () => {
         const ready = await host.start()

+ 19 - 0
apps/desktop/src/node-environment.ts

@@ -0,0 +1,19 @@
+/** Electron's Node mode inherited by the Host, pnpm, and their subprocesses. */
+
+import { delimiter } from 'node:path'
+
+/**
+ * Select Electron's Node mode and the shell launcher used by package scripts.
+ * @param executable - Electron executable running the application.
+ * @param bin - Directory containing the node shell launcher.
+ * @param environment - Caller environment preserved for plugin execution.
+ * @returns Environment for a Node-mode child process.
+ */
+export function desktopNodeEnvironment(executable: string, bin: string | undefined, environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
+  return {
+    ...environment,
+    ELECTRON_RUN_AS_NODE: '1',
+    DSH_DESKTOP_NODE_EXECUTABLE: executable,
+    ...(bin === undefined ? {} : { PATH: `${bin}${delimiter}${environment.PATH ?? ''}` }),
+  }
+}

+ 6 - 4
apps/desktop/src/project-manager.ts

@@ -15,7 +15,8 @@ import {
   writeFileSync,
   writeSync,
 } from 'node:fs'
-import { delimiter, dirname, join } from 'node:path'
+import { join } from 'node:path'
+import { desktopNodeEnvironment } from './node-environment.ts'
 import {
   DESKTOP_HOST_PACKAGE,
   desktopCorePackageOverrides,
@@ -42,6 +43,7 @@ export interface DesktopPluginRecord {
 /** Exact executables the desktop shell bundles. */
 export interface DesktopRuntimeExecutables {
   readonly node: string
+  readonly nodeBin?: string
   readonly pnpm: string
   readonly dsh: string
 }
@@ -106,7 +108,7 @@ export class DesktopProjectManager {
 
   /**
    * @param paths - Electron-owned package state and reserved desktop profile paths.
-   * @param runtime - absolute bundled Node.js and pnpm entry paths.
+   * @param runtime - absolute Electron and pnpm entry paths.
    */
   constructor(
     readonly paths: DesktopPaths,
@@ -220,9 +222,9 @@ export class DesktopProjectManager {
 
   private async runPnpm(projectDir: string, args: readonly string[]): Promise<void> {
     await new Promise<void>((settle, reject) => {
-      const child = spawn(this.runtime.node, [this.runtime.pnpm, ...args], {
+      const child = spawn(this.runtime.node, ['--expose-internals', this.runtime.pnpm, ...args], {
         cwd: projectDir,
-        env: { ...process.env, PATH: `${dirname(this.runtime.node)}${delimiter}${process.env.PATH ?? ''}` },
+        env: desktopNodeEnvironment(this.runtime.node, this.runtime.nodeBin, process.env),
         stdio: ['ignore', 'pipe', 'pipe'],
       })
       let failure: Error | undefined

+ 1 - 1
apps/desktop/tests/desktop-build-paths.spec.ts

@@ -17,7 +17,7 @@ describe('desktop build paths', () => {
       'packageSet',
       'dsh',
       'dshPnpm',
-      'nodeExtract',
+      'electron',
       'packedDsh',
       'packedVendor',
       'packedLandlock',

+ 0 - 70
apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi

@@ -1,70 +0,0 @@
-; Compile with OUTPUT_FILE and RESULT_FILE in a private test directory, then run silently.
-Unicode true
-RequestExecutionLevel user
-SilentInstall silent
-Name "Desktop installer cleanup smoke"
-OutFile "${OUTPUT_FILE}"
-!include "..\..\scripts\installer.nsh"
-
-Section
-  InitPluginsDir
-  SetOutPath "$PLUGINSDIR"
-  CreateDirectory "$PLUGINSDIR\7z-out\nested"
-  FileOpen $1 "$PLUGINSDIR\7z-out\nested\extracted-file" w
-  FileWrite $1 "remove"
-  FileClose $1
-  FileOpen $1 "$PLUGINSDIR\archive.7z" w
-  FileWrite $1 "archive sentinel"
-  FileClose $1
-  FileOpen $1 "$PLUGINSDIR\plugin.dll" w
-  FileWrite $1 "plugin sentinel"
-  FileClose $1
-  CreateDirectory "$PLUGINSDIR\old-install"
-  FileOpen $1 "$PLUGINSDIR\old-install\sentinel" w
-  FileWrite $1 "rollback sentinel"
-  FileClose $1
-
-  StrCpy $0 "register sentinel"
-  ClearErrors
-  !insertmacro customInstall
-  IfErrors failed
-  StrCmp $0 "register sentinel" 0 failed
-  StrCmp $OUTDIR $PLUGINSDIR 0 failed
-  IfFileExists "$PLUGINSDIR\7z-out\*.*" failed
-
-  FileOpen $1 "$PLUGINSDIR\archive.7z" r
-  FileRead $1 $2
-  FileClose $1
-  StrCmp $2 "archive sentinel" 0 failed
-  FileOpen $1 "$PLUGINSDIR\plugin.dll" r
-  FileRead $1 $2
-  FileClose $1
-  StrCmp $2 "plugin sentinel" 0 failed
-  FileOpen $1 "$PLUGINSDIR\old-install\sentinel" r
-  FileRead $1 $2
-  FileClose $1
-  StrCmp $2 "rollback sentinel" 0 failed
-
-  CreateDirectory "$PLUGINSDIR\7z-out"
-  SetErrors
-  !insertmacro customInstall
-  IfErrors +2
-    Goto failed
-  IfFileExists "$PLUGINSDIR\7z-out\*.*" failed
-
-  ClearErrors
-  !insertmacro customInstall
-  IfErrors failed
-  FileOpen $1 "${RESULT_FILE}" w
-  FileWrite $1 "scratch removed; archive, plugin, rollback, registers and error flags preserved"
-  FileClose $1
-  SetErrorLevel 0
-  Quit
-
-  failed:
-  FileOpen $1 "${RESULT_FILE}" w
-  FileWrite $1 "cleanup smoke failed"
-  FileClose $1
-  SetErrorLevel 1
-  Quit
-SectionEnd

+ 36 - 0
apps/desktop/tests/fixtures/installer-directory-smoke.nsi

@@ -0,0 +1,36 @@
+; Compile against the production directory transaction using a private payload and target.
+Unicode true
+RequestExecutionLevel user
+SilentInstall silent
+Name "Desktop directory replacement smoke"
+OutFile "${OUTPUT_FILE}"
+LoadLanguageFile "${NSISDIR}\Contrib\Language files\English.nlf"
+LangString decompressionFailed ${LANG_ENGLISH} "Payload extraction failed"
+
+!macro installApplicationFiles
+  !insertmacro dshExtractPayload "${PAYLOAD_FILE}"
+!macroend
+!include "..\..\scripts\installer-directories.nsh"
+
+Section
+  InitPluginsDir
+  StrCpy $INSTDIR "${TARGET_DIR}"
+  !insertmacro dshStageApplication
+  !ifdef CANCELLED
+    SetErrorLevel 2
+    Call dshCleanupDirectories
+    Quit
+  !endif
+  !ifdef MISSING_STAGE
+    ; A missing source makes the second rename fail after the old directory moved.
+    RMDir /r "\\?\$dshNewDirectory"
+  !endif
+  Call dshPromoteDirectories
+  IfErrors failed
+  !insertmacro dshFinishDirectories
+  SetErrorLevel 0
+  Quit
+  failed:
+  SetErrorLevel 2
+  Quit
+SectionEnd

+ 39 - 6
apps/desktop/tests/fixtures/runtime-payload-smoke.mjs

@@ -1,32 +1,64 @@
-/** Exercise filtered Desktop native and HTML dependencies under its bundled Node. */
+/** Exercise filtered Desktop native and HTML dependencies under its Electron Node runtime. */
 
 import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
 import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
 import { rm } from 'node:fs/promises'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
-import { join, resolve } from 'node:path'
+import { delimiter, dirname, join, resolve } from 'node:path'
 
 const runtime = process.argv[2]
 assert.ok(runtime, 'Pass the filtered resources/dsh directory')
 const root = resolve(runtime)
 const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), 'utf8'))
-assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the bundled Node version')
+assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the Electron Node runtime version')
 assert.equal(process.platform, descriptor.platform)
 assert.equal(process.arch, descriptor.arch)
 const requireRuntime = createRequire(join(root, 'package.json'))
 const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-'))
 
+/** Run a package script with only the shipped node launcher available on PATH. */
+function checkPnpm() {
+  const resources = dirname(root)
+  const bin = join(resources, 'runtime', 'bin')
+  const pnpm = join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')
+  writeFileSync(join(scratch, 'package.json'), JSON.stringify({
+    name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' },
+  }))
+  writeFileSync(join(scratch, 'check.cjs'), `
+const assert = require('node:assert/strict')
+assert.equal(process.execPath, ${JSON.stringify(process.execPath)})
+assert.ok(process.versions.electron)
+assert.ok(process.execArgv.includes('--expose-internals'))
+assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function')
+console.log('desktop-node-script-ok')
+`)
+  const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec)$/iu.test(name)))
+  const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin'
+  const output = execFileSync(process.execPath, ['--expose-internals', pnpm, 'run', 'check'], {
+    cwd: scratch, encoding: 'utf8', timeout: 45_000,
+    env: { ...environment, ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
+      PATH: `${bin}${delimiter}${systemBin}`, HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch },
+  })
+  assert.match(output, /desktop-node-script-ok/u)
+}
+
 /** Spawn only a fixed Node program and await the terminal's drained exit event. */
 async function checkPty() {
   const pty = requireRuntime('node-pty')
   const script = join(scratch, 'pty.cjs')
   writeFileSync(script, "process.stdout.write('runtime-payload-pty-ok\\n')\n", { flag: 'wx', mode: 0o600 })
   const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
-    /^(?:path|systemroot|windir|comspec)$/iu.test(name)
+    /^(?:path|systemroot|windir|comspec|ELECTRON_RUN_AS_NODE)$/iu.test(name)
   )))
   Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch })
-  const terminal = pty.spawn(process.execPath, [script], { cwd: scratch, env, cols: 80, rows: 24 })
+  env.DSH_DESKTOP_NODE_EXECUTABLE = process.execPath
+  env.PATH = `${join(dirname(root), 'runtime', 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}`
+  // A Windows GUI executable needs a console-owning shell when launched inside ConPTY.
+  const executable = process.platform === 'win32' ? process.env.ComSpec : process.execPath
+  const args = process.platform === 'win32' ? ['/d', '/c', 'node', script] : [script]
+  const terminal = pty.spawn(executable, args, { cwd: scratch, env, cols: 80, rows: 24 })
   let output = ''
   let exited = false
   let timedOut = false
@@ -105,6 +137,7 @@ function checkHtml() {
 }
 
 try {
+  checkPnpm()
   checkKoffi()
   await checkSharp()
   checkHtml()
@@ -117,5 +150,5 @@ try {
 // Natural event-loop drain includes node-pty's worker and console-list helper teardown.
 process.once('beforeExit', () => {
   console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch,
-    koffi: true, sharp: true, html: true, pty: true }))
+    koffi: true, sharp: true, html: true, pty: true, pnpm: true }))
 })

+ 2 - 2
apps/desktop/tests/host-process.spec.ts

@@ -24,7 +24,7 @@ const server = createServer((request, response) => {
     return
   }
   response.setHeader('content-type', 'application/json')
-  response.end(JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH, registry: process.env.NPM_CONFIG_REGISTRY, nodeOptions: process.env.NODE_OPTIONS}))
+  response.end(JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH, registry: process.env.NPM_CONFIG_REGISTRY, nodeOptions: process.env.NODE_OPTIONS, runAsNode: process.env.ELECTRON_RUN_AS_NODE, internals: process.execArgv.includes('--expose-internals')}))
 })
 server.listen(0, '127.0.0.1', () => {
   process.send({ type: 'ready', url: 'http://127.0.0.1:' + server.address().port + '/?token=fixture' })
@@ -126,7 +126,7 @@ describe('desktop host process', () => {
     })
     const { url } = await host.start()
     const response = await fetch(url)
-    expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile), nodePath: '/custom', registry: 'https://registry.example.test/', nodeOptions: '--no-warnings' })
+    expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile), nodePath: '/custom', registry: 'https://registry.example.test/', nodeOptions: '--no-warnings', runAsNode: '1', internals: true })
   })
 
   it.each([

+ 1 - 1
apps/desktop/tests/macos-signature.spec.ts

@@ -84,7 +84,7 @@ describe('desktop macOS release signature', () => {
     expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true)
     expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true)
     for (const path of [
-      '/App.app/Contents/Resources/runtime/node/node',
+      '/App.app/Contents/MacOS/DeepSeek Harness',
       '/App.app/Contents/Resources/runtime/pnpm/addon.node',
       '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib',
       '/App.app/Contents/Frameworks/Electron.framework',

+ 2 - 3
apps/desktop/tests/main-startup.spec.ts

@@ -138,7 +138,6 @@ beforeEach(() => {
   vi.clearAllMocks()
   vi.useFakeTimers()
   harness.reset()
-  vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node')
   vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
   vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
   vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
@@ -358,7 +357,7 @@ describe('desktop main startup', () => {
     await Promise.all([retry, secondRetry, harness.navigated.promise])
     expect(harness.applyRelease).toHaveBeenCalledTimes(1)
     expect(harness.hosts[0]).toMatchObject({
-      node: join('desktop-test-resources', 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node'),
+      node: process.execPath,
       runtime: join('desktop-test-resources', 'dsh'),
       profile: 'desktop-test-profile',
     })
@@ -376,7 +375,7 @@ describe('desktop main startup', () => {
     harness.prepared.resolve()
     await harness.hostStarted.promise
     const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project')
-    expect(harness.hosts[0]).toMatchObject({ node: 'test-node', runtime: project, profile: 'desktop-test-profile' })
+    expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project, profile: 'desktop-test-profile' })
     expect(harness.applyRelease).toHaveBeenCalledOnce()
     harness.hosts[0]!.ready.resolve()
     await harness.navigated.promise

+ 37 - 0
apps/desktop/tests/windows-directory-installer.spec.ts

@@ -0,0 +1,37 @@
+/** The pinned builder template keeps its registration flow around staged directory replacement. */
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
+import { expect, it } from 'vitest'
+import { directoryInstallerExits, directoryInstallSection } from '../scripts/windows-directory-installer.mjs'
+
+const require = createRequire(import.meta.url)
+const section = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')),
+  'templates/nsis/installSection.nsh'), 'utf8')
+
+it.each(['allowOnlyOneInstallerInstance.nsh', 'installUtil.nsh'])('cleans staged files before %s exits', (helper) => {
+  const source = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')), 'templates/nsis/include', helper), 'utf8')
+  const exits = source.match(/^\s*Quit\s*$/gm) ?? []
+  expect(exits.length).toBeGreaterThan(0)
+  const adapted = directoryInstallerExits(source)
+  expect(adapted.match(/Call dshCleanupDirectories/g)).toHaveLength(exits.length)
+  expect(adapted).toContain('!ifndef BUILD_UNINSTALLER')
+})
+
+it('stages before stopping the application and promotes before registering the installation', () => {
+  const result = directoryInstallSection(section)
+  expect(result.indexOf('!insertmacro dshStageApplication')).toBeLessThan(result.indexOf('!insertmacro CHECK_APP_RUNNING'))
+  expect(result.indexOf('Call dshPromoteDirectories')).toBeLessThan(result.indexOf('!insertmacro registryAddInstallInfo'))
+  expect(result).toContain('!insertmacro addStartMenuLink $keepShortcuts')
+  expect(result).toContain('!insertmacro addDesktopLink $keepShortcuts')
+  expect(result).toContain('!insertmacro handleUninstallResult HKEY_CURRENT_USER')
+  expect(result).not.toContain('!insertmacro installApplicationFiles')
+  expect(result).not.toContain('File /oname=uninstallerIcon.ico')
+})
+
+it.each(['!include installer.nsh', '!insertmacro setLinkVars', '!insertmacro installApplicationFiles'])(
+  'rejects a missing or duplicate upstream insertion point: %s', (point) => {
+    expect(() => directoryInstallSection(section.replace(point, ''))).toThrow('Desktop NSIS template changed')
+    expect(() => directoryInstallSection(`${section}\n${point}`)).toThrow('Desktop NSIS template changed')
+  },
+)

+ 32 - 0
apps/desktop/tests/windows-sign.spec.ts

@@ -1,3 +1,4 @@
+import { execFile } from 'node:child_process'
 import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
@@ -11,6 +12,11 @@ import {
   scrubWindowsSigningEnvironment,
 } from '../scripts/windows-sign.mjs'
 
+vi.mock('node:child_process', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:child_process')>()
+  return { ...actual, execFile: vi.fn() }
+})
+
 vi.mock('node:crypto', () => ({
   X509Certificate: class {
     readonly ca = false
@@ -28,6 +34,32 @@ const CERTIFICATE_FILE = 'C:\\release\\server.cer'
 const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd')
 
 describe('Windows token signing', () => {
+  it('stops concurrent and subsequent signing tasks after a PIN failure', async () => {
+    const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-pin-failure-'))
+    try {
+      const certificateFile = join(directory, 'server.cer')
+      const signTool = join(directory, 'signtool.exe')
+      const path = join(directory, 'application.exe')
+      await writeFile(certificateFile, 'code-signing-certificate-fixture')
+      await writeFile(signTool, 'fixture')
+      await writeFile(path, 'fixture')
+      vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => {
+        const callback = args.at(-1) as (error: Error) => void
+        callback(Object.assign(new Error('signing failed'), { stderr: 'SignTool Error: No private key is available.', code: 1 }))
+        return undefined as unknown as ReturnType<typeof execFile>
+      })
+      const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container' })
+      const task = { path, hash: 'sha256', isNest: false }
+      const results = await Promise.allSettled([sign(task), sign(task), sign(task)])
+      expect(results.map(result => result.status)).toEqual(['rejected', 'rejected', 'rejected'])
+      await expect(sign(task)).rejects.toThrow('No private key is available.')
+      expect(execFile).toHaveBeenCalledTimes(1)
+    } finally {
+      vi.mocked(execFile).mockReset()
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
   it('passes only the validated BAT fields to the signing command interpreter', () => {
     expect(buildWindowsSigningEnvironment({
       SystemRoot: 'C:\\Windows',

+ 7 - 4
pnpm-lock.yaml

@@ -502,6 +502,9 @@ importers:
       '@deepseek-ai/dsh-home-paths':
         specifier: workspace:^
         version: link:../../packages/util/home-paths
+      '@electron/get':
+        specifier: ^5.1.0
+        version: 5.1.0
       '@electron/notarize':
         specifier: 2.5.0
         version: 2.5.0
@@ -519,7 +522,7 @@ importers:
         version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
       electron:
         specifier: ^44.0.0
-        version: 44.0.0(supports-color@9.4.0)
+        version: 44.0.0
       electron-builder:
         specifier: ^26.15.3
         version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
@@ -18954,7 +18957,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@electron/get@5.1.0(supports-color@9.4.0)':
+  '@electron/get@5.1.0':
     dependencies:
       debug: 4.4.3(supports-color@9.4.0)
       env-paths: 3.0.0
@@ -22011,10 +22014,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  electron@44.0.0(supports-color@9.4.0):
+  electron@44.0.0:
     dependencies:
       '@electron-internal/extract-zip': 1.0.5
-      '@electron/get': 5.1.0(supports-color@9.4.0)
+      '@electron/get': 5.1.0
       '@types/node': 24.13.3
     transitivePeerDependencies:
       - supports-color