Просмотр исходного кода

Merge fix/bundle-speed into fix/parallel-macos-notarization

07akioni 17 часов назад
Родитель
Сommit
7814ccdfd9
55 измененных файлов с 1985 добавлено и 260 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  2. 18 10
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  3. 18 10
      .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. 11 3
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  6. 11 3
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  8. 29 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  9. 29 0
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  10. 1 1
      THIRD_PARTY_NOTICES.md
  11. 2 2
      apps/desktop/README.i18n.yaml
  12. 32 12
      apps/desktop/README.md
  13. 32 12
      apps/desktop/README.zh.md
  14. 4 1
      apps/desktop/electron-builder.config.d.mts
  15. 16 9
      apps/desktop/electron-builder.config.mjs
  16. 1 0
      apps/desktop/package.json
  17. 2 2
      apps/desktop/renderer/plugin-manager.js
  18. 16 0
      apps/desktop/renderer/startup.css
  19. 23 0
      apps/desktop/renderer/startup.html
  20. 37 0
      apps/desktop/renderer/startup.js
  21. 17 0
      apps/desktop/scripts/installer.nsh
  22. 28 4
      apps/desktop/scripts/package-target.ts
  23. 9 7
      apps/desktop/scripts/prepare-dsh.ts
  24. 40 0
      apps/desktop/scripts/runtime-file-policy.ts
  25. 146 0
      apps/desktop/src/backend-controller.ts
  26. 11 2
      apps/desktop/src/host-process.ts
  27. 10 1
      apps/desktop/src/ipc.ts
  28. 8 0
      apps/desktop/src/locale.ts
  29. 97 95
      apps/desktop/src/main.ts
  30. 26 0
      apps/desktop/src/owned-directory.ts
  31. 21 3
      apps/desktop/src/preload-app.ts
  32. 6 0
      apps/desktop/src/preload.ts
  33. 5 5
      apps/desktop/src/profile-packages.ts
  34. 10 23
      apps/desktop/src/project-manager.ts
  35. 73 23
      apps/desktop/src/runtime-tree.ts
  36. 170 0
      apps/desktop/tests/backend-controller.spec.ts
  37. 70 0
      apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi
  38. 55 0
      apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi
  39. 29 0
      apps/desktop/tests/fixtures/owned-directory-smoke.mjs
  40. 138 0
      apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
  41. 25 1
      apps/desktop/tests/host-process.spec.ts
  42. 25 2
      apps/desktop/tests/macos-signature.spec.ts
  43. 200 0
      apps/desktop/tests/main-startup.spec.ts
  44. 53 0
      apps/desktop/tests/owned-directory.spec.ts
  45. 40 0
      apps/desktop/tests/package-target.spec.ts
  46. 1 1
      apps/desktop/tests/plugin-manager.spec.ts
  47. 1 1
      apps/desktop/tests/plugin-pnpm.spec.ts
  48. 37 0
      apps/desktop/tests/preload-app.spec.ts
  49. 32 3
      apps/desktop/tests/project-manager.spec.ts
  50. 81 0
      apps/desktop/tests/runtime-file-policy.spec.ts
  51. 49 0
      apps/desktop/tests/runtime-tree-read-failure.spec.ts
  52. 40 12
      apps/desktop/tests/runtime-tree.spec.ts
  53. 134 0
      apps/desktop/tests/startup-renderer.spec.ts
  54. 5 8
      apps/desktop/tsdown.config.ts
  55. 1 0
      package.json

+ 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: 7a12cefe2ba8f9cb721a3f7eaf329964813f5bbb
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5c766fc94cd6c88d04bfb2fe13cecc936da17c87
+2026-08-25-electron-desktop-packaging-and-updates.md: b01af48d1cddc061440c6c2c1605f8360e0676d0
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 3affa9d8c663b73c204d9ef23cc9472ef82b0b5c

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

@@ -65,30 +65,38 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
 
 ## Installation and resolution
 
-The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile.
+The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Activation stops the active backend, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile.
 
 The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered.
 
-Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). A desktop plugin contributing `dsh.client` code enters the client graph only after the complete profile passes health checking.
+Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts.
 
 ## Updates and recovery
 
 Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release.
 
-Before opening the product window, startup validates the current bundled runtime and reconciles external plugins according to the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Backend failure opens the Electron-owned recovery window instead of booting a mismatched dsh release.
+The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns the local loading page, direct Host startup, and recovery in the main window. Profile reconciliation follows the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md).
 
 `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations.
 
 ## Security and release policy
 
-Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, and health checking are required before activation.
+Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, and user-only directory permissions are required before activation.
 
-Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
+Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
 
 The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
 
 Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
 
+Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification.
+
+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.
+
 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.
 
 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.
@@ -135,8 +143,8 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 - The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments.
 - The backend and browser application cannot mutate desktop packages.
 - npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`.
-- The active backend and Web UI report the same dsh version and a compatible shell API before the product window opens.
-- Failed installation, health checking, or update leaves the current profile usable or restores `rollback/profile` after restart.
+- The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads.
+- Failed installation, backend activation, or update leaves the current profile usable or restores `rollback/profile` after restart.
 - One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart.
 - Shared `.dsh` data rejects incompatible readers before migration or mutation.
 - No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs.
@@ -149,10 +157,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 
 | Decision | Recommendation |
 |---|---|
-| First launch | Validate bundled production packages and create profile links without installing core dependencies |
+| First launch | Check bundled release metadata and create profile links without installing core dependencies |
 | Desktop profile | One Electron-owned reserved profile for external plugins and shared package links |
 | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path |
-| Activation | Staging project, complete health check, journaled directory replacement, one rollback copy |
+| Activation | Staging project, journaled directory replacement, actual Host startup, one rollback copy |
 | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target |
 | Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation |
 
@@ -160,7 +168,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
 
 Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships.
 
-Updating the bound dsh can invalidate plugin peer dependencies or native modules. pnpm resolution and full-project health checking must reject the staged project before replacing the active one.
+Updating the bound dsh can invalidate plugin peer dependencies or native modules. Dependency validation rejects incompatible metadata before replacement; actual Host startup can fail after replacement and trigger profile rollback.
 
 An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing.
 

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

@@ -65,30 +65,38 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 ## 安装与解析
 
-安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。
+安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。激活过程停止活跃后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。
 
 进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。
 
-核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。提供 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才会进入客户端依赖图
+核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。
 
 ## 更新与恢复
 
 Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
 
-打开产品窗口前,启动过程会验证当前内置运行时,并根据[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)协调外部插件。后端失败会打开 Electron 拥有的恢复窗口,不会启动版本不匹配的 dsh
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。
 
 `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。
 
 ## 安全与发布策略
 
-核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合、仅限用户访问的目录权限和健康检查
+核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限
 
-Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 
 Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
+Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。
+
+本地 Windows 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有 EV Token 时诊断安装问题,也能防止本地测试产物通过发布上传校验。
+
+NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录。Finish 启动应用后,默认退出清理可能与后端的文件读取重叠。[安装器 hook](../../../../apps/desktop/scripts/installer.nsh) 在 `customInstall` 阶段仅删除该解压目录,早于交互和静默启动分支。它保留包归档、插件 DLL、回滚目录、寄存器和错误状态;[原生清理 smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) 检查这些约束。把清理移入安装阶段并不会减少文件系统工作,因此必须分别测量安装总耗时与点击 Finish 到窗口出现的耗时。
+
+安装器不开启直接向应用目录执行 `Nsis7z::Extract`。原生[文件占用探针](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi)会在未报错的情况下留下被占用的旧文件和新资源;暂存后执行的 `CopyFiles` 在相同替换失败时会设置错误标志。Windows 上同一份 737,557,488 字节载荷经过解压、复制和清理耗时 172.625 秒,直接解压耗时 28.031 秒,但每条路径的单次样本不足以支持放弃失败检测。计时不包括注册表修改、旧版删除及解压后的验证,也没有清空系统缓存。桌面专用载荷过滤减少需要复制的文件,同时保留安装器的替换错误处理。这种处理并不承诺完整的安装回滚。
+
 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。
 
 在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。
@@ -135,8 +143,8 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 
 - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
 - 后端与浏览器应用不能修改桌面包。
 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
-- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
-- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。
+- 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
+- 安装、后端激活或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。
 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
 - 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。
@@ -149,10 +157,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 
 
 | 决策 | 建议 |
 |---|---|
-| 首次启动 | 验证内置生产包并创建 profile 链接,不安装核心依赖 |
+| 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 |
 | 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 |
 | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
-| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 |
+| 激活 | 暂存项目、记录式目录替换、实际 Host 启动和一个回滚副本 |
 | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
 | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh |
 
@@ -160,7 +168,7 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 
 
 插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。
 
-更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目
+更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。依赖验证在替换前拒绝不兼容的元数据;实际 Host 启动可能在替换后失败,并触发 profile 回滚
 
 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
 

+ 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: 8278107fb3c37aca909541886d9379a1bfd64bac
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: b29eb1e6cbe19d4a5b4ea23a5d6159cfd3973bcd
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a662af570c017d6fd3d38cf066c0a02e0049a84f
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 6284949f6747cbe47a5de149ff598354c9b9cc58

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

@@ -20,6 +20,10 @@ Desktop has not been released. This is its first installation format; there are
 
 The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization.
 
+The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, clears inherited `NODE_OPTIONS`, and does not enable source mapping. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
+
+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.
+
 Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
 
 External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient.
@@ -28,16 +32,20 @@ The profile manifest records exact installed plugin dependencies separately from
 
 ## Transactions and upgrades
 
-First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, validates enabled peers, and boots the staged backend. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile.
+First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, and validates enabled peers. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile.
+
+Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary staging and backend replacement. Transaction cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
 
 Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction.
 
 The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes.
 
-An Electron-owned recovery window remains available when the backend cannot start. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity.
+The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window, replacing staged backend probes. Users can open plugin management to update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity.
 
 ## Alternatives considered
 
+Full runtime verification belongs to packaging. Startup reads the descriptor, validates release and target compatibility, checks shared manifests and required Host entries, and uses the recorded runtime identity for profile reuse. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory.
+
 - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
 - **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions.
 - **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.
@@ -46,6 +54,6 @@ An Electron-owned recovery window remains available when the backend cannot star
 
 ## Consequences
 
-Core package installation is absent from first launch and compatible upgrades. Runtime integrity verification and a complete backend health check still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails.
+Core package installation is absent from first launch and compatible upgrades. Metadata checks and backend loading still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails.
 
 The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them.

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

@@ -20,6 +20,10 @@ Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 se
 
 资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。
 
+[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,清除继承的 `NODE_OPTIONS`,且不开启源码映射。经过审核的插件生命周期构建面向原生依赖,不执行任意 TypeScript 编译。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
+
+包专用排除项包括 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。
+
 dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。
 
 外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。
@@ -28,16 +32,20 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 
 
 ## 事务与升级
 
-首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,验证已启用的 peer,并启动 staging 后端。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。
+首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,并验证已启用的 peer。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。
+
+共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 staging 和后端替换。事务清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
 
 依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。
 
 事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。
 
-后端无法启动时,Electron 拥有的恢复窗口仍可使用。用户可以更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host 和主窗口恢复,取代 staging 后端探针。用户可以打开插件管理,更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。
 
 ## 考虑过的替代方案
 
+完整运行时验证属于打包流程。启动读取描述文件,验证发布版本与目标平台的兼容性,检查共享包 manifest 和必要的 Host 入口,并使用记录的运行时身份复用 profile。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。
+
 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
 - **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。
 - **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
@@ -46,6 +54,6 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 
 
 ## 影响
 
-首次启动和兼容升级不安装核心包。运行时完整性验证和完整后端健康检查仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。
+首次启动和兼容升级不安装核心包。元数据检查和后端加载仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。
 
 [Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-09-desktop-immediate-window-and-direct-start.md
+2026-09-09-desktop-immediate-window-and-direct-start.md: 19443bb22e5fa3e50ce027b474cd439928db9992
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 0d79638d331bb8b66a80f37f532b36ebb1e65316

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

@@ -0,0 +1,29 @@
+# Agent Note: Show the Desktop window before starting the Host
+
+Status: implemented
+
+English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)
+
+## Problem
+
+Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process.
+
+## Decision
+
+Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit.
+
+Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes.
+
+This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.
+
+## Alternatives considered
+
+**Keep a complete staged health check.** It can reject a startup failure before replacing the active profile, but executes plugin initialization twice and cannot guarantee that the subsequent serving process will start. The actual startup result and journaled rollback provide recovery without the extra probe.
+
+**Keep the main window hidden until readiness.** This avoids presenting a loading page but gives users no visible progress or interaction while the backend loads. A shell-owned page can remain available when Host startup fails.
+
+## Consequences
+
+Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Activation can fail after profile replacement; the journal retains recovery for profile files.
+
+Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests.

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

@@ -0,0 +1,29 @@
+# Agent Note: Show the Desktop window before starting the Host
+
+Status: implemented
+
+[English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文
+
+## 问题
+
+等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。
+
+## 决策
+
+Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断、重试和插件管理操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。
+
+profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。
+
+本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
+
+## 考虑过的替代方案
+
+**保留完整 staging 健康检查。** 它可以在替换活动 profile 前拒绝启动失败,但会执行两次插件初始化,也不能保证后续服务进程能够启动。实际启动结果与记录式回滚无需额外探针即可提供恢复能力。
+
+**在就绪前隐藏主窗口。** 这避免显示加载页,但后端加载期间用户看不到进度,也无法交互。壳拥有的页面可以在 Host 启动失败时继续使用。
+
+## 后果
+
+产品 UI 可用前,用户就能看到启动进度并从失败中恢复。窗口可响应并不表示后端已就绪,启动延迟仍需测量安装产物。激活可能在替换 profile 后失败;事务日志保留 profile 文件的恢复能力。
+
+验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。

+ 1 - 1
THIRD_PARTY_NOTICES.md

@@ -115,6 +115,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 
 pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
 
+- `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch)
 - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
 - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
 
@@ -190,7 +191,6 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
 | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
 | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
-| [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
 | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
 | [`playwright`](https://github.com/microsoft/playwright) | 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: 42ae18fa0520401adec7eaef9bb4c019911cbeb3
-README.zh.md: 65be3c1a0568a5d70f64bd4b783cfd59be8fce4b
+README.md: 854858b89a444f7e12c019bf2ad72a9037d554ee
+README.zh.md: a73f12964ebc955d0204b4e556b5347f34750787

+ 32 - 12
apps/desktop/README.md

@@ -14,7 +14,7 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no
 | Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. |
 | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
 | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. |
-| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes install in staging, boot a complete backend health check, and replace the active profile only after success; a journal and one rollback profile cover interrupted replacement. |
+| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes prepare in staging and start the actual backend after profile replacement; a journal and one rollback profile cover failed or interrupted activation. |
 | 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. |
 
 The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions.
@@ -23,23 +23,23 @@ The [Electron packaging and update Agent Note](../../.agents/notes/implemented/a
 
 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
 
-The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
+The local startup page exposes startup status, retry, and plugin-management actions; the loaded dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
-Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
+Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
 
 ### 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 verifies the tree before creating host links. 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, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup checks release metadata, shared package manifests, and required Host entry files without enumerating or hashing the runtime tree. Full file integrity verification runs during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
 
-1. A fresh profile creates its manifest and shared package links, then boots and stops the complete backend before activation.
-2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, checks enabled plugins’ peer requirements, and runs the same backend health check. Plugin versions and lockfile remain unchanged; pnpm does not run.
+1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links, then activates and starts the actual backend once.
+2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, and checks enabled plugins’ peer requirements. Plugin versions and lockfile remain unchanged; pnpm does not run.
 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again.
 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile.
-5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. A failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell.
+5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. The actual backend starts after replacement; failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell.
 
-The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts.
+The loading page does not depend on the Host. Startup failures appear in the same main window with retry and plugin-management actions. Closing during loading waits for the pending child to exit. The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts.
 
-Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; hardlinks are unsuitable because they cannot represent package directories and would retain old file content after application replacement. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
+Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
 
 ## Develop
 
@@ -90,6 +90,12 @@ The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on In
 
 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.
 
+### 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; the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) exercises the resulting native modules and HTML converter under the bundled Node.
+
 ### 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`.
@@ -121,8 +127,22 @@ macOS signing visits real files without following Framework symlink aliases. PAK
 
 Company proxies can accelerate uploads to Apple's notarization service. See the company internal documentation for configuration.
 
+### Unsigned Windows test installer
+
+On Windows x64, use the complete unsigned packaging command for local installation testing:
+
+```sh
+pnpm run package:desktop:win:x64:unsigned
+```
+
+The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements.
+
 ### Windows EV signing
 
+Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
+
+NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
+
 Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target:
 
 ```powershell
@@ -133,7 +153,7 @@ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
 pnpm run package:desktop:win:x64
 ```
 
-Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
+Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
 
 The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable.
 
@@ -158,9 +178,9 @@ An unpacked artifact contains Electron, the materialized dsh production tree, up
 
 ## Updates
 
-A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound runtime before reopening the product window.
+A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch displays the local loading page while reconciling the version-bound runtime.
 
-Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
+Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
 
 ## Low-level development overrides
 

+ 32 - 12
apps/desktop/README.zh.md

@@ -14,7 +14,7 @@
 | 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 |
 | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
 | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 |
-| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先安装到 staging,并启动完整后端执行健康检查;只有成功后才替换活跃 profile,中断替换由事务日志和一个 rollback profile 恢复。 |
+| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先在 staging 准备,替换 profile 后启动实际后端;失败或中断的激活由事务日志和一个 rollback profile 恢复。 |
 | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
 [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。
@@ -23,23 +23,23 @@
 
 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
-dsh 渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
+本地启动页提供启动状态、重试和插件管理操作;加载后的 dsh 渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
 
-Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
+Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
 
 ### 运行时与插件激活
 
-签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动会先验证依赖树,再创建宿主链接。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
+签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动检查发布元数据、共享包 manifest 和必要的 Host 入口文件,不枚举运行时目录树或计算其文件哈希。完整的文件完整性验证在打包时执行。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
-1. 新 profile 创建 manifest 和共享包链接,然后启动并停止完整后端,通过后才激活
-2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,检查已启用插件的 peer 要求,并执行相同的后端健康检查。插件版本和锁文件保持不变;不运行 pnpm。
+1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest 和共享包链接,然后激活并启动一次实际后端
+2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,检查已启用插件的 peer 要求。插件版本和锁文件保持不变;不运行 pnpm。
 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。
 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。
-5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。
+5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。替换后启动实际后端;激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。
 
-后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。
+加载页不依赖 Host。启动失败在同一个主窗口显示,并提供重试和插件管理操作。加载期间关闭应用会等待正在启动的子进程退出。后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。
 
-包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;硬链接无法表示包目录,而且应用替换后仍会保留旧文件内容,因此不适合此用途。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。
+包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。
 
 ## 开发
 
@@ -90,6 +90,12 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 
 
 每个目标都在 `apps/desktop/.desktop-build/targets/<target>/` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.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)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)使用内置 Node 验证过滤后的原生模块和 HTML 转换器。
+
 ### 上传更新
 
 `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`。
@@ -121,8 +127,22 @@ macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 
 
 可通过公司代理加速向 Apple 公证服务上传。代理配置参见公司内部文档。
 
+### 未签名 Windows 测试安装包
+
+在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试:
+
+```sh
+pnpm run package:desktop:win:x64:unsigned
+```
+
+该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。
+
 ### Windows EV 签名
 
+Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
+
+NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
+
 Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入:
 
 ```powershell
@@ -133,7 +153,7 @@ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
 pnpm run package:desktop:win:x64
 ```
 
-打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 打包会失败,不会生成未签名产物。
+打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
 
 PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。
 
@@ -158,9 +178,9 @@ pnpm run prepare:desktop
 
 ## 更新
 
-打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的运行时,再重新打开产品窗口
+打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动在显示本地加载页的同时校准版本绑定的运行时
 
-Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
+签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
 
 ## 底层开发覆盖项
 

+ 4 - 1
apps/desktop/electron-builder.config.d.mts

@@ -19,8 +19,11 @@ export interface DesktopElectronBuilderConfig {
     readonly sign: boolean
     readonly writeUpdateInfo: boolean
   }
+  readonly nsis: {
+    readonly include: string
+  }
   readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
-  readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }]
+  readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
 }
 
 /**

+ 16 - 9
apps/desktop/electron-builder.config.mjs

@@ -1,4 +1,5 @@
 import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import {
   resolveDesktopAppId,
   resolveMacOSNotarizationEnvironment,
@@ -11,7 +12,7 @@ import {
   installWindowsNsisBootstrapSigner,
 } from './scripts/windows-sign.mjs'
 import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs'
-import { desktopTargetBuildPaths } from './scripts/desktop-build-paths.mjs'
+import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs'
 
 /**
  * Create electron-builder configuration from one release environment.
@@ -29,11 +30,16 @@ export function createElectronBuilderConfig(
   const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM
   const resolvedPlatform = targetPlatform ?? hostPlatform
   const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch
+  if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) {
+    throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1')
+  }
+  const unsigned = env.DSH_DESKTOP_UNSIGNED === '1'
+  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'
   const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
   if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
-  const windowsSigner = packagesWindows
+  const windowsSigner = packagesWindows && !unsigned
     ? createWindowsTokenSigner({
         certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
         signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
@@ -44,13 +50,13 @@ export function createElectronBuilderConfig(
   if (windowsSigner !== undefined) {
     installWindowsNsisBootstrapSigner({ sign: windowsSigner })
   }
-  const update = resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
-  const buildPaths = desktopTargetBuildPaths(update.target)
+  const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
+  const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
   return {
     appId,
     productName: 'DeepSeek Harness',
     artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
-    directories: { output: buildPaths.artifacts },
+    directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts },
     asar: true,
     files: [
       'lib/*.js',
@@ -80,13 +86,13 @@ export function createElectronBuilderConfig(
     },
     afterPack: async context => {
       const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
-      verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'),
+      await verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'),
         context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
     },
     afterSign: async context => {
       if (context.electronPlatformName !== 'darwin') return
       const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
-      verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'),
+      await verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'),
         context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch })
       verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
     },
@@ -99,7 +105,7 @@ export function createElectronBuilderConfig(
       )
     },
     win: {
-      forceCodeSigning: true,
+      forceCodeSigning: !unsigned,
       signtoolOptions: {
         sign: windowsSigner,
         signingHashAlgorithms: ['sha256'],
@@ -111,11 +117,12 @@ export function createElectronBuilderConfig(
       target: ['AppImage'],
     },
     nsis: {
+      include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)),
       oneClick: false,
       allowToChangeInstallationDirectory: true,
       differentialPackage: true,
     },
-    publish: [{ provider: 'generic', url: update.publicUrl }],
+    publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }],
   }
 }
 

+ 1 - 0
apps/desktop/package.json

@@ -22,6 +22,7 @@
     "package:mac:x64": "tsx scripts/package-target.ts mac-x64",
     "package:mac:x64:dir": "tsx scripts/package-target.ts mac-x64 --dir",
     "package:win:x64": "tsx scripts/package-target.ts win-x64",
+    "package:win:x64:unsigned": "tsx scripts/package-target.ts win-x64 --unsigned",
     "package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir",
     "upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64",
     "upload:mac:x64": "tsx scripts/upload-target.ts mac-x64",

+ 2 - 2
apps/desktop/renderer/plugin-manager.js

@@ -32,8 +32,8 @@ async function main() {
 
   async function render() {
     const backend = await api.backend.status()
-    document.querySelector('#recovery').hidden = backend.ready
-    document.querySelector('#startup-error').textContent = backend.error ?? ''
+    document.querySelector('#recovery').hidden = backend.phase !== 'error'
+    document.querySelector('#startup-error').textContent = backend.phase === 'error' ? backend.message : ''
     const plugins = await api.plugins.list()
     list.replaceChildren(...plugins.map(plugin => {
       const item = document.createElement('li')

+ 16 - 0
apps/desktop/renderer/startup.css

@@ -0,0 +1,16 @@
+:root { color-scheme: light dark; font-family: system-ui, sans-serif; color: #202124; background: #fafafa; }
+body { margin: 0; min-height: 100vh; display: grid; place-items: center; }
+main { width: min(560px, calc(100vw - 64px)); padding: 40px 0; text-align: center; }
+h1 { margin: 24px 0 12px; font-size: 22px; font-weight: 600; }
+p { color: #666; line-height: 1.6; }
+#spinner { width: 32px; height: 32px; margin: auto; border: 3px solid #dedee3; border-top-color: #4d6bfe; border-radius: 50%; animation: spin 0.9s linear infinite; }
+#error { padding: 16px; border: 1px solid #e0e0e5; border-radius: 10px; max-height: 220px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; text-align: left; font: 13px/1.6 ui-monospace, monospace; }
+#actions { margin-top: 24px; }
+button { margin: 4px; padding: 10px 18px; border: 1px solid transparent; border-radius: 8px; color: #fff; background: #4d6bfe; font: inherit; cursor: pointer; }
+button.secondary { color: inherit; background: transparent; border-color: #c8c8d0; }
+button:disabled { opacity: 0.5; cursor: default; }
+button:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
+[hidden] { display: none !important; }
+@keyframes spin { to { transform: rotate(360deg); } }
+@media (prefers-color-scheme: dark) { :root { color: #ededf0; background: #171719; } p { color: #aaaab3; } #error { border-color: #38383f; } #spinner { border-color: #38383f; border-top-color: #6a85ff; } }
+@media (prefers-reduced-motion: reduce) { #spinner { animation: none; } }

+ 23 - 0
apps/desktop/renderer/startup.html

@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self' data:">
+    <title id="page-title"></title>
+    <link rel="stylesheet" href="startup.css">
+  </head>
+  <body>
+    <main aria-busy="true">
+      <div id="spinner" aria-hidden="true"></div>
+      <h1 id="title" role="status" aria-live="polite"></h1>
+      <p id="description"></p>
+      <pre id="error" role="alert" hidden></pre>
+      <div id="actions" hidden>
+        <button id="retry" type="button"></button>
+        <button id="plugins" class="secondary" type="button"></button>
+      </div>
+    </main>
+    <script src="startup.js"></script>
+  </body>
+</html>

+ 37 - 0
apps/desktop/renderer/startup.js

@@ -0,0 +1,37 @@
+const api = window.dshDesktop
+
+async function main() {
+  const { id, messages } = await api.locale()
+  document.documentElement.lang = id
+  document.querySelector('#page-title').textContent = messages.startupLoading
+  document.querySelector('#retry').textContent = messages.retry
+  document.querySelector('#plugins').textContent = messages.managePlugins
+  function render(state) {
+    const failed = state.phase === 'error'
+    document.querySelector('main').setAttribute('aria-busy', String(!failed))
+    document.querySelector('#spinner').hidden = failed
+    document.querySelector('#title').textContent = failed ? messages.startupFailed : messages.startupLoading
+    document.querySelector('#description').textContent = failed ? messages.startupErrorDescription : messages.startupLoadingDescription
+    document.querySelector('#error').hidden = !failed
+    document.querySelector('#error').textContent = failed ? state.message : ''
+    document.querySelector('#actions').hidden = !failed
+    document.querySelector('#retry').disabled = !failed
+    document.querySelector('#plugins').disabled = !failed
+  }
+  let changed = false
+  const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) })
+  window.addEventListener('pagehide', unsubscribe, { once: true })
+  const initial = await api.backend.status()
+  if (!changed) render(initial)
+  document.querySelector('#retry').addEventListener('click', async () => {
+    render({ phase: 'starting' })
+    try { await api.backend.retry() }
+    catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) }
+  })
+  document.querySelector('#plugins').addEventListener('click', async () => {
+    try { await api.openPlugins() }
+    catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) }
+  })
+}
+
+void main()

+ 17 - 0
apps/desktop/scripts/installer.nsh

@@ -0,0 +1,17 @@
+!include "LogicLib.nsh"
+
+!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
+!macroend

+ 28 - 4
apps/desktop/scripts/package-target.ts

@@ -73,6 +73,25 @@ export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv)
     .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX)))
 }
 
+/**
+ * Select signing and NSIS-compatible archive filters for electron-builder.
+ * @param environment - Target packaging environment.
+ * @param unsigned - Whether to create a local unsigned Windows artifact.
+ * @returns Packaging environment without certificate inputs for unsigned builds.
+ */
+export function desktopElectronBuilderEnvironment(environment: NodeJS.ProcessEnv, unsigned: boolean): NodeJS.ProcessEnv {
+  const selected: NodeJS.ProcessEnv = { ...environment, DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0' }
+  // The bundled NSIS decoder cannot extract 7-Zip's automatic ARM64-filtered entries.
+  if (environment.DSH_DESKTOP_TARGET_PLATFORM === 'win32') selected.ELECTRON_BUILDER_7Z_FILTER = 'BCJ'
+  if (!unsigned) return selected
+  return {
+    ...Object.fromEntries(Object.entries(withoutWindowsSigningEnvironment(selected))
+      .filter(([name]) => !/^(?:WIN_)?CSC_/iu.test(name))),
+    CSC_IDENTITY_AUTO_DISCOVERY: 'false',
+    DSH_DESKTOP_UNSIGNED: '1',
+  }
+}
+
 /**
  * Remove upload-only COS credentials from every packaging subprocess.
  * @param environment - Packaging command environment.
@@ -153,6 +172,7 @@ interface DesktopPackageInvocation {
   readonly target: DesktopPackageTarget
   readonly directory: boolean
   readonly prepareOnly: boolean
+  readonly unsigned: boolean
 }
 
 function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
@@ -179,14 +199,18 @@ export function parseDesktopPackageInvocation(
     options: {
       dir: { type: 'boolean', default: false },
       'prepare-only': { type: 'boolean', default: false },
+      unsigned: { type: 'boolean', default: false },
     },
   })
   if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
   const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch)
+  if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64')
+  if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only')
   return {
     target: resolveDesktopPackageTarget(name, hostPlatform, hostArch),
     directory: values.dir,
     prepareOnly: values['prepare-only'],
+    unsigned: values.unsigned,
   }
 }
 
@@ -248,7 +272,7 @@ async function main(): Promise<void> {
   const { target } = invocation
   const buildPaths = desktopTargetBuildPaths(target.name)
   const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
-  if (!invocation.prepareOnly) {
+  if (!invocation.prepareOnly && !invocation.unsigned) {
     rmSync(releaseRecordPath, { force: true })
     rmSync(`${releaseRecordPath}.tmp`, { force: true })
   }
@@ -258,9 +282,9 @@ async function main(): Promise<void> {
     DSH_DESKTOP_TARGET_PLATFORM: target.platform,
     DSH_DESKTOP_TARGET_ARCH: target.arch,
   }
-  const electronBuilderEnv = { ...targetEnv }
+  const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned)
   for (const name of WINDOWS_SIGNING_ENV_NAMES) {
-    if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
+    if (!invocation.unsigned && process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
   }
   await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
   await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
@@ -300,7 +324,7 @@ async function main(): Promise<void> {
   } else {
     await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
   }
-  if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
+  if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
 }
 
 if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()

+ 9 - 7
apps/desktop/scripts/prepare-dsh.ts

@@ -3,7 +3,7 @@
 import { spawn } 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, resolve } from 'node:path'
+import { delimiter, dirname, join, relative, resolve } from 'node:path'
 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'
@@ -25,6 +25,7 @@ import {
   signMacOSRuntime,
 } from './macos-runtime.ts'
 import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
+import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts'
 
 const APP_ROOT = resolve(import.meta.dirname, '..')
 const BUILD_PATHS = resolveDesktopTargetBuildPaths()
@@ -115,10 +116,13 @@ async function main(): Promise<void> {
     )
     await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])
     const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version)
+    const targetName = resolveDesktopBuildTarget()
+    const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
+    const modules = join(BUILD_ROOT, 'node_modules')
     mkdirSync(DSH_OUTPUT_ROOT, { recursive: true })
-    cpSync(join(BUILD_ROOT, 'node_modules'), join(DSH_OUTPUT_ROOT, 'node_modules'), {
+    cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), {
       recursive: true, dereference: true,
-      filter: source => !source.split(/[\\/]/u).some(part => part === '.bin' || part === '.pnpm' || part === '.modules.yaml' || part === '.pnpm-workspace-state-v1.json'),
+      filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined,
     })
     writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({
       name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module',
@@ -132,12 +136,10 @@ async function main(): Promise<void> {
     if (process.platform === 'darwin') {
       await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
     }
-    const targetName = resolveDesktopBuildTarget()
-    const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
     writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
-    const descriptor = verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
+    const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
     await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor)
-    verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
+    await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
   } catch (error) {
     rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
     throw error

+ 40 - 0
apps/desktop/scripts/runtime-file-policy.ts

@@ -0,0 +1,40 @@
+/** Desktop-only omissions from already installed production npm packages. */
+
+/**
+ * Identify build and diagnostic files omitted from the immutable Desktop runtime.
+ * 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.
+ * @returns Omission reason, or undefined when the entry must be copied.
+ */
+export function desktopRuntimeFileExclusion(
+  path: string, target: { platform: NodeJS.Platform; arch: string },
+): string | undefined {
+  const parts = path.split(/[\\/]/u)
+  if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) {
+    return 'package-manager metadata'
+  }
+  const file = parts.at(-1) ?? ''
+  if (/\.(?:[cm]?[jt]s|css)\.map$/u.test(file)) return 'source map'
+  if (/\.d\.[cm]?ts$/u.test(file)) return 'TypeScript declaration'
+  if (/\.tsbuildinfo$/u.test(file)) return 'TypeScript build cache'
+  const packageParts = parts.slice(parts.lastIndexOf('node_modules') + 1)
+  const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1
+  const name = packageParts.slice(0, nameParts).join('/')
+  const entry = packageParts.slice(nameParts).join('/')
+  if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) {
+    return 'fs-ext compiler output'
+  }
+  if (name === 'fs-ext' && /^build\/(?:binding\.sln|config\.gypi|fs_ext\.vcxproj(?:\.filters)?)$/u.test(entry)) {
+    return 'fs-ext build configuration'
+  }
+  if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures'
+  if (name === 'node-pty' && entry.startsWith('prebuilds/')) {
+    const platform = packageParts[nameParts + 1]
+    if (platform !== undefined && platform !== `${target.platform}-${target.arch}`) return 'node-pty other platform'
+    if (file.endsWith('.pdb')) return 'node-pty debug symbols'
+  }
+  if (name === '@koromix/koffi-win32-x64' && entry === 'win32_x64/koffi.lib') return 'Koffi import library'
+  return undefined
+}

+ 146 - 0
apps/desktop/src/backend-controller.ts

@@ -0,0 +1,146 @@
+/** Owns one backend startup and its quiescent teardown independently of windows. */
+
+/** Backend availability presented by the desktop window. */
+export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string }
+
+/** Child lifecycle owned by the desktop backend controller. */
+export interface DesktopBackendHost {
+  /** @returns Readiness after the child accepts application requests. */
+  start(): Promise<unknown>
+  /** @returns Completion of child exit. */
+  stop(): Promise<void>
+}
+
+interface Attempt<Host> {
+  cancelled: boolean
+  failure?: Error
+  host?: Host
+  cleanup?: Promise<void>
+}
+
+/** Serializes retries and prevents children from outliving a closed window. */
+export class DesktopBackendController<Host extends DesktopBackendHost> {
+  private current: DesktopBackendState = { phase: 'starting' }
+  private attempt: Attempt<Host> | undefined
+  private pending: Promise<void> | undefined
+  private stopping: Promise<void> | undefined
+  private closed = false
+
+  /**
+   * @param createHost - Allocate a child and route its fatal failures to the supplied callback.
+   * @param publish - Receive availability changes until the controller closes.
+   */
+  constructor(
+    private readonly createHost: (onFailure: (error: Error) => void) => Host,
+    private readonly publish: (state: DesktopBackendState) => void,
+  ) {}
+
+  /** Current availability, including the last startup or child failure. */
+  get state(): DesktopBackendState { return this.current }
+
+  /** Child available to application requests; absent during startup and teardown. */
+  get host(): Host | undefined { return !this.closed && !this.attempt?.cancelled && this.current.phase === 'ready' ? this.attempt?.host : undefined }
+
+  /**
+   * Prepare the profile and start one child; concurrent callers share the attempt.
+   * @param prepare - Profile preparation that must finish before spawning.
+   * @returns Completion of startup, rejecting on preparation, startup, or cleanup failure.
+   */
+  start(prepare: () => Promise<void>): Promise<void> {
+    if (this.closed) return Promise.reject(new Error('desktop backend is closed'))
+    if (this.stopping !== undefined) return Promise.reject(new Error('desktop backend is stopping'))
+    if (this.pending !== undefined) return this.pending
+    if (this.current.phase === 'ready') return Promise.resolve()
+    const previous = this.attempt
+    const attempt: Attempt<Host> = { cancelled: false, ...(previous?.cleanup === undefined ? {} : { cleanup: previous.cleanup }) }
+    this.attempt = attempt
+    this.update({ phase: 'starting' })
+    const pending = Promise.resolve().then(async () => {
+      try {
+        await previous?.cleanup
+        delete attempt.cleanup
+        if (attempt.cancelled) return
+        await prepare()
+        // stop() can cancel this attempt while preparation is pending.
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        if (attempt.cancelled) return
+        const host = this.createHost((error) => { this.failed(attempt, error) })
+        attempt.host = host
+        await host.start()
+        if (attempt.failure !== undefined) throw attempt.failure
+        // stop() can cancel this attempt while the child starts.
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        if (!attempt.cancelled) this.update({ phase: 'ready' })
+      } catch (error) {
+        const cancelled = attempt.cancelled
+        attempt.cancelled = true
+        let failure = error
+        try { await this.cleanup(attempt) } catch (cleanupError) {
+          if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed')
+        }
+        if (!cancelled) this.update({ phase: 'error', message: failure instanceof Error ? failure.message : String(failure) })
+        throw failure
+      }
+    }).finally(() => { if (this.pending === pending) this.pending = undefined })
+    this.pending = pending
+    return pending
+  }
+
+  /**
+   * Stop pending preparation and the child before allowing another start.
+   * @returns Completion of pending work and child exit; rejects if cleanup fails.
+   */
+  stop(): Promise<void> {
+    if (this.stopping !== undefined) return this.stopping
+    const attempt = this.attempt
+    if (attempt !== undefined) attempt.cancelled = true
+    if (!this.closed) this.update({ phase: 'starting' })
+    const pending = this.pending
+    const stopping = Promise.allSettled([
+      attempt === undefined ? Promise.resolve() : this.cleanup(attempt),
+      pending,
+    ]).then((results) => {
+      const cleanup = results[0]
+      if (cleanup.status === 'rejected') throw cleanup.reason
+      if (this.attempt === attempt) this.attempt = undefined
+    }).finally(() => { if (this.stopping === stopping) this.stopping = undefined })
+    this.stopping = stopping
+    return stopping
+  }
+
+  /**
+   * Permanently prevent startup and suppress further availability notifications.
+   * @returns Completion of pending work and child exit; rejects if cleanup fails.
+   */
+  close(): Promise<void> {
+    this.closed = true
+    return this.stop()
+  }
+
+  private cleanup(attempt: Attempt<Host>): Promise<void> {
+    if (attempt.cleanup === undefined) {
+      attempt.cleanup = Promise.resolve().then(async () => { await attempt.host?.stop() })
+    }
+    return attempt.cleanup
+  }
+
+  private failed(attempt: Attempt<Host>, error: Error): void {
+    if (this.attempt !== attempt || attempt.cancelled) return
+    attempt.failure = error
+    if (this.current.phase !== 'ready') return
+    attempt.cancelled = true
+    const cleanup = this.cleanup(attempt)
+    this.update({ phase: 'error', message: error.message })
+    void cleanup.catch((cleanupError: unknown) => {
+      if (this.attempt === attempt) this.update({ phase: 'error', message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) })
+    })
+  }
+
+  private update(state: DesktopBackendState): void {
+    if (this.closed) return
+    this.current = state
+    try { this.publish(state) } catch (error) {
+      console.error('desktop backend state listener failed', error)
+    }
+  }
+}

+ 11 - 2
apps/desktop/src/host-process.ts

@@ -83,6 +83,7 @@ export class DesktopHostProcess {
   })
   private exitPromise: Promise<void> | undefined
   private stderr = ''
+  private failureReported = false
 
   /**
    * @param node - absolute bundled upstream Node.js executable.
@@ -90,6 +91,7 @@ export class DesktopHostProcess {
    * @param projectDir - active or staged desktop plugin profile.
    * @param inspectPort - optional loopback inspector port for workspace development.
    * @param environment - Child environment; runtime and package-manager overrides are removed.
+   * @param onFailure - Receives the first fatal child or transport failure, including after readiness.
    */
   constructor(
     private readonly node: string,
@@ -97,6 +99,7 @@ export class DesktopHostProcess {
     private readonly projectDir: string,
     private readonly inspectPort?: number,
     private readonly environment: NodeJS.ProcessEnv = process.env,
+    private readonly onFailure?: (error: Error) => void,
   ) {}
 
   /** Start the child once and resolve only after its complete composition is active. */
@@ -149,7 +152,7 @@ export class DesktopHostProcess {
     })
     child.once('error', (error) => { this.fail(error) })
     this.exitPromise = new Promise<void>((resolve) => {
-      child.once('exit', (code) => {
+      child.once('close', (code) => {
         const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
         if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
         else this.fail(new Error(`dsh desktop host stopped${suffix}`))
@@ -278,7 +281,7 @@ export class DesktopHostProcess {
   private send(message: DesktopHostCommand): void {
     const child = this.child
     if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable')
-    child.send(message)
+    child.send(message, (error) => { if (error !== null) this.fail(error) })
   }
 
   private acceptResponseBytes(chunk: Buffer): void {
@@ -405,6 +408,12 @@ export class DesktopHostProcess {
 
   private fail(error: Error): void {
     this.readyReject(error)
+    if (!this.failureReported) {
+      this.failureReported = true
+      try { this.onFailure?.(error) } catch (listenerError) {
+        console.error('desktop host failure listener failed', listenerError)
+      }
+    }
     for (const pending of this.pending.values()) {
       void pending.requestReader?.cancel(error).catch(() => undefined)
       if (pending.controller === undefined) pending.reject(error)

+ 10 - 1
apps/desktop/src/ipc.ts

@@ -2,11 +2,13 @@
 
 import type { DesktopPluginRecord } from './project-manager.ts'
 import type { DesktopLocale } from './locale.ts'
+import type { DesktopBackendState } from './backend-controller.ts'
 
 /** IPC channel names kept private to the desktop application bundle. */
 export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
   pluginsList: 'dsh-desktop:plugins-list',
+  pluginsOpen: 'dsh-desktop:plugins-open',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
   pluginsUpdate: 'dsh-desktop:plugins-update',
@@ -14,6 +16,7 @@ export const DESKTOP_IPC = {
   pluginsDisableAll: 'dsh-desktop:plugins-disable-all',
   backendStatus: 'dsh-desktop:backend-status',
   backendRetry: 'dsh-desktop:backend-retry',
+  backendState: 'dsh-desktop:backend-state',
   updatesCheck: 'dsh-desktop:updates-check',
   updatesInstall: 'dsh-desktop:updates-install',
   updatesState: 'dsh-desktop:updates-state',
@@ -39,8 +42,9 @@ export interface DshDesktopApi {
     disableAll(): Promise<void>
   }
   readonly backend: {
-    status(): Promise<{ readonly ready: boolean; readonly error?: string }>
+    status(): Promise<DesktopBackendState>
     retry(): Promise<void>
+    subscribe(listener: (state: DesktopBackendState) => void): () => void
   }
   readonly updates: {
     check(): Promise<DesktopUpdateState>
@@ -48,3 +52,8 @@ export interface DshDesktopApi {
     subscribe(listener: (state: DesktopUpdateState) => void): () => void
   }
 }
+
+/** Startup-page controls, unavailable to backend-provided application documents. */
+export interface DshDesktopStartupApi extends Pick<DshDesktopApi, 'protocolVersion' | 'locale' | 'backend'> {
+  openPlugins(): Promise<void>
+}

+ 8 - 0
apps/desktop/src/locale.ts

@@ -3,6 +3,10 @@
 export const en = {
   application: 'Application',
   startupFailed: 'DeepSeek Harness could not start',
+  startupLoading: 'Starting DeepSeek Harness…',
+  startupLoadingDescription: 'Your workspace will open when it is ready.',
+  startupErrorDescription: 'The application could not start. Retry or manage Desktop plugins to resolve the problem.',
+  managePlugins: 'Manage plugins',
   pluginsMenu: 'Desktop Plugins…',
   pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)',
   checkUpdatesMenu: 'Check for Updates…',
@@ -49,6 +53,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 export const zh = {
   application: '应用',
   startupFailed: 'DeepSeek Harness 无法启动',
+  startupLoading: '正在启动 DeepSeek Harness…',
+  startupLoadingDescription: '准备就绪后将自动打开工作区。',
+  startupErrorDescription: '应用未能启动。你可以重试,或管理桌面插件以解决问题。',
+  managePlugins: '管理插件',
   pluginsMenu: '桌面插件…',
   pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)',
   checkUpdatesMenu: '检查更新…',

+ 97 - 95
apps/desktop/src/main.ts

@@ -15,6 +15,7 @@ import {
 import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
+import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts'
 import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
@@ -79,13 +80,13 @@ function developmentHostInspectPort(enabled: boolean): number | undefined {
   return port
 }
 
-function createWindow(preload: string): BrowserWindow {
+function createWindow(preload: string, show = false): BrowserWindow {
   const window = new BrowserWindow({
     width: 1280,
     height: 840,
     minWidth: 880,
     minHeight: 600,
-    show: false,
+    show,
     webPreferences: {
       preload,
       nodeIntegration: false,
@@ -137,8 +138,9 @@ async function main(): Promise<void> {
   const activeProject = development ?? paths.profile
   const hostInspectPort = developmentHostInspectPort(development !== undefined)
   const manager = new DesktopProjectManager(paths, resources)
-  let host: DesktopHostProcess | undefined
-  let startupError: string | undefined
+  let pageError: string | undefined
+  let quitting = false
+  let startup: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
   let pluginWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
@@ -147,6 +149,38 @@ async function main(): Promise<void> {
   const messages = locale.messages
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
+  const startupUrl = `${SCHEME}://shell/startup.html`
+  const applicationUrl = `${SCHEME}://app/index.html`
+  let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
+
+  const navigateMain = (url: string): Promise<void> => {
+    const window = mainWindow
+    if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve()
+    if (navigation?.window === window && navigation.url === url) return navigation.promise
+    const next = { window, url, promise: Promise.resolve() }
+    next.promise = window.loadURL(url).catch((error: unknown) => {
+      if (quitting || window.isDestroyed() || navigation !== next) return
+      navigation = undefined
+      throw error
+    })
+    navigation = next
+    return next.promise
+  }
+  const backendState = (): DesktopBackendState => pageError === undefined
+    ? backend.state : { phase: 'error', message: pageError }
+  const publishBackend = (state: DesktopBackendState): void => {
+    for (const window of BrowserWindow.getAllWindows()) {
+      window.webContents.send(DESKTOP_IPC.backendState, state)
+    }
+  }
+  const backend = new DesktopBackendController((onFailure) => {
+    if (development === undefined) manager.assertProfileRuntime(activeProject)
+    return new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, onFailure)
+  }, (state) => {
+    if (state.phase === 'starting') pageError = undefined
+    publishBackend(backendState())
+    if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) })
+  })
 
   const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
     updateState = state
@@ -156,78 +190,39 @@ async function main(): Promise<void> {
     return state
   }
 
-  const startHost = async (projectDir = activeProject): Promise<DesktopHostProcess> => {
-    if (development === undefined) manager.assertProfileRuntime(projectDir)
-    const next = new DesktopHostProcess(resources.node, development ?? resources.dsh, projectDir, hostInspectPort)
-    try {
-      await next.start()
-      return next
-    } catch (error) {
-      await next.stop()
-      throw error
-    }
-  }
   const hooks: DesktopProjectHooks = {
-    healthCheck: async (projectDir) => {
-      const active = host
-      host = undefined
-      await active?.stop()
-      let healthFailure: unknown
-      let probe: DesktopHostProcess | undefined
-      try {
-        probe = await startHost(projectDir)
-        await probe.stop()
-      } catch (error) {
-        healthFailure = error
-        await probe?.stop().catch(() => undefined)
-      }
-      let restartFailure: unknown
-      if (active !== undefined) {
-        try {
-          host = await startHost()
-        } catch (error) {
-          restartFailure = error
-        }
-      }
-      if (healthFailure !== undefined && restartFailure !== undefined) {
-        throw new AggregateError([
-          errorOf(healthFailure, 'desktop project: staged health check failed'),
-          errorOf(restartFailure, 'desktop project: active backend restart failed'),
-        ], 'desktop project: staged health check and active backend restart failed')
-      }
-      if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed')
-      if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed')
-    },
-    beforeActivate: async () => {
-      const active = host
-      host = undefined
-      await active?.stop()
-    },
-    afterActivate: async () => {
-      host = await startHost()
-    },
+    beforeActivate: () => backend.stop(),
+    afterActivate: () => backend.start(async () => {}),
   }
 
-  const reconcileBackend = async (): Promise<void> => {
-    try {
-      if (development === undefined) {
-        await manager.applyRelease(app.getVersion(), { ...hooks, beforeActivate: async () => {}, afterActivate: async () => {} })
-      }
-      host = await startHost()
-      startupError = undefined
-    } catch (error) {
-      startupError = errorOf(error, messages.startupFailed).message
+  const showStartupError = async (error: unknown): Promise<void> => {
+    if (quitting) return
+    pageError = errorOf(error, messages.startupFailed).message
+    await navigateMain(startupUrl)
+    publishBackend(backendState())
+  }
+  const reconcileBackend = (): Promise<void> => {
+    startup ??= (async () => {
+      pageError = undefined
+      await navigateMain(startupUrl)
+      await backend.start(async () => {
+        if (development === undefined) {
+          await manager.applyRelease(app.getVersion(), { beforeActivate: async () => {}, afterActivate: async () => {} })
+        }
+      })
+      if (backend.host !== undefined) await navigateMain(applicationUrl)
+    })().catch(async (error: unknown) => {
+      await showStartupError(error)
       throw error
-    }
+    }).finally(() => { startup = undefined })
+    return startup
   }
 
   const updates = new DesktopUpdateCoordinator(
     publishUpdate,
     async () => {
       shellInstallerOwnsQuit = true
-      const active = host
-      host = undefined
-      await active?.stop()
+      await backend.stop()
     },
   )
 
@@ -235,7 +230,7 @@ async function main(): Promise<void> {
     const url = new URL(request.url)
     if (url.hostname === 'shell') return serveShellAsset(request)
     if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 }))
-    const active = host
+    const active = backend.host
     if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 }))
     return active.fetch(request)
   })
@@ -245,10 +240,16 @@ async function main(): Promise<void> {
     if (development !== undefined) {
       throw new Error('dsh desktop: plugin package changes require a packaged application')
     }
-    await manager.mutate(mutation, hooks)
-    startupError = undefined
-    if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload()
-    else focusPrimaryWindow()
+    await startup?.catch(() => undefined)
+    pageError = undefined
+    await navigateMain(startupUrl)
+    try {
+      await manager.mutate(mutation, hooks)
+      await navigateMain(applicationUrl)
+    } catch (error) {
+      await showStartupError(error)
+      throw error
+    }
   }
   ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
     assertDesktopSender(event, ['shell'])
@@ -280,11 +281,11 @@ async function main(): Promise<void> {
   ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' }))
   ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => {
     assertDesktopSender(event, ['shell'])
-    return { ready: host !== undefined, ...(startupError === undefined ? {} : { error: startupError }) }
+    return backendState()
   })
   ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
     assertDesktopSender(event, ['shell'])
-    if (host === undefined) await reconcileBackend()
+    await reconcileBackend()
     focusPrimaryWindow()
   })
   ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
@@ -350,6 +351,10 @@ async function main(): Promise<void> {
     pluginWindow.once('closed', () => { pluginWindow = undefined })
     void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
   }
+  ipcMain.handle(DESKTOP_IPC.pluginsOpen, (event) => {
+    assertDesktopSender(event, ['shell'])
+    openPluginWindow()
+  })
 
   Menu.setApplicationMenu(Menu.buildFromTemplate([{
     label: process.platform === 'darwin' ? app.name : messages.application,
@@ -367,18 +372,17 @@ async function main(): Promise<void> {
   }]))
 
   const createMainWindow = (): BrowserWindow => {
-    const window = createWindow(appPreload)
+    const window = createWindow(appPreload, true)
     mainWindow = window
-    window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
     return window
   }
   focusPrimaryWindow = () => {
-    if (host === undefined) { openPluginWindow(); return }
     const window = mainWindow
     if (window === undefined || window.isDestroyed()) {
-      const replacement = createMainWindow()
-      void replacement.loadURL(`${SCHEME}://app/index.html`)
+      createMainWindow()
+      void navigateMain(backendState().phase === 'ready' ? applicationUrl : startupUrl)
+        .catch((error: unknown) => { console.error(error) })
       return
     }
     if (window.isMinimized()) window.restore()
@@ -386,18 +390,6 @@ async function main(): Promise<void> {
     window.focus()
   }
 
-  await reconcileBackend().catch(() => undefined)
-  if (host === undefined) openPluginWindow()
-  else {
-    mainWindow = createMainWindow()
-    await mainWindow.loadURL(`${SCHEME}://app/index.html`)
-  }
-  if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
-    mainWindow.webContents.openDevTools({ mode: 'detach' })
-  }
-  publishUpdate(updateState)
-  setTimeout(() => { void checkAndPrompt(false) }, 10_000)
-
   app.on('activate', () => {
     if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
   })
@@ -405,13 +397,23 @@ async function main(): Promise<void> {
     if (process.platform !== 'darwin') app.quit()
   })
   app.on('before-quit', (event) => {
-    if (shellInstallerOwnsQuit) return
-    if (host === undefined) return
+    if (shellInstallerOwnsQuit || quitting) return
     event.preventDefault()
-    const active = host
-    host = undefined
-    void active.stop().finally(() => { app.quit() })
+    quitting = true
+    void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
   })
+
+  mainWindow = createMainWindow()
+  await reconcileBackend().catch(() => undefined)
+  // Window lifecycle callbacks run while backend startup is pending.
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+  if (quitting) return
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+  if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
+    mainWindow.webContents.openDevTools({ mode: 'detach' })
+  }
+  publishUpdate(updateState)
+  setTimeout(() => { void checkAndPrompt(false) }, 10_000)
 }
 
 const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })

+ 26 - 0
apps/desktop/src/owned-directory.ts

@@ -0,0 +1,26 @@
+/** Desktop transaction cleanup that unlinks directory links without visiting their targets. */
+
+import { lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs'
+import { join } from 'node:path'
+
+/**
+ * Remove an owned directory and its contents, unlinking root and nested links.
+ * Missing roots are ignored; existing roots must be directories or links.
+ * @param path - Owned directory or link to remove; link targets are preserved.
+ */
+export function removeOwnedDirectory(path: string): void {
+  const stat = lstatSync(path, { throwIfNoEntry: false })
+  if (stat === undefined) return
+  if (stat.isSymbolicLink()) {
+    unlinkSync(path)
+    return
+  }
+  if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`)
+  // Electron's recursive rm follows nested Windows junctions into installed resources.
+  for (const entry of readdirSync(path, { withFileTypes: true })) {
+    const child = join(path, entry.name)
+    if (entry.isDirectory()) removeOwnedDirectory(child)
+    else unlinkSync(child)
+  }
+  rmdirSync(path)
+}

+ 21 - 3
apps/desktop/src/preload-app.ts

@@ -1,5 +1,23 @@
-/** Minimal marker that selects the desktop custom-protocol API carrier. */
+/** Startup controls for shell documents; application documents receive only the carrier marker. */
 
-import { contextBridge } from 'electron'
+import { contextBridge, ipcRenderer } from 'electron'
+import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts'
+import type { DesktopBackendState } from './backend-controller.ts'
 
-contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 })
+const startup: DshDesktopStartupApi = {
+  protocolVersion: 1,
+  locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType<DshDesktopStartupApi['locale']>,
+  backend: {
+    status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopStartupApi['backend']['status']>,
+    retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise<void>,
+    subscribe(listener) {
+      const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
+      ipcRenderer.on(DESKTOP_IPC.backendState, handle)
+      return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
+    },
+  },
+  openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise<void>,
+}
+
+contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell'
+  ? startup : { protocolVersion: 1 })

+ 6 - 0
apps/desktop/src/preload.ts

@@ -2,6 +2,7 @@
 
 import { contextBridge, ipcRenderer } from 'electron'
 import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts'
+import type { DesktopBackendState } from './backend-controller.ts'
 
 const api: DshDesktopApi = {
   protocolVersion: 1,
@@ -17,6 +18,11 @@ const api: DshDesktopApi = {
   backend: {
     status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopApi['backend']['status']>,
     retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise<void>,
+    subscribe(listener) {
+      const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
+      ipcRenderer.on(DESKTOP_IPC.backendState, handle)
+      return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
+    },
   },
   updates: {
     check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise<DesktopUpdateState>,

+ 5 - 5
apps/desktop/src/profile-packages.ts

@@ -175,7 +175,7 @@ function packageFrom(anchor: string, name: string): string | undefined {
   if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`)
   for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) {
     const path = join(modules, name)
-    if (existsSync(join(path, 'package.json'))) return realpathSync(path)
+    if (existsSync(join(path, 'package.json'))) return realpathSync.native(path)
   }
   return undefined
 }
@@ -190,8 +190,8 @@ function packageFrom(anchor: string, name: string): string | undefined {
 export function validateDesktopPluginGraph(
   profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[],
 ): void {
-  const profileRoot = realpathSync(profile)
-  const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync(runtimePath(root, entry.path))]))
+  const profileRoot = realpathSync.native(profile)
+  const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync.native(runtimePath(root, entry.path))]))
   for (const [name, path] of shared) {
     if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`)
   }
@@ -199,7 +199,7 @@ export function validateDesktopPluginGraph(
   const scan = (modules: string): void => {
     if (!existsSync(modules)) return
     if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`)
-    const directory = realpathSync(modules)
+    const directory = realpathSync.native(modules)
     if (scanned.has(directory)) return
     scanned.add(directory)
     for (const entry of readdirSync(modules, { withFileTypes: true })) {
@@ -207,7 +207,7 @@ export function validateDesktopPluginGraph(
       const path = join(modules, entry.name)
       if (entry.name.startsWith('@')) { scan(path); continue }
       if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`)
-      const canonical = realpathSync(path)
+      const canonical = realpathSync.native(path)
       const info = manifest(canonical)
       const host = shared.get(info.name)
       if (host !== undefined) {

+ 10 - 23
apps/desktop/src/project-manager.ts

@@ -12,8 +12,8 @@ import {
   openSync,
   closeSync,
   readFileSync,
+  realpathSync,
   renameSync,
-  rmSync,
   unlinkSync,
   writeFileSync,
   writeSync,
@@ -25,8 +25,9 @@ import {
   verifyDesktopCorePackageSet,
 } from './core-package-set.ts'
 import type { DesktopPaths } from './paths.ts'
+import { removeOwnedDirectory } from './owned-directory.ts'
 import type { DesktopRelease } from './release.ts'
-import { desktopRuntimeId, verifyDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
+import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
   copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
   unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState,
@@ -69,10 +70,8 @@ export interface DesktopRuntimeExecutables {
   readonly dsh: string
 }
 
-/** Hooks that bind project replacement to backend lifecycle and health. */
+/** Hooks that bind project replacement to the active backend lifecycle. */
 export interface DesktopProjectHooks {
-  /** Prove the staged dependency graph while the active backend is stopped. */
-  healthCheck(projectDir: string): Promise<void>
   /** Stop the active backend and await process exit before directory moves. */
   beforeActivate(): Promise<void>
   /** Start the selected active project after commit or rollback. */
@@ -163,17 +162,6 @@ export function packageNameFromSpec(spec: string): string | undefined {
   return name
 }
 
-function removeOwnedDirectory(path: string): void {
-  if (!existsSync(path)) return
-  const stat = lstatSync(path)
-  if (stat.isSymbolicLink()) {
-    unlinkSync(path)
-    return
-  }
-  if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`)
-  rmSync(path, { recursive: true })
-}
-
 function projectManifest(projectDir: string): DesktopProjectManifest {
   const path = join(projectDir, 'package.json')
   const value = readJson(path)
@@ -324,7 +312,7 @@ export class DesktopProjectManager {
   }
 
   private currentRuntime(): DesktopRuntimeDescriptor {
-    if (this.descriptor === undefined) throw new Error('desktop project: runtime has not been verified')
+    if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded')
     return this.descriptor
   }
 
@@ -334,11 +322,11 @@ export class DesktopProjectManager {
     validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir))
   }
 
-  /** Verify this release and reconcile its external profile without installing core packages. */
+  /** Read release metadata and reconcile its external profile without installing core packages. */
   async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise<boolean> {
     return this.withLock(async () => {
       this.recover()
-      const target = verifyDesktopRuntime(this.runtime.dsh, electronVersion)
+      const target = readDesktopRuntime(this.runtime.dsh, electronVersion)
       this.descriptor = target
       const previous = readDesktopProfileState(this.paths.profile)
       if (existsSync(this.paths.profile) && previous === undefined) {
@@ -347,8 +335,9 @@ export class DesktopProjectManager {
       if (previous?.runtimeId === desktopRuntimeId(target)
         && previous.lockHash === desktopPluginLockHash(this.paths.profile)
         && previous.links.length === target.sharedPackages.length
-        && previous.links.every(link => link.target === join(this.runtime.dsh, 'node_modules', link.name)
-          && existsSync(join(this.paths.profile, 'node_modules', link.name)))) {
+        && previous.links.every(link => existsSync(link.target)
+          && existsSync(join(this.paths.profile, 'node_modules', link.name))
+          && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) {
         validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile))
         return false
       }
@@ -357,7 +346,6 @@ export class DesktopProjectManager {
         if (previous === undefined) createPluginProfile(stagingProfile)
         else copyDesktopProfile(this.paths.profile, stagingProfile)
         await this.reconcileProfile(stagingProfile, previous)
-        await hooks.healthCheck(stagingProfile)
         await this.activate(stagingProfile, hooks)
         return true
       } catch (error) {
@@ -379,7 +367,6 @@ export class DesktopProjectManager {
         await this.applyMutation(stagingProfile, mutation)
         await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile),
           mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all')
-        await hooks.healthCheck(stagingProfile)
         await this.activate(stagingProfile, hooks)
       } catch (error) {
         removeOwnedDirectory(stagingProfile)

+ 73 - 23
apps/desktop/src/runtime-tree.ts

@@ -1,8 +1,9 @@
 /** Relocatable, integrity-recorded production packages carried by one Desktop release. */
 
 import { createHash } from 'node:crypto'
-import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { existsSync, lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs'
 import { isAbsolute, join, relative, sep } from 'node:path'
+import { promisify } from 'node:util'
 import { valid } from 'semver'
 import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts'
 import { parseDesktopRelease, type DesktopRelease } from './release.ts'
@@ -36,6 +37,7 @@ export interface DesktopRuntimeDescriptor {
 }
 
 const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
+const readRuntimeFile = promisify(readFile)
 
 function record(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -55,27 +57,59 @@ export function runtimePath(root: string, path: string): string {
   return join(root, ...path.split('/'))
 }
 
-/**
- * Inventory a materialized runtime without following links or including its descriptor.
- * @param root - Self-contained runtime directory.
- * @returns Sorted final-file inventory.
- */
-export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] {
-  const files: DesktopRuntimeFile[] = []
+function runtimeFiles(root: string): { path: string; name: string }[] {
+  const files: { path: string; name: string }[] = []
   const visit = (directory: string): void => {
     for (const entry of readdirSync(directory, { withFileTypes: true })) {
       const path = join(directory, entry.name)
       const name = relative(root, path).split(sep).join('/')
       if (name === DESKTOP_RUNTIME_FILE) continue
       if (entry.isDirectory()) visit(path)
-      else if (entry.isFile()) {
-        const body = readFileSync(path)
-        files.push({ path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'),
-          executable: (lstatSync(path).mode & 0o111) !== 0 })
-      } else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`)
+      else if (entry.isFile()) files.push({ path, name })
+      else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`)
     }
   }
   visit(root)
+  return files
+}
+
+function runtimeFile(path: string, name: string, body: Buffer): DesktopRuntimeFile {
+  return { path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'),
+    // Windows has no portable Unix executable permission bits.
+    executable: process.platform !== 'win32' && (lstatSync(path).mode & 0o111) !== 0 }
+}
+
+/**
+ * Inventory a materialized runtime without following links or including its descriptor.
+ * @param root - Self-contained runtime directory.
+ * @returns Sorted final-file inventory; executable permissions are false on Windows.
+ */
+export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] {
+  return runtimeFiles(root).map(({ path, name }) => runtimeFile(path, name, readFileSync(path)))
+    .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
+}
+
+async function inventoryRuntimeForVerification(root: string): Promise<DesktopRuntimeFile[]> {
+  const entries = runtimeFiles(root)
+  const files: DesktopRuntimeFile[] = []
+  const remaining = entries.values()
+  let failed = false
+  const workers = Array.from({ length: Math.min(8, entries.length) }, async () => {
+    while (!failed) {
+      const next = remaining.next()
+      if (next.done) return
+      const { path, name } = next.value
+      try {
+        files.push(runtimeFile(path, name, await readRuntimeFile(path)))
+      } catch (error) {
+        failed = true
+        throw error
+      }
+    }
+  })
+  // Failure returns only after every outstanding file read has closed its descriptor.
+  const results = await Promise.allSettled(workers)
+  for (const result of results) if (result.status === 'rejected') throw result.reason
   return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
 }
 
@@ -109,13 +143,13 @@ export function writeDesktopRuntime(
 }
 
 /**
- * Verify the runtime before linking any of its packages into writable state.
+ * Read release metadata and check shared manifests and Host entries without scanning runtime contents.
  * @param root - Current application's runtime resources.
  * @param electronVersion - Expected shell version.
  * @param target - Required execution target; defaults to the current process.
  * @returns Validated runtime descriptor.
  */
-export function verifyDesktopRuntime(
+export function readDesktopRuntime(
   root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
 ): DesktopRuntimeDescriptor {
   const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8'))
@@ -144,13 +178,6 @@ export function verifyDesktopRuntime(
     runtimePath(root, entry.path)
     return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable }
   }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
-  const actual = inventoryDesktopRuntime(root)
-  // Windows has no portable Unix executable permission bits.
-  const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'
-    ? items.map(({ executable: _executable, ...item }) => item) : items
-  if (JSON.stringify(comparable(files)) !== JSON.stringify(comparable(actual))) {
-    throw new Error('desktop runtime: integrity verification failed')
-  }
   for (const entry of sharedPackages) {
     const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8'))
     if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) {
@@ -163,13 +190,36 @@ export function verifyDesktopRuntime(
     }
   }
   for (const file of DESKTOP_HOST_RUNTIME_FILES) {
-    if (!files.some(entry => entry.path === `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`)) {
+    const path = `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`
+    if (!files.some(entry => entry.path === path) || !existsSync(runtimePath(root, path))
+      || !lstatSync(runtimePath(root, path)).isFile()) {
       throw new Error(`desktop runtime: missing Host file ${file}`)
     }
   }
   return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files }
 }
 
+/**
+ * Verify every packaged runtime file against its recorded bytes and permissions at build time.
+ * @param root - Materialized runtime resources.
+ * @param electronVersion - Expected shell version.
+ * @param target - Required execution target; defaults to the current process.
+ * @returns Validated runtime descriptor.
+ */
+export async function verifyDesktopRuntime(
+  root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
+): Promise<DesktopRuntimeDescriptor> {
+  const descriptor = readDesktopRuntime(root, electronVersion, target)
+  const actual = await inventoryRuntimeForVerification(root)
+  // Windows has no portable Unix executable permission bits.
+  const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'
+    ? items.map(({ executable: _executable, ...item }) => item) : items
+  if (JSON.stringify(comparable(descriptor.files)) !== JSON.stringify(comparable(actual))) {
+    throw new Error('desktop runtime: integrity verification failed')
+  }
+  return descriptor
+}
+
 /**
  * Identify exact runtime content independently of its installation path.
  * @param descriptor - Validated runtime metadata.

+ 170 - 0
apps/desktop/tests/backend-controller.spec.ts

@@ -0,0 +1,170 @@
+import { describe, expect, it, vi } from 'vitest'
+import { DesktopBackendController, type DesktopBackendState } from '../src/backend-controller.ts'
+
+function deferred() {
+  let resolve!: () => void
+  let reject!: (error: Error) => void
+  const promise = new Promise<void>((accept, decline) => { resolve = accept; reject = decline })
+  return { promise, resolve, reject }
+}
+
+function fixture() {
+  const started = deferred()
+  const ready = deferred()
+  const stopping = deferred()
+  const exited = deferred()
+  const states: DesktopBackendState[] = []
+  let fail!: (error: Error) => void
+  const host = {
+    start: vi.fn(() => { started.resolve(); return ready.promise }),
+    stop: vi.fn(() => { stopping.resolve(); return exited.promise }),
+  }
+  const create = vi.fn((onFailure: (error: Error) => void) => { fail = onFailure; return host })
+  const controller = new DesktopBackendController(create, state => states.push(state))
+  return { controller, host, create, states, started, ready, stopping, exited, fail: (error: Error) => { fail(error) } }
+}
+
+describe('desktop backend controller', () => {
+  it('shares preparation and startup between concurrent retries', async () => {
+    const f = fixture()
+    const prepare = vi.fn(async () => {})
+    const first = f.controller.start(prepare)
+    expect(f.controller.start(prepare)).toBe(first)
+    await f.started.promise
+    expect(f.controller.host).toBeUndefined()
+    expect(f.states).toEqual([{ phase: 'starting' }])
+    f.ready.resolve()
+    await first
+    expect(f.controller.host).toBe(f.host)
+    await f.controller.start(prepare)
+    expect(prepare).toHaveBeenCalledTimes(1)
+    expect(f.host.start).toHaveBeenCalledTimes(1)
+    f.exited.resolve()
+    await f.controller.close()
+  })
+
+  it('waits for pending preparation on close and never spawns afterward', async () => {
+    const f = fixture()
+    const preparing = deferred()
+    const prepared = deferred()
+    const start = f.controller.start(() => { preparing.resolve(); return prepared.promise })
+    await preparing.promise
+    let closed = false
+    const close = f.controller.close().then(() => { closed = true })
+    await Promise.resolve()
+    expect(closed).toBe(false)
+    prepared.resolve()
+    await Promise.all([start, close])
+    expect(f.create).not.toHaveBeenCalled()
+    expect(f.states).toEqual([{ phase: 'starting' }])
+    await expect(f.controller.start(async () => {})).rejects.toThrow('closed')
+  })
+
+  it('stops a pending child once and waits for startup and child exit on close', async () => {
+    const f = fixture()
+    const start = f.controller.start(async () => {})
+    const rejected = expect(start).rejects.toThrow('stopped')
+    await f.started.promise
+    const close = f.controller.close()
+    expect(f.controller.close()).toBe(close)
+    await f.stopping.promise
+    let closed = false
+    void close.then(() => { closed = true })
+    f.ready.reject(new Error('stopped'))
+    await Promise.resolve()
+    expect(closed).toBe(false)
+    f.exited.resolve()
+    await Promise.all([rejected, close])
+    expect(f.host.stop).toHaveBeenCalledTimes(1)
+    expect(f.states).toEqual([{ phase: 'starting' }])
+    expect(f.controller.host).toBeUndefined()
+  })
+
+  it('publishes preparation errors and permits retry', async () => {
+    const f = fixture()
+    await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile')
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' })
+    expect(f.create).not.toHaveBeenCalled()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    expect(f.controller.state).toEqual({ phase: 'ready' })
+    f.exited.resolve()
+    await f.controller.close()
+  })
+
+  it('cleans up a failed startup before rejecting and displays the actual failure', async () => {
+    const f = fixture()
+    const start = f.controller.start(async () => {})
+    const rejected = expect(start).rejects.toThrow('plugin failed')
+    await f.started.promise
+    f.ready.reject(new Error('plugin failed'))
+    await f.stopping.promise
+    f.exited.resolve()
+    await rejected
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' })
+    expect(f.host.stop).toHaveBeenCalledTimes(1)
+    await f.controller.close()
+  })
+
+  it('waits for failed ready child cleanup before preparing a retry', async () => {
+    const f = fixture()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    f.fail(new Error('transport failed'))
+    expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' })
+    expect(f.controller.host).toBeUndefined()
+    await f.stopping.promise
+    const prepare = vi.fn(async () => {})
+    const retry = f.controller.start(prepare)
+    await Promise.resolve()
+    expect(prepare).not.toHaveBeenCalled()
+    f.exited.resolve()
+    await retry
+    expect(prepare).toHaveBeenCalledTimes(1)
+    expect(f.create).toHaveBeenCalledTimes(2)
+    await f.controller.close()
+  })
+
+  it('does not publish ready when the child fails as readiness settles', async () => {
+    const f = fixture()
+    const start = f.controller.start(async () => {})
+    const rejected = expect(start).rejects.toThrow('immediate failure')
+    await f.started.promise
+    f.ready.resolve()
+    f.fail(new Error('immediate failure'))
+    await f.stopping.promise
+    f.exited.resolve()
+    await rejected
+    expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }])
+    await f.controller.close()
+  })
+
+  it('ignores failure callbacks caused by intentional stop and permits subsequent start', async () => {
+    const f = fixture()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    const stop = f.controller.stop()
+    await f.stopping.promise
+    f.fail(new Error('stopped'))
+    await expect(f.controller.start(async () => {})).rejects.toThrow('stopping')
+    expect(f.controller.state).toEqual({ phase: 'starting' })
+    f.exited.resolve()
+    await stop
+    await f.controller.start(async () => {})
+    expect(f.create).toHaveBeenCalledTimes(2)
+    await f.controller.close()
+  })
+
+  it('propagates cleanup failure and prevents retry from overlapping the remaining child', async () => {
+    const f = fixture()
+    f.ready.resolve()
+    await f.controller.start(async () => {})
+    f.fail(new Error('fatal'))
+    await f.stopping.promise
+    f.exited.reject(new Error('child did not exit'))
+    await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
+    await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit')
+    expect(f.create).toHaveBeenCalledTimes(1)
+    await expect(f.controller.close()).rejects.toThrow('child did not exit')
+  })
+})

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

@@ -0,0 +1,70 @@
+; 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

+ 55 - 0
apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi

@@ -0,0 +1,55 @@
+; Compile with OUTPUT_FILE, RESULT_FILE, PAYLOAD_FILE, TARGET_DIR and PLUGIN_DIR.
+; All paths must belong to a private test directory, except the NSIS plugin directory.
+; PAYLOAD_FILE contains locked.txt="new runtime" and asset.txt="new asset".
+; Before running /S, create TARGET_DIR\locked.txt="old runtime" and hold it open
+; for reading with FileShare.Read until the native process exits. asset.txt is absent.
+; Define DIRECT for direct extraction; otherwise extract to temporary files and copy.
+; Both modes retain "old runtime" and write "new asset" under that lock. The copy
+; sets errorFlag=true; direct extraction reports false despite the mixed versions.
+; Exit code 0 alone cannot establish successful extraction; inspect RESULT_FILE.
+Unicode true
+RequestExecutionLevel user
+SilentInstall silent
+Name "Desktop occupied-file extraction smoke"
+OutFile "${OUTPUT_FILE}"
+!addplugindir /x86-unicode "${PLUGIN_DIR}"
+
+Section
+  InitPluginsDir
+  SetOutPath "${TARGET_DIR}"
+  !ifdef DIRECT
+    ClearErrors
+    Nsis7z::Extract "${PAYLOAD_FILE}"
+  !else
+    SetOutPath "$PLUGINSDIR\7z-out"
+    Nsis7z::Extract "${PAYLOAD_FILE}"
+    SetOutPath "${TARGET_DIR}"
+    ClearErrors
+    CopyFiles /SILENT "$PLUGINSDIR\7z-out\*" $OUTDIR
+  !endif
+
+  StrCpy $0 "false"
+  IfErrors 0 +2
+    StrCpy $0 "true"
+  ClearErrors
+  FileOpen $1 "${RESULT_FILE}" w
+  IfErrors failed
+  FileWrite $1 "errorFlag=$0$\r$\n"
+  FileOpen $2 "${TARGET_DIR}\locked.txt" r
+  IfErrors failed
+  FileRead $2 $3
+  FileClose $2
+  FileWrite $1 "locked=$3$\r$\n"
+  FileOpen $2 "${TARGET_DIR}\asset.txt" r
+  IfErrors failed
+  FileRead $2 $3
+  FileClose $2
+  FileWrite $1 "asset=$3$\r$\n"
+  FileClose $1
+  SetErrorLevel 0
+  Quit
+
+  failed:
+  SetErrorLevel 1
+  Quit
+SectionEnd

+ 29 - 0
apps/desktop/tests/fixtures/owned-directory-smoke.mjs

@@ -0,0 +1,29 @@
+/** Built Desktop cleanup smoke; run with Electron and ELECTRON_RUN_AS_NODE=1. */
+
+import assert from 'node:assert/strict'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { removeOwnedDirectory } from '../../lib/types/owned-directory.js'
+import { inventoryDesktopRuntime } from '../../lib/types/runtime-tree.js'
+
+assert.ok(process.versions.electron, 'This regression must run under Electron')
+const root = mkdtempSync(join(tmpdir(), 'desktop-electron-cleanup-'))
+try {
+  const resources = join(root, 'resources')
+  mkdirSync(resources)
+  writeFileSync(join(resources, 'sentinel'), 'installed runtime bytes')
+  const inventory = inventoryDesktopRuntime(resources)
+  const profile = join(root, 'rollback-profile')
+  const packages = join(profile, 'node_modules', '@unknown')
+  mkdirSync(packages, { recursive: true })
+  symlinkSync(resources, join(packages, 'host-package'), process.platform === 'win32' ? 'junction' : 'dir')
+  writeFileSync(join(packages, 'private-file'), 'removed')
+  removeOwnedDirectory(profile)
+  assert.equal(existsSync(profile), false)
+  assert.deepEqual(inventoryDesktopRuntime(resources), inventory)
+  assert.equal(readFileSync(join(resources, 'sentinel'), 'utf8'), 'installed runtime bytes')
+  console.log(JSON.stringify({ electron: process.versions.electron, node: process.version, preservedLinkTarget: true }))
+} finally {
+  removeOwnedDirectory(root)
+}

+ 138 - 0
apps/desktop/tests/fixtures/runtime-payload-smoke.mjs

@@ -0,0 +1,138 @@
+/** Exercise filtered Desktop native and HTML dependencies under its bundled Node. */
+
+import assert from 'node:assert/strict'
+import { closeSync, mkdtempSync, openSync, readFileSync, readSync, 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'
+
+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.platform, descriptor.platform)
+assert.equal(process.arch, descriptor.arch)
+const requireRuntime = createRequire(join(root, 'package.json'))
+const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-'))
+
+/** 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)
+  )))
+  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 })
+  let output = ''
+  let exited = false
+  let timedOut = false
+  let exitSubscription
+  const exit = new Promise(resolveExit => {
+    exitSubscription = terminal.onExit(event => {
+      exited = true
+      resolveExit(event)
+    })
+  })
+  const dataSubscription = terminal.onData(data => { output += data })
+  let timer
+  try {
+    const deadline = new Promise((_, reject) => {
+      timer = setTimeout(() => {
+        timedOut = true
+        reject(new Error('Packaged PTY did not exit within 45 seconds'))
+      }, 45_000)
+    })
+    const result = await Promise.race([exit, deadline])
+    assert.equal(timedOut, false)
+    assert.ok(result.signal === undefined || result.signal === 0, 'PTY exited without a signal')
+    assert.equal(result.exitCode, 0)
+    assert.match(output, /runtime-payload-pty-ok/u)
+  } finally {
+    clearTimeout(timer)
+    dataSubscription.dispose()
+    try {
+      // node-pty's Windows natural-exit event closes output but leaves its ConPTY worker owned by kill().
+      if (!exited || process.platform === 'win32') terminal.kill()
+      await exit
+    } finally {
+      exitSubscription.dispose()
+    }
+  }
+}
+
+/** fs-ext implements seek on Windows through SetFilePointerEx and on POSIX through lseek. */
+function checkFsExt() {
+  const fsExt = requireRuntime('fs-ext')
+  const file = join(scratch, 'seek.txt')
+  writeFileSync(file, 'abcdef', { flag: 'wx', mode: 0o600 })
+  const fd = openSync(file, 'r')
+  try {
+    assert.equal(fsExt.seekSync(fd, 2, fsExt.constants.SEEK_SET), 2)
+    const bytes = Buffer.alloc(4)
+    assert.equal(readSync(fd, bytes, 0, bytes.length, null), 4)
+    assert.equal(bytes.toString(), 'cdef')
+  } finally {
+    closeSync(fd)
+  }
+}
+
+/** Resolve one system function through Koffi's packaged native module. */
+function checkKoffi() {
+  const koffi = requireRuntime('koffi')
+  const library = koffi.load(process.platform === 'win32' ? 'kernel32.dll' : null)
+  try {
+    const getPid = process.platform === 'win32'
+      ? library.func('uint32_t __stdcall GetCurrentProcessId(void)')
+      : library.func('int getpid(void)')
+    assert.equal(getPid(), process.pid)
+  } finally {
+    library.unload()
+  }
+}
+
+/** Encode and decode a pixel through the packaged libvips binary. */
+async function checkSharp() {
+  const sharp = requireRuntime('sharp')
+  const pixel = Buffer.from([17, 103, 231])
+  const png = await sharp(pixel, { raw: { width: 1, height: 1, channels: 3 } }).png().toBuffer()
+  const decoded = await sharp(png).raw().toBuffer({ resolveWithObject: true })
+  assert.equal(decoded.info.width, 1)
+  assert.equal(decoded.info.height, 1)
+  assert.equal(decoded.info.channels, 3)
+  assert.deepEqual(decoded.data, pixel)
+}
+
+/** Exercise Domino parsing through the HTML converter and GFM plugin used by web_fetch. */
+function checkHtml() {
+  const Turndown = requireRuntime('turndown')
+  const { gfm } = requireRuntime('@joplin/turndown-plugin-gfm')
+  const converter = new Turndown({ bulletListMarker: '-' })
+  converter.use(gfm)
+  const markdown = converter.turndown('<p>A &amp; B &copy;</p><ul><li>first</li><li>second</li></ul>'
+    + '<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>x</td><td>7</td></tr></tbody></table>')
+  assert.match(markdown, /A & B ©/u)
+  assert.match(markdown, /-\s+first\n-\s+second/u)
+  assert.match(markdown, /\| Name \| Value \|/u)
+  assert.match(markdown, /\| x\s+\| 7\s+\|/u)
+}
+
+try {
+  checkFsExt()
+  checkKoffi()
+  await checkSharp()
+  checkHtml()
+  await checkPty()
+} finally {
+  // This private tree contains only fixture files; Windows may release handles after terminal exit.
+  await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 })
+}
+
+// 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,
+    fsExt: true, koffi: true, sharp: true, html: true, pty: true }))
+})

+ 25 - 1
apps/desktop/tests/host-process.spec.ts

@@ -1,7 +1,7 @@
 import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { DesktopHostProcess } from '../src/host-process.ts'
 
 const roots: string[] = []
@@ -75,6 +75,30 @@ afterEach(() => {
 })
 
 describe('desktop host process', () => {
+  it('reports a fatal event after readiness once and stops the child', async () => {
+    const runtime = projectWithHost(`
+process.send({ type: 'ready', protocolVersion: 3, dshVersion: '1.0.0' })
+function onRequestFrame(frame) {
+  if (frame.type === 1) process.send({ type: 'fatal', message: 'plugin unavailable' })
+}
+`)
+    const failure = vi.fn()
+    const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, failure)
+    try {
+      await host.start()
+      await expect(host.fetch(new Request('dsh-app://app/'))).rejects.toThrow('plugin unavailable')
+      await host.stop()
+      expect(failure).toHaveBeenCalledTimes(1)
+      expect(failure).toHaveBeenCalledWith(new Error('plugin unavailable'))
+    } finally { await host.stop() }
+  })
+
+  it('settles teardown when the executable cannot be spawned', async () => {
+    const runtime = projectWithHost('function onRequestFrame() {}')
+    const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime)
+    try { await expect(host.start()).rejects.toThrow() } finally { await host.stop() }
+  })
+
   it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => {
     const runtime = projectWithHost(`
 process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' })

+ 25 - 2
apps/desktop/tests/macos-signature.spec.ts

@@ -105,10 +105,10 @@ describe('desktop macOS release signature', () => {
         join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value,
       ))
       await copyFiles(matchers.slice(0, 1))
-      expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).toThrow(/integrity/u)
+      await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).rejects.toThrow(/ENOENT/u)
       rmSync(destination, { recursive: true })
       await copyFiles(matchers)
-      expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).not.toThrow()
+      await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).resolves.toMatchObject({ release: { version: '1.0.0' } })
     } finally { rmSync(root, { recursive: true, force: true }) }
   })
 
@@ -120,6 +120,29 @@ describe('desktop macOS release signature', () => {
     }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
   })
 
+  it('isolates unsigned Windows artifacts and omits updater metadata without release credentials', async () => {
+    const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
+    const config = createElectronBuilderConfig({
+      DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
+      DSH_DESKTOP_TARGET_PLATFORM: 'win32',
+      DSH_DESKTOP_UNSIGNED: '1',
+    }, 'win32', 'x64')
+    expect(portablePath(config.directories.output)).toContain('/targets/win-x64/unsigned-artifacts')
+    expect(portablePath(config.nsis.include)).toMatch(/\/scripts\/installer\.nsh$/u)
+    expect(config).toMatchObject({
+      win: { forceCodeSigning: false, signtoolOptions: { sign: undefined } },
+      publish: null,
+    })
+  })
+
+  it('rejects unsigned macOS builds and malformed signing modes', async () => {
+    const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
+    expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: '1' }))
+      .toThrow(/unsigned builds require Windows/u)
+    expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: 'yes' }))
+      .toThrow(/must be 0 or 1/u)
+  })
+
   it('accepts the configured authority and team', () => {
     const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT)
     expect(() => {

+ 200 - 0
apps/desktop/tests/main-startup.spec.ts

@@ -0,0 +1,200 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DESKTOP_IPC } from '../src/ipc.ts'
+
+const harness = await vi.hoisted(async () => {
+  const { EventEmitter } = await import('node:events')
+  function deferred() {
+    let resolve!: () => void
+    let reject!: (error: Error) => void
+    const promise = new Promise<void>((accept, decline) => { resolve = accept; reject = decline })
+    return { promise, resolve, reject }
+  }
+  const windows: FakeWindow[] = []
+  const hosts: FakeHost[] = []
+  const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
+  let preparing = deferred()
+  let prepared = deferred()
+  let hostStarted = deferred()
+  let navigated = deferred()
+  let errorPublished = deferred()
+  let quitCompleted = deferred()
+  class FakeWindow extends EventEmitter {
+    destroyed = false
+    readonly urls: string[] = []
+    readonly webContents = Object.assign(new EventEmitter(), {
+      setWindowOpenHandler: vi.fn(),
+      openDevTools: vi.fn(),
+      send: vi.fn((channel: string, state: { phase?: string }) => {
+        if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve()
+      }),
+    })
+    readonly show = vi.fn()
+    readonly focus = vi.fn()
+    readonly restore = vi.fn()
+    constructor(readonly options: { show: boolean }) { super(); windows.push(this) }
+    isDestroyed() { return this.destroyed }
+    isMinimized() { return false }
+    async loadURL(url: string) {
+      this.urls.push(url)
+      if (url === 'dsh-app://app/index.html') navigated.resolve()
+    }
+    static getAllWindows() { return windows.filter(window => !window.destroyed) }
+    close() { this.destroyed = true; this.emit('closed') }
+  }
+  class FakeHost {
+    readonly ready = deferred()
+    readonly exited = deferred()
+    readonly stopping = deferred()
+    readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise })
+    readonly stop = vi.fn(() => {
+      this.stopping.resolve()
+      this.ready.reject(new Error('child stopped'))
+      return this.exited.promise
+    })
+    constructor(..._args: unknown[]) { hosts.push(this) }
+  }
+  const app = Object.assign(new EventEmitter(), {
+    isPackaged: false,
+    name: 'Desktop test',
+    whenReady: () => Promise.resolve(),
+    getLocale: () => 'en-US',
+    getVersion: () => '1.0.0',
+    requestSingleInstanceLock: () => true,
+    exit: vi.fn(),
+    quit: vi.fn(() => {
+      const event = { preventDefault: vi.fn() }
+      app.emit('before-quit', event)
+      if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve()
+    }),
+  })
+  return {
+    windows, hosts, handlers, app, FakeWindow, FakeHost,
+    dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
+    applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
+    get preparing() { return preparing }, get prepared() { return prepared },
+    get hostStarted() { return hostStarted }, get navigated() { return navigated },
+    get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
+    nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
+    reset() {
+      windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
+      preparing = deferred(); prepared = deferred(); hostStarted = deferred()
+      navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred()
+    },
+  }
+})
+
+vi.mock('electron', () => ({
+  app: harness.app,
+  BrowserWindow: harness.FakeWindow,
+  dialog: harness.dialog,
+  ipcMain: {
+    handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) },
+  },
+  Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() },
+  protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
+}))
+vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
+vi.mock('../src/project-manager.ts', () => ({
+  DesktopProjectManager: class {
+    readonly applyRelease = harness.applyRelease
+    readonly assertProfileRuntime = vi.fn()
+  },
+}))
+vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost }))
+vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() }))
+
+function invoke(channel: string): unknown {
+  const handler = harness.handlers.get(channel)
+  if (handler === undefined) throw new Error(`missing handler ${channel}`)
+  return handler({ senderFrame: { url: 'dsh-app://shell/startup.html' } })
+}
+
+beforeEach(() => {
+  vi.resetModules()
+  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.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
+})
+
+afterEach(async () => {
+  harness.prepared.resolve()
+  for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() }
+  harness.app.quit()
+  await harness.quitCompleted.promise
+  vi.clearAllTimers()
+  vi.useRealTimers()
+  vi.unstubAllEnvs()
+})
+
+describe('desktop main startup', () => {
+  it('shows the loading window before profile preparation and starts one actual Host', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    expect(harness.windows).toHaveLength(1)
+    const window = harness.windows[0]!
+    expect(window.options.show).toBe(true)
+    expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
+    expect(harness.hosts).toHaveLength(0)
+    const retry = invoke(DESKTOP_IPC.backendRetry)
+    const secondRetry = invoke(DESKTOP_IPC.backendRetry)
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    expect(harness.hosts).toHaveLength(1)
+    expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
+    harness.hosts[0]!.ready.resolve()
+    await Promise.all([retry, secondRetry, harness.navigated.promise])
+    expect(harness.applyRelease).toHaveBeenCalledTimes(1)
+    expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
+    expect(harness.windows).toHaveLength(1)
+    expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html'])
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
+  })
+
+  it('keeps startup errors and a successful retry in the same window', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    const first = harness.hosts[0]!
+    const failedRetry = expect(Promise.resolve(invoke(DESKTOP_IPC.backendRetry))).rejects.toThrow('plugin composition failed')
+    first.exited.resolve()
+    first.ready.reject(new Error('plugin composition failed'))
+    await harness.errorPublished.promise
+    await failedRetry
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' })
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html'])
+    const nextStarted = harness.nextHostStart()
+    const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    await nextStarted
+    expect(harness.hosts).toHaveLength(2)
+    harness.hosts[1]!.ready.resolve()
+    await retry
+    expect(harness.windows).toHaveLength(1)
+    expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/index.html')
+    expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
+  })
+
+  it('waits for a pending child to exit on quit without late window navigation', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    const window = harness.windows[0]!
+    const host = harness.hosts[0]!
+    host.stop.mockImplementation(() => { host.stopping.resolve(); return host.exited.promise })
+    window.close()
+    harness.app.quit()
+    await host.stopping.promise
+    expect(harness.app.quit).toHaveBeenCalledTimes(1)
+    host.ready.resolve()
+    host.exited.resolve()
+    await harness.quitCompleted.promise
+    expect(host.stop).toHaveBeenCalledTimes(1)
+    expect(window.urls).toEqual(['dsh-app://shell/startup.html'])
+    expect(harness.windows).toHaveLength(1)
+  })
+})

+ 53 - 0
apps/desktop/tests/owned-directory.spec.ts

@@ -0,0 +1,53 @@
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import { removeOwnedDirectory } from '../src/owned-directory.ts'
+
+const roots: string[] = []
+function fixture(): string {
+  const root = mkdtempSync(join(tmpdir(), 'desktop-owned-directory-'))
+  roots.push(root)
+  return root
+}
+
+afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
+
+it('removes nested files and directories while preserving unknown directory-link targets', () => {
+  const root = fixture()
+  const target = join(root, 'target')
+  mkdirSync(target)
+  writeFileSync(join(target, 'sentinel'), 'preserved')
+  const owned = join(root, 'owned')
+  const nested = join(owned, 'node_modules', '@unknown', 'private')
+  mkdirSync(nested, { recursive: true })
+  writeFileSync(join(nested, 'file'), 'remove')
+  symlinkSync(target, join(nested, 'link'), process.platform === 'win32' ? 'junction' : 'dir')
+  symlinkSync(join(root, 'missing-target'), join(owned, 'broken'), process.platform === 'win32' ? 'junction' : 'dir')
+  removeOwnedDirectory(owned)
+  expect(existsSync(owned)).toBe(false)
+  expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved')
+})
+
+it.each([false, true])('unlinks a root directory link with missing target: %s', (missing) => {
+  const root = fixture()
+  const target = join(root, 'target')
+  if (!missing) {
+    mkdirSync(target)
+    writeFileSync(join(target, 'sentinel'), 'preserved')
+  }
+  const link = join(root, 'link')
+  symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir')
+  removeOwnedDirectory(link)
+  expect(lstatSync(link, { throwIfNoEntry: false })).toBeUndefined()
+  if (!missing) expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved')
+})
+
+it('ignores a missing root and refuses to delete a regular-file root', () => {
+  const root = fixture()
+  expect(() => { removeOwnedDirectory(join(root, 'missing')) }).not.toThrow()
+  const file = join(root, 'file')
+  writeFileSync(file, 'preserved')
+  expect(() => { removeOwnedDirectory(file) }).toThrow(/not a directory/u)
+  expect(readFileSync(file, 'utf8')).toBe('preserved')
+})

+ 40 - 0
apps/desktop/tests/package-target.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import {
   desktopElectronBuilderArguments,
+  desktopElectronBuilderEnvironment,
   parseDesktopPackageInvocation,
   resolveDesktopPackageTarget,
   withoutDesktopUploadCredentials,
@@ -56,6 +57,45 @@ describe('desktop package target', () => {
     expect(desktopElectronBuilderArguments(target, true)).toContain('--dir')
   })
 
+  it('accepts unsigned Windows artifacts and rejects other targets or preparation-only use', () => {
+    expect(parseDesktopPackageInvocation(['win-x64', '--unsigned'], 'win32', 'x64').unsigned).toBe(true)
+    expect(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64').unsigned).toBe(false)
+    expect(parseDesktopPackageInvocation(['--unsigned', '--dir'], 'win32', 'x64')).toMatchObject({
+      unsigned: true, directory: true,
+    })
+    expect(() => parseDesktopPackageInvocation(['mac-arm64', '--unsigned'], 'darwin', 'arm64'))
+      .toThrow(/requires win-x64/u)
+    expect(() => parseDesktopPackageInvocation(['--unsigned', '--prepare-only'], 'win32', 'x64'))
+      .toThrow(/cannot use --prepare-only/u)
+  })
+
+  it('removes ambient certificate inputs for unsigned builds and overrides an inherited signing mode', () => {
+    const environment = {
+      DSH_DESKTOP_APP_ID: 'com.example.desktop',
+      DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
+      CSC_LINK: 'private.pfx',
+      CSC_KEY_PASSWORD: 'secret',
+      WIN_CSC_LINK: 'windows.pfx',
+      CSC_IDENTITY_AUTO_DISCOVERY: 'true',
+      DSH_DESKTOP_UNSIGNED: '1',
+    }
+    expect(desktopElectronBuilderEnvironment(environment, true)).toEqual({
+      DSH_DESKTOP_APP_ID: 'com.example.desktop',
+      CSC_IDENTITY_AUTO_DISCOVERY: 'false',
+      DSH_DESKTOP_UNSIGNED: '1',
+    })
+    expect(desktopElectronBuilderEnvironment(environment, false)).toEqual({ ...environment, DSH_DESKTOP_UNSIGNED: '0' })
+  })
+
+  it.each([false, true])('pins the Windows archive filter for the NSIS decoder (unsigned: %s)', (unsigned) => {
+    expect(desktopElectronBuilderEnvironment({
+      DSH_DESKTOP_TARGET_PLATFORM: 'win32', ELECTRON_BUILDER_7Z_FILTER: 'ARM64',
+    }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('BCJ')
+    expect(desktopElectronBuilderEnvironment({
+      DSH_DESKTOP_TARGET_PLATFORM: 'darwin', ELECTRON_BUILDER_7Z_FILTER: 'ARM',
+    }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('ARM')
+  })
+
   it('keeps Windows signing fields out of build and runtime preparation subprocesses', () => {
     expect(withoutWindowsSigningEnvironment({
       DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer',

+ 1 - 1
apps/desktop/tests/plugin-manager.spec.ts

@@ -12,7 +12,7 @@ it('keeps disabled packages visible and offers recovery without a running backen
   const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active })
   const api = {
     locale: async () => resolveDesktopLocale('en'),
-    backend: { status: async () => ({ ready, error: ready ? undefined : 'plugin requires Cordis ^2.0.0' }), retry: vi.fn() },
+    backend: { status: async () => ready ? { phase: 'ready' } : { phase: 'error', message: 'plugin requires Cordis ^2.0.0' }, retry: vi.fn() },
     plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle },
   }
   Object.defineProperty(dom.window, 'dshDesktop', { value: api })

+ 1 - 1
apps/desktop/tests/plugin-pnpm.spec.ts

@@ -47,7 +47,7 @@ it('installs a real pnpm graph, then executes approved scripts with the shared h
     const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs')
     writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`)
     const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh })
-    const hooks: DesktopProjectHooks = { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {} }
+    const hooks: DesktopProjectHooks = { beforeActivate: async () => {}, afterActivate: async () => {} }
     await manager.applyRelease('1.0.0', hooks)
     await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks)
     expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }])

+ 37 - 0
apps/desktop/tests/preload-app.spec.ts

@@ -0,0 +1,37 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { DESKTOP_IPC, type DshDesktopStartupApi } from '../src/ipc.ts'
+
+const electron = vi.hoisted(() => ({
+  contextBridge: { exposeInMainWorld: vi.fn() },
+  ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn() },
+}))
+vi.mock('electron', () => electron)
+
+afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
+
+it.each(['dsh-app://app/index.html', 'https://shell/startup.html'])('exposes only the carrier marker to %s', async (url) => {
+  vi.stubGlobal('location', new URL(url))
+  await import('../src/preload-app.ts')
+  expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
+})
+
+it('provides startup controls and a removable state subscription to shell documents', async () => {
+  vi.stubGlobal('location', new URL('dsh-app://shell/startup.html'))
+  await import('../src/preload-app.ts')
+  const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]?.[1] as DshDesktopStartupApi
+  await api.locale()
+  await api.backend.status()
+  await api.backend.retry()
+  await api.openPlugins()
+  expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
+    [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen],
+  ])
+  const listener = vi.fn()
+  const dispose = api.backend.subscribe(listener)
+  const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void
+  handler({}, { phase: 'error', message: 'startup failed' })
+  expect(listener).toHaveBeenCalledWith({ phase: 'error', message: 'startup failed' })
+  dispose()
+  expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.backendState, handler)
+  expect(api).not.toHaveProperty('plugins')
+})

+ 32 - 3
apps/desktop/tests/project-manager.spec.ts

@@ -48,7 +48,7 @@ if (command !== 'rebuild') {
   return path
 }
 function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
-  return { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides }
+  return { beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides }
 }
 function setup(): { root: string; manager: DesktopProjectManager } {
   const root = temporaryRoot()
@@ -91,6 +91,36 @@ describe('desktop external plugin profile', () => {
     expect(calls(root)).toEqual([])
   })
 
+  it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => {
+    const { manager } = setup()
+    await manager.applyRelease('1.0.0', hooks())
+    const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() })
+    let started = false
+    await expect(relaunched.applyRelease('1.0.0', hooks({
+      afterActivate: async () => { started = true },
+    }))).resolves.toBe(false)
+    expect(started).toBe(false)
+  })
+
+  it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
+    const { root, manager } = setup()
+    if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}')
+    if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
+    if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
+    if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json'))
+    let starts = 0
+    await expect(manager.applyRelease('1.0.0', hooks({
+      afterActivate: async () => { starts++ },
+    }))).resolves.toBe(true)
+    const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
+    await expect(relaunched.applyRelease('1.0.0', hooks({
+      afterActivate: async () => { starts++ },
+    }))).resolves.toBe(false)
+    expect(starts).toBe(1)
+    expect(existsSync(manager.paths.profile)).toBe(true)
+    expect(calls(root)).toEqual([])
+  })
+
   it('installs only plugins and checks the graph before running lifecycle scripts', async () => {
     const { root, manager } = setup()
     await manager.applyRelease('1.0.0', hooks())
@@ -166,12 +196,11 @@ describe('desktop external plugin profile', () => {
     expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
   })
 
-  it.each(['health', 'before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => {
+  it.each(['before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => {
     const { manager } = setup()
     await manager.applyRelease('1.0.0', hooks())
     let starts = 0
     await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
-      healthCheck: async () => { if (phase === 'health') throw new Error('health failed') },
       beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') },
       afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') },
     }))).rejects.toThrow(`${phase} failed`)

+ 81 - 0
apps/desktop/tests/runtime-file-policy.spec.ts

@@ -0,0 +1,81 @@
+import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, relative } from 'node:path'
+import { expect, it } from 'vitest'
+import { desktopRuntimeFileExclusion } from '../scripts/runtime-file-policy.ts'
+import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts'
+import { runtimeFixture } from './runtime-fixture.ts'
+
+const windows = { platform: 'win32' as const, arch: 'x64' }
+
+it('omits development artifacts while preserving executable modules, assets and license files', async () => {
+  const root = mkdtempSync(join(tmpdir(), 'desktop-file-policy-'))
+  const source = join(root, 'source')
+  const output = join(root, 'output')
+  const removed = [
+    'example/index.d.ts', 'example/index.d.mts', 'example/index.d.cts',
+    'example/index.js.map', 'example/index.mjs.map', 'example/index.cjs.map',
+    'example/style.css.map', 'example/index.d.ts.map', 'example/index.d.mts.map',
+    'example/index.d.cts.map', 'example/tsconfig.tsbuildinfo',
+    'fs-ext/build/Release/obj/fs_ext/native.obj', 'fs-ext/build/Release/fs_ext.pdb',
+    'fs-ext/build/Release/fs_ext.lib', 'fs-ext/build/Release/fs_ext.exp',
+    'fs-ext/build/Release/fs_ext.iobj', 'fs-ext/build/Release/fs_ext.ipdb',
+    'fs-ext/build/binding.sln', 'fs-ext/build/config.gypi',
+    'fs-ext/build/fs_ext.vcxproj', 'fs-ext/build/fs_ext.vcxproj.filters',
+    'node-pty/prebuilds/win32-arm64/conpty.node',
+    'node-pty/prebuilds/linux-x64/pty.node', 'node-pty/prebuilds/darwin-x64/pty.node',
+    'node-pty/prebuilds/win32-x64/conpty.pdb',
+    '@koromix/koffi-win32-x64/win32_x64/koffi.lib',
+    '@mixmark-io/domino/test/entities.html',
+    '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache',
+  ]
+  const retained = [
+    'example/index.js', 'example/index.cjs', 'example/index.mjs', 'example/worker.js',
+    'example/source.ts', 'example/src/entry.ts', 'example/test/runtime-fixture.json',
+    'example/locale.json', 'example/data.map', 'example/module.wasm',
+    'example/package.json', 'example/LICENSE', 'example/NOTICE', 'example/README.md',
+    'example/native.lib', 'example/native.obj', 'example/symbols.pdb',
+    'fs-ext/build/Release/fs_ext.node', 'fs-ext/build/Release/runtime.dll',
+    'node-pty/prebuilds/win32-x64/conpty.node',
+    'node-pty/prebuilds/win32-x64/conpty_console_list.node',
+    'node-pty/prebuilds/win32-x64/conpty/conpty.dll',
+    'node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe',
+    'node-pty/third_party/conpty/win10-x64/OpenConsole.exe',
+    '@koromix/koffi-win32-x64/win32_x64/koffi.node',
+    '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js',
+    '@img/sharp-win32-x64/lib/libvips-42.dll',
+  ]
+  try {
+    const runtime = runtimeFixture(source)
+    const modules = join(source, 'node_modules')
+    for (const path of [...removed, ...retained]) {
+      mkdirSync(join(modules, path, '..'), { recursive: true })
+      writeFileSync(join(modules, path), `payload:${path}`)
+    }
+    cpSync(modules, join(output, 'node_modules'), {
+      recursive: true, dereference: true,
+      filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows) === undefined,
+    })
+    for (const path of removed) expect(existsSync(join(output, 'node_modules', path)), path).toBe(false)
+    for (const path of retained) expect(readFileSync(join(output, 'node_modules', path), 'utf8'), path).toBe(`payload:${path}`)
+    const sealed = writeDesktopRuntime(output, runtime.release, runtime.sharedPackages.map(entry => entry.name))
+    expect(await verifyDesktopRuntime(output, runtime.release.version)).toEqual(sealed)
+    expect(readFileSync(join(modules, removed[0]!), 'utf8')).toBe(`payload:${removed[0]}`)
+  } finally {
+    rmSync(root, { recursive: true, force: true })
+  }
+})
+
+it('applies package-specific rules inside scoped and nested dependency containers', () => {
+  expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows)).toBeDefined()
+  expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows)).toBeDefined()
+  expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows)).toBeUndefined()
+  expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows)).toBeUndefined()
+})
+
+it('retains native prebuilds for the selected macOS architecture', () => {
+  const mac = { platform: 'darwin' as const, arch: 'arm64' }
+  expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac)).toBeUndefined()
+  expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac)).toBeDefined()
+  expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac)).toBeDefined()
+})

+ 49 - 0
apps/desktop/tests/runtime-tree-read-failure.spec.ts

@@ -0,0 +1,49 @@
+import { mkdtempSync, readFile, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { setImmediate } from 'node:timers/promises'
+import { expect, it, vi } from 'vitest'
+import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
+import { runtimeFixture } from './runtime-fixture.ts'
+
+vi.mock('node:fs', async importOriginal => ({
+  ...await importOriginal<typeof import('node:fs')>(),
+  readFile: vi.fn(),
+}))
+
+it('drains outstanding reads before rejecting and stops scheduling after a read failure', async () => {
+  const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-read-failure-'))
+  const pending: ((error?: Error) => void)[] = []
+  vi.mocked(readFile).mockImplementation((...args: unknown[]) => {
+    const path = args[0]
+    const callback = args.at(-1)
+    if (typeof path !== 'string' || typeof callback !== 'function') throw new Error('unexpected readFile arguments')
+    const complete = callback as (error: Error | null, body: Buffer | undefined) => void
+    pending.push((error) => { complete(error ?? null, error === undefined ? readFileSync(path) : undefined) })
+  })
+  let outcome: Promise<unknown> | undefined
+  try {
+    runtimeFixture(root)
+    let settled = false
+    outcome = verifyDesktopRuntime(root, '1.0.0').finally(() => { settled = true }).catch((error: unknown) => error)
+    const inFlight = pending.length
+    expect(inFlight).toBeGreaterThan(1)
+    const failure = new Error('runtime read failed')
+    pending.shift()!(failure)
+    try {
+      // Pending callbacks keep the reads open across a complete event-loop turn.
+      await setImmediate()
+      expect(settled).toBe(false)
+      expect(pending).toHaveLength(inFlight - 1)
+    } finally {
+      for (const complete of pending.splice(0)) complete()
+    }
+    expect(await outcome).toBe(failure)
+    expect(pending).toHaveLength(0)
+  } finally {
+    for (const complete of pending.splice(0)) complete(new Error('test cleanup'))
+    await outcome
+    vi.mocked(readFile).mockReset()
+    rmSync(root, { recursive: true, force: true })
+  }
+})

+ 40 - 12
apps/desktop/tests/runtime-tree.spec.ts

@@ -1,8 +1,9 @@
-import { cpSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, expect, it } from 'vitest'
-import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
+import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts'
+import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
 import { runtimeFixture } from './runtime-fixture.ts'
 
 const roots: string[] = []
@@ -14,32 +15,59 @@ function fixture(): string {
 }
 afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
 
-it('verifies a runtime after relocation without depending on build paths', () => {
+it('verifies a runtime after relocation without depending on build paths', async () => {
   const root = fixture()
-  const before = verifyDesktopRuntime(join(root, 'dsh'), '1.0.0')
+  const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0')
   cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true })
-  expect(desktopRuntimeId(verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before))
+  expect(desktopRuntimeId(await verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before))
 })
-it.each(['changed', 'extra', 'missing'])('rejects %s runtime bytes', (operation) => {
+it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => {
   const dsh = join(fixture(), 'dsh')
+  const before = readDesktopRuntime(dsh, '1.0.0')
   if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}')
+  if (operation === 'same-size') writeFileSync(join(dsh, 'package.json'), '{"type":"Module"}\n')
   if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '')
   if (operation === 'missing') rmSync(join(dsh, 'package.json'))
-  expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/integrity/u)
+  expect(readDesktopRuntime(dsh, '1.0.0')).toEqual(before)
+  await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u)
 })
-it('rejects filesystem links and incompatible targets', () => {
+it('rejects filesystem links and incompatible targets', async () => {
   const dsh = join(fixture(), 'dsh')
-  expect(() => verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u)
+  await expect(verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).rejects.toThrow(/incompatible/u)
   symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir')
-  expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/unsupported filesystem/u)
+  expect(readDesktopRuntime(dsh, '1.0.0').release.version).toBe('1.0.0')
+  await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/unsupported filesystem/u)
 })
-it('rejects a descriptor that maps a shared package outside node_modules', () => {
+it.each(['missing', 'directory'])('rejects a %s Host entry during startup metadata loading', (operation) => {
+  const dsh = join(fixture(), 'dsh')
+  const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES[0])
+  rmSync(path)
+  if (operation === 'directory') mkdirSync(path)
+  expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/missing Host file/u)
+})
+it('rejects incompatible targets and shared package metadata during startup metadata loading', () => {
+  const dsh = join(fixture(), 'dsh')
+  expect(() => readDesktopRuntime(dsh, '2.0.0')).toThrow(/does not match Electron/u)
+  expect(() => readDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u)
+  writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}')
+  expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package metadata mismatch/u)
+})
+it('rejects a descriptor that maps a shared package outside node_modules', async () => {
   const dsh = join(fixture(), 'dsh')
   const path = join(dsh, DESKTOP_RUNTIME_FILE)
   const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { sharedPackages: { path: string }[] }
   descriptor.sharedPackages[0]!.path = '../outside'
   writeFileSync(path, JSON.stringify(descriptor))
-  expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package record/u)
+  await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/shared package record/u)
+})
+it('verifies recorded executable permissions only on Unix', async () => {
+  const dsh = join(fixture(), 'dsh')
+  const path = join(dsh, DESKTOP_RUNTIME_FILE)
+  const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: { executable: boolean }[] }
+  descriptor.files[0]!.executable = !descriptor.files[0]!.executable
+  writeFileSync(path, JSON.stringify(descriptor))
+  if (process.platform === 'win32') await expect(verifyDesktopRuntime(dsh, '1.0.0')).resolves.toMatchObject(descriptor)
+  else await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u)
 })
 it.each(['../outside', '/absolute', 'C:/absolute', 'a\\b', 'a//b', './a'])('rejects nonportable path %s', (path) => {
   expect(() => runtimePath('/runtime', path)).toThrow(/invalid relative path/u)

+ 134 - 0
apps/desktop/tests/startup-renderer.spec.ts

@@ -0,0 +1,134 @@
+import { readFileSync } from 'node:fs'
+import { runInContext } from 'node:vm'
+import { JSDOM } from 'jsdom'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import type { DesktopBackendState } from '../src/backend-controller.ts'
+import type { DshDesktopStartupApi } from '../src/ipc.ts'
+import { resolveDesktopLocale } from '../src/locale.ts'
+
+function startup(locale = 'en', status: Promise<DesktopBackendState> = Promise.resolve({ phase: 'starting' })) {
+  const dom = new JSDOM(readFileSync(new URL('../renderer/startup.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
+  onTestFinished(() => {
+    dom.window.dispatchEvent(new dom.window.Event('pagehide'))
+    dom.window.close()
+  })
+  const listeners = new Set<(state: DesktopBackendState) => void>()
+  const unsubscribe = vi.fn(() => { listeners.clear() })
+  const retry = vi.fn(async () => {})
+  const openPlugins = vi.fn(async () => {})
+  const queried = Promise.withResolvers<undefined>()
+  const api: DshDesktopStartupApi = {
+    protocolVersion: 1,
+    locale: async () => resolveDesktopLocale(locale),
+    backend: {
+      status: () => { queried.resolve(undefined); return status },
+      retry,
+      subscribe: (listener) => { listeners.add(listener); return unsubscribe },
+    },
+    openPlugins,
+  }
+  Object.defineProperty(dom.window, 'dshDesktop', { value: api })
+  runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
+  const document = dom.window.document
+  const element = (selector: string): HTMLElement => {
+    const result = document.querySelector<HTMLElement>(selector)
+    if (result === null) throw new Error(`Missing startup element: ${selector}`)
+    return result
+  }
+  const button = (selector: string): HTMLButtonElement => {
+    const result = document.querySelector<HTMLButtonElement>(selector)
+    if (result === null) throw new Error(`Missing startup button: ${selector}`)
+    return result
+  }
+  const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) }
+  const copy = (): string => [element('#title').textContent, element('#description').textContent,
+    ...['#error', '#actions'].filter(selector => !element(selector).hidden)
+      .flatMap(selector => selector === '#actions'
+        ? [button('#retry').textContent, button('#plugins').textContent]
+        : [element(selector).textContent]),
+  ].join('\n')
+  return { dom, document, element, button, publish, copy, retry, openPlugins, unsubscribe, queried: queried.promise }
+}
+
+it('shows English loading and recovery actions without a Host document', async () => {
+  const page = startup()
+  await expect.poll(() => page.element('#title').textContent).not.toBe('')
+  expect(page.copy()).toMatchInlineSnapshot(`
+    "Starting DeepSeek Harness…
+    Your workspace will open when it is ready."
+  `)
+  expect(page.element('main').getAttribute('aria-busy')).toBe('true')
+  expect(page.element('#spinner').hidden).toBe(false)
+  expect(page.element('#actions').hidden).toBe(true)
+  expect(page.button('#retry').disabled).toBe(true)
+  expect(page.button('#plugins').disabled).toBe(true)
+  page.publish({ phase: 'error', message: 'Plugin failed to load' })
+  expect(page.copy()).toMatchInlineSnapshot(`
+    "DeepSeek Harness could not start
+    The application could not start. Retry or manage Desktop plugins to resolve the problem.
+    Plugin failed to load
+    Retry startup
+    Manage plugins"
+  `)
+  expect(page.element('main').getAttribute('aria-busy')).toBe('false')
+  expect(page.element('#spinner').hidden).toBe(true)
+  page.button('#plugins').click()
+  expect(page.openPlugins).toHaveBeenCalledOnce()
+  page.button('#retry').click()
+  expect(page.retry).toHaveBeenCalledOnce()
+  expect(page.element('#actions').hidden).toBe(true)
+  expect(page.element('#error').textContent).toBe('')
+  expect(page.element('main').getAttribute('aria-busy')).toBe('true')
+})
+
+it('shows Chinese loading and recovery copy', async () => {
+  const page = startup('zh-CN')
+  await expect.poll(() => page.element('#title').textContent).not.toBe('')
+  expect(page.document.documentElement.lang).toBe('zh-CN')
+  expect(page.copy()).toMatchInlineSnapshot(`
+    "正在启动 DeepSeek Harness…
+    准备就绪后将自动打开工作区。"
+  `)
+  page.publish({ phase: 'error', message: '插件加载失败' })
+  expect(page.copy()).toMatchInlineSnapshot(`
+    "DeepSeek Harness 无法启动
+    应用未能启动。你可以重试,或管理桌面插件以解决问题。
+    插件加载失败
+    重试启动
+    管理插件"
+  `)
+})
+
+it('renders diagnostic markup as text and exposes failures from recovery actions', async () => {
+  const page = startup()
+  await expect.poll(() => page.element('#title').textContent).not.toBe('')
+  const diagnostic = '<img src=x onerror="window.compromised=true">'
+  page.publish({ phase: 'error', message: diagnostic })
+  expect(page.element('#error').textContent).toBe(diagnostic)
+  expect(page.element('#error').childElementCount).toBe(0)
+  page.openPlugins.mockRejectedValueOnce(new page.dom.window.Error('Cannot open plugin manager'))
+  page.button('#plugins').click()
+  await expect.poll(() => page.element('#error').textContent).toBe('Cannot open plugin manager')
+  page.retry.mockRejectedValueOnce(new page.dom.window.Error('Retry failed'))
+  page.button('#retry').click()
+  await expect.poll(() => page.element('#error').textContent).toBe('Retry failed')
+  expect(page.button('#retry').disabled).toBe(false)
+  expect(page.button('#plugins').disabled).toBe(false)
+})
+
+it('keeps subscribed state when initial status arrives late and detaches on pagehide', async () => {
+  const initial = Promise.withResolvers<DesktopBackendState>()
+  const page = startup('en', initial.promise)
+  await page.queried
+  page.publish({ phase: 'error', message: 'Fresh startup failure' })
+  initial.resolve({ phase: 'starting' })
+  await initial.promise
+  expect(page.element('#error').textContent).toBe('Fresh startup failure')
+  expect(page.element('#actions').hidden).toBe(false)
+  page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide'))
+  expect(page.unsubscribe).toHaveBeenCalledOnce()
+  page.publish({ phase: 'starting' })
+  expect(page.element('#error').textContent).toBe('Fresh startup failure')
+  page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide'))
+  expect(page.unsubscribe).toHaveBeenCalledOnce()
+})

+ 5 - 8
apps/desktop/tsdown.config.ts

@@ -12,19 +12,16 @@ export default defineConfig([
     clean: false,
     deps: { neverBundle: ['electron'] },
   },
-  {
+  ...(['preload', 'preload-app'] as const).map(name => ({
     // Sandboxed Electron preloads run as CommonJS even though the application package is ESM.
-    entry: {
-      preload: 'lib/types/preload.js',
-      'preload-app': 'lib/types/preload-app.js',
-    },
+    entry: { [name]: `lib/types/${name}.js` },
     outDir: 'lib',
-    format: ['cjs'],
-    platform: 'node',
+    format: ['cjs'] as const,
+    platform: 'node' as const,
     target: 'es2024',
     fixedExtension: false,
     dts: false,
     clean: false,
     deps: { neverBundle: ['electron'] },
-  },
+  })),
 ])

+ 1 - 0
package.json

@@ -35,6 +35,7 @@
     "package:desktop:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64",
     "package:desktop:mac:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64:dir",
     "package:desktop:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64",
+    "package:desktop:win:x64:unsigned": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:unsigned",
     "package:desktop:win:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:dir",
     "upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64",
     "upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64",