Browse Source

fix(desktop): bound fatal diagnostics and restore failed package operations

07akioni 1 month ago
parent
commit
791f731c08
28 changed files with 160 additions and 256 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
  5. 3 3
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
  6. 3 3
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
  7. 2 2
      apps/desktop/README.i18n.yaml
  8. 5 5
      apps/desktop/README.md
  9. 5 5
      apps/desktop/README.zh.md
  10. 0 6
      apps/desktop/renderer/plugin-manager.html
  11. 0 8
      apps/desktop/renderer/plugin-manager.js
  12. 3 10
      apps/desktop/scripts/smoke-windows.ps1
  13. 13 2
      apps/desktop/src/fatal-recovery.ts
  14. 0 2
      apps/desktop/src/ipc.ts
  15. 4 6
      apps/desktop/src/locale.ts
  16. 9 13
      apps/desktop/src/main.ts
  17. 0 26
      apps/desktop/src/owned-directory.ts
  18. 0 1
      apps/desktop/src/preload.ts
  19. 13 6
      apps/desktop/src/project-manager.ts
  20. 1 1
      apps/desktop/tests/expected/fatal-dialog-en.txt
  21. 1 1
      apps/desktop/tests/expected/fatal-dialog-zh-CN.txt
  22. 19 0
      apps/desktop/tests/fatal-recovery.spec.ts
  23. 0 29
      apps/desktop/tests/fixtures/owned-directory-smoke.mjs
  24. 47 52
      apps/desktop/tests/main-startup.spec.ts
  25. 0 53
      apps/desktop/tests/owned-directory.spec.ts
  26. 4 11
      apps/desktop/tests/plugin-manager.spec.ts
  27. 1 1
      apps/desktop/tests/preload-app.spec.ts
  28. 19 2
      apps/desktop/tests/project-manager.spec.ts

+ 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: 33a1852ef7dbae136370e6ee30f557534ab51751
-2026-09-10-desktop-web-wrapper.zh.md: 7b652fddd0ef92a4797ae4f1a799f9815e787843
+2026-09-10-desktop-web-wrapper.md: 13b2a36643198f649d6c4eb56df3e01aa98b50ac
+2026-09-10-desktop-web-wrapper.zh.md: de1f5e671c88ea03d6bd36e88692c81ff6f58404

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

@@ -14,7 +14,7 @@ Separate Desktop composition and request transport require their own configurati
 
 The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
 
-The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and patch-reload policy and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
+The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
 
 The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
 
@@ -40,6 +40,6 @@ This partially supersedes the private composition and portless transport in the
 
 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.
+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 native recovery dialog rather than triggering stricter admission checks or automatic rollback.
 
 Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement.

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

@@ -14,7 +14,7 @@ Status: implemented
 
 私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
 
-共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表和 patch 重载策略初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
 
 [内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
 
@@ -40,6 +40,6 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。
 
-用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
+用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。
 
 验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.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-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
+2026-09-15-desktop-native-fatal-recovery.md: 6922b65495656a52b449b94e23f24b036a0bf79b
+2026-09-15-desktop-native-fatal-recovery.zh.md: 32c35753a6346849b3da26c5492a245fee5ab991

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

@@ -10,11 +10,11 @@ A recovery document depends on the renderer and preload whose failure can preven
 
 ## 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.
+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; the Host restarts after failed package writes, and a failed Host restart enters native recovery; 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 first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog 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.
+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, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
 
 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.
 

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

@@ -10,11 +10,11 @@ Status: implemented
 
 ## Decision
 
-Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
+Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
 
-首次报告在等待对话框前取得展示权。后续报告保留在日志中。对话框保留首次诊断,提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
 
-Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。Desktop 不提供 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)仍负责包事务和部分变更。
 

+ 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: b02c8f675b3fbb75fe30ad41db12064b433bafe1
-README.zh.md: 402dd3310b5caeec6867f4c6dc0f11d3ce69296b
+README.md: 6551f2d038f64c8226b041c6f40a34df69f4ebc0
+README.zh.md: 82002f7b24e8eb513167c24e7364eb14051df4c7

+ 5 - 5
apps/desktop/README.md

@@ -54,13 +54,13 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
 2. Application upgrades preserve plugin files, configuration, versions, and lockfile; pnpm does not run at startup.
 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
 4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
-5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
+5. Plugin changes stop the backend before modifying the current profile. The Host restarts after every attempted package change, including failed package operations. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
 
 CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-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.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, 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, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native 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.
+Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. 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.
 
 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.
 
@@ -118,10 +118,10 @@ Production packages first pass through npm's publication rules and dependency in
 
 The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
 
-Windows release qualification also runs [native cleanup and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Electron` to the prepared Electron executable and `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies Electron junction cleanup, directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
+Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
 
 ```powershell
-pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
+pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
 The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.

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

@@ -54,13 +54,13 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 2. 应用升级保留插件文件、配置、版本和锁文件;启动时不运行 pnpm。
 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
 4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
-5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
+5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
 
 CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航、普通请求和包操作错误不会触发恢复。不通过启动超时推断故障。
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
 
-Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
+原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
 恢复操作等待 Host 关闭后才修改插件启用状态。禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
 
@@ -118,10 +118,10 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 
 
 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
 
-Windows 发布验收还需在 Desktop 构建后手动运行[原生清理和替换检查](scripts/smoke-windows.ps1)。将 `$Electron` 设为已准备的 Electron 可执行文件,将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 Electron junction 清理、目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
+Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
 
 ```powershell
-pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
+pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
 Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。

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

@@ -16,12 +16,6 @@
         </div>
         <button id="refresh" class="quiet" type="button"></button>
       </header>
-      <section id="recovery" hidden>
-        <p id="recovery-description"></p>
-        <p id="startup-error" role="alert"></p>
-        <button id="retry" type="button"></button>
-        <button id="disable-all" type="button"></button>
-      </section>
       <form id="install-form">
         <label id="package-label" for="package-spec"></label>
         <div class="install-row">

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

@@ -14,9 +14,6 @@ async function main() {
   document.querySelector('#installed-heading').textContent = messages.installed
   document.querySelector('#empty').textContent = messages.noPlugins
 
-  document.querySelector('#recovery-description').textContent = messages.recoveryDescription
-  document.querySelector('#retry').textContent = messages.retry
-  document.querySelector('#disable-all').textContent = messages.disableAll
 
   const list = document.querySelector('#plugins')
   const empty = document.querySelector('#empty')
@@ -37,9 +34,6 @@ async function main() {
   }
 
   async function render() {
-    const backend = await api.backend.status()
-    document.querySelector('#recovery').hidden = backend.phase !== 'error'
-    document.querySelector('#startup-error').textContent = backend.phase === 'error' ? backend.message : ''
     const plugins = await api.plugins.list()
     list.replaceChildren(...plugins.map(plugin => {
       const item = document.createElement('li')
@@ -128,8 +122,6 @@ async function main() {
     updateForm.hidden = true
     updateTrigger.focus()
   })
-  document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
-  document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
   refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
 
   await load(messages.loadingPlugins, '')

+ 3 - 10
apps/desktop/scripts/smoke-windows.ps1

@@ -1,14 +1,13 @@
-# Run native Electron cleanup and NSIS replacement checks against the prepared Windows target.
+# Run NSIS directory and replacement checks against the prepared Windows target.
 param(
-  [Parameter(Mandatory)][string]$Electron,
   [Parameter(Mandatory)][string]$Makensis,
   [Parameter(Mandatory)][string]$SevenZip,
   [Parameter(Mandatory)][string]$PluginDir
 )
 $ErrorActionPreference = 'Stop'
 $desktopRoot = Split-Path $PSScriptRoot -Parent
-$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-desktop-native-').FullName
 $fixtureRoot = Join-Path $desktopRoot 'tests/fixtures'
+$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-desktop-native-').FullName
 
 function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
   & $Executable @Arguments | Out-Host
@@ -17,12 +16,6 @@ function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
 
 try {
   & (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') -Makensis $Makensis -SevenZip $SevenZip
-  $previousRunAsNode = $env:ELECTRON_RUN_AS_NODE
-  try {
-    $env:ELECTRON_RUN_AS_NODE = '1'
-    Invoke-Checked $electron @((Join-Path $fixtureRoot 'owned-directory-smoke.mjs'))
-  } finally { $env:ELECTRON_RUN_AS_NODE = $previousRunAsNode }
-
   $payload = Join-Path $scratch 'payload'
   New-Item -ItemType Directory -Path $payload | Out-Null
   [System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime')
@@ -49,7 +42,7 @@ try {
       throw "Unexpected NSIS $mode replacement result"
     }
   }
-  Write-Output 'Electron cleanup and NSIS directory/replacement smokes passed.'
+  Write-Output 'NSIS directory/replacement smokes passed.'
 } finally {
   $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
   $resolvedScratch = [System.IO.Path]::GetFullPath($scratch)

+ 13 - 2
apps/desktop/src/fatal-recovery.ts

@@ -13,6 +13,14 @@ interface RecoveryOperations {
   restart(): void
 }
 
+function dialogDetail(error: string, messages: DesktopMessages): string {
+  const advice = `\n\n${messages.startupReinstallAdvice}`
+  const tail = error.split(/\r\n|[\n\r\u2028\u2029]/u).slice(-8).join('\n')
+  const budget = 1200 - advice.length - messages.diagnosticTruncated.length - 1
+  const shortened = tail.slice(-budget).replace(/^[\uDC00-\uDFFF]/u, '')
+  return `${shortened === error ? error : `${messages.diagnosticTruncated}\n${shortened}`}${advice}`
+}
+
 /** Deduplicates fatal reports while keeping explicit recovery-operation failures actionable. */
 export class DesktopFatalRecovery {
   private reported = false
@@ -20,6 +28,9 @@ export class DesktopFatalRecovery {
   /** @param operations - Native presentation and application-owned shutdown operations. */
   constructor(private readonly operations: RecoveryOperations) {}
 
+  /** Whether this process requires a native recovery action before further plugin changes. */
+  get active(): boolean { return this.reported }
+
   /**
    * Show the first fatal error; later reports cannot replace it or open another dialog.
    * @param error - Fatal failure, including nested diagnostic causes.
@@ -30,13 +41,13 @@ export class DesktopFatalRecovery {
     this.reported = true
     const messages = this.operations.messages()
     let detail = desktopErrorState(error).message
-    let message = messages.startupFailed
+    let message = messages.fatalSummary
     for (;;) {
       const { response } = await this.operations.show({
         type: 'error',
         title: messages.startupFailed,
         message,
-        detail: `${detail}\n\n${messages.startupReinstallAdvice}`,
+        detail: dialogDetail(detail, messages),
         buttons: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins],
         defaultId: 1,
         cancelId: 0,

+ 0 - 2
apps/desktop/src/ipc.ts

@@ -16,7 +16,6 @@ export const DESKTOP_IPC = {
   pluginsToggle: 'dsh-desktop:plugins-toggle',
   pluginsDisableAll: 'dsh-desktop:plugins-disable-all',
   backendStatus: 'dsh-desktop:backend-status',
-  backendRetry: 'dsh-desktop:backend-retry',
   backendState: 'dsh-desktop:backend-state',
   updatesCheck: 'dsh-desktop:updates-check',
   updatesInstall: 'dsh-desktop:updates-install',
@@ -45,7 +44,6 @@ export interface DshDesktopApi {
   }
   readonly backend: {
     status(): Promise<DesktopBackendState>
-    retry(): Promise<void>
     subscribe(listener: (state: DesktopBackendState) => void): () => void
   }
   readonly updates: {

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

@@ -3,6 +3,8 @@
 export const en = {
   application: 'Application',
   startupFailed: 'DeepSeek Harness is unavailable',
+  fatalSummary: 'The application could not start or stopped unexpectedly.',
+  diagnosticTruncated: '… Error details shortened. The full diagnostic was written to the Electron console.',
   startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
   exitApplication: 'Exit',
   restartApplication: 'Restart',
@@ -27,9 +29,6 @@ export const en = {
   enable: 'Enable',
   disable: 'Disable',
   disabled: 'Disabled',
-  retry: 'Retry startup',
-  disableAll: 'Disable all plugins and retry',
-  recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.',
   changingActivation: 'Changing plugin activation…',
   npmPackage: 'npm package',
   install: 'Install',
@@ -54,6 +53,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 export const zh = {
   application: '应用',
   startupFailed: 'DeepSeek Harness 无法使用',
+  fatalSummary: '应用无法启动或已意外停止。',
+  diagnosticTruncated: '… 错误详情已截短,完整诊断已写入 Electron 控制台。',
   startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
   exitApplication: '退出',
   restartApplication: '重启',
@@ -78,9 +79,6 @@ export const zh = {
   enable: '启用',
   disable: '禁用',
   disabled: '已禁用',
-  retry: '重试启动',
-  disableAll: '禁用全部插件并重试',
-  recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。',
   changingActivation: '正在更改插件启用状态…',
   npmPackage: 'npm 包',
   install: '安装',

+ 9 - 13
apps/desktop/src/main.ts

@@ -335,9 +335,15 @@ async function main(): Promise<void> {
   const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
     assertDesktopSender(event, ['shell'])
     await startup?.catch(() => undefined)
-    await manager.mutate(mutation, hooks)
-    navigation = undefined
-    await navigateMain(applicationUrl).catch(reportFatal)
+    if (recovery.active) throw new Error(messages.fatalSummary)
+    try {
+      await manager.mutate(mutation, hooks)
+    } finally {
+      if (backend.state.phase === 'ready') {
+        navigation = undefined
+        await navigateMain(applicationUrl).catch(reportFatal)
+      }
+    }
   }
 
   ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
@@ -376,16 +382,6 @@ async function main(): Promise<void> {
     assertDesktopSender(event, ['shell'])
     return backend.state
   })
-  ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    const retrying = backend.state.phase === 'error'
-    await reconcileBackend()
-    if (retrying) {
-      navigation = undefined
-      await navigateMain(applicationUrl).catch(reportFatal)
-    }
-    focusPrimaryWindow()
-  })
   ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
     assertDesktopSender(event, ['shell'])
     return updates.check()

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

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

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

@@ -18,7 +18,6 @@ const api: DshDesktopApi = {
   },
   backend: {
     status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopApi['backend']['status']>,
-    retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise<void>,
     subscribe(listener) {
       const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
       ipcRenderer.on(DESKTOP_IPC.backendState, handle)

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

@@ -46,11 +46,11 @@ export interface DesktopRuntimeExecutables {
   readonly dsh: string
 }
 
-/** Hooks that stop the backend before profile writes and restart it after success. */
+/** Hooks that stop the backend before profile writes and restart it after each attempted change. */
 export interface DesktopProjectHooks {
   /** Stop the active backend and await process exit before modifying its files. */
   beforeChange(): Promise<void>
-  /** Start the modified profile after package preparation succeeds. */
+  /** Start the current profile even when package preparation failed with partial writes. */
   afterChange(): Promise<void>
 }
 
@@ -167,10 +167,17 @@ export class DesktopProjectManager {
       this.currentRuntime()
       if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
       await hooks.beforeChange()
-      if (mutation.type === 'plugins-disable-all') {
-        writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
-      } else {
-        await this.applyMutation(this.paths.profile, mutation)
+      try {
+        if (mutation.type === 'plugins-disable-all') {
+          writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
+        } else {
+          await this.applyMutation(this.paths.profile, mutation)
+        }
+      } catch (error) {
+        try { await hooks.afterChange() } catch (restartError) {
+          throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed')
+        }
+        throw error
       }
       await hooks.afterChange()
     })

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

@@ -1,5 +1,5 @@
 DeepSeek Harness is unavailable
-DeepSeek Harness is unavailable
+The application could not start or stopped unexpectedly.
 Desktop Host failed
 Plugin initialization failed
 

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

@@ -1,5 +1,5 @@
 DeepSeek Harness 无法使用
-DeepSeek Harness 无法使用
+应用无法启动或已意外停止。
 Desktop Host failed
 Plugin initialization failed
 

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

@@ -83,3 +83,22 @@ it('allows exit after shutdown cleanup fails', async () => {
   await pending
   expect(operations.exit).toHaveBeenCalledOnce()
 })
+
+it.each(['en', 'zh-CN'])('bounds long diagnostics and recovery-operation errors in %s', async (locale) => {
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  const { operations, stopped, recovery } = fixture(locale)
+  operations.show.mockResolvedValueOnce({ response: 2, checkboxChecked: false })
+    .mockResolvedValueOnce({ response: 0, checkboxChecked: false })
+  operations.disablePlugins.mockRejectedValueOnce(new Error('read-only\n'.repeat(5000) + 'final write failure'))
+  stopped.resolve(undefined)
+  await recovery.report(new Error('😀'.repeat(32768) + '\nfinal backend failure'))
+  for (const [options] of operations.show.mock.calls) {
+    expect(options.detail!.length).toBeLessThanOrEqual(1200)
+    expect(options.detail!.split('\n').length).toBeLessThanOrEqual(12)
+    expect(options.detail).toContain(operations.messages().diagnosticTruncated)
+    expect(options.detail).toContain(operations.messages().startupReinstallAdvice)
+    expect(options.detail!.isWellFormed()).toBe(true)
+  }
+  expect(operations.show.mock.calls[0]![0].detail).toContain('final backend failure')
+  expect(operations.show.mock.calls[1]![0].detail).toContain('final write failure')
+})

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

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

+ 47 - 52
apps/desktop/tests/main-startup.spec.ts

@@ -133,8 +133,15 @@ vi.mock('../src/project-manager.ts', () => ({
     disableAllPlugins = harness.disableAllPlugins
     async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
       await hooks.beforeChange()
-      harness.mutateFailure()
-      harness.pluginsEnabled = false
+      try {
+        harness.mutateFailure()
+        harness.pluginsEnabled = false
+      } catch (error) {
+        try { await hooks.afterChange() } catch (restartError) {
+          throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed')
+        }
+        throw error
+      }
       await hooks.afterChange()
     }
 
@@ -146,7 +153,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/plugin-manager.html' } })
+  return handler({ senderFrame: { url: channel === DESKTOP_IPC.boot ? 'dsh-app://app/' : 'dsh-app://shell/plugin-manager.html' } })
 }
 
 beforeEach(() => {
@@ -196,7 +203,7 @@ describe('desktop main startup', () => {
     harness.prepared.resolve()
     await harness.hostStarted.promise
     harness.hosts[0]!.ready.resolve()
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
     const handler = harness.socketHeaders.mock.calls[0]![1] as (
       details: { url: string; webContentsId: number; requestHeaders: Record<string, string> },
       callback: (result: unknown) => void,
@@ -258,36 +265,6 @@ describe('desktop main startup', () => {
     expect(harness.menu.buildFromTemplate).not.toHaveBeenCalled()
   })
 
-  it('keeps a local document while retry connects a replacement Host', async () => {
-    await import('../src/main.ts')
-    await harness.preparing.promise
-    harness.prepared.resolve()
-    await harness.hostStarted.promise
-    const first = harness.hosts[0]!
-    first.url = 'http://127.0.0.1:40001/?token=first'
-    first.ready.resolve()
-    await harness.navigated.promise
-    const window = harness.windows[0]!
-    expect(window.urls).toEqual(['dsh-app://app/'])
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    first.onFailure!(new Error('backend exited'))
-    await harness.errorPublished.promise
-    await first.stopping.promise
-    first.exited.resolve()
-    const nextStarted = harness.nextHostStart()
-    const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    await nextStarted
-    const replacement = harness.hosts[1]!
-    replacement.url = 'http://127.0.0.1:40002/?token=replacement'
-    replacement.ready.resolve()
-    await retry
-    expect(harness.windows).toHaveLength(1)
-    expect(window.urls).toEqual([
-      'dsh-app://app/', 'dsh-app://app/',
-    ])
-    expect(harness.openExternal).not.toHaveBeenCalled()
-  })
-
   it('opens message links externally while retaining same-origin application navigation', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -358,11 +335,37 @@ describe('desktop main startup', () => {
     await harness.hostStarted.promise
     const host = harness.hosts[0]!
     host.ready.resolve()
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
     harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') })
     host.exited.resolve()
-    await expect(invoke(DESKTOP_IPC.pluginsDisableAll)).rejects.toThrow('package write failed')
+    const nextStarted = harness.nextHostStart()
+    const failure = expect(invoke(DESKTOP_IPC.pluginsDisableAll)).rejects.toThrow('package write failed')
+    await nextStarted
+    harness.hosts[1]!.ready.resolve()
+    await failure
+    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' })
     expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/'])
+  })
+
+  it('opens fatal recovery when the Host cannot restart after a package change', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    harness.prepared.resolve()
+    await harness.hostStarted.promise
+    const host = harness.hosts[0]!
+    host.ready.resolve()
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
+    host.exited.resolve()
+    const nextStarted = harness.nextHostStart()
+    const failure = expect(invoke(DESKTOP_IPC.pluginsDisableAll)).rejects.toThrow('new Host failed')
+    await nextStarted
+    const replacement = harness.hosts[1]!
+    replacement.exited.resolve()
+    replacement.ready.reject(new Error('new Host failed'))
+    await failure
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('new Host failed')
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
   })
 
@@ -419,7 +422,7 @@ describe('desktop main startup', () => {
     await harness.hostStarted.promise
     const host = harness.hosts[0]!
     host.ready.resolve()
-    await Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
+    await Promise.resolve(invoke(DESKTOP_IPC.boot))
     host.onFailure!(new Error('backend exited'))
     await host.stopping.promise
     expect(harness.app.relaunch).not.toHaveBeenCalled()
@@ -440,8 +443,8 @@ describe('desktop main startup', () => {
     expect(window.options.show).toBe(true)
     expect(window.urls).toEqual(['dsh-app://app/'])
     expect(harness.hosts).toHaveLength(0)
-    const retry = invoke(DESKTOP_IPC.backendRetry)
-    const secondRetry = invoke(DESKTOP_IPC.backendRetry)
+    const retry = invoke(DESKTOP_IPC.boot)
+    const secondRetry = invoke(DESKTOP_IPC.boot)
     harness.prepared.resolve()
     await harness.hostStarted.promise
     expect(harness.hosts).toHaveLength(1)
@@ -477,28 +480,20 @@ describe('desktop main startup', () => {
     expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
   })
 
-  it('keeps startup errors and a successful retry in the same window', async () => {
+  it('keeps startup errors in the existing window without a shell retry handler', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
     harness.prepared.resolve()
     await harness.hostStarted.promise
     const first = harness.hosts[0]!
-    const failedRetry = expect(Promise.resolve(invoke(DESKTOP_IPC.backendRetry))).rejects.toThrow('plugin composition failed')
     first.exited.resolve()
     first.ready.reject(new Error('plugin composition failed'))
     await harness.errorPublished.promise
-    await failedRetry
-    expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' })
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
-    const nextStarted = harness.nextHostStart()
-    const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry))
-    await nextStarted
-    expect(harness.hosts).toHaveLength(2)
-    harness.hosts[1]!.ready.resolve()
-    await retry
-    expect(harness.windows).toHaveLength(1)
-    expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/')
-    expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
+    expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
+    expect(harness.handlers.has('dsh-desktop:backend-retry')).toBe(false)
+    await expect(invoke(DESKTOP_IPC.pluginsDisableAll)).rejects.toThrow('The application could not start or stopped unexpectedly.')
+    expect(harness.hosts).toHaveLength(1)
   })
 
   it('waits for a pending child to exit on quit without late window navigation', async () => {

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

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

+ 4 - 11
apps/desktop/tests/plugin-manager.spec.ts

@@ -4,27 +4,20 @@ import { JSDOM } from 'jsdom'
 import { expect, it, vi } from 'vitest'
 import { resolveDesktopLocale } from '../src/locale.ts'
 
-it('keeps disabled packages visible and offers recovery without a running backend', async () => {
+it('keeps disabled packages visible without an in-page recovery panel', async () => {
   const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
-  let enabled = true
-  let ready = false
-  const disableAll = vi.fn(async () => { enabled = false; ready = true })
+  let enabled = false
   const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active })
   const api = {
     locale: async () => resolveDesktopLocale('en'),
-    backend: { status: async () => ready ? { phase: 'ready' } : { phase: 'error', message: 'plugin requires Cordis ^2.0.0' }, retry: vi.fn() },
-    plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle },
+    plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], toggle },
   }
   Object.defineProperty(dom.window, 'dshDesktop', { value: api })
   try {
     runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
     const document = dom.window.document
     await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin')
-    expect(document.querySelector<HTMLElement>('#recovery')?.hidden).toBe(false)
-    expect(document.querySelector('#startup-error')?.textContent).toBe('plugin requires Cordis ^2.0.0')
-    document.querySelector<HTMLButtonElement>('#disable-all')?.click()
-    await expect.poll(() => document.querySelector<HTMLElement>('#recovery')?.hidden).toBe(true)
-    expect(disableAll).toHaveBeenCalledOnce()
+    expect(document.querySelector('#recovery')).toBeNull()
     expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"')
     document.querySelector<HTMLButtonElement>('#plugins li button')?.click()
     await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]])

+ 1 - 1
apps/desktop/tests/preload-app.spec.ts

@@ -11,7 +11,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
 
 afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
 
-it.each(['dsh-app://app/index.html', 'https://shell/startup.html'])('exposes only the carrier marker to %s', async (url) => {
+it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes only the carrier marker to %s', async (url) => {
   vi.stubGlobal('location', new URL(url))
   await import('../src/preload-app.ts')
   expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })

+ 19 - 2
apps/desktop/tests/project-manager.spec.ts

@@ -360,16 +360,33 @@ describe('desktop external plugin profile', () => {
       afterChange: async () => { starts++ },
     }))).rejects.toThrow(/pnpm exited with 1/u)
     expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
-    expect(starts).toBe(0)
+    expect(starts).toBe(1)
     expect(existsSync(manager.paths.lock)).toBe(false)
     writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '')
     await manager.applyRelease()
     await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => { starts++ } }))
-    expect(starts).toBe(1)
+    expect(starts).toBe(2)
     await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
     expect(manager.listPlugins()).toEqual([])
   })
 
+  it('retains both package and restart errors when the changed profile cannot start', async () => {
+    const { root, manager } = setup()
+    await manager.applyRelease()
+    const failingPnpm = join(root, 'failing-restart.mjs')
+    writeFileSync(failingPnpm, 'process.exitCode = 1')
+    const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
+    await worker.applyRelease()
+    const afterChange = vi.fn(async () => { throw new Error('Host initialization failed') })
+    const failure = await worker.mutate({ type: 'plugin-add', spec: 'missing' }, hooks({ afterChange }))
+      .catch((error: unknown) => error)
+    expect(failure).toBeInstanceOf(AggregateError)
+    expect((failure as AggregateError).errors.map((error: Error) => error.message))
+      .toEqual([expect.stringContaining('pnpm exited with 1'), 'Host initialization failed'])
+    expect(afterChange).toHaveBeenCalledOnce()
+    expect(existsSync(manager.paths.lock)).toBe(false)
+  })
+
   it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => {
     const { root, manager } = setup()
     await manager.applyRelease()