Ver Fonte

feat(desktop): replace failure pages with native recovery dialog

07akioni há 3 dias atrás
pai
commit
dfb07a038c
45 ficheiros alterados com 621 adições e 730 exclusões
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
  11. 27 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
  12. 27 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
  13. 2 2
      apps/desktop/README.i18n.yaml
  14. 6 6
      apps/desktop/README.md
  15. 6 6
      apps/desktop/README.zh.md
  16. 0 16
      apps/desktop/renderer/startup.css
  17. 0 26
      apps/desktop/renderer/startup.html
  18. 0 45
      apps/desktop/renderer/startup.js
  19. 1 1
      apps/desktop/src/backend-controller.ts
  20. 62 0
      apps/desktop/src/fatal-recovery.ts
  21. 1 10
      apps/desktop/src/ipc.ts
  22. 10 16
      apps/desktop/src/locale.ts
  23. 59 123
      apps/desktop/src/main.ts
  24. 7 22
      apps/desktop/src/preload-app.ts
  25. 6 22
      apps/desktop/src/project-manager.ts
  26. 0 26
      apps/desktop/src/startup-document.ts
  27. 1 1
      apps/desktop/tests/development-project.spec.ts
  28. 9 0
      apps/desktop/tests/expected/fatal-dialog-en.txt
  29. 9 0
      apps/desktop/tests/expected/fatal-dialog-zh-CN.txt
  30. 0 8
      apps/desktop/tests/expected/startup-en-profile.txt
  31. 0 5
      apps/desktop/tests/expected/startup-en-restart.txt
  32. 0 8
      apps/desktop/tests/expected/startup-zh-CN-profile.txt
  33. 0 5
      apps/desktop/tests/expected/startup-zh-CN-restart.txt
  34. 85 0
      apps/desktop/tests/fatal-recovery.spec.ts
  35. 95 98
      apps/desktop/tests/main-startup.spec.ts
  36. 4 26
      apps/desktop/tests/preload-app.spec.ts
  37. 34 44
      apps/desktop/tests/project-manager.spec.ts
  38. 0 175
      apps/desktop/tests/startup-renderer.spec.ts
  39. 29 19
      apps/web/src/main.ts
  40. 64 0
      apps/web/tests/desktop-boot.spec.ts
  41. 2 2
      packages/client/web/README.i18n.yaml
  42. 1 1
      packages/client/web/README.md
  43. 1 1
      packages/client/web/README.zh.md
  44. 8 4
      packages/client/web/src/boot.ts
  45. 47 0
      packages/client/web/tests/boot.client.spec.ts

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

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

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

@@ -14,7 +14,7 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 Electron creates the main window with the packaged Web loading page before profile reconciliation or Host startup. The Web entry draws its boot page before awaiting Host readiness. The owned preload delivers structured boot injections, and the existing document activates its client plugins after they are applied; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
 
-The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available with loaded runtime metadata and available resources, including in development mode. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again.
+Fatal presentation follows [native Desktop recovery](2026-09-15-desktop-native-fatal-recovery.md). Window timing, direct Host startup, and shutdown ownership remain governed here.
 
 Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
 

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

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

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

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

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

@@ -16,7 +16,7 @@ Package or Host failures retain partial changes for repair and retry. There is n
 
 This supersedes staging and rollback in [the packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md), [the bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), and [the immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md). Host boot follows the [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md); release, module ownership, and window lifecycle remain separate decisions.
 
-Desktop delegates installation and lifecycle scripts to pnpm, without a pending-operation startup gate, frozen-lockfile reinstall, or automatic rebuild. Failed package operations preserve partial changes and leave disable, remove, reset, and startup retry available. The Host inherits the user environment, and profiles may use directory links. An unchanged legacy Desktop-generated pnpm configuration is replaced with the Web defaults; customized configuration remains user-owned.
+Desktop delegates installation and lifecycle scripts to pnpm, without a pending-operation startup gate, frozen-lockfile reinstall, or automatic rebuild. Failed package operations preserve partial changes and leave disable, remove, and startup retry available. The Host inherits the user environment, and profiles may use directory links. An unchanged legacy Desktop-generated pnpm configuration is replaced with the Web defaults; customized configuration remains user-owned.
 
 ## Alternatives considered
 

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

@@ -16,7 +16,7 @@ Desktop 停止 Host 后直接修改当前 profile。共享 app-boot 清理在包
 
 本记录取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)、[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)及[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的暂存与回滚。Host 启动遵循[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md);发布、模块归属与窗口生命周期仍由各自决策负责。
 
-Desktop 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除、重置和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理。
+Desktop 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理。
 
 ## 考虑过的替代方案
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 5b8df22e520af752ac1b8fefbf2cf2442ce17eca
-2026-09-10-desktop-web-wrapper.zh.md: 44c79d8c914f0b2bdeb69dbeb09f22ce84efe9c8
+2026-09-10-desktop-web-wrapper.md: 33a1852ef7dbae136370e6ee30f557534ab51751
+2026-09-10-desktop-web-wrapper.zh.md: 7b652fddd0ef92a4797ae4f1a799f9815e787843

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

@@ -38,7 +38,7 @@ This partially supersedes the private composition and portless transport in the
 
 ## Consequences
 
-Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; independent recovery resources remain available when startup fails.
+Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; native recovery remains available when startup fails.
 
 User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the independent recovery UI rather than triggering stricter admission checks or automatic rollback.
 

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

@@ -38,7 +38,7 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 ## Consequences
 
-Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;独立恢复资源在启动失败时仍可用。
+Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。
 
 用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.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-15-desktop-native-fatal-recovery.md
+2026-09-15-desktop-native-fatal-recovery.md: 2c38433fb1e12b6d292982d709ddd7d53a93851b
+2026-09-15-desktop-native-fatal-recovery.zh.md: 50e352f4378722d08bd5815cf0274e31c57baaac

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md

@@ -0,0 +1,27 @@
+# Agent Note: Native Desktop fatal recovery
+
+Status: implemented
+
+English | [中文](2026-09-15-desktop-native-fatal-recovery.zh.md)
+
+## Problem
+
+A recovery document depends on the renderer and preload whose failure can prevent application startup. Multiple reports from one failed startup can also obscure the original diagnostic and interrupt recovery.
+
+## Decision
+
+Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
+
+The first report claims presentation before awaiting the dialog. Later reports remain in logs. The dialog retains the first diagnostic and offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
+
+The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. Desktop has no profile reset or emergency recovery document.
+
+This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [in-place profile decision](2026-09-09-desktop-in-place-profile.md) still owns package transactions and partial changes.
+
+## Alternatives considered
+
+A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control.
+
+## Consequences
+
+Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 原生 Desktop 致命错误恢复
+
+Status: implemented
+
+[English](2026-09-15-desktop-native-fatal-recovery.md) | 中文
+
+## Problem
+
+恢复文档依赖渲染器和 preload,而这些组件的故障本身就可能阻止应用启动。同一次启动失败的多个报告还可能掩盖原始诊断并打断恢复操作。
+
+## Decision
+
+Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
+
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。对话框保留首次诊断,提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+
+Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。Desktop 不提供 profile 重置或应急恢复文档。
+
+这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。
+
+## Alternatives considered
+
+Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。
+
+## Consequences
+
+恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。

+ 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: 2793dae3a6bd0ca9ce5b4a7c13e539a861741994
-README.zh.md: 2beb07eb7580154a3aafb6fbea37157529e8d4f8
+README.md: 4616ec154a73a871a0e995fd231db1ba62e62671
+README.zh.md: 5bfa15dcd309b28fd9f3f4c668417fb98a152b00

+ 6 - 6
apps/desktop/README.md

@@ -29,7 +29,7 @@ Node prepares the bundled interpreters and Python libraries without a system Pyt
 | Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
 | Shared modules | Host APIs can depend on module identity. | The shared profile runner projects missing installation and bundle dependencies inside the Desktop profile; pnpm-managed packages take precedence. |
 | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
-| Transport | Reusing Web serving and authentication keeps application behavior in one implementation. | Electron loads the Host’s authenticated HTTP URL directly; child IPC carries lifecycle messages, and the local shell protocol serves startup and management pages. |
+| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. The shell protocol serves plugin management. |
 | Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. |
 | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
 
@@ -39,11 +39,11 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
 
 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
 
-The local startup page exposes startup status and available recovery actions. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
+The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
 The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles and patch-reload policy.
 
-Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
+Electron 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's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
 
@@ -59,13 +59,13 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
 
 CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows the first error and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, ordinary requests, and package-operation errors do not trigger recovery. There is no startup timeout heuristic.
 
 Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
-Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
+Recovery waits for Host shutdown before changing plugin activation. Disabling third-party bundles writes the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document.
 
-Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; the Host recreates needed links on startup. Reset preserves the profile directory and its lock until initialization and Host startup finish. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
+Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; the Host recreates needed links on startup. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 
 ## Develop
 

+ 6 - 6
apps/desktop/README.zh.md

@@ -29,7 +29,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
 | 共享模块 | Host API 可能依赖模块身份。 | 共享 profile runner 在 Desktop profile 内补全安装包与 bundle 缺失的依赖;pnpm 管理的包优先。 |
 | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
-| 传输 | 复用 Web 服务与认证,让应用行为由同一份实现负责。 | Electron 直接加载 Host 的认证 HTTP URL;子进程 IPC 承载生命周期消息,本地壳协议提供启动和管理页面。 |
+| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。shell 协议提供插件管理页面。 |
 | 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
 | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
@@ -39,11 +39,11 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 
 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
-本地启动页面展示启动状态及可用恢复操作。产品渲染进程使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两个渲染进程都不会获得文件系统、原始 Electron IPC、shell 或任意 pnpm 参数访问权
+应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
 
 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表和 patch 重载策略初始化新 profile。
 
-Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件
+Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单、原生对话框和插件管理渲染器使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码
 
 Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
 
@@ -59,13 +59,13 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 
 CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航、普通请求和包操作错误不会触发恢复。不通过启动超时推断故障
 
 Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
-重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload
+恢复操作等待 Host 关闭后才修改插件启用状态。禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档
 
-包事务独占 `$DSH_HOME/profiles/desktop/lock` 直到 pnpm 进程退出。pnpm 运行前,共享模块补全 helper 仅移除其拥有的链接,并保留 pnpm 管理的目录;Host 在启动时重新创建所需链接。重置保留 profile 目录与锁,直到初始化和 Host 启动结束。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备负责独立的构建时许可列表。
+包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
 
 ## 开发
 

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

@@ -1,16 +0,0 @@
-: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; } }

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

@@ -1,26 +0,0 @@
-<!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>
-      <p id="reset-advice" hidden></p>
-      <p id="reinstall-advice" hidden></p>
-      <pre id="error" role="alert" hidden></pre>
-      <div id="actions" hidden>
-        <button id="restart" type="button"></button>
-        <button id="disable-plugins" type="button"></button>
-        <button id="reset-configuration" type="button"></button>
-      </div>
-    </main>
-    <script src="startup.js"></script>
-  </body>
-</html>

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

@@ -1,45 +0,0 @@
-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('#restart').textContent = messages.restartApplication
-  document.querySelector('#disable-plugins').textContent = messages.disableThirdPartyPlugins
-  document.querySelector('#reset-configuration').textContent = messages.resetConfiguration
-  document.querySelector('#reset-advice').textContent = messages.startupConfigurationAdvice
-  document.querySelector('#reinstall-advice').textContent = messages.startupReinstallAdvice
-  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
-    for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed
-    for (const selector of ['#disable-plugins', '#reset-configuration', '#reset-advice']) {
-      document.querySelector(selector).hidden = !failed || !state.profileRecovery
-    }
-    document.querySelector('#reinstall-advice').hidden = !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)
-  async function recover(operation) {
-    render({ phase: 'starting' })
-    try { await operation() }
-    catch (error) {
-      const current = await api.backend.status().catch(() => undefined)
-      render(current?.phase === 'error' ? current : { phase: 'error', message: error instanceof Error ? error.message : String(error) })
-    }
-  }
-  document.querySelector('#disable-plugins').addEventListener('click', () => { void recover(() => api.disablePlugins()) })
-  document.querySelector('#reset-configuration').addEventListener('click', () => { void recover(() => api.resetConfiguration()) })
-  document.querySelector('#restart').addEventListener('click', () => { void recover(() => api.restart()) })
-}
-
-void main()

+ 1 - 1
apps/desktop/src/backend-controller.ts

@@ -3,7 +3,7 @@
 import { desktopErrorState } from './startup-error.ts'
 
 /** Backend availability presented by the desktop window. */
-export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly profileRecovery?: boolean }
+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 {

+ 62 - 0
apps/desktop/src/fatal-recovery.ts

@@ -0,0 +1,62 @@
+/** Native recovery for the first fatal failure in one Desktop process. */
+
+import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
+import type { DesktopMessages } from './locale.ts'
+import { desktopErrorState } from './startup-error.ts'
+
+interface RecoveryOperations {
+  messages(): DesktopMessages
+  show(options: MessageBoxOptions): Promise<MessageBoxReturnValue>
+  stop(): Promise<void>
+  disablePlugins(): Promise<void>
+  exit(): void
+  restart(): void
+}
+
+/** Deduplicates fatal reports while keeping explicit recovery-operation failures actionable. */
+export class DesktopFatalRecovery {
+  private reported = false
+
+  /** @param operations - Native presentation and application-owned shutdown operations. */
+  constructor(private readonly operations: RecoveryOperations) {}
+
+  /**
+   * Show the first fatal error; later reports cannot replace it or open another dialog.
+   * @param error - Fatal failure, including nested diagnostic causes.
+   * @returns Completion of the user's recovery action; duplicate reports resolve immediately.
+   */
+  async report(error: unknown): Promise<void> {
+    if (this.reported) return
+    this.reported = true
+    const messages = this.operations.messages()
+    let detail = desktopErrorState(error).message
+    let message = messages.startupFailed
+    for (;;) {
+      const { response } = await this.operations.show({
+        type: 'error',
+        title: messages.startupFailed,
+        message,
+        detail: `${detail}\n\n${messages.startupReinstallAdvice}`,
+        buttons: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins],
+        defaultId: 1,
+        cancelId: 0,
+        noLink: true,
+      })
+      if (response === 0) {
+        try { await this.operations.stop() } catch (failure) { console.error(failure) }
+        this.operations.exit()
+        return
+      }
+      try {
+        await this.operations.stop()
+        if (response === 2) await this.operations.disablePlugins()
+        this.operations.restart()
+        return
+      } catch (failure) {
+        console.error(failure)
+        message = messages.recoveryOperationFailed
+        detail = desktopErrorState(failure).message
+      }
+    }
+  }
+}

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

@@ -8,6 +8,7 @@ import type { DesktopBackendState } from './backend-controller.ts'
 export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
   boot: 'dsh-desktop:boot',
+  bootFailed: 'dsh-desktop:boot-failed',
   pluginsList: 'dsh-desktop:plugins-list',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
@@ -16,8 +17,6 @@ export const DESKTOP_IPC = {
   pluginsDisableAll: 'dsh-desktop:plugins-disable-all',
   backendStatus: 'dsh-desktop:backend-status',
   backendRetry: 'dsh-desktop:backend-retry',
-  applicationRestart: 'dsh-desktop:application-restart',
-  configurationReset: 'dsh-desktop:configuration-reset',
   backendState: 'dsh-desktop:backend-state',
   updatesCheck: 'dsh-desktop:updates-check',
   updatesInstall: 'dsh-desktop:updates-install',
@@ -55,11 +54,3 @@ 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'> {
-  readonly backend: Omit<DshDesktopApi['backend'], 'retry'>
-  disablePlugins(): Promise<void>
-  restart(): Promise<void>
-  resetConfiguration(): Promise<void>
-}

+ 10 - 16
apps/desktop/src/locale.ts

@@ -2,15 +2,12 @@
 
 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: 'Choose a recovery action below. Disabling third-party plugins retains their files.',
+  startupFailed: 'DeepSeek Harness is unavailable',
   startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
-  startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.',
-  restartApplication: 'Close and restart',
-  resetConfiguration: 'Reset Desktop and retry',
-  disableThirdPartyPlugins: 'Disable all third-party plugins and retry',
+  exitApplication: 'Exit',
+  restartApplication: 'Restart',
+  recoveryOperationFailed: 'The recovery operation failed',
+  disableThirdPartyPlugins: 'Disable all third-party plugins and restart',
   pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
@@ -56,15 +53,12 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 
 export const zh = {
   application: '应用',
-  startupFailed: 'DeepSeek Harness 无法启动',
-  startupLoading: '正在启动 DeepSeek Harness…',
-  startupLoadingDescription: '准备就绪后将自动打开工作区。',
-  startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。',
+  startupFailed: 'DeepSeek Harness 无法使用',
   startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
-  startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。',
-  restartApplication: '关闭并重启',
-  resetConfiguration: '重置 Desktop 并重试',
-  disableThirdPartyPlugins: '禁用全部第三方插件并重',
+  exitApplication: '退出',
+  restartApplication: '重启',
+  recoveryOperationFailed: '恢复操作失败',
+  disableThirdPartyPlugins: '禁用全部第三方插件并重',
   pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',

+ 59 - 123
apps/desktop/src/main.ts

@@ -25,27 +25,29 @@ import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
-import { desktopErrorState } from './startup-error.ts'
 import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
-import { startupFailureDocument } from './startup-document.ts'
+import { DesktopFatalRecovery } from './fatal-recovery.ts'
 
 const SCHEME = 'dsh-app'
 let focusPrimaryWindow = (): void => {}
-type RecoveryAction = 'restart' | 'plugins' | 'reset'
-let profileRecoveryAvailable = (): boolean => false
-const emergencyPages = new WeakMap<BrowserWindow, { url: string; message: string; busy: boolean }>()
-let recoverApplication = (action: RecoveryAction): Promise<void> => {
-  if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application'))
-  app.relaunch()
-  app.quit()
-  return Promise.resolve()
-}
+let stopForRecovery = async (): Promise<void> => {}
+let shuttingDown = false
+const recovery = new DesktopFatalRecovery({
+  messages: () => resolveDesktopLocale(app.getLocale()).messages,
+  show: options => dialog.showMessageBox(options),
+  stop: () => { shuttingDown = true; return stopForRecovery() },
+  disablePlugins: async () => {
+    const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
+    await manager.disableAllPlugins()
+  },
+  exit: () => { app.quit() },
+  restart: () => { app.relaunch(); app.quit() },
+})
 
-async function showEmergencyDocument(window: BrowserWindow, message: string): Promise<void> {
-  const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message, profileRecoveryAvailable())
-  const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}`
-  emergencyPages.set(window, { url, message, busy: false })
-  await window.loadURL(url)
+function reportFatal(error: unknown): void {
+  console.error(error)
+  if (shuttingDown) return
+  void recovery.report(error).catch((failure: unknown) => { console.error(failure); app.exit(1) })
 }
 
 protocol.registerSchemesAsPrivileged([{
@@ -153,15 +155,6 @@ function createWindow(preload: string, show = false): BrowserWindow {
       event.preventDefault()
       if (['http:', 'https:'].includes(destination.protocol)) void shell.openExternal(url)
     }
-    const page = emergencyPages.get(window)
-    if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return
-    const action = new URL(url)
-    if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return
-    if (action.hostname !== 'restart' && !profileRecoveryAvailable()) return
-    page.busy = true
-    void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => {
-      if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`)
-    }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false })
   })
   return window
 }
@@ -201,8 +194,6 @@ async function main(): Promise<void> {
   const development = !app.isPackaged
   const activeProject = paths.profile
   const manager = new DesktopProjectManager(paths, resources)
-  profileRecoveryAvailable = () => manager.canRecoverProfile()
-  let pageError: Extract<DesktopBackendState, { phase: 'error' }> | undefined
   let quitting = false
   let startup: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
@@ -213,39 +204,25 @@ 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/`
   let hostUrl: string | undefined
   let hostCookie: string | undefined
   let injections: readonly unknown[] = []
   let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
-  let emergencyDocument = false
-
-  const showEmergencyError = async (error: unknown): Promise<void> => {
-    if (quitting || emergencyDocument) return
-    emergencyDocument = true
-    const diagnostic = desktopErrorState(error).message
-    pageError = { phase: 'error', message: diagnostic }
-    if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic)
-  }
-
   const navigateMain = (url: string): Promise<void> => {
     const window = mainWindow
-    if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve()
+    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
+      if (quitting || shuttingDown || window.isDestroyed() || navigation !== next
+        || (error instanceof Error && 'code' in error && error.code === 'ERR_ABORTED')) return
       navigation = undefined
       throw error
     })
     navigation = next
     return next.promise
   }
-  const backendState = (): DesktopBackendState => {
-    const state = pageError ?? backend.state
-    return state.phase === 'error' ? { ...state, profileRecovery: profileRecoveryAvailable() } : state
-  }
   const publishBackend = (state: DesktopBackendState): void => {
     for (const window of BrowserWindow.getAllWindows()) {
       window.webContents.send(DESKTOP_IPC.backendState, state)
@@ -269,9 +246,8 @@ async function main(): Promise<void> {
       stop: () => host.stop(),
     }
   }, (state) => {
-    if (state.phase === 'starting' && !emergencyDocument) pageError = undefined
-    publishBackend(backendState())
-    if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) })
+    if (state.phase === 'error') reportFatal(new Error(state.message))
+    publishBackend(state)
   })
 
   const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
@@ -287,42 +263,17 @@ async function main(): Promise<void> {
     afterChange: () => backend.start(async () => {}),
   }
 
-  recoverApplication = async (action): Promise<void> => {
-    await startup?.catch(() => undefined)
-    await backend.stop()
-    if (action === 'restart') {
-      app.relaunch()
-      app.quit()
-      return
-    }
-    if (!profileRecoveryAvailable()) throw new Error(messages.startupReinstallAdvice)
-    if (action === 'reset') await manager.resetConfiguration(hooks)
-    else await manager.mutate({ type: 'plugins-disable-all' }, hooks)
-    emergencyDocument = false
-    pageError = undefined
-    navigation = undefined
-    await navigateMain(applicationUrl)
-  }
+  stopForRecovery = () => backend.close()
 
-  const showStartupError = async (error: unknown): Promise<void> => {
-    if (quitting) return
-    pageError = desktopErrorState(error)
-    try { await navigateMain(startupUrl) }
-    catch (navigationError) {
-      await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed))
-    }
-    publishBackend(backendState())
-  }
   const reconcileBackend = (): Promise<void> => {
     startup ??= (async () => {
-      pageError = undefined
       await navigateMain(applicationUrl)
       await backend.start(async () => {
         await manager.applyRelease()
       })
       // The existing Web document resumes through the boot IPC response.
-    })().catch(async (error: unknown) => {
-      await showStartupError(error)
+    })().catch((error: unknown) => {
+      reportFatal(error)
       throw error
     }).finally(() => { startup = undefined })
     return startup
@@ -348,13 +299,7 @@ async function main(): Promise<void> {
       }
       return forwardWebRequest(request, hostUrl, hostCookie)
     }
-    if (url.hostname === 'shell') return serveShellAsset(request).then((response) => {
-      if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) {
-        void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`))
-          .catch((error: unknown) => { console.error(error) })
-      }
-      return response
-    })
+    if (url.hostname === 'shell') return serveShellAsset(request)
     return Promise.resolve(new Response(null, { status: 404 }))
   })
 
@@ -365,6 +310,15 @@ async function main(): Promise<void> {
     return { injections, streamBaseUrl: new URL(hostUrl).origin }
   })
 
+  ipcMain.handle(DESKTOP_IPC.bootFailed, (event, message: unknown) => {
+    assertDesktopSender(event, ['app'])
+    if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) {
+      throw new Error('dsh desktop: rejected startup failure from a non-primary frame')
+    }
+    if (typeof message !== 'string') throw new Error('dsh desktop: startup failure must be text')
+    reportFatal(new Error(message))
+  })
+
   session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
     if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
       callback({})
@@ -381,16 +335,11 @@ async function main(): Promise<void> {
   const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
     assertDesktopSender(event, ['shell'])
     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
-    }
+    await manager.mutate(mutation, hooks)
+    navigation = undefined
+    await navigateMain(applicationUrl).catch(reportFatal)
   }
+
   ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
     assertDesktopSender(event, ['shell'])
     return locale
@@ -425,33 +374,17 @@ 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 backendState()
+    return backend.state
   })
   ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
     assertDesktopSender(event, ['shell'])
+    const retrying = backend.state.phase === 'error'
     await reconcileBackend()
-    focusPrimaryWindow()
-  })
-  ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    try {
-      await recoverApplication('restart')
-    } catch (error) {
-      await showStartupError(error)
-    }
-  })
-  ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    const failure = backendState()
-    if (failure.phase !== 'error') {
-      throw new Error('Desktop profile reset requires a startup failure')
-    }
-    await startup?.catch(() => undefined)
-    try {
-      await recoverApplication('reset')
-    } catch (error) {
-      await showStartupError(error)
+    if (retrying) {
+      navigation = undefined
+      await navigateMain(applicationUrl).catch(reportFatal)
     }
+    focusPrimaryWindow()
   })
   ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
     assertDesktopSender(event, ['shell'])
@@ -535,23 +468,27 @@ async function main(): Promise<void> {
     const window = createWindow(appPreload, true)
     mainWindow = window
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
+    window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
+      if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {
+        reportFatal(new Error(`Desktop page failed to load: ${url} (${String(code)}: ${description})`))
+      }
+    })
     window.webContents.on('preload-error', (_event, _path, error) => {
-      void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) })
+      if (!quitting && !window.isDestroyed()) reportFatal(error)
     })
     window.webContents.on('render-process-gone', (_event, details) => {
       navigation = undefined
-      emergencyDocument = false
-      void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`))
-        .catch((failure: unknown) => { console.error(failure) })
+      if (!quitting && !window.isDestroyed() && details.reason !== 'clean-exit') {
+        reportFatal(new Error(`Desktop renderer exited: ${details.reason}`))
+      }
     })
     return window
   }
   focusPrimaryWindow = () => {
     const window = mainWindow
     if (window === undefined || window.isDestroyed()) {
-      createMainWindow()
-      void navigateMain(applicationUrl)
-        .catch((error: unknown) => { console.error(error) })
+      try { createMainWindow() } catch (error) { reportFatal(error); return }
+      void navigateMain(applicationUrl).catch(reportFatal)
       return
     }
     if (window.isMinimized()) window.restore()
@@ -566,6 +503,7 @@ async function main(): Promise<void> {
     if (process.platform !== 'darwin') app.quit()
   })
   app.on('before-quit', (event) => {
+    shuttingDown = true
     if (shellInstallerOwnsQuit || quitting) return
     event.preventDefault()
     quitting = true
@@ -594,9 +532,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk
   if (diagnosticFile !== undefined) {
     await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
   }
-  const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true)
-  window.once('closed', () => { app.quit() })
-  await showEmergencyDocument(window, message)
+  reportFatal(error)
 }).catch((error: unknown) => {
   console.error(error)
   app.exit(1)

+ 7 - 22
apps/desktop/src/preload-app.ts

@@ -1,32 +1,17 @@
-/** Startup controls for shell documents; application documents receive only the carrier marker. */
+/** Context-isolated application boot bridge and desktop carrier marker. */
 
 import { contextBridge, ipcRenderer } from 'electron'
-import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts'
+import { DESKTOP_IPC } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
 import { syncNativeTheme } from './preload-theme.ts'
-import type { DesktopBackendState } from './backend-controller.ts'
-
-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']>,
-    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) }
-    },
-  },
-  disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
-  restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise<void>,
-  resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
-}
 
 if (location.protocol === 'dsh-app:' && location.hostname === 'app') {
-  contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown> })
+  contextBridge.exposeInMainWorld('dshDesktopBoot', {
+    ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown>,
+    failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise<void>,
+  })
 }
 
 markDocumentPlatform()
 syncNativeTheme()
-contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell'
-  ? startup : { protocolVersion: 1 })
+contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 })

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

@@ -10,7 +10,6 @@ import {
   openSync,
   closeSync,
   readFileSync,
-  readdirSync,
   unlinkSync,
   writeFileSync,
   writeSync,
@@ -23,7 +22,6 @@ 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 { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
 import {
@@ -124,22 +122,13 @@ export class DesktopProjectManager {
   }
 
   /**
-   * Reinitialize the profile, deleting configuration and third-party packages without a backup.
-   * @param hooks - Stop the Host before resetting files; restart after preparation succeeds.
-   * @returns Completion of reset; the held lock and shared product data are preserved.
+   * Disable third-party bundles without loading application resources or deleting plugin files.
+   * @returns Completion of the locked profile write; the caller must stop the Host first.
    */
-  async resetConfiguration(hooks: DesktopProjectHooks): Promise<void> {
-    await this.withLock(async () => {
-      await hooks.beforeChange()
-      this.descriptor = this.readRuntime()
-      for (const entry of readdirSync(this.paths.profile, { withFileTypes: true })) {
-        const path = join(this.paths.profile, entry.name)
-        if (path === this.paths.lock) continue
-        if (entry.isDirectory()) removeOwnedDirectory(path)
-        else unlinkSync(path)
-      }
-      createPluginProfile(this.paths.profile)
-      await hooks.afterChange()
+  async disableAllPlugins(): Promise<void> {
+    await this.withLock(() => {
+      if (!existsSync(join(this.paths.profile, 'package.json'))) return
+      writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
     })
   }
 
@@ -148,11 +137,6 @@ export class DesktopProjectManager {
     return this.currentRuntime().release.version
   }
 
-  /** @returns Whether application resources support profile recovery. */
-  canRecoverProfile(): boolean {
-    return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh)
-  }
-
   private currentRuntime(): DesktopRuntimeDescriptor {
     if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded')
     return this.descriptor

+ 0 - 26
apps/desktop/src/startup-document.ts

@@ -1,26 +0,0 @@
-/** Self-contained recovery document for an unavailable shell renderer or preload. */
-
-import type { DesktopLocale } from './locale.ts'
-
-/**
- * Render escaped diagnostics without depending on application resource files.
- * @param locale - Shell-owned translations.
- * @param message - Failure details displayed as plain text.
- * @param profileRecovery - Whether the initialized application can repair its profile.
- * @returns An HTML document suitable for an isolated emergency window.
- */
-export function startupFailureDocument(locale: DesktopLocale, message: string, profileRecovery = false): string {
-  const escape = (value: string): string => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;')
-    .replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
-  return `<!doctype html><html lang="${locale.id}"><meta charset="utf-8">
-<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; form-action dsh-recovery:">
-<title>${escape(locale.messages.startupFailed)}</title>
-<style>:root{color-scheme:light dark;font-family:system-ui}body{max-width:720px;margin:10vh auto;padding:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style>
-<main><h1>${escape(locale.messages.startupFailed)}</h1><p>${escape(locale.messages.startupReinstallAdvice)}</p>
-${profileRecovery ? `<p>${escape(locale.messages.startupConfigurationAdvice)}</p>` : ''}
-<pre role="alert">${escape(message)}</pre>
-<form action="dsh-recovery://restart"><button>${escape(locale.messages.restartApplication)}</button></form>
-${profileRecovery ? `<form action="dsh-recovery://plugins"><button>${escape(locale.messages.disableThirdPartyPlugins)}</button></form>
-<form action="dsh-recovery://reset"><button>${escape(locale.messages.resetConfiguration)}</button></form>` : ''}
-</main></html>`
-}

+ 1 - 1
apps/desktop/tests/development-project.spec.ts

@@ -79,7 +79,7 @@ describe('desktop development project', () => {
     expect(manager.listPlugins()).toEqual([{ name: 'development-plugin', version: '1.0.0', enabled: true }])
     await manager.mutate({ type: 'plugin-remove', name: 'development-plugin' }, hooks)
     expect(manager.listPlugins()).toEqual([])
-    await manager.resetConfiguration(hooks)
+    await manager.disableAllPlugins()
     expect(readFileSync(join(cli, 'package.json'), 'utf8')).toBe('{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n')
     expect(readFileSync(join(host, 'lib', 'index.js'), 'utf8')).toBe('')
 

+ 9 - 0
apps/desktop/tests/expected/fatal-dialog-en.txt

@@ -0,0 +1,9 @@
+DeepSeek Harness is unavailable
+DeepSeek Harness is unavailable
+Desktop Host failed
+Plugin initialization failed
+
+If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
+Exit
+Restart
+Disable all third-party plugins and restart

+ 9 - 0
apps/desktop/tests/expected/fatal-dialog-zh-CN.txt

@@ -0,0 +1,9 @@
+DeepSeek Harness 无法使用
+DeepSeek Harness 无法使用
+Desktop Host failed
+Plugin initialization failed
+
+如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
+退出
+重启
+禁用全部第三方插件并重启

+ 0 - 8
apps/desktop/tests/expected/startup-en-profile.txt

@@ -1,8 +0,0 @@
-DeepSeek Harness could not start
-Choose a recovery action below. Disabling third-party plugins retains their files.
-Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.
-If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
-Failure details
-Close and restart
-Disable all third-party plugins and retry
-Reset Desktop and retry

+ 0 - 5
apps/desktop/tests/expected/startup-en-restart.txt

@@ -1,5 +0,0 @@
-DeepSeek Harness could not start
-Choose a recovery action below. Disabling third-party plugins retains their files.
-If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
-Failure details
-Close and restart

+ 0 - 8
apps/desktop/tests/expected/startup-zh-CN-profile.txt

@@ -1,8 +0,0 @@
-DeepSeek Harness 无法启动
-请选择下方的恢复操作。禁用第三方插件会保留插件文件。
-重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
-如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
-Failure details
-关闭并重启
-禁用全部第三方插件并重试
-重置 Desktop 并重试

+ 0 - 5
apps/desktop/tests/expected/startup-zh-CN-restart.txt

@@ -1,5 +0,0 @@
-DeepSeek Harness 无法启动
-请选择下方的恢复操作。禁用第三方插件会保留插件文件。
-如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
-Failure details
-关闭并重启

+ 85 - 0
apps/desktop/tests/fatal-recovery.spec.ts

@@ -0,0 +1,85 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import type { MessageBoxOptions } from 'electron'
+import { DesktopFatalRecovery } from '../src/fatal-recovery.ts'
+import { resolveDesktopLocale } from '../src/locale.ts'
+
+function fixture(locale = 'en') {
+  const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>()
+  const stopped = Promise.withResolvers<undefined>()
+  const operations = {
+    messages: () => resolveDesktopLocale(locale).messages,
+    show: vi.fn((_options: MessageBoxOptions) => choice.promise),
+    stop: vi.fn(() => stopped.promise),
+    disablePlugins: vi.fn(async () => {}),
+    exit: vi.fn(),
+    restart: vi.fn(),
+  }
+  return { operations, choice, stopped, recovery: new DesktopFatalRecovery(operations) }
+}
+
+afterEach(() => { vi.restoreAllMocks() })
+
+it.each(['en', 'zh-CN'])('records the %s native recovery dialog', async (locale) => {
+  const { operations, choice, stopped, recovery } = fixture(locale)
+  const pending = recovery.report(new AggregateError([new Error('Plugin initialization failed')], 'Desktop Host failed'))
+  const options = operations.show.mock.calls[0]![0]
+  await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n')
+    .toMatchFileSnapshot(`expected/fatal-dialog-${locale}.txt`)
+  choice.resolve({ response: 0, checkboxChecked: false })
+  stopped.resolve(undefined)
+  await pending
+})
+
+it('locks the first report before the dialog settles and never resets after an action', async () => {
+  const { operations, choice, stopped, recovery } = fixture()
+  const pending = recovery.report(new Error('first failure'))
+  await recovery.report(new Error('second failure'))
+  expect(operations.show).toHaveBeenCalledOnce()
+  expect(operations.show.mock.calls[0]![0].detail).toContain('first failure')
+  choice.resolve({ response: 1, checkboxChecked: false })
+  stopped.resolve(undefined)
+  await pending
+  await recovery.report(new Error('third failure'))
+  expect(operations.show).toHaveBeenCalledOnce()
+})
+
+it.each([0, 1, 2])('waits for shutdown before executing choice %s', async (response) => {
+  const { operations, choice, stopped, recovery } = fixture()
+  const stopping = Promise.withResolvers<undefined>()
+  operations.stop.mockImplementation(() => { stopping.resolve(undefined); return stopped.promise })
+  const pending = recovery.report(new Error('fatal'))
+  choice.resolve({ response, checkboxChecked: false })
+  await stopping.promise
+  expect(operations.exit).not.toHaveBeenCalled()
+  expect(operations.restart).not.toHaveBeenCalled()
+  expect(operations.disablePlugins).not.toHaveBeenCalled()
+  stopped.resolve(undefined)
+  await pending
+  expect(operations.exit).toHaveBeenCalledTimes(response === 0 ? 1 : 0)
+  expect(operations.restart).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
+  expect(operations.disablePlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
+})
+
+it('reports a user-requested disable failure and allows exit without restarting', async () => {
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  const { operations, stopped, recovery } = fixture()
+  operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false })
+    .mockResolvedValueOnce({ response: 0, checkboxChecked: false })
+  operations.disablePlugins.mockRejectedValueOnce(new Error('profile is read-only'))
+  stopped.resolve(undefined)
+  await recovery.report(new Error('fatal'))
+  expect(operations.show).toHaveBeenCalledTimes(2)
+  expect(operations.show.mock.calls[1]![0].detail).toContain('profile is read-only')
+  expect(operations.restart).not.toHaveBeenCalled()
+  expect(operations.exit).toHaveBeenCalledOnce()
+})
+
+it('allows exit after shutdown cleanup fails', async () => {
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  const { operations, choice, stopped, recovery } = fixture()
+  const pending = recovery.report(new Error('fatal'))
+  choice.resolve({ response: 0, checkboxChecked: false })
+  stopped.reject(new Error('cleanup failed'))
+  await pending
+  expect(operations.exit).toHaveBeenCalledOnce()
+})

+ 95 - 98
apps/desktop/tests/main-startup.spec.ts

@@ -1,5 +1,6 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { join } from 'node:path'
+import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
 import { DESKTOP_IPC } from '../src/ipc.ts'
 
 vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
@@ -13,6 +14,7 @@ const harness = await vi.hoisted(async () => {
     return { promise, resolve, reject }
   }
   const windows: FakeWindow[] = []
+  let windowFailure: Error | undefined
   const hosts: FakeHost[] = []
   const handlers = new Map<string, (event: { senderFrame: { url: string } }) => unknown>()
   let pluginsEnabled = false
@@ -37,7 +39,7 @@ const harness = await vi.hoisted(async () => {
     readonly show = vi.fn()
     readonly focus = vi.fn()
     readonly restore = vi.fn()
-    constructor(readonly options: { show: boolean }) { super(); windows.push(this) }
+    constructor(readonly options: { show: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) }
     isDestroyed() { return this.destroyed }
     isMinimized() { return false }
     async loadURL(url: string) {
@@ -83,13 +85,15 @@ const harness = await vi.hoisted(async () => {
   const popup = vi.fn()
   return {
     windows, hosts, handlers, app, FakeWindow, FakeHost,
+    failWindow(error: Error) { windowFailure = error },
     popup,
     socketHeaders: vi.fn(),
     menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
-    dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
+    dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>() },
     openExternal: vi.fn(),
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
-    canRecoverProfile: vi.fn(() => true),
+    mutateFailure: vi.fn<() => void>(),
+    disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }),
     get preparing() { return preparing }, get prepared() { return prepared },
     get hostStarted() { return hostStarted }, get navigated() { return navigated },
     get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted },
@@ -100,6 +104,7 @@ const harness = await vi.hoisted(async () => {
     reset() {
       windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
       app.isPackaged = true
+      windowFailure = undefined
       pluginsEnabled = false
       preparing = deferred(); prepared = deferred(); hostStarted = deferred()
       navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred()
@@ -125,15 +130,14 @@ vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desk
 vi.mock('../src/project-manager.ts', () => ({
   DesktopProjectManager: class {
     readonly applyRelease = harness.applyRelease
-    canRecoverProfile = harness.canRecoverProfile
+    disableAllPlugins = harness.disableAllPlugins
     async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
       await hooks.beforeChange()
+      harness.mutateFailure()
       harness.pluginsEnabled = false
       await hooks.afterChange()
     }
-    async resetConfiguration(hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
-      await this.mutate(undefined, hooks)
-    }
+
   },
 }))
 vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost }))
@@ -142,7 +146,7 @@ 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' } })
+  return handler({ senderFrame: { url: 'dsh-app://shell/plugin-manager.html' } })
 }
 
 beforeEach(() => {
@@ -150,6 +154,8 @@ beforeEach(() => {
   vi.clearAllMocks()
   vi.useFakeTimers()
   harness.reset()
+  harness.dialog.showMessageBox.mockImplementation(() => new Promise(() => {}))
+  vi.spyOn(console, 'error').mockImplementation(() => {})
   vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
   vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
   vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
@@ -162,7 +168,6 @@ afterEach(async () => {
   harness.app.quit()
   await harness.quitCompleted.promise
   vi.restoreAllMocks()
-  harness.canRecoverProfile.mockReturnValue(true)
   vi.clearAllTimers()
   vi.useRealTimers()
   vi.unstubAllEnvs()
@@ -278,7 +283,7 @@ describe('desktop main startup', () => {
     await retry
     expect(harness.windows).toHaveLength(1)
     expect(window.urls).toEqual([
-      'dsh-app://app/', 'dsh-app://shell/startup.html', 'dsh-app://app/',
+      'dsh-app://app/', 'dsh-app://app/',
     ])
     expect(harness.openExternal).not.toHaveBeenCalled()
   })
@@ -308,131 +313,123 @@ describe('desktop main startup', () => {
     expect(harness.openExternal).not.toHaveBeenCalled()
   })
 
-  it('exits with a diagnostic when both initialization and emergency navigation fail', async () => {
-    const exited = Promise.withResolvers<undefined>()
-    vi.spyOn(harness.app, 'getLocale').mockImplementationOnce(() => { throw new Error('locale unavailable') })
-    vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('emergency navigation failed'))
-    vi.spyOn(console, 'error').mockImplementation(() => {})
-    harness.app.exit.mockImplementationOnce(() => { exited.resolve(undefined) })
+  it('reports a window construction failure without requiring a window', async () => {
+    const shown = Promise.withResolvers<undefined>()
+    harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
+    harness.failWindow(new Error('window creation failed'))
     await import('../src/main.ts')
-    await exited.promise
-    expect(harness.app.exit).toHaveBeenCalledWith(1)
-    expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'emergency navigation failed' }))
+    await shown.promise
+    expect(harness.windows).toHaveLength(0)
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('window creation failed')
   })
 
-  it('withholds profile recovery after application resources fail to load', async () => {
-    harness.canRecoverProfile.mockReturnValue(false)
+  it('accepts Web fatal reports only from the primary application frame', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
-    harness.prepared.reject(new Error('runtime resources missing'))
-    await harness.errorPublished.promise
-    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: false })
     const window = harness.windows[0]!
-    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
-    const html = decodeURIComponent(window.urls.at(-1)!)
-    expect(html).toContain('dsh-recovery://restart')
-    expect(html).not.toContain('dsh-recovery://reset')
-    expect(html).not.toContain('dsh-recovery://plugins')
+    const frame = { url: 'dsh-app://app/' }
+    Object.assign(window.webContents, { mainFrame: frame })
+    const handler = harness.handlers.get(DESKTOP_IPC.bootFailed)! as unknown as (event: unknown, message: unknown) => void
+    const event = { sender: window.webContents, senderFrame: frame }
+    expect(() => { handler({ ...event, senderFrame: { url: 'https://other.example/' } }, 'untrusted') }).toThrow('unowned renderer')
+    expect(() => { handler({ ...event, senderFrame: { ...frame } }, 'subframe') }).toThrow('non-primary frame')
+    expect(() => { handler(event, {}) }).toThrow('must be text')
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+    handler(event, 'client mount failed')
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('client mount failed')
+    expect(window.urls).toEqual(['dsh-app://app/'])
   })
 
-  it('reloads a crashed startup renderer in the same window', async () => {
+  it('ignores subresource failures and navigation cancellation but reports a failed main document', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
     const window = harness.windows[0]!
-    window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
-    await harness.errorPublished.promise
-    expect(window.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
-    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' })
+    window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/image.png', false)
+    window.webContents.emit('did-fail-load', {}, -3, 'aborted', 'dsh-app://app/', true)
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+    window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/', true)
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('Desktop page failed to load')
   })
 
-  it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => {
+  it('returns package-operation failures locally without opening fatal recovery', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
-    const window = harness.windows[0]!
-    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
     harness.prepared.resolve()
     await harness.hostStarted.promise
-    harness.hosts[0]!.ready.resolve()
+    const host = harness.hosts[0]!
+    host.ready.resolve()
     await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    const started = harness.nextHostStart()
-    const navigated = harness.nextNavigation()
-    const event = { preventDefault: vi.fn() }
-    window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`)
-    await harness.hosts[0]!.stopping.promise
-    harness.hosts[0]!.exited.resolve()
-    await started
-    harness.hosts[1]!.ready.resolve()
-    await navigated
-    expect(event.preventDefault).toHaveBeenCalled()
-    expect(window.urls.at(-1)).toBe('dsh-app://app/')
+    harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') })
+    host.exited.resolve()
+    await expect(invoke(DESKTOP_IPC.pluginsDisableAll)).rejects.toThrow('package write failed')
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
-  it('allows a full profile reset for an unclassified startup failure', async () => {
+  it('offers all recovery choices when resources fail before the Host starts', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
-    harness.prepared.resolve()
-    await harness.hostStarted.promise
-    harness.hosts[0]!.exited.resolve()
-    harness.hosts[0]!.ready.reject(new Error('Unknown startup failure'))
+    harness.prepared.reject(new Error('runtime resources missing'))
     await harness.errorPublished.promise
-    const started = harness.nextHostStart()
-    const reset = Promise.resolve(invoke(DESKTOP_IPC.configurationReset))
-    await started
-    harness.hosts[1]!.ready.resolve()
-    await reset
-    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart'])
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
-  it('keeps a self-contained reinstall document in the main window after preload failure', async () => {
+  it.each(['preload', 'renderer'])('retains the document after a fatal %s failure and reports only the first error', async (kind) => {
     await import('../src/main.ts')
     await harness.preparing.promise
     const window = harness.windows[0]!
-    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
-    expect(window.urls.at(-1)).toContain('data:text/html')
-    expect(decodeURIComponent(window.urls.at(-1)!)).toContain('preload unavailable')
-    harness.prepared.resolve()
-    await harness.hostStarted.promise
-    harness.hosts[0]!.ready.resolve()
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    expect(harness.windows).toHaveLength(1)
-    expect(window.urls.at(-1)).toContain('data:text/html')
-    expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
+    if (kind === 'preload') window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable'))
+    else window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
+    window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('secondary failure'))
+    harness.prepared.reject(new Error('backend also failed'))
+    await harness.errorPublished.promise
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).not.toContain('secondary failure')
+    expect(window.urls).toEqual(['dsh-app://app/'])
   })
 
-  it.each([true, false])('offers plugin recovery and disables plugins in packaged=%s mode', async (packaged) => {
-    harness.app.isPackaged = packaged
-    harness.pluginsEnabled = true
+  it('ignores clean renderer exits and exits of a closed window', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
-    harness.prepared.resolve()
-    await harness.hostStarted.promise
-    harness.hosts[0]!.exited.resolve()
-    harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed'))
-    await harness.errorPublished.promise
-    expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: true })
-    const nextStarted = harness.nextHostStart()
-    const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll))
-    await nextStarted
-    expect(harness.pluginsEnabled).toBe(false)
-    harness.hosts[1]!.ready.resolve()
-    await recovery
-    expect(harness.windows).toHaveLength(1)
-    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
+    const window = harness.windows[0]!
+    window.webContents.emit('render-process-gone', {}, { reason: 'clean-exit' })
+    window.close()
+    window.webContents.emit('render-process-gone', {}, { reason: 'crashed' })
+    expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+  })
+
+  it('reports a rejected document load without navigating to a recovery page', async () => {
+    const shown = Promise.withResolvers<undefined>()
+    harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) })
+    vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('document missing'))
+    await import('../src/main.ts')
+    await shown.promise
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('document missing')
+    expect(harness.hosts).toHaveLength(0)
   })
 
-  it('waits for Host exit before relaunching the application', async () => {
+  it.each([0, 1, 2])('waits for Host exit before recovery action %s', async (response) => {
+    harness.dialog.showMessageBox.mockResolvedValue({ response, checkboxChecked: false })
     await import('../src/main.ts')
     await harness.preparing.promise
     harness.prepared.resolve()
     await harness.hostStarted.promise
-    harness.hosts[0]!.ready.resolve()
-    await harness.navigated.promise
-    const restart = Promise.resolve(invoke(DESKTOP_IPC.applicationRestart))
-    await harness.hosts[0]!.stopping.promise
+    const host = harness.hosts[0]!
+    host.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    host.onFailure!(new Error('backend exited'))
+    await host.stopping.promise
     expect(harness.app.relaunch).not.toHaveBeenCalled()
-    harness.hosts[0]!.exited.resolve()
-    await restart
-    expect(harness.app.relaunch).toHaveBeenCalledOnce()
+    expect(harness.disableAllPlugins).not.toHaveBeenCalled()
+    host.exited.resolve()
+    await harness.quitCompleted.promise
+    expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
+    expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
   it('shows the loading window before profile preparation and starts one actual Host', async () => {
@@ -491,8 +488,8 @@ describe('desktop main startup', () => {
     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', profileRecovery: true })
-    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://shell/startup.html'])
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' })
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
     const nextStarted = harness.nextHostStart()
     const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
     await nextStarted

+ 4 - 26
apps/desktop/tests/preload-app.spec.ts

@@ -1,5 +1,5 @@
 import { afterEach, expect, it, vi } from 'vitest'
-import { DESKTOP_IPC, type DshDesktopStartupApi } from '../src/ipc.ts'
+import { DESKTOP_IPC } from '../src/ipc.ts'
 
 const electron = vi.hoisted(() => ({
   contextBridge: { exposeInMainWorld: vi.fn() },
@@ -17,35 +17,13 @@ it.each(['dsh-app://app/index.html', 'https://shell/startup.html'])('exposes onl
   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.disablePlugins()
-  await api.resetConfiguration()
-  await api.restart()
-  expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
-    [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus],
-    [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart],
-  ])
-  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')
-})
-
-
 it('exposes asynchronous boot only to the local application document', async () => {
   vi.stubGlobal('location', new URL('dsh-app://app/'))
   await import('../src/preload-app.ts')
-  const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktopBoot')?.[1] as { ready(): Promise<unknown> }
+  const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktopBoot')?.[1] as { ready(): Promise<unknown>; failed(message: string): Promise<void> }
   await api.ready()
+  await api.failed('client mount failed')
+  expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.bootFailed, 'client mount failed')
   expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.boot)
   vi.resetModules()
   electron.contextBridge.exposeInMainWorld.mockClear()

+ 34 - 44
apps/desktop/tests/project-manager.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -71,7 +71,7 @@ afterEach(async () => {
 })
 
 describe('desktop external plugin profile', () => {
-  it('reuses plugin files without scanning manifests and can disable or reset them', async () => {
+  it('reuses plugin files without scanning manifests and can disable them', async () => {
     const { manager } = setup()
     await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
@@ -81,9 +81,6 @@ describe('desktop external plugin profile', () => {
     await manager.mutate({ type: 'plugins-disable-all' }, hooks())
     await expect(manager.applyRelease()).resolves.toBeUndefined()
     expect(readFileSync(manifest, 'utf8')).toBe('{broken')
-    await manager.resetConfiguration(hooks())
-    expect(existsSync(manifest)).toBe(false)
-    await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
   it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
@@ -105,46 +102,40 @@ describe('desktop external plugin profile', () => {
     await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
-  it('resets the entire profile without backups while retaining its lock and shared data', async () => {
-    const { root, manager } = setup()
+  it('disables plugins before runtime initialization and preserves configuration and package files', async () => {
+    const { manager } = setup()
     await manager.applyRelease()
     await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
-    const profile = manager.paths.profile
-    expect(manager.paths.lock).toBe(join(profile, 'lock'))
-    const task = join(root, '.dsh', 'task-sentinel')
-    const homeEnvironment = join(root, '.dsh', '.env')
-    writeFileSync(homeEnvironment, 'HOME_SETTING=retained')
-    writeFileSync(task, 'retained task')
-    writeFileSync(join(profile, 'desktop-runtime-state.json'), '{broken')
-    writeFileSync(join(profile, 'cordis.patch.yml'), ': broken')
-    writeFileSync(join(profile, '.env'), 'NODE_OPTIONS=--bad')
-    mkdirSync(join(profile, '.extra'))
-    writeFileSync(join(profile, '.extra', 'custom-file'), 'remove')
-    const shared = join(root, 'shared-data')
-    mkdirSync(shared)
-    writeFileSync(join(shared, 'sentinel'), 'preserve')
-    symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir')
-    await expect(manager.applyRelease()).rejects.toThrow()
-    await manager.resetConfiguration(hooks({
-      beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') },
-      afterChange: async () => {
-        expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(String(process.pid))
-        await expect(manager.applyRelease()).rejects.toThrow('another package transaction is active')
-      },
-    }))
-    expect(manager.listPlugins()).toEqual([])
-    expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false)
-    expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toContain('[]')
-    expect(existsSync(join(profile, '.env'))).toBe(false)
-    expect(existsSync(join(profile, '.extra'))).toBe(false)
-    expect(existsSync(join(profile, 'external-link'))).toBe(false)
-    expect(readFileSync(join(shared, 'sentinel'), 'utf8')).toBe('preserve')
-    expect(readFileSync(task, 'utf8')).toBe('retained task')
-    expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained')
-    expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false)
-    expect(calls(root)).toHaveLength(1)
-    await expect(manager.applyRelease()).resolves.toBeUndefined()
-    expect(existsSync(homeEnvironment)).toBe(true)
+    const patch = join(manager.paths.profile, 'cordis.patch.yml')
+    writeFileSync(patch, ': broken')
+    const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
+    await uninitialized.disableAllPlugins()
+    expect(readFileSync(patch, 'utf8')).toBe(': broken')
+    expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
+    const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
+      dependencies: Record<string, string>
+      dsh: { profile: { bundles: string[] } }
+    }
+    expect(manifest.dependencies.plugin).toBe('1.0.0')
+    expect(manifest.dsh.profile.bundles).not.toContain('plugin')
+    expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app')
+  })
+
+  it('needs no runtime or package manifest when no plugins have been installed', async () => {
+    const { manager } = setup()
+    await manager.disableAllPlugins()
+    expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false)
+    expect(existsSync(manager.paths.lock)).toBe(false)
+  })
+
+  it('reports invalid profile JSON without replacing it', async () => {
+    const { manager } = setup()
+    await manager.applyRelease()
+    const path = join(manager.paths.profile, 'package.json')
+    writeFileSync(path, '{broken')
+    await expect(manager.disableAllPlugins()).rejects.toThrow()
+    expect(readFileSync(path, 'utf8')).toBe('{broken')
+    expect(existsSync(manager.paths.lock)).toBe(false)
   })
 
   it.each(['missing', 'malformed', 'unversioned'] as const)('lists, disables, and removes a package with %s metadata', async (damage) => {
@@ -219,7 +210,6 @@ describe('desktop external plugin profile', () => {
     const { manager } = setup()
     writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken')
     await expect(manager.applyRelease()).rejects.toThrow()
-    expect(manager.canRecoverProfile()).toBe(false)
   })
 
   it('preserves unknown files when initializing a profile', async () => {

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

@@ -1,175 +0,0 @@
-import { readFileSync } from 'node:fs'
-import { runInContext } from 'node:vm'
-import { fileURLToPath } from 'node:url'
-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'
-import { startupFailureDocument } from '../src/startup-document.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 disablePlugins = vi.fn(async () => {})
-  const resetConfiguration = vi.fn(async () => {})
-  const restart = vi.fn(async () => {})
-  const queried = Promise.withResolvers<undefined>()
-  const api: DshDesktopStartupApi = {
-    protocolVersion: 1,
-    locale: async () => resolveDesktopLocale(locale),
-    backend: {
-      status: () => { queried.resolve(undefined); return status },
-      subscribe: (listener) => { listeners.add(listener); return unsubscribe },
-    },
-    disablePlugins, resetConfiguration, restart,
-  }
-  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,
-    ...['#reset-advice', '#reinstall-advice', '#error', '#actions'].filter(selector => !element(selector).hidden)
-      .flatMap(selector => selector === '#actions'
-        ? [...document.querySelectorAll<HTMLButtonElement>('#actions button')].filter(button => !button.hidden).map(button => button.textContent)
-        : [element(selector).textContent]),
-  ].join('\n')
-  return { dom, document, element, button, publish, copy,
-    disablePlugins, resetConfiguration, restart, 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('#restart').disabled).toBe(true)
-  expect(page.button('#disable-plugins').disabled).toBe(true)
-  page.publish({ phase: 'error', profileRecovery: true, message: 'Plugin failed to load' })
-  expect(page.copy()).toMatchInlineSnapshot(`
-    "DeepSeek Harness could not start
-    Choose a recovery action below. Disabling third-party plugins retains their files.
-    Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.
-    If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
-    Plugin failed to load
-    Close and restart
-    Disable all third-party plugins and retry
-    Reset Desktop and retry"
-  `)
-  expect(page.element('main').getAttribute('aria-busy')).toBe('false')
-  expect(page.element('#spinner').hidden).toBe(true)
-  page.button('#restart').click()
-  expect(page.restart).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', profileRecovery: true, message: '插件加载失败' })
-  expect(page.copy()).toMatchInlineSnapshot(`
-    "DeepSeek Harness 无法启动
-    请选择下方的恢复操作。禁用第三方插件会保留插件文件。
-    重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。
-    如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
-    插件加载失败
-    关闭并重启
-    禁用全部第三方插件并重试
-    重置 Desktop 并重试"
-  `)
-})
-
-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', profileRecovery: true, message: diagnostic })
-  expect(page.element('#error').textContent).toBe(diagnostic)
-  expect(page.element('#error').childElementCount).toBe(0)
-  page.restart.mockRejectedValueOnce(new page.dom.window.Error('Retry failed'))
-  page.button('#restart').click()
-  await expect.poll(() => page.element('#error').textContent).toBe('Retry failed')
-  expect(page.button('#restart').disabled).toBe(false)
-  expect(page.button('#disable-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', profileRecovery: true, 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()
-})
-
-it.each(['en', 'zh-CN'])('offers recovery actions with %s guidance and preserves diagnostic text', async (locale) => {
-  const page = startup(locale)
-  await expect.poll(() => page.button('#restart').disabled).toBe(true)
-  page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' })
-  await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-profile.txt`, import.meta.url)))
-  for (const action of ['#disable-plugins', '#reset-configuration', '#restart']) {
-    page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' })
-    page.button(action).click()
-    expect(page.element('#actions').hidden).toBe(true)
-  }
-  expect(page.disablePlugins).toHaveBeenCalledOnce()
-  expect(page.resetConfiguration).toHaveBeenCalledOnce()
-  expect(page.restart).toHaveBeenCalledOnce()
-  page.publish({ phase: 'error', profileRecovery: false, message: 'Failure details' })
-  expect(page.button('#disable-plugins').hidden).toBe(true)
-  expect(page.button('#reset-configuration').hidden).toBe(true)
-  await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-restart.txt`, import.meta.url)))
-})
-
-it('keeps emergency diagnostics inert without shell assets', () => {
-  const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '<script>alert(1)</script>', true)
-  const dom = new JSDOM(html)
-  expect(dom.window.document.querySelector('script')).toBeNull()
-  expect(dom.window.document.querySelector('pre')?.textContent).toBe('<script>alert(1)</script>')
-  expect(dom.window.document.querySelector('p')?.textContent).toContain('重新安装')
-  expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual([
-    'dsh-recovery://restart', 'dsh-recovery://plugins', 'dsh-recovery://reset',
-  ])
-  dom.window.close()
-})
-
-it('offers only restart before emergency profile recovery is available', () => {
-  const dom = new JSDOM(startupFailureDocument(resolveDesktopLocale('en'), 'Resources unavailable'))
-  expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual(['dsh-recovery://restart'])
-  dom.window.close()
-})

+ 29 - 19
apps/web/src/main.ts

@@ -1,29 +1,39 @@
 /** Browser entry for the Web client. */
 import { AppWebEntry, applyIndexInjections } from '@deepseek-ai/dsh-client-web'
 
-const el = document.getElementById('root')
-if (el === null) throw new Error('web app: missing #root')
-const entry = new AppWebEntry(el)
 interface DesktopBootGlobal {
   dshDesktopBoot?: {
+    failed(message: string): Promise<void>
     ready(): Promise<{ injections: Parameters<typeof applyIndexInjections>[0]; streamBaseUrl: string }>
   }
 }
 const desktop = (globalThis as DesktopBootGlobal).dshDesktopBoot
-if (desktop !== undefined) {
-  const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers<void> }).__DSH_BOOT_READY__
-  if (gate === undefined) throw new Error('desktop web: boot readiness is missing')
-  void desktop.ready().then(async ({ injections, streamBaseUrl }) => {
-    const transport = globalThis as { __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string } }
-    transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl }
-    await applyIndexInjections(injections, src => new Promise<void>((resolve, reject) => {
-      const script = document.createElement('script')
-      script.src = src
-      script.onload = () => { resolve() }
-      script.onerror = () => { reject(new Error(`desktop web: failed to load ${src}`)) }
-      document.head.append(script)
-    }))
-    gate.resolve()
-  }).catch((error: unknown) => { gate.reject(error) })
+const reportFailure = (reason: unknown): void => {
+  if (desktop === undefined) throw reason
+  void desktop.failed(reason instanceof Error ? reason.message : String(reason)).catch(console.error)
+}
+
+try {
+  const el = document.getElementById('root')
+  if (el === null) throw new Error('web app: missing #root')
+  const entry = new AppWebEntry(el)
+  if (desktop !== undefined) {
+    const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers<void> }).__DSH_BOOT_READY__
+    if (gate === undefined) throw new Error('desktop web: boot readiness is missing')
+    void desktop.ready().then(async ({ injections, streamBaseUrl }) => {
+      const transport = globalThis as { __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string } }
+      transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl }
+      await applyIndexInjections(injections, src => new Promise<void>((resolve, reject) => {
+        const script = document.createElement('script')
+        script.src = src
+        script.onload = () => { resolve() }
+        script.onerror = () => { reject(new Error(`desktop web: failed to load ${src}`)) }
+        document.head.append(script)
+      }))
+      gate.resolve()
+    }).catch((error: unknown) => { gate.reject(error) })
+  }
+  void entry.run(desktop === undefined ? undefined : reportFailure)
+} catch (reason) {
+  reportFailure(reason)
 }
-void entry.run()

+ 64 - 0
apps/web/tests/desktop-boot.spec.ts

@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from 'vitest'
+
+const boot = vi.hoisted(() => ({
+  run: vi.fn(),
+  applyIndexInjections: vi.fn(async () => {}),
+}))
+vi.mock('@deepseek-ai/dsh-client-web', () => ({
+  AppWebEntry: class { run = boot.run },
+  applyIndexInjections: boot.applyIndexInjections,
+}))
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  vi.clearAllMocks()
+  vi.resetModules()
+  document.body.replaceChildren()
+})
+
+it('forwards client initialization failures to native recovery', async () => {
+  document.body.innerHTML = '<div id="root"></div>'
+  const gate = Promise.withResolvers<undefined>()
+  const failed = vi.fn(async () => {})
+  vi.stubGlobal('__DSH_BOOT_READY__', gate)
+  vi.stubGlobal('dshDesktopBoot', { ready: async () => ({ injections: [], streamBaseUrl: 'http://127.0.0.1:3080' }), failed })
+  vi.stubGlobal('__DSH_TRANSPORT__', undefined)
+  await import('../src/main.ts')
+  await gate.promise
+  expect(boot.applyIndexInjections).toHaveBeenCalledWith([], expect.any(Function))
+  const report = boot.run.mock.calls[0]![0] as (reason: unknown) => void
+  report(new Error('client plugin activation failed'))
+  expect(failed).toHaveBeenCalledWith('client plugin activation failed')
+})
+
+it('rejects the shared boot wait when the Desktop Host cannot start', async () => {
+  document.body.innerHTML = '<div id="root"></div>'
+  const gate = Promise.withResolvers<undefined>()
+  const failure = new Error('Host failed')
+  const rejected = expect(gate.promise).rejects.toBe(failure)
+  vi.stubGlobal('__DSH_BOOT_READY__', gate)
+  vi.stubGlobal('dshDesktopBoot', { ready: async () => { throw failure }, failed: vi.fn() })
+  await import('../src/main.ts')
+  await rejected
+  expect(boot.applyIndexInjections).not.toHaveBeenCalled()
+  expect(boot.run).toHaveBeenCalledWith(expect.any(Function))
+})
+
+it('leaves browser failure presentation with the Web boot kernel', async () => {
+  document.body.innerHTML = '<div id="root"></div>'
+  vi.stubGlobal('dshDesktopBoot', undefined)
+  await import('../src/main.ts')
+  expect(boot.run).toHaveBeenCalledWith(undefined)
+})
+
+it.each(['root', 'readiness'])('reports missing %s before client plugin startup', async (missing) => {
+  if (missing !== 'root') document.body.innerHTML = '<div id="root"></div>'
+  const failed = vi.fn(async () => {})
+  vi.stubGlobal('dshDesktopBoot', { ready: vi.fn(), failed })
+  vi.stubGlobal('__DSH_BOOT_READY__', undefined)
+  await import('../src/main.ts')
+  expect(failed).toHaveBeenCalledWith(missing === 'root'
+    ? 'web app: missing #root' : 'desktop web: boot readiness is missing')
+  expect(boot.run).not.toHaveBeenCalled()
+})

+ 2 - 2
packages/client/web/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 packages/client/web/README.md
-README.md: c994f725b7a0eec1a694f1e544a0f979f5fcc31d
-README.zh.md: 5fc5d574961500e781b670877295b7efb187b3fa
+README.md: c66fbf049c0c561e7856802004b249ab39485a29
+README.zh.md: 080630a56be0f07ff1b99d4cab287a9114dcb19b

+ 1 - 1
packages/client/web/README.md

@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
 
 Use it when you assemble the browser application: `apps/web`'s Vite entry runs `new AppWebEntry(container).run()` against the mount point, and the boot page carries the user through activation. Ordinary browser callers pass no options. A pre-injected page transport is the default ahead of the `seams` override: when `globalThis.__DSH_TRANSPORT__` carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch, while explicit `seams` still win (for example jsdom tests, where external `<script>` execution cannot reach the page context).
 
-Static application pages install `__DSH_BOOT_READY__` before the entry runs. The boot page renders immediately while `run()` waits; the page owner applies the Host rows with `applyIndexInjections` (also exported from `./injections`) and resolves the deferred after all scripts finish. A rejected deferred renders a boot failure. Desktop and WebWorker share this interpreter. Static boot consumes structured rows; server-side `tapIndex` HTML transforms apply only to served documents.
+Static application pages install `__DSH_BOOT_READY__` before the entry runs. The boot page renders immediately while `run()` waits; the page owner applies the Host rows with `applyIndexInjections` (also exported from `./injections`) and resolves the deferred after all scripts finish. A rejected deferred renders a boot failure unless the caller supplies `run(onFailure)` to present the error externally while retaining the loading page. Desktop uses this callback to request native recovery. Desktop and WebWorker share the injection interpreter; server-side `tapIndex` HTML transforms apply only to served documents.
 
 The shell base styles apply automatic CJK/Latin spacing to ordinary content in supporting browsers. Semantic code and terminal, diff, read, and search output containers retain literal source spacing and column alignment; browsers without `text-autospace` support ignore both declarations.
 

+ 1 - 1
packages/client/web/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-library"
 
 组装浏览器应用时使用它:`apps/web` 的 Vite 入口对挂载点运行 `new AppWebEntry(container).run()`,启动页会在激活过程中向用户展示进度。普通浏览器调用方不传任何选项。默认使用预注入的页面传输,除非提供 `seams` 覆盖:当 `globalThis.__DSH_TRANSPORT__` 携带 `loadBundle` 时,模块阶段将其采纳为 bundle 传输并跳过 `immediately` 层级的 HTTP 预取,而显式 `seams` 仍然优先(例如外部 `<script>` 执行无法到达页面上下文的 jsdom 测试)。
 
-静态应用页面在入口运行前设置 `__DSH_BOOT_READY__`。启动页立即显示,`run()` 保持等待;页面所有者通过 `applyIndexInjections`(也从 `./injections` 导出)应用 Host 注入,并在所有脚本完成后解除等待。等待被拒绝时显示启动失败。Desktop 与 WebWorker 共享这个解释器。静态启动使用结构化注入;服务端 `tapIndex` HTML 转换只作用于服务端提供的文档。
+静态应用页面在入口运行前安装 `__DSH_BOOT_READY__`。`run()` 等待期间会立即显示启动页;页面所有者通过 `applyIndexInjections`(也从 `./injections` 导出)应用 Host 注入项,并在所有脚本完成后兑现延迟对象。延迟对象拒绝时显示启动失败;若调用方提供 `run(onFailure)`,则由外部呈现错误并保留加载页。Desktop 使用该回调请求原生恢复。Desktop 与 WebWorker 共享注入解释器;服务端 `tapIndex` HTML 转换仅适用于服务端提供的文档。
 
 外壳基础样式会在支持的浏览器中为普通内容自动添加中西文间距。语义化代码以及终端、diff、读取和搜索输出容器会保留源码中的原始间距和列对齐;不支持 `text-autospace` 的浏览器会忽略这两项声明。
 

+ 8 - 4
packages/client/web/src/boot.ts

@@ -41,9 +41,10 @@ export class AppWebEntry {
   /**
    * Load and activate every client entry, then hand the mount point to the
    * UI renderer. Plugin failures remain visible on the boot page.
-   * @returns Resolves after application mount or failure rendering.
+   * @param onFailure - Optional carrier-owned fatal presentation; keeps the boot page visible.
+   * @returns Resolves after application mount or failure reporting.
    */
-  async run(): Promise<void> {
+  async run(onFailure?: (reason: unknown) => void): Promise<void> {
     try {
       // Boot-readiness gate: whichever bootstrap applies the injection table
       // settles this deferred once every row has taken effect — the served
@@ -82,12 +83,15 @@ export class AppWebEntry {
         ctx,
         modules: this.modules,
         manifest: this.manifest,
-        onEntryState: (name, state) => { this.page.setState(name, state) },
+        onEntryState: (name, state) => {
+          if (onFailure === undefined || state !== 'failed') this.page.setState(name, state)
+        },
       })
       await mountClient(ctx, this.container)
     } catch (reason) {
       console.error(reason)
-      this.page.fail(reason instanceof Error ? reason.message : String(reason))
+      if (onFailure !== undefined) onFailure(reason)
+      else this.page.fail(reason instanceof Error ? reason.message : String(reason))
     }
   }
 

+ 47 - 0
packages/client/web/tests/boot.client.spec.ts

@@ -56,6 +56,53 @@ async function expectBootFailure(setup: () => void, message: string): Promise<vo
 }
 
 describe('bootstrap failure rendering', () => {
+  it('leaves the loading page visible when a carrier owns fatal presentation', async () => {
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+    const container = document.createElement('div')
+    document.body.append(container)
+    const entry = new AppWebEntry(container)
+    const failure = vi.fn()
+    await entry.run(failure)
+    expect(failure).toHaveBeenCalledWith(new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing'))
+    expect(container.textContent).toContain('Loading plugins')
+    expect(container.textContent).not.toContain('Failed to load plugins')
+    expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
+    await entry.dispose()
+  })
+
+  it.each([false, true])('routes failed plugin activation with carrier presentation=%s', async (carrier) => {
+    vi.spyOn(console, 'error').mockImplementation(() => {})
+    const container = document.createElement('div')
+    document.body.append(container)
+    const target = installFacade()
+    win.__DSH_BOOT__ = {
+      rev: 'graph',
+      entries: [{ id: 'broken', url: '/broken.js', rev: '1' }],
+      batches: [{ phase: 'application', url: '/application.js', rev: '1', entries: ['broken'] }],
+    }
+    const entry = new AppWebEntry(container, {
+      loadBundle: async () => {
+        target.load({ id: 'broken', factory: () => ({ apply() { throw new Error('plugin activation failed') } }) })
+      },
+    })
+    const report = vi.fn<(reason: unknown) => void>()
+    try {
+      await entry.run(carrier ? report : undefined)
+      if (carrier) {
+        expect(report).toHaveBeenCalledOnce()
+        expect(String(report.mock.calls[0]![0])).toContain('broken')
+        expect(container.querySelector('[data-dsh-boot-spinner]')).not.toBeNull()
+        expect(container.textContent).not.toContain('Failed to load plugins')
+      } else {
+        expect(report).not.toHaveBeenCalled()
+        expect(container.textContent).toContain('Failed to load plugins')
+        expect(container.textContent).toContain('broken')
+      }
+    } finally {
+      await entry.dispose()
+    }
+  })
+
   it('renders a missing bootstrap facade', async () => {
     await expectBootFailure(
       () => { delete win.__ModuleLoader__ },