Browse Source

Merge origin/master into feat/desktop-update

winewill 1 week ago
parent
commit
721e2e13c7
47 changed files with 237 additions and 1359 deletions
  1. 2 2
      .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  2. 1 0
      .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md
  3. 1 0
      .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  6. 8 20
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  7. 22 50
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  8. 2 2
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  9. 4 4
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  10. 4 4
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  12. 4 4
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  13. 4 4
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  14. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  15. 6 4
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  16. 6 4
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  17. 2 2
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
  18. 4 4
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
  19. 4 4
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
  20. 2 2
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml
  21. 1 1
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
  22. 1 1
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md
  23. 2 2
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml
  24. 3 3
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md
  25. 3 3
      .agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md
  26. 2 2
      apps/desktop/README.i18n.yaml
  27. 9 10
      apps/desktop/README.md
  28. 9 9
      apps/desktop/README.zh.md
  29. 0 108
      apps/desktop/renderer/plugin-manager.css
  30. 0 43
      apps/desktop/renderer/plugin-manager.html
  31. 0 130
      apps/desktop/renderer/plugin-manager.js
  32. 4 2
      apps/desktop/scripts/electron-builder-config.mjs
  33. 1 1
      apps/desktop/scripts/prepare-installed-update-application.ts
  34. 1 30
      apps/desktop/src/ipc.ts
  35. 0 48
      apps/desktop/src/locale.ts
  36. 2 115
      apps/desktop/src/main.ts
  37. 0 29
      apps/desktop/src/preload.ts
  38. 13 192
      apps/desktop/src/project-manager.ts
  39. 1 9
      apps/desktop/tests/development-project.spec.ts
  40. 2 2
      apps/desktop/tests/installed-update-builder.spec.ts
  41. 2 2
      apps/desktop/tests/installed-update-package-content.spec.ts
  42. 3 9
      apps/desktop/tests/locale.spec.ts
  43. 11 71
      apps/desktop/tests/main-startup.spec.ts
  44. 0 75
      apps/desktop/tests/plugin-manager.spec.ts
  45. 0 119
      apps/desktop/tests/plugin-pnpm.spec.ts
  46. 81 226
      apps/desktop/tests/project-manager.spec.ts
  47. 1 1
      apps/desktop/tsdown.config.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml → .agents/notes/archived/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: 6f0e9598903d922e4b765d785be6dd3aa79ce93c
-2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd
+2026-09-09-desktop-in-place-profile.md: 57dabc0d03c999b90dc18baa9d833aa90320863a
+2026-09-09-desktop-in-place-profile.zh.md: 2b57c49ce45b1937552596cf4f59e0251b2d5774

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

@@ -1,6 +1,7 @@
 # Agent Note: Modify the Desktop profile in place
 
 Status: implemented
+Archived: 2026-09-17
 
 English | [中文](2026-09-09-desktop-in-place-profile.zh.md)
 

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

@@ -1,6 +1,7 @@
 # Agent Note: 直接修改 Desktop profile
 
 Status: implemented
+Archived: 2026-09-17
 
 [English](2026-09-09-desktop-in-place-profile.md) | 中文
 

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -364,6 +364,9 @@
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
+    "architecture/2026-09-09-desktop-in-place-profile.i18n.yaml": "sha256:08543070a32503373d38ca98b7ce96ee60c972302534030c5ba380f19e04fa00",
+    "architecture/2026-09-09-desktop-in-place-profile.md": "sha256:82bdcad48207c5aecbc9695717aaaa4c8cca90c82ca58884bfa653b60f192290",
+    "architecture/2026-09-09-desktop-in-place-profile.zh.md": "sha256:f105636c9160c64b12bd1822c8ec81ee744ba27c0b0ab6eeed870561ccd81ce7",
     "architecture/2026-09-10-local-office-preview.i18n.yaml": "sha256:a43a2370c7434293ef98d1901a29b7b4b03f400a108c119f3ec5f2f7ca8ca3f8",
     "architecture/2026-09-10-local-office-preview.md": "sha256:754cfb4dde9f1ee70e495095f12fc9e3c43af4a959250bb3a59ae44fd32fd763",
     "architecture/2026-09-10-local-office-preview.zh.md": "sha256:d8fb9a65f437b585f2dfa2b4aa091634eeabf571e98e881cf7344b7574704e53",

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

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

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


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

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文
 
-profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
 
 [Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
 
@@ -18,21 +18,19 @@ DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用
 
 ## 决策
 
-共享版本的 production/test 派生遵循[发布版本决策](../process/2026-09-16-desktop-release-version-derivation.zh.md),本记录的包版本一致性要求保持适用。
-
 交付小型 Electron 壳和固定版本 pnpm;[运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)持有可执行文件选择。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责 Host 启动与传输:私有 Host 运行共享 Web profile runner,Electron 加载其认证 HTTP URL,子进程 IPC 承载生命周期消息。
 
 Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源存储、外部插件依赖、共享包链接和 profile 协调。私有 Desktop Host 保持独立于公共 CLI 包,且不会发布到 npm。
 
 一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。
 
-浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。
+Desktop Host 为保留 profile 提供共享 Web 插件管理器,并通过启动器信息提供内置 pnpm。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在 profile 恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。
 
 ## 归属
 
 | Owner | 职责 |
 |---|---|
-| Electron 壳 | 窗口与子进程生命周期、本地壳页面、保留 desktop profile、插件 GUI、更新协调 |
+| Electron 壳 | 窗口与子进程生命周期、保留 desktop profile 准备、原生恢复、更新协调 |
 | Electron RunAsNode 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 |
 | Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
 | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
@@ -40,9 +38,7 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 | 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 |
 | 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 |
 
-渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。
-
-应用菜单以原生 About role 和分隔线开头。Electron 关于面板负责各平台的显示、无障碍支持和关闭行为;静态发布信息若使用自定义渲染进程,会重复承担这些职责。面板读取与更新器相同的发布标识 `app.getVersion()`,并隐藏独立的应用包构建号。图标使用打包的平台图案:Windows 读取独立 PNG 资源,macOS 使用应用图标。发布描述符未记录发布日期,因此面板不展示日期。菜单与面板输入由归属桌面端的预期输出 fixture 覆盖;原生外观仍需平台 GUI 验收。
+渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 提供启动就绪与失败报告、原生目录选择、主题同步,以及 Windows 菜单和外观适配。它不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 菜单与原生对话框使用类型化中英文文案,并以英文回退;Windows 跟随主文档语言。共享 Web 插件管理器负责自身客户端文案。
 
 ## 文件系统布局
 
@@ -63,39 +59,29 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 ## 安装与解析
 
-Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。
+共享 Web 插件管理器负责 profile 包操作。Electron 保留启动准备和原生恢复;[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这一职责划分。
 
-进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。
+Electron 进程生命周期锁是 Desktop 的权威所有者。profile 准备和原生恢复使用记录 Electron PID 的排他锁;存活的所有者阻止竞争操作,失效 PID 可被回收。Web 包操作使用共享插件管理器的锁。
 
 核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。
 
 ## 更新与恢复
 
-共享 Web Host 通过 Connection 的请求 waterfall 接纳已认证的 HTTP API 请求。Desktop 的安装锁拒绝新请求,同时统计已接纳的响应传输和真实 Agent/job 工作;它不会取消进行中的任务。Electron 仅接受所属主窗口中、位于当前 Host origin 的顶层 frame 发起的更新操作。安装既要求进程成功退出,也要求收到共享 profile 清理完成后发送的 IPC 确认:profile runner 超时后可能以零退出码强制退出,因此仅凭退出码不足以判断清理成功。
-
-Desktop 在排他创建锁文件和启动 pnpm 前解析 profile 目录的实际路径。否则,Windows 目录 junction 可能让排他创建报告不存在的锁已存在,或让子进程无法创建嵌套包目录。锁仍位于同一 profile 内,并保留 PID 归属校验。
-
-Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。[更新策略提案](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md)负责固定 Nightly 和独立的下载/重启授权;本记录继续负责发布身份、签名和发布完整性。
+Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
 
 [立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。
 
-`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试环境,也可选择生产环境,并同时决定目标专用的 generic-provider URL 与 COS 目标。部署配置提供测试 origin 和 bucket 身份;生产使用 `https://download.deepseek.com`。打包排除凭据,并仅在签名和公证完成后记录完成状态。上传在读取凭据前验证完成记录、目标、共同版本号、文件名、大小和 SHA-512。带版本的二进制文件和 blockmap 位于 `dsh-desk/bin/<target>/`;可变 YAML 在二进制文件上传后写入 `dsh-desk/feeds/<target>/`。固定 Nightly 与额外的稳定版元数据遵循更新策略。上传将缓存头交给部署基础设施管理,并保留历史对象以供差分更新。发布操作人员负责串行发布同一目标并验证公网字节;仓库测试不等于云端交付验收。
+`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。
 
 ## 安全与发布策略
 
-Windows NSIS 分发包含固定版本构建器生成的独立 `.exe.blockmap`。上传拒绝缺失或空 blockmap,并在发布 feed 前上传它。构建器的外部映射元数据不要求 `blockMapSize`;要求该 web-installer 字段会拒绝有效 NSIS 产物。回归 fixture 执行真实 blockmap 生成器,并证明修复前不匹配的验证会失败。文件验证仍不能证明签名合格或已安装应用升级成功。
-
-本地 Windows COS 上传通过[凭据启动器](../../../../apps/desktop/scripts/upload-with-credentials.ps1)读取仓库外经 DPAPI 加密的 CLIXML 凭据对象。显式选择部署环境,避免根据文件名路由凭据;默认动作只检查本地子进程注入,不连接 COS。只有显式上传才调用发布上传程序。上传程序会拒绝与目标 dotenv 文件不一致的部署环境或 bucket,并使用选定的 DPAPI 凭据对而不是 dotenv 凭据。解密后的凭据仅存在于进程内存和上传子进程环境中,不进入命令参数或持久环境设置。启动器清除该子进程的无关密钥及 Node 预加载钩子,不显示原始 stderr,并遮盖 stdout 中的凭据值。DPAPI 将文件绑定到 Windows 用户和机器,但不能隔离以同一用户身份运行的其他代码。[Windows 凭据测试](../../../../apps/desktop/tests/upload-with-credentials.spec.ts)覆盖本地注入、父进程状态不变及拒绝时不输出密钥;云端授权和签名发布上传需要单独验收。
-
 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
 
-Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
-
-macOS 签名必须提供本地 p12 及显式导出密码。打包调用从导入与预检开始持有临时钥匙串,覆盖所有签名工作,并在正常完成或失败后删除。显式选择钥匙串使运行时准备不依赖开发者登录状态或 electron-builder 后续的证书导入。构建子进程只接收临时钥匙串路径,p12 密码保留在父进程中。强制终止后由 CI 清理临时凭据。
+Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 
-Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入签名预检、运行时签名与 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。
 
 Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。
 
@@ -107,19 +93,11 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst
 
 分架构构建报告实际组件级压缩体积和安装体积。
 
-Windows updater 在 `win.signtoolOptions.publisherName` 中固定公开发布证书的 `CN`、`O` 和 `C`,electron-builder 将其写入已安装应用的 `app-update.yml`。仅提供自定义签名回调不会产生这项元数据,字段缺失时 electron-updater 会跳过验签。这些属性在 Node/OpenSSL 与 Windows 证书主题中使用相同名称;发布者序列化会转义 DN 分隔符,并拒绝缺失或多值身份属性。保留身份而非证书指纹,允许同一发布者续期证书;身份变更需要单独验收转换流程。[真实文件验签检查](../../../../apps/desktop/scripts/test-windows-update-signature.mjs) 验证匹配、身份不符和未签名输入,不执行文件;这与已安装应用升级验收分开进行。
-
 ## 实现
 
-Windows 签名打包在耗时构建阶段前执行[一次小探针预检](../../../../apps/desktop/scripts/windows-signing-preflight.ts)。静态检查失败或遗留锁都会阻止访问硬件;只有正式签名器、时间戳服务和证书验证一次成功,才继续构建。探针保留在私有审计目录中,绝不执行。受监督阶段设有时限,避免无人处理的认证弹窗让构建无限等待。驱动可能缓存认证,因此预检成功不等于独立验证 PIN;流程不会为证明这一点而注销令牌或重试。后续运行时、签名器和构建器失败仍阻止发布完成。显式未签名及仅准备模式跳过所有硬件操作,而不只是此次预检。测试在不使用发布令牌的情况下覆盖失败顺序和凭据隔离;真实令牌验收仍由发布主机操作人员负责。
-
-发布[上传执行器](../../../../apps/desktop/scripts/desktop-upload-run.ts)要求两种部署环境都保留持久化本地证据。它在发送请求前保存已验证目标、产物哈希和 feed 字节,每次 PUT 前刷盘意图、之后刷盘可用响应元数据,上传或证据写入失败即停止。每个对象都以一次流式腾讯 COS PUT 发送,并携带显式长度与 Content-MD5;SDK 的重试路径要求请求体不是流,因此不会重复结果不确定的写入;结果不确定的写入必须保留为一次可检查的尝试,不能静默重复更新可变 feed。终端断连后,仅有终端输出无法确定哪次操作已完成;原始 SDK 错误可能暴露签名请求数据,因此不予记录。记录独立于产物清理保留,但本地存储不是远端审计服务,缺少最终结果不能证明远端失败。上传回执不认证 CDN 传播。发布者保留记录并独立验证公网字节。
-
-Windows 打包通过[监督程序](../../../../apps/desktop/scripts/packaging-run.mjs)保留每次运行的脱敏输出和带时间戳的阶段/签名事件。[硬件锁定机制](../../../../apps/desktop/scripts/windows-signing-state.mjs)在调用命令解释器前记录意图,失败或运行中断后仍在同一账户的不同进程间保留。致命通知会终止所属阶段的进程树;失败运行绝不生成发布完成记录。只有签名成功才释放锁定状态。恢复必须由操作者明确批准,因为进程内的已拒绝 Promise 无法在重新启动构建后保护令牌。这些记录证明应用级操作,不代表 CSP 内部的 PIN 尝试次数。测试使用假签名和真实隔离进程树,绝不使用发布令牌。
-
 | 表面 | 实现 |
 |---|---|
-| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
+| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、profile 准备、原生恢复、更新协调和 electron-builder 配置。 |
 | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 调用共享 profile runner,并向 Electron 报告认证 Web URL。 |
 | 包状态 | Electron RunAsNode 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
 | 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
@@ -128,15 +106,13 @@ Windows 打包通过[监督程序](../../../../apps/desktop/scripts/packaging-ru
 
 ## 考虑过的替代方案
 
-**将本地 COS 密钥持久保存在明文文件或用户环境中。** 两者都会使上传调用之外的进程能够读取密钥。仓库外的 DPAPI 文件避免明文存储,同时允许专用发布进程解密;同用户进程仍能访问凭据,这是明确保留的限制。
-
 **使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。
 
 **通过 JSON IPC 以 Base64 承载 Fetch 消息体。** 这会膨胀请求与响应消息体、在两个进程中构造大字符串,并在分派前缓冲请求。原始分帧管道避免 Base64 膨胀,但仍需维护第二套传输;[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)选择已有的 Web HTTP 传输。
 
 **把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。
 
-**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。
+**维护 Electron 专用插件安装器。** 独立页面、IPC API 和包服务会重复共享 Web 管理器的功能。共享服务使用保留 Desktop profile 和启动器提供的 pnpm,同时继续禁止 CLI 访问该 profile。
 
 **让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。
 
@@ -148,21 +124,19 @@ Windows 打包通过[监督程序](../../../../apps/desktop/scripts/packaging-ru
 
 **把凭据写进已跟踪脚本或系统环境。** 本地平台文件把配置限制在单个 checkout,并使打包输入明确。代价是凭据以明文落盘:构建账号需要限制文件访问权限,CI 必须清理临时配置,Git 与发布文件映射都必须排除真实配置。已提交的模板不含凭据;Windows CMD 只包含变量引用,签名串行执行并在首次失败后停止,文件格式不会免除 Token 的错误 PIN 计数。
 
-**依赖构建器队列或单实例 Promise 控制失败。** 构建器在一个文件失败后仍会继续处理其他文件,新签名器实例也不会保留已拒绝的 Promise。持久化的单账户锁定状态和父进程控制的终止机制阻止这些继续执行路径;任何超时都不会自动授权再次尝试。
-
 **让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。
 
 ## 结果
 
 - 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。
 - 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。
-- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。
-- 每次 Desktop 包操作都使用内置 pnpm,并遵循用户的正常环境与 profile 配置。
-- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
-- 后端与浏览器应用不能修改桌面包。
+- `.dsh/profiles/desktop/node_modules` 保存由共享 Web 插件管理器管理的外部插件。
+- Desktop 包操作使用启动器提供的内置 pnpm,以及共享管理器的子进程环境与 profile 配置。
+- 主应用的“插件”页面向共享 Host 服务发送结构化包操作与激活请求。
+- 独立 CLI 仍不能访问保留 Desktop profile。
 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
 - 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
-- 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。
+- 共享插件管理器负责包操作失败处理;原生恢复负责 Host 致命故障。
 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
 - Web 应用负责 HTTP 认证与服务;沙箱渲染进程不能访问任意文件系统或 Electron API。
@@ -177,8 +151,8 @@ Windows 打包通过[监督程序](../../../../apps/desktop/scripts/packaging-ru
 |---|---|
 | 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 |
 | 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 |
-| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
-| 激活 | 直接修改包后启动实际 Host |
+| 插件管理 | 共享 Web“插件”页面与 Host 服务,使用启动器提供的内置 pnpm |
+| 激活 | 启用 HMR 时由共享管理器应用配置;否则变更需要重启 |
 | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
 | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh |
 
@@ -186,16 +160,14 @@ Windows 打包通过[监督程序](../../../../apps/desktop/scripts/packaging-ru
 
 插件生命周期脚本会执行第三方代码。profile 的 `allowBuilds` 配置决定哪些构建获准执行。
 
-更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。已安装插件文件保留原位;加载失败需要通过恢复 UI 显式修复。
+更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。原生恢复可以禁用第三方 bundle 并重启应用,之后可通过共享“插件”页面修复。
 
 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
 
-包操作中断会保留部分变更。恢复操作重试实际 Host,或允许显式修复插件,不会自动重新安装包。
+包操作失败与取消遵循共享插件管理器的还原规则。原生恢复不会重新安装包。
 
 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
 
 ## 相关提案
 
-[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)取代无端口传输与私有后端组合。发布身份、签名、进程归属及仅限 Electron 的包授权仍由本记录负责。
-
-[桌面更新提案](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md)定义待实现的交互与强制策略变更;签名、发布身份与发布完整性仍由本记录负责。
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 传输和插件管理。发布身份、签名与进程归属仍由本记录负责。

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

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

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

@@ -4,7 +4,7 @@ Status: implemented
 
 English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)
 
-Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
+Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
 
 The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
 
@@ -40,11 +40,11 @@ First launch creates profile metadata and host links without running pnpm, prese
 
 Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
 
-Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime.
+The shared [plugin manager](../../../../packages/boot/plugin-manager/README.md) owns supported package specifications, bundle validation, activation, and installation failure handling. The bundled pnpm reads normal user and profile settings. Development uses the same Web manager against a separate Desktop profile, with workspace packages supplied by the development runtime.
 
-Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery.
+The shared Web plugin manager owns package mutations and activation; Electron retains profile preparation and native recovery. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns these responsibilities.
 
-The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded.
+The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup. The native recovery dialog can disable third-party bundles and back up the profile patch when the Web application cannot start. Installed plugin files remain available for repair.
 
 ## Alternatives considered
 

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

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文
 
-profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
 
 [Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
 
@@ -40,11 +40,11 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。
 
 共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
 
-依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供。
+共享[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)负责支持的包规格、bundle 验证、激活和安装失败处理。内置 pnpm 读取正常的用户和 profile 设置。开发模式使用相同的 Web 管理器操作独立 Desktop profile,工作区包由开发运行时提供。
 
-Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复。
+共享 Web 插件管理器负责包变更和激活;Electron 保留 profile 准备和原生恢复。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这些职责。
 
-[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host。Web 应用无法启动时,原生恢复对话框可禁用第三方 bundle 并备份 profile patch。已安装插件文件保留以供修复。
 
 ## 考虑过的替代方案
 

+ 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: 2d325d5c9f9c3fc8dd83816538d0a1b4b4c5480b
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 8aebe6bc8c86cc14b78d9dc979eeb902493de8ae
+2026-09-09-desktop-immediate-window-and-direct-start.md: e7ccf9b7456213d69795a40d9cd2a8509adfc44e
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: c59cc91f0013293cbaf2c086822085000dd3afd2

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

@@ -4,7 +4,7 @@ Status: implemented
 
 English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)
 
-Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
+Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md).
 
 ## Problem
 
@@ -16,9 +16,9 @@ Electron creates the main window with the packaged Web loading page before profi
 
 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.
+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.
 
-This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.
+This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, and resource ownership rationale. Full runtime file verification remains a packaging operation.
 
 ## Alternatives considered
 
@@ -30,4 +30,4 @@ This partially supersedes staged backend probes and waiting to create the main w
 
 Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails.
 
-Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests.
+Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, native recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle tests.

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

@@ -4,7 +4,7 @@ Status: implemented
 
 [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文
 
-profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
+插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。
 
 ## 问题
 
@@ -16,9 +16,9 @@ Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主
 
 致命错误展示遵循[原生 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。
+Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪信息提供认证后的 Host URL 和启动注入。桌面壳用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为所属应用源认证直接 WebSocket 请求。此承载适配保留 Web 路由和流语义,同时允许静态 HTML 在 Host 就绪前显示。
 
-本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
+本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输与资源归属的理由。完整运行时文件验证仍属于打包操作。
 
 ## 考虑过的替代方案
 
@@ -30,4 +30,4 @@ Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-
 
 用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。
 
-验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。
+验证覆盖 Host 延迟时可见的加载页、新 profile 仅启动一次服务、同一窗口中的失败与重试、原生恢复,以及子进程启动时关闭。安装后 GUI 证据补充生命周期测试。

+ 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: 445c574f86d6e465763603e7d447d92ffefb4f99
-2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f
+2026-09-10-desktop-web-wrapper.md: 05210e2ab85f0afe6d0275e25f31ce8addf11332
+2026-09-10-desktop-web-wrapper.zh.md: 84e72c9261250b103245f974e1a0546a4898e9ca

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

@@ -14,13 +14,13 @@ 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 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 and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates.
 
-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.
+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 public CLI continues to reject the reserved Desktop profile. Electron profile preparation and native recovery remain available without a working Host.
 
 Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web.
 
-App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared.
+The shared Web plugin manager owns installation, activation, errors, and restart requirements. Electron has no separate plugin-management renderer, preload, shell asset route, plugin IPC, or package-mutation runner. Packaging explicitly selects the main entry and application preload so stale build outputs cannot restore the removed bridge.
 
 Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
 
@@ -38,12 +38,14 @@ Native directory selection in the local application uses a narrow preload IPC ca
 
 **Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
 
+**Keep a separate native plugin-management page.** It can operate while the Web Host is unavailable, but duplicates package operations, renderer IPC, localization, and backend restart handling. Native recovery already supports disabling third-party bundles and backing up the profile patch without the Host. Reintroduction requires a repair operation that this recovery cannot provide.
+
 **Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation.
 
 ## Consequences
 
 Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; 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 native recovery dialog 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. The shared Web manager owns package-failure handling; Electron retains native recovery for fatal startup failures. The signed core runtime does not attest to user-installed plugin code.
 
 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.

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

@@ -14,13 +14,13 @@ 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 列表初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 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。
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。公开 CLI 继续拒绝保留的 Desktop profile。Electron profile 准备和原生恢复在 Host 不可用时仍可执行。
 
 独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。
 
-App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。
+共享 Web 插件管理器负责安装、激活、错误和重启要求。Electron 不提供独立插件管理渲染器、preload、shell 资源路由、插件 IPC 或包变更执行器。打包明确选择主入口和应用 preload,避免旧构建产物重新带入已删除的桥接。
 
 共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
 
@@ -38,12 +38,14 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 **保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
 
+**保留独立原生插件管理页。** 它可以在 Web Host 不可用时运行,但重复维护包操作、渲染器 IPC、本地化和后端重启处理。原生恢复已能在没有 Host 时禁用第三方 bundle 并备份 profile patch。只有出现此恢复方式无法提供的修复操作时,才应重新引入。
+
 **固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。
 
 ## Consequences
 
 Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。
 
-用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。
+用户选择的运行时选项、包来源和允许的生命周期脚本可能影响 Host 执行、加载第三方代码或造成启动失败。共享 Web 管理器负责包操作失败处理;Electron 为致命启动失败保留原生恢复。签名核心运行时不为用户安装的插件代码背书。
 
 验证需要覆盖共享 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: 63ec81991f3e396075fe4ed09a8ddaa87c865862
-2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e
+2026-09-15-desktop-native-fatal-recovery.md: 94ead425f5483ba3e6ed5d9d595edb4483254c27
+2026-09-15-desktop-native-fatal-recovery.zh.md: 2138fb11405d00d51775350bf60d2702621aaa19

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

@@ -10,13 +10,13 @@ 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; 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.
+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 retain local error handling, and the shared Web plugin manager owns package-operation failures. 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 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. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. 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. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. 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. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. 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.
+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. Backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset 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.
+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 [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared plugin management and native profile preparation.
 
 ## Alternatives considered
 

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

@@ -10,13 +10,13 @@ Status: implemented
 
 ## Decision
 
-Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
+Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
 
-首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
 
-Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
+Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
 
-这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。
+本决策取代[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页面和重置行为;其立即显示与直接启动 Host 的理由仍有效。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享插件管理和原生 profile 准备。
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.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/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
-2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87
-2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c
+2026-09-15-desktop-profile-core-cleanup.md: da189664e2b55a9310b36ed02f0b275e2c1b1370
+2026-09-15-desktop-profile-core-cleanup.zh.md: dec12e4fe7f34beb5931961183041c3f642b44ae

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md

@@ -12,7 +12,7 @@ Old Desktop profiles contain installed core packages and local tarball dependenc
 
 Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup.
 
-The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged.
+The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [bundled runtime decision](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md); direct writes and failure recovery remain unchanged.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。
 
-实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
+实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[内置运行时决策](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.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/feature/2026-09-16-windows-desktop-titlebar.md
-2026-09-16-windows-desktop-titlebar.md: 679e24b9e5b27e1f7dfce86ef35fd3d472651c37
-2026-09-16-windows-desktop-titlebar.zh.md: 6cc3c1ce35d150b8211caf5bcf350607f9b173fa
+2026-09-16-windows-desktop-titlebar.md: 17fd2aac308cff0b4357a0c39c3fe757cfc60f5b
+2026-09-16-windows-desktop-titlebar.zh.md: 05903d86960942204d7b18d4155266c144effc1a

+ 3 - 3
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md

@@ -12,11 +12,11 @@ Windows needs a compact caption that keeps sidebar navigation available when the
 
 The Windows main window combines Electron's hidden titlebar with a native window-controls overlay. Only its local application preload publishes `data-windows-titlebar`; shared layout, sidebar, and fullscreen-panel rules require that marker. Native caption colors and context-menu language follow the application document. Renderer messages are accepted only from the primary window's main frame.
 
-Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Ctrl+, retains the Desktop Plugins shortcut. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS.
+Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS.
 
 ## Alternatives considered
 
-An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and Desktop package management; the runtime plugin panel cannot replace the independent Desktop Plugins window. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components.
+An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and editing commands. Plugin management uses the main application’s Plugins page. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components.
 
 Custom HTML window controls would make the application responsible for native caption interactions. The native overlay preserves those controls while allowing application content beside them. Global CSS changes would affect Web and macOS; the preload-owned marker restricts the presentation to the Windows main window.
 
@@ -26,4 +26,4 @@ The caption remains available above fullscreen file panels, and collapsed naviga
 
 ## Testing
 
-Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, plugin-window opening, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail.
+Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the absence of the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail.

+ 3 - 3
.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md

@@ -12,11 +12,11 @@ Windows 需要紧凑的顶栏,在侧栏隐藏或文件铺满内容区时仍保
 
 Windows 主窗口组合 Electron 隐藏标题栏与原生窗口按钮覆盖层。只有本地应用 preload 发布 `data-windows-titlebar`;共享布局、侧栏和全屏面板规则均要求该标记。原生顶栏颜色与右键菜单语言跟随应用文档。渲染器消息仅接受主窗口的主框架来源。
 
-Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。Ctrl+, 保留桌面插件快捷键。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。
+Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。
 
 ## Alternatives considered
 
-按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和 Desktop 包管理;运行时插件面板不能替代独立的桌面插件窗口。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。
+按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和编辑命令。插件管理使用主应用的“插件”页面。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。
 
 自定义 HTML 窗口按钮会让应用承担原生顶栏交互。原生覆盖层保留这些按钮,同时允许应用内容显示在旁边。全局 CSS 修改会影响 Web 和 macOS;由 preload 持有的标记将呈现限制在 Windows 主窗口。
 
@@ -26,4 +26,4 @@ Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root
 
 ## Testing
 
-桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、插件窗口打开、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。
+桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键的缺失。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。

+ 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: a2b2cf3ffd52f7c835ba164fc907f18f775b0239
-README.zh.md: 660c84d81c2a69192d458863d974d2691f90fe68
+README.md: 14bbdb7ec0fddd47821c12ffe46549dd29abe0de
+README.zh.md: 1e704900743ec6f339efdf849e60767c961d07ee

+ 9 - 10
apps/desktop/README.md

@@ -36,8 +36,8 @@ Node prepares the bundled interpreters and Python libraries without a system Pyt
 | Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. |
 | Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
 | 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 | 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. |
+| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. |
+| Plugin changes | Desktop and Web need the same installation and activation behavior. | The main application uses the shared Web Plugin Manager and bundled pnpm. |
 | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
 
 The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md) owns shared Web behavior and Desktop adapters. The [Electron packaging and update decision](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns release identity, signing, and update qualification.
@@ -46,13 +46,13 @@ 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 application preload exposes boot readiness and fatal startup reporting. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. The separate plugin window receives structured package-management operations. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
+The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron exposes no plugin-management IPC or separate management document. No 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.
 
-Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts, plugin-window titles, plugin-manager locale responses, and plugin-operation error text. The repository Client UI i18n gate checks desktop sources.
+Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
 
-Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Desktop Plugins, Check for Updates, and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Ctrl+, opens the separate Desktop Plugins window, which manages Desktop packages rather than the sidebar's runtime plugin panel. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
+Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
 
 On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
 
@@ -63,18 +63,17 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
 1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
 2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm.
 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
-4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
-5. Plugin changes stop the backend before modifying the current profile. 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.
+4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration.
+5. The shared manager owns installation errors, activation, and restart requirements. Native recovery can disable third-party bundles even when the Host cannot start.
 
-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 [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the management interface. Desktop profile initialization and recovery preserve installed plugin files.
 
-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 third-party plugins, back up profile patch, 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. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart.
+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 third-party plugins, back up profile patch, 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. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart.
 
 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. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery 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; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 
 ## Develop
 

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

@@ -36,8 +36,8 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 | 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 |
 | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
 | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
-| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。shell 协议提供插件管理页面。 |
-| 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
+| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。 |
+| 插件变更 | Desktop 与 Web 需要一致的安装和激活行为。 | 主应用使用共享 Web 插件管理器和内置 pnpm。 |
 | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
 [薄壳决策](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 行为与 Desktop 适配。[Electron 打包与更新决策](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责发布身份、签名及更新验收。
@@ -46,13 +46,13 @@ 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。
 
-应用 preload 暴露启动就绪和致命启动失败上报。产品页面通过共享认证 HTTP API 访问业务,并获得 Desktop 标记、更新展示数据和打开原生确认的操作;它不能选择安装产物或授权安装。独立插件窗口获得结构化的包管理操作。任何渲染器都无法获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
+应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。
 
 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
 
-Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示、插件窗口标题、插件管理器语言响应及插件操作错误文案。仓库 Client UI i18n 检查覆盖桌面端源码。
+Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
 
-Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供桌面插件、检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。Ctrl+, 打开独立的桌面插件窗口,管理 Desktop 包,与侧栏的运行时插件面板不同。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
+Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
 
 macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
 
@@ -63,12 +63,12 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
 1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
 2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 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 目录、激活日志或回滚副本。
+4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。
+5. 共享管理器负责安装错误、激活和重启要求。即使 Host 无法启动,原生恢复仍可禁用第三方 bundle。
 
-CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
+[Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。
 
-主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。
 
 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 

+ 0 - 108
apps/desktop/renderer/plugin-manager.css

@@ -1,108 +0,0 @@
-:root {
-  color-scheme: light dark;
-  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-  background: Canvas;
-  color: CanvasText;
-}
-
-body {
-  margin: 0;
-}
-
-main {
-  max-width: 680px;
-  margin: 0 auto;
-  padding: 32px;
-}
-
-header,
-.install-row,
-li {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-header {
-  justify-content: space-between;
-}
-
-h1,
-h2,
-p {
-  margin-top: 0;
-}
-
-header p {
-  color: GrayText;
-}
-
-form,
-section {
-  margin-top: 28px;
-}
-
-label {
-  display: block;
-  margin-bottom: 8px;
-  font-weight: 600;
-}
-
-input {
-  flex: 1;
-  min-width: 0;
-  padding: 9px 11px;
-  border: 1px solid ButtonBorder;
-  border-radius: 7px;
-  background: Field;
-  color: FieldText;
-}
-
-button {
-  padding: 9px 14px;
-  border: 1px solid ButtonBorder;
-  border-radius: 7px;
-  background: AccentColor;
-  color: AccentColorText;
-  cursor: pointer;
-}
-
-button.quiet,
-li button {
-  background: ButtonFace;
-  color: ButtonText;
-}
-
-button:disabled,
-input:disabled {
-  opacity: 0.55;
-  cursor: wait;
-}
-
-#status {
-  min-height: 1.5em;
-  margin-top: 16px;
-  color: GrayText;
-}
-
-ul {
-  margin: 0;
-  padding: 0;
-  list-style: none;
-}
-
-li {
-  justify-content: space-between;
-  padding: 12px 0;
-  border-bottom: 1px solid ButtonBorder;
-}
-
-.package-version {
-  color: GrayText;
-  margin-left: 8px;
-}
-
-.package-actions {
-  display: flex;
-  gap: 8px;
-}

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

@@ -1,43 +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="plugin-manager.css">
-  </head>
-  <body>
-    <main>
-      <header>
-        <div>
-          <h1 id="title"></h1>
-          <p id="description"></p>
-        </div>
-        <button id="refresh" class="quiet" type="button"></button>
-      </header>
-      <form id="install-form">
-        <label id="package-label" for="package-spec"></label>
-        <div class="install-row">
-          <input id="package-spec" name="package-spec" autocomplete="off" placeholder="@scope/plugin@1.2.3" required>
-          <button id="install" type="submit"></button>
-        </div>
-      </form>
-      <form id="update-form" hidden aria-labelledby="update-label">
-        <label id="update-label" for="update-version"></label>
-        <div class="install-row">
-          <input id="update-version" name="update-version" autocomplete="off" required>
-          <button id="update-submit" type="submit"></button>
-          <button id="update-cancel" class="quiet" type="button"></button>
-        </div>
-      </form>
-      <p id="status" role="status" aria-live="polite"></p>
-      <section aria-labelledby="installed-heading">
-        <h2 id="installed-heading"></h2>
-        <ul id="plugins"></ul>
-        <p id="empty"></p>
-      </section>
-    </main>
-    <script src="plugin-manager.js"></script>
-  </body>
-</html>

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

@@ -1,130 +0,0 @@
-const api = window.dshDesktop
-
-async function main() {
-  const locale = await api.locale()
-  const messages = locale.messages
-  const message = (key, values = {}) => messages[key].replaceAll(/\{([^{}]+)\}/gu, (placeholder, name) => values[name] ?? placeholder)
-  document.documentElement.lang = locale.id
-  document.querySelector('#page-title').textContent = messages.pluginManagerTitle
-  document.querySelector('#title').textContent = messages.pluginManagerTitle
-  document.querySelector('#description').textContent = messages.pluginManagerDescription
-  document.querySelector('#refresh').textContent = messages.refresh
-  document.querySelector('#package-label').textContent = messages.npmPackage
-  document.querySelector('#install').textContent = messages.install
-  document.querySelector('#installed-heading').textContent = messages.installed
-  document.querySelector('#empty').textContent = messages.noPlugins
-
-
-  const list = document.querySelector('#plugins')
-  const empty = document.querySelector('#empty')
-  const status = document.querySelector('#status')
-  const form = document.querySelector('#install-form')
-  const input = document.querySelector('#package-spec')
-  const refresh = document.querySelector('#refresh')
-  const updateForm = document.querySelector('#update-form')
-  const updateVersion = document.querySelector('#update-version')
-  let updateName = ''
-  let updateTrigger
-  document.querySelector('#update-submit').textContent = messages.update
-  document.querySelector('#update-cancel').textContent = messages.cancel
-
-  function setBusy(busy, statusMessage = '') {
-    for (const control of document.querySelectorAll('button, input')) control.disabled = busy
-    status.textContent = statusMessage
-  }
-
-  async function render() {
-    const plugins = await api.plugins.list()
-    list.replaceChildren(...plugins.map(plugin => {
-      const item = document.createElement('li')
-      const identity = document.createElement('span')
-      const version = document.createElement('span')
-      version.className = 'package-version'
-      version.textContent = plugin.enabled ? plugin.version : `${plugin.version} · ${messages.disabled}`
-      identity.append(document.createTextNode(plugin.name), version)
-      const remove = document.createElement('button')
-      remove.type = 'button'
-      remove.textContent = messages.remove
-      remove.addEventListener('click', () => void run(
-        () => api.plugins.remove(plugin.name),
-        message('removing', { name: plugin.name }),
-      ))
-      const update = document.createElement('button')
-      update.type = 'button'
-      update.textContent = messages.update
-      update.addEventListener('click', () => {
-        updateName = plugin.name
-        updateTrigger = update
-        document.querySelector('#update-label').textContent = message('targetVersion', { name: plugin.name })
-        updateVersion.value = plugin.version
-        updateForm.hidden = false
-        updateVersion.focus()
-        updateVersion.select()
-      })
-      const actions = document.createElement('span')
-      actions.className = 'package-actions'
-      const toggle = document.createElement('button')
-      toggle.type = 'button'
-      toggle.textContent = plugin.enabled ? messages.disable : messages.enable
-      toggle.addEventListener('click', () => void run(
-        () => api.plugins.toggle(plugin.name, !plugin.enabled), messages.changingActivation,
-      ))
-      actions.append(toggle, update, remove)
-      item.append(identity, actions)
-      return item
-    }))
-    empty.hidden = plugins.length !== 0
-  }
-
-  async function run(operation, statusMessage) {
-    setBusy(true, statusMessage)
-    try {
-      await operation()
-      await render()
-      status.textContent = messages.operationComplete
-    } catch (error) {
-      status.textContent = error instanceof Error ? error.message : String(error)
-    } finally {
-      setBusy(false, status.textContent)
-    }
-  }
-
-  async function load(statusMessage, success) {
-    setBusy(true, statusMessage)
-    try {
-      await render()
-      status.textContent = success
-    } catch (error) {
-      status.textContent = error instanceof Error ? error.message : String(error)
-    } finally {
-      setBusy(false, status.textContent)
-    }
-  }
-
-  form.addEventListener('submit', (event) => {
-    event.preventDefault()
-    const spec = input.value.trim()
-    if (spec === '') return
-    void run(async () => {
-      await api.plugins.add(spec)
-      input.value = ''
-    }, message('installing', { spec }))
-  })
-  updateForm.addEventListener('submit', (event) => {
-    event.preventDefault()
-    const version = updateVersion.value.trim()
-    if (version === '') return
-    const name = updateName
-    updateForm.hidden = true
-    void run(() => api.plugins.update(name, version), message('updating', { name }))
-  })
-  document.querySelector('#update-cancel').addEventListener('click', () => {
-    updateForm.hidden = true
-    updateTrigger.focus()
-  })
-  refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
-
-  await load(messages.loadingPlugins, '')
-}
-
-void main()

+ 4 - 2
apps/desktop/scripts/electron-builder-config.mjs

@@ -98,8 +98,10 @@ export function createElectronBuilderConfig(
       return true
     },
     files: [
-      'lib/*.js',
-      'lib/*.cjs',
+      'lib/main.js',
+      'lib/preload-app.cjs',
+      'lib/preload-mandatory.cjs',
+      'lib/preload-update-dialog.cjs',
       'renderer/**/*',
       'package.json',
       { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },

+ 1 - 1
apps/desktop/scripts/prepare-installed-update-application.ts

@@ -33,7 +33,7 @@ export async function prepareInstalledUpdateApplication(manifest: string, source
       .filter(entry => /\.(?:js|cjs)$/u.test(entry.name))
     if (modules.some(entry => !entry.isFile())) throw new Error('installed update: application modules must be regular files')
     const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer')].sort()
-    for (const required of ['lib/main.js', 'lib/preload.cjs', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', 'lib/preload-update-dialog.cjs']) {
+    for (const required of ['lib/main.js', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', 'lib/preload-update-dialog.cjs']) {
       if (!paths.includes(required)) throw new Error('installed update: rebuild Desktop before freezing application files')
     }
     const files: ApplicationFile[] = []

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

@@ -1,23 +1,12 @@
 /** Typed preload operations exposed only by the Electron shell. */
 
 import type { IpcMainInvokeEvent } from 'electron'
-import type { DesktopPluginRecord } from './project-manager.ts'
-import type { DesktopLocale } from './locale.ts'
 
 /** IPC channel names kept private to the desktop application bundle. */
 export const DESKTOP_IPC = {
-  localeGet: 'dsh-desktop:locale-get',
   boot: 'dsh-desktop:boot',
   bootFailed: 'dsh-desktop:boot-failed',
   directoryPick: 'dsh-desktop:directory-pick',
-  pluginsList: 'dsh-desktop:plugins-list',
-  pluginsAdd: 'dsh-desktop:plugins-add',
-  pluginsRemove: 'dsh-desktop:plugins-remove',
-  pluginsUpdate: 'dsh-desktop:plugins-update',
-  pluginsToggle: 'dsh-desktop:plugins-toggle',
-  updatesCheck: 'dsh-desktop:updates-check',
-  updatesInstall: 'dsh-desktop:updates-install',
-  updatesState: 'dsh-desktop:updates-state',
   updatesStatus: 'dsh-desktop:updates-status',
   updatesOpen: 'dsh-desktop:updates-open',
   updatesPresentation: 'dsh-desktop:updates-presentation',
@@ -71,25 +60,7 @@ export interface DshDesktopProductApi {
   }
 }
 
-/** Narrow bridge exposed through context isolation. */
-export interface DshDesktopApi {
-  readonly protocolVersion: 1
-  locale(): Promise<DesktopLocale>
-  readonly plugins: {
-    list(): Promise<readonly DesktopPluginRecord[]>
-    add(spec: string): Promise<void>
-    remove(name: string): Promise<void>
-    update(name: string, version: string): Promise<void>
-    toggle(name: string, enabled: boolean): Promise<void>
-  }
-  readonly updates: {
-    check(): Promise<DesktopUpdateState>
-    install(): Promise<void>
-    subscribe(listener: (state: DesktopUpdateState) => void): () => void
-  }
-}
-
-/** Scheme of Desktop-owned application and shell documents. */
+/** Scheme of Desktop-owned application documents. */
 export const SCHEME = 'dsh-app'
 
 /**

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

@@ -21,7 +21,6 @@ export const en = {
   restartApplication: 'Restart',
   recoveryOperationFailed: 'The recovery operation failed',
   disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
-  pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
   updateCheckFailed: 'Could not check for updates. Please try again later.',
@@ -87,29 +86,6 @@ export const en = {
   mandatoryCopyFailed: 'Copy failed. Select and copy the address below manually.',
   mandatoryAddress: 'Download address',
   mandatoryNotification: 'Return to the application to confirm installation and restart.',
-  pluginManagerTitle: 'Desktop Plugins',
-  pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins',
-  pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.',
-  refresh: 'Refresh',
-  enable: 'Enable',
-  disable: 'Disable',
-  disabled: 'Disabled',
-  changingActivation: 'Changing plugin activation…',
-  npmPackage: 'npm package',
-  install: 'Install',
-  installed: 'Installed',
-  noPlugins: 'No Desktop plugins are installed.',
-  remove: 'Remove',
-  update: 'Update',
-  cancel: 'Cancel',
-  targetVersion: 'Enter the target version for {name}',
-  removing: 'Removing {name}…',
-  updating: 'Updating {name}…',
-  installing: 'Installing {spec}…',
-  operationComplete: 'Done. The Desktop backend has restarted.',
-  refreshing: 'Refreshing…',
-  refreshed: 'Plugin list refreshed.',
-  loadingPlugins: 'Reading Desktop plugins…',
 } as const
 
 /** Every Desktop locale supplies the complete English key set. */
@@ -136,7 +112,6 @@ export const zh = {
   restartApplication: '重启',
   recoveryOperationFailed: '恢复操作失败',
   disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
-  pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',
   updateCheckFailed: '检查更新失败,请稍后重试。',
@@ -202,29 +177,6 @@ export const zh = {
   mandatoryCopyFailed: '复制失败,请手动选择下方地址复制。',
   mandatoryAddress: '下载地址',
   mandatoryNotification: '返回应用确认安装并重启。',
-  pluginManagerTitle: '桌面插件',
-  pluginWindowTitle: 'DeepSeek Harness — 桌面插件',
-  pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。',
-  refresh: '刷新',
-  enable: '启用',
-  disable: '禁用',
-  disabled: '已禁用',
-  changingActivation: '正在更改插件启用状态…',
-  npmPackage: 'npm 包',
-  install: '安装',
-  installed: '已安装',
-  noPlugins: '还没有安装桌面插件。',
-  remove: '移除',
-  update: '更新',
-  cancel: '取消',
-  targetVersion: '输入 {name} 的目标版本',
-  removing: '正在移除 {name}…',
-  updating: '正在更新 {name}…',
-  installing: '正在安装 {spec}…',
-  operationComplete: '操作完成,桌面后端已重新启动。',
-  refreshing: '正在刷新…',
-  refreshed: '插件列表已刷新。',
-  loadingPlugins: '正在读取桌面插件…',
 } as const satisfies DesktopMessages
 
 /** Locale payload exposed to the Desktop-owned renderer. */

+ 2 - 115
apps/desktop/src/main.ts

@@ -2,7 +2,7 @@ import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
 /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
 
 import { readFile, writeFile } from 'node:fs/promises'
-import { extname, join, normalize, resolve, sep } from 'node:path'
+import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import {
   app,
@@ -19,7 +19,7 @@ import {
   type MenuItemConstructorOptions,
 } from 'electron'
 import { resolveDesktopPaths } from './paths.ts'
-import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
+import { DesktopProjectManager } from './project-manager.ts'
 import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
 import { installDesktopDirectoryPicker } from './directory-picker.ts'
 import { DesktopBackendController } from './backend-controller.ts'
@@ -79,13 +79,6 @@ protocol.registerSchemesAsPrivileged([{
   },
 }])
 
-const MIME: Readonly<Record<string, string>> = {
-  '.css': 'text/css; charset=utf-8',
-  '.html': 'text/html; charset=utf-8',
-  '.js': 'text/javascript; charset=utf-8',
-  '.svg': 'image/svg+xml',
-}
-
 interface RuntimeResources {
   readonly nodeBin: string
   readonly node: string
@@ -189,26 +182,6 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
   return window
 }
 
-async function serveShellAsset(request: Request): Promise<Response> {
-  if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
-  const root = resolve(app.getAppPath(), 'renderer')
-  const url = new URL(request.url)
-  let pathname: string
-  try {
-    pathname = decodeURIComponent(url.pathname)
-  } catch {
-    return new Response(null, { status: 400 })
-  }
-  const target = resolve(normalize(join(root, pathname)))
-  if (target !== root && !target.startsWith(root + sep)) return new Response(null, { status: 403 })
-  try {
-    const body = request.method === 'HEAD' ? null : await readFile(target)
-    return new Response(body, { headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' } })
-  } catch {
-    return new Response(null, { status: 404 })
-  }
-}
-
 async function main(): Promise<void> {
   const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
   const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
@@ -221,7 +194,6 @@ async function main(): Promise<void> {
   let startup: Promise<void> | undefined
   let workspaceRecovery: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
-  let pluginWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
   let requireCleanStop = false
   let updateStoppedHost = false
@@ -236,7 +208,6 @@ async function main(): Promise<void> {
   const messages = locale.messages
   const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), locale)
   const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
-  const assertPolicyAllowsBusiness = (): void => { if (isMandatory()) throw new Error(messages.mandatoryTitle) }
   const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
     const controller = new AbortController()
     ordinaryDialogs.add(controller)
@@ -247,7 +218,6 @@ async function main(): Promise<void> {
     finally { ordinaryDialogs.delete(controller) }
   }
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
-  const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
   const applicationUrl = `${SCHEME}://app/`
   let hostUrl: string | undefined
   let hostCookie: string | undefined
@@ -321,7 +291,6 @@ async function main(): Promise<void> {
     updateState = state
     mandatoryUI?.sync()
     for (const window of BrowserWindow.getAllWindows()) {
-      window.webContents.send(DESKTOP_IPC.updatesState, state)
       window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
     }
     if (state.phase === 'error' && state.failedOperation !== 'check') {
@@ -350,11 +319,6 @@ async function main(): Promise<void> {
     return state
   }
 
-  const hooks: DesktopProjectHooks = {
-    beforeChange: () => backend.stop(),
-    afterChange: () => backend.start(async () => {}),
-  }
-
   stopForRecovery = () => backend.close()
 
   const reconcileBackend = (): Promise<void> => {
@@ -442,7 +406,6 @@ async function main(): Promise<void> {
       }
       return forwardWebRequest(request, hostUrl, hostCookie)
     }
-    if (url.hostname === 'shell') return serveShellAsset(request)
     return Promise.resolve(new Response(null, { status: 404 }))
   })
 
@@ -477,63 +440,11 @@ async function main(): Promise<void> {
     callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
   })
 
-  const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
-    assertDesktopSender(event, ['shell'])
-    assertPolicyAllowsBusiness()
-    if (updates.state.phase === 'installing') throw new Error(messages.updateInstalling)
-    await startup?.catch(() => undefined)
-    if (recovery.active) throw new Error(messages.fatalSummary)
-    assertPolicyAllowsBusiness()
-    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) => {
-    assertDesktopSender(event, ['shell'])
-    return currentDesktopLocale()
-  })
   // Only the main window may synchronize its palette with the native material.
   ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
     if (mainWindow === undefined || event.sender !== mainWindow.webContents) return
     if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source
   })
-  ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => {
-    assertDesktopSender(event, ['shell'])
-    return manager.listPlugins()
-  })
-  ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => {
-    if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string')
-    return mutate(event, { type: 'plugin-add', spec })
-  })
-  ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => {
-    if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string')
-    return mutate(event, { type: 'plugin-remove', name })
-  })
-  ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => {
-    if (typeof name !== 'string' || typeof version !== 'string') {
-      throw new Error('dsh desktop: plugin name and version must be strings')
-    }
-    return mutate(event, { type: 'plugin-update', name, version })
-  })
-  ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => {
-    if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request')
-    return mutate(event, { type: 'plugin-toggle', name, enabled })
-  })
-  ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    updateJournal?.action('check-requested')
-    return updateSchedule.check(true)
-  })
-  ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    await openUpdatePrompt()
-  })
   ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
     assertProductSender(event)
     return presentDesktopUpdate(updates.state)
@@ -663,20 +574,6 @@ async function main(): Promise<void> {
     updates.dispose()
   })
 
-  const openPluginWindow = (): void => {
-    if (isMandatory()) { mandatoryUI?.focus(); return }
-    if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) {
-      pluginWindow.focus()
-      return
-    }
-    pluginWindow = createWindow(managementPreload)
-    pluginWindow.setSize(900, 620)
-    pluginWindow.setTitle(currentDesktopLocale().messages.pluginWindowTitle)
-    pluginWindow.once('ready-to-show', () => { pluginWindow?.show() })
-    pluginWindow.once('closed', () => { pluginWindow = undefined })
-    void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
-  }
-
   app.setAboutPanelOptions({
     applicationName: 'DeepSeek Harness',
     applicationVersion: app.getVersion(),
@@ -698,7 +595,6 @@ async function main(): Promise<void> {
   const applicationItems = (): MenuItemConstructorOptions[] => [
     { label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
     { type: 'separator' },
-    { label: currentDesktopLocale().messages.pluginsMenu, accelerator: 'CmdOrCtrl+,', click: openPluginWindow },
     { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
     { type: 'separator' },
     ...hideCommands,
@@ -762,14 +658,6 @@ async function main(): Promise<void> {
     const window = createWindow(appPreload, true, true)
     mainWindow = window
     window.on('focus', automaticCheck)
-    if (process.platform === 'win32') {
-      window.webContents.on('before-input-event', (event, input) => {
-        if (input.type === 'keyDown' && input.control && !input.alt && !input.shift && input.key === ',') {
-          event.preventDefault()
-          openPluginWindow()
-        }
-      })
-    }
     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()) {
@@ -854,7 +742,6 @@ async function main(): Promise<void> {
       if (state.blocking) {
         for (const controller of ordinaryDialogs) controller.abort()
         if (!wasBlocking) updateDialog.cancel()
-        pluginWindow?.close()
       }
       mandatoryUI?.sync()
       if (state.blocking && !wasBlocking) void updateSchedule.check(false, true).catch((error: unknown) => { console.error(error) })

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

@@ -1,29 +0,0 @@
-/** Context-isolated renderer bridge for desktop package and update operations. */
-
-import { contextBridge, ipcRenderer } from 'electron'
-import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts'
-import { markDocumentPlatform } from './preload-platform.ts'
-
-const api: DshDesktopApi = {
-  protocolVersion: 1,
-  locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise<ReturnType<DshDesktopApi['locale']> extends Promise<infer T> ? T : never>,
-  plugins: {
-    list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise<ReturnType<DshDesktopApi['plugins']['list']> extends Promise<infer T> ? T : never>,
-    add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise<void>,
-    remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise<void>,
-    toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise<void>,
-    update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise<void>,
-  },
-  updates: {
-    check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise<DesktopUpdateState>,
-    install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise<void>,
-    subscribe(listener) {
-      const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) }
-      ipcRenderer.on(DESKTOP_IPC.updatesState, handle)
-      return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) }
-    },
-  },
-}
-
-markDocumentPlatform()
-contextBridge.exposeInMainWorld('dshDesktop', api)

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

@@ -1,22 +1,19 @@
-/** In-place owner of the desktop profile and its pnpm operations. */
+/** Desktop profile initialization and native recovery. */
 
-import { spawn } from 'node:child_process'
 import {
   existsSync,
   fsyncSync,
-  ftruncateSync,
   lstatSync,
   mkdirSync,
   openSync,
+  realpathSync,
   closeSync,
   readFileSync,
-  realpathSync,
   unlinkSync,
   writeFileSync,
   writeSync,
 } from 'node:fs'
 import { join } from 'node:path'
-import { desktopNodeEnvironment } from './node-environment.ts'
 import {
   DESKTOP_HOST_PACKAGE,
   desktopCorePackageOverrides,
@@ -24,56 +21,18 @@ import {
 } from './core-package-set.ts'
 import type { DesktopPaths } from './paths.ts'
 import type { DesktopRelease } from './release.ts'
-import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
+import { readDesktopRuntime } from './runtime-tree.ts'
 import {
-  initProfile, PROFILE_TEMPLATES, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile,
-  unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
+  initProfile, PROFILE_TEMPLATES, sanitizeProfile, type ProfileTemplate,
 } from '@deepseek-ai/dsh-app-boot'
 import { migrateDesktopProfileLinks } from './profile-packages.ts'
 import { cleanProfileCorePackages } from './profile-core-cleanup.ts'
 
-/** Desktop plugin record derived from the installed profile. */
-export interface DesktopPluginRecord {
-  readonly name: string
-  /** Installed version, or the dependency spec when installed metadata is unavailable. */
-  readonly version: string
-  readonly enabled: boolean
-}
-
-/** Exact executables the desktop shell bundles. */
-export interface DesktopRuntimeExecutables {
-  readonly node: string
-  readonly nodeBin?: string
-  readonly pnpm: string
-  readonly dsh: string
-}
-
-/** 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 current profile even when package preparation failed with partial writes. */
-  afterChange(): Promise<void>
-}
-
-/** Supported dependency mutation. */
-export type DesktopProjectMutation =
-  | { readonly type: 'plugin-add'; readonly spec: string }
-  | { readonly type: 'plugin-remove'; readonly name: string }
-  | { readonly type: 'plugin-update'; readonly name: string; readonly version: string }
-  | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean }
-
 const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
 const DSH_PACKAGE = '@deepseek-ai/dsh'
 const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local'
 const WEB_PROFILE = PROFILE_TEMPLATES.web as ProfileTemplate
 const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\n'
-const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024
-
-function errorOf(reason: unknown, fallback: string): Error {
-  return reason instanceof Error ? reason : new Error(fallback)
-}
-
 function writeJson(path: string, value: unknown): void {
   writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 })
 }
@@ -100,28 +59,17 @@ function migrateProfileSettings(projectDir: string): void {
   }
 }
 
-/** Desktop npm project manager with direct writes and no rollback. */
+/** Initializes the Desktop profile and disables third-party bundles during recovery. */
 export class DesktopProjectManager {
-  private lockDescriptor: number | undefined
-  private descriptor: DesktopRuntimeDescriptor | undefined
-
   /**
    * @param paths - Electron-owned package state and reserved desktop profile paths.
-   * @param runtime - absolute Electron and pnpm entry paths.
+   * @param runtime - location of the bundled application runtime.
    */
   constructor(
     readonly paths: DesktopPaths,
-    readonly runtime: DesktopRuntimeExecutables,
+    readonly runtime: { readonly dsh: string },
   ) {}
 
-  /** Read the active desktop plugin inventory. */
-  listPlugins(): readonly DesktopPluginRecord[] {
-    if (!existsSync(this.paths.profile)) return []
-    return readProfilePlugins(this.pluginLocation(this.paths.profile)).dependencies
-      .map(({ name, version, enabled }) => ({ name, version, enabled }))
-      .sort((left, right) => left.name.localeCompare(right.name))
-  }
-
   /**
    * Back up the profile patch and disable third-party bundles without loading application resources.
    * The caller must stop the Host first.
@@ -131,148 +79,22 @@ export class DesktopProjectManager {
     return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles))
   }
 
-  /** Read the dsh version supplied by this application's verified resources. */
-  dshVersion(): string {
-    return this.currentRuntime().release.version
-  }
-
-  private currentRuntime(): DesktopRuntimeDescriptor {
-    if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded')
-    return this.descriptor
-  }
-
-  private readRuntime(): DesktopRuntimeDescriptor {
-    this.descriptor = undefined
-    return readDesktopRuntime(this.runtime.dsh)
-  }
-
-  private pluginLocation(profileDir: string) {
-    return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') }
-  }
-
   /**
    * Load application metadata and prepare the external plugin profile without installing packages.
    * @param production - Remove application-owned profile packages before packaged Host startup.
    */
   async applyRelease(production = false): Promise<void> {
     await this.withLock(() => {
-      this.descriptor = this.readRuntime()
-      cleanProfileCorePackages(this.paths.profile, this.descriptor.sharedPackages.map(entry => entry.name), production)
+      const descriptor = readDesktopRuntime(this.runtime.dsh)
+      cleanProfileCorePackages(this.paths.profile, descriptor.sharedPackages.map(entry => entry.name), production)
       migrateProfileSettings(this.paths.profile)
       migrateDesktopProfileLinks(this.paths.profile)
       createPluginProfile(this.paths.profile)
     })
   }
 
-  /** Modify the current profile while its backend is stopped; failures retain partial changes. */
-  async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise<void> {
-    await this.withLock(async () => {
-      this.currentRuntime()
-      if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
-      await hooks.beforeChange()
-      try {
-        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()
-    })
-  }
-
-  private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise<void> {
-    const location = this.pluginLocation(projectDir)
-    const before = readProfilePlugins(location)
-    const bundles = before.manifest.dsh?.profile?.bundles ?? []
-    switch (mutation.type) {
-      case 'plugin-add':
-      case 'plugin-update': {
-        const spec = mutation.type === 'plugin-add' ? mutation.spec : `${mutation.name}@${mutation.version}`
-        unlinkProfileModuleFallback(projectDir)
-        await this.runPnpm(projectDir, ['add', '--', spec])
-        reconcileProfilePlugins({ ...location, before, preserveDisabled: true })
-        return
-      }
-      case 'plugin-remove':
-        unlinkProfileModuleFallback(projectDir)
-        await this.runPnpm(projectDir, ['remove', '--', mutation.name])
-        reconcileProfilePlugins({ ...location, before, preserveDisabled: true })
-        return
-      case 'plugin-toggle':
-        writeProfileBundles(projectDir, before.manifest, mutation.enabled
-          ? bundles.includes(mutation.name) ? bundles : [...bundles, mutation.name]
-          : bundles.filter(name => name !== mutation.name))
-        return
-      default:
-        mutation satisfies never
-    }
-  }
-
-  private async runPnpm(projectDir: string, args: readonly string[]): Promise<void> {
-    await new Promise<void>((settle, reject) => {
-      const child = spawn(this.runtime.node, ['--expose-internals', this.runtime.pnpm, ...args], {
-        cwd: realpathSync(projectDir),
-        env: desktopNodeEnvironment(this.runtime.node, this.runtime.nodeBin, process.env),
-        stdio: ['ignore', 'pipe', 'pipe'],
-      })
-      let failure: Error | undefined
-      let diagnostics = ''
-      let completed = false
-      const appendDiagnostics = (chunk: string): void => {
-        diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES)
-      }
-      child.stdout.setEncoding('utf8')
-      child.stdout.on('data', appendDiagnostics)
-      child.stderr.setEncoding('utf8')
-      child.stderr.on('data', appendDiagnostics)
-      const complete = (settleChild: () => void): void => {
-        if (completed) return
-        completed = true
-        try {
-          this.writeLockOwner(process.pid)
-        } catch (error) {
-          reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron'))
-          return
-        }
-        settleChild()
-      }
-      child.once('error', (error) => { failure = error })
-      child.once('close', (code, signal) => {
-        complete(() => {
-          if (failure !== undefined) { reject(failure); return }
-          if (code === 0) {
-            settle()
-            return
-          }
-          reject(new Error(
-            `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`,
-          ))
-        })
-      })
-      try {
-        if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id')
-        this.writeLockOwner(child.pid)
-      } catch (error) {
-        failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')
-        child.kill('SIGKILL')
-      }
-    })
-  }
-
-  private writeLockOwner(pid: number): void {
-    const descriptor = this.lockDescriptor
-    if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock')
-    const content = Buffer.from(`${String(pid)}\n`)
-    ftruncateSync(descriptor, 0)
-    writeSync(descriptor, content, 0, content.byteLength, 0)
-    fsyncSync(descriptor)
-  }
-
   private async withLock<T>(operation: () => T | Promise<T>): Promise<T> {
     mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 })
-    // Windows exclusive creation through a directory junction can return EEXIST for an absent lock.
     const lockPath = join(realpathSync(this.paths.profile), 'lock')
     let descriptor: number
     try {
@@ -281,7 +103,7 @@ export class DesktopProjectManager {
       if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
         const lock = lstatSync(lockPath)
         if (lock.isSymbolicLink() || !lock.isFile()) {
-          throw new Error('desktop project: package transaction lock is not a regular file')
+          throw new Error('desktop project: profile lock is not a regular file')
         }
         const owner = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10)
         let active = !Number.isSafeInteger(owner) || owner <= 0
@@ -293,7 +115,7 @@ export class DesktopProjectManager {
             active = (signalError as NodeJS.ErrnoException).code !== 'ESRCH'
           }
         }
-        if (active) throw new Error('desktop project: another package transaction is active')
+        if (active) throw new Error('desktop project: another profile operation is active')
         unlinkSync(lockPath)
         descriptor = openSync(lockPath, 'wx', 0o600)
       } else {
@@ -301,11 +123,10 @@ export class DesktopProjectManager {
       }
     }
     try {
-      this.lockDescriptor = descriptor
-      this.writeLockOwner(process.pid)
+      writeSync(descriptor, `${String(process.pid)}\n`)
+      fsyncSync(descriptor)
       return await operation()
     } finally {
-      this.lockDescriptor = undefined
       closeSync(descriptor)
       unlinkSync(lockPath)
     }

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

@@ -6,7 +6,6 @@ import { prepareDevelopmentProject } from '../scripts/development-project.ts'
 import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
 import { DesktopProjectManager } from '../src/project-manager.ts'
 import { resolveDesktopPaths } from '../src/paths.ts'
-import { writePackage } from './runtime-fixture.ts'
 import type { DesktopRelease } from '../src/release.ts'
 
 const roots: string[] = []
@@ -69,16 +68,9 @@ describe('desktop development project', () => {
     expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3')
     expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3')
     const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), {
-      node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh: project,
+      dsh: project,
     })
-    const hooks = { beforeChange: async () => {}, afterChange: async () => {} }
     await manager.applyRelease()
-    const local = writePackage(join(root, 'packages'), 'development-plugin', { dsh: { bundle: { patch: 'bundle.yml' } } })
-    writeFileSync(join(local, 'bundle.yml'), '[]\n')
-    await manager.mutate({ type: 'plugin-add', spec: `file:${local}` }, hooks)
-    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.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('')

+ 2 - 2
apps/desktop/tests/installed-update-builder.spec.ts

@@ -35,7 +35,7 @@ async function fixture<T>(body: (manifest: string, source: string) => Promise<T>
     const source = join(root, 'app')
     await mkdir(join(source, 'lib'), { recursive: true })
     await mkdir(join(source, 'renderer'))
-    for (const file of ['main.js', 'preload.cjs', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
+    for (const file of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
       await writeFile(join(source, 'lib', file), '// inert fixture\n')
     }
     await writeFile(join(source, 'renderer', 'index.html'), '<p>fixture</p>')
@@ -102,7 +102,7 @@ describe('installed-update application inputs and builder configuration', () =>
 
   it('retains a failed preparation without a completion receipt and refuses an unrelated version', async () => {
     await fixture(async (manifest, source) => {
-      await rm(join(source, 'lib/preload.cjs'))
+      await rm(join(source, 'lib/preload-app.cjs'))
       await expect(prepareInstalledUpdateApplication(manifest, source)).rejects.toThrow('rebuild Desktop')
       expect((await readdir(join(manifest, '../application'))).sort()).toEqual(['failed.json', 'started.json'])
       await expect(createInstalledUpdateBuilderConfig(manifest, '9.0.0', environment)).rejects.toThrow('outside')

+ 2 - 2
apps/desktop/tests/installed-update-package-content.spec.ts

@@ -52,7 +52,7 @@ async function fixture(body: (context: {
     const source = join(root, 'source')
     await mkdir(join(source, 'lib'), { recursive: true })
     await mkdir(join(source, 'renderer'))
-    for (const name of ['main.js', 'preload.cjs', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
+    for (const name of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
       await writeFile(join(source, 'lib', name), '// inert fixture\n')
     }
     await writeFile(join(source, 'renderer/index.html'), '<p>test</p>')
@@ -94,7 +94,7 @@ describe('installed update archive contents', () => {
   it.each(versions)('reads real ASAR and runtime inventories for %s without claiming installation', async (version) => {
     await fixture(async ({ manifest, payload }) => {
       expect(await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).toMatchObject({
-        version, applicationFiles: 8, dependenciesFrozen: false, installed: false,
+        version, applicationFiles: 7, dependenciesFrozen: false, installed: false,
         resignedExecutables: [join(payload, 'resources/app.asar.unpacked/dsh/tool.exe')],
       })
     }, version)

+ 3 - 9
apps/desktop/tests/locale.spec.ts

@@ -1,13 +1,12 @@
-import { readFileSync } from 'node:fs'
 import { describe, expect, it } from 'vitest'
 import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts'
 
 describe('desktop locale dictionaries', () => {
   it('ships the same key set in English and Chinese', () => {
     expect(Object.keys(zh)).toEqual(Object.keys(en))
-    expect(resolveDesktopLocale('zh-Hans-CN')).toEqual({ id: 'zh-CN', messages: zh })
-    expect(resolveDesktopLocale('en-US')).toEqual({ id: 'en', messages: en })
-    expect(resolveDesktopLocale('fr-FR')).toEqual({ id: 'en', messages: en })
+    expect(resolveDesktopLocale('zh-Hans-CN').messages).toEqual(zh)
+    expect(resolveDesktopLocale('en-US').messages).toEqual(en)
+    expect(resolveDesktopLocale('fr-FR').messages).toEqual(en)
   })
 
   it('formats named values without consuming unknown placeholders', () => {
@@ -15,9 +14,4 @@ describe('desktop locale dictionaries', () => {
       .toBe('plugin@1.2.3 {missing}')
   })
 
-  it('keeps visible plugin-manager HTML copy in the locale dictionaries', () => {
-    const html = readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8')
-    const staticText = [...html.matchAll(/>([^<]*\p{L}[^<]*)</gu)].map(match => match[1]?.trim())
-    expect(staticText).toEqual([])
-  })
 })

+ 11 - 71
apps/desktop/tests/main-startup.spec.ts

@@ -146,7 +146,6 @@ const harness = await vi.hoisted(async () => {
     dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
     openExternal: vi.fn(),
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
-    mutateFailure: vi.fn<() => void>(),
     disableAllPlugins: vi.fn(async () => {
       pluginsEnabled = false
       return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000'
@@ -226,19 +225,6 @@ vi.mock('../src/project-manager.ts', () => ({
   DesktopProjectManager: class {
     readonly applyRelease = harness.applyRelease
     disableAllPlugins = harness.disableAllPlugins
-    async mutate(_mutation: unknown, hooks: { beforeChange(): Promise<void>; afterChange(): Promise<void> }) {
-      await hooks.beforeChange()
-      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()
-    }
 
   },
 }))
@@ -359,12 +345,12 @@ describe('desktop main startup', () => {
       expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({
         message: en.policyLoginRequired, buttons: [en.policyLogin, en.later], cancelId: 1,
       }))
-      checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesInstall)))
+      checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')))
       expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
       expect(testAuth.login).not.toHaveBeenCalled()
       explanation.resolve({ response: 0 })
       await entered.promise
-      checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesInstall)))
+      checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')))
       expect(testAuth.focus).toHaveBeenCalled()
       expect(testAuth.login).toHaveBeenCalledOnce()
       expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
@@ -463,7 +449,9 @@ describe('desktop main startup', () => {
       vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', directory)
       await readyForUpdate()
       harness.publishUpdate({ phase: 'error', failedOperation: 'download', version: '1.2.3', message: 'ENOSPC secret-url' })
-      await invoke(DESKTOP_IPC.updatesCheck)
+      const checkUpdates = applicationMenuItems().find(item => item.label === en.checkUpdatesMenu)!.click as () => void
+      checkUpdates()
+      await vi.advanceTimersByTimeAsync(0)
       for (const host of harness.hosts) host.exited.resolve()
       harness.app.quit()
       await harness.quitCompleted.promise
@@ -538,7 +526,7 @@ describe('desktop main startup', () => {
     listener(event, {}, '#fff', '#000')
     expect(window.setTitleBarOverlay).toHaveBeenLastCalledWith({ color: '#fff', symbolColor: '#000' })
     window.setTitleBarOverlay.mockClear()
-    window.webContents.mainFrame.url = 'dsh-app://shell/plugin-manager.html'
+    window.webContents.mainFrame.url = 'dsh-app://unowned/index.html'
     listener(event, 'zh-CN', '#fff', '#000')
     expect(window.setTitleBarOverlay).not.toHaveBeenCalled()
     expect(harness.menu.setApplicationMenu).toHaveBeenCalledExactlyOnceWith(null)
@@ -559,7 +547,7 @@ describe('desktop main startup', () => {
     expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request')
     const application = handler(event, 'application', 48, 34)
     expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([
-      '关于 DeepSeek Harness', 'separator', '桌面插件…', '检查更新…', 'separator', '退出',
+      '关于 DeepSeek Harness', 'separator', '检查更新…', 'separator', '退出',
     ])
     expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 })
     expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function')
@@ -579,11 +567,6 @@ describe('desktop main startup', () => {
     }
     harness.popup.mock.lastCall![0].callback!()
     await edit
-    const preventDefault = vi.fn()
-    window.webContents.emit('before-input-event', { preventDefault }, { type: 'keyDown', control: true, key: ',' })
-    expect(preventDefault).toHaveBeenCalledOnce()
-    expect(harness.windows[1]!.urls).toEqual(['dsh-app://shell/plugin-manager.html'])
-    expect(harness.windows[1]!.options).not.toHaveProperty('titleBarStyle')
   })
 
   it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
@@ -601,8 +584,8 @@ describe('desktop main startup', () => {
       : ['Application', 'editMenu'])
     const application = template[0]!.submenu as MenuItemConstructorOptions[]
     expect(application.map(describeItem)).toEqual(platform === 'darwin'
-      ? ['about', 'separator', en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
-      : ['about', 'separator', en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'quit'])
+      ? ['about', 'separator', en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
+      : ['about', 'separator', en.checkUpdatesMenu, 'separator', 'quit'])
     expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
   })
 
@@ -749,7 +732,7 @@ describe('desktop main startup', () => {
     await vi.advanceTimersByTimeAsync(1)
     expect(harness.updateCheck).toHaveBeenCalledTimes(2)
     harness.updateCheck.mockResolvedValue({ phase: 'idle' })
-    await invoke(DESKTOP_IPC.updatesCheck)
+    await invoke(DESKTOP_IPC.updatesOpen, 'app')
     expect(harness.updateCheck).toHaveBeenLastCalledWith(true)
     await vi.advanceTimersByTimeAsync(1000)
     expect(harness.updateCheck).toHaveBeenCalledTimes(4)
@@ -794,8 +777,6 @@ describe('desktop main startup', () => {
     expect(harness.updateInstall).not.toHaveBeenCalled()
     expect(host.stop).not.toHaveBeenCalled()
     expect(harness.updateDownload).not.toHaveBeenCalled()
-    await expect(invoke(DESKTOP_IPC.pluginsAdd, 'shell', 'example-plugin')).rejects.toThrow('Update required')
-    await expect(invoke(DESKTOP_IPC.pluginsToggle, 'shell', 'example-plugin', true)).rejects.toThrow('Update required')
     await invoke(DESKTOP_IPC.updatesOpen, 'app')
     expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
     request.mockImplementationOnce(async () => Response.json({ code: 500 }, { status: 503 }))
@@ -1215,46 +1196,6 @@ describe('desktop main startup', () => {
     expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('Desktop page failed to load')
   })
 
-  it('returns package-operation failures locally without opening fatal recovery', 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))
-    harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') })
-    host.exited.resolve()
-    const nextStarted = harness.nextHostStart()
-    const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'shell', 'example-plugin')).rejects.toThrow('package write failed')
-    await nextStarted
-    harness.hosts[1]!.ready.resolve()
-    await failure
-    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.pluginsAdd, 'shell', 'example-plugin')).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] as MessageBoxOptions).detail).toContain('new Host failed')
-    expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
-  })
-
   it('offers all recovery choices when resources fail before the Host starts', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -1373,7 +1314,7 @@ describe('desktop main startup', () => {
     expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
   })
 
-  it('keeps startup errors in the existing window without a shell retry handler', async () => {
+  it('keeps startup errors in the existing window without a retry handler', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
     harness.prepared.resolve()
@@ -1385,7 +1326,6 @@ describe('desktop main startup', () => {
     expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
     expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
     expect(harness.handlers.has('dsh-desktop:backend-retry')).toBe(false)
-    await expect(invoke(DESKTOP_IPC.pluginsAdd, 'shell', 'example-plugin')).rejects.toThrow('The application could not start or stopped unexpectedly.')
     expect(harness.hosts).toHaveLength(1)
   })
 

+ 0 - 75
apps/desktop/tests/plugin-manager.spec.ts

@@ -1,75 +0,0 @@
-import { readFileSync } from 'node:fs'
-import { runInContext } from 'node:vm'
-import { JSDOM } from 'jsdom'
-import { expect, it, vi } from 'vitest'
-import { resolveDesktopLocale } from '../src/locale.ts'
-
-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 = false
-  const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active })
-  const api = {
-    locale: async () => resolveDesktopLocale('en'),
-    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('#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]])
-    await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove')
-  } finally { dom.window.close() }
-})
-
-it.each(['en', 'zh-CN'])('updates through the inline form, including the installed version (%s)', async (language) => {
-  const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
-  const locale = resolveDesktopLocale(language)
-  const update = vi.fn(async () => {})
-  Object.defineProperty(dom.window, 'dshDesktop', { value: {
-    locale: async () => locale,
-    plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled: true }], update },
-  } })
-  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')
-    const updateButton = () => document.querySelectorAll<HTMLButtonElement>('#plugins li button')[1]!
-    const form = document.querySelector<HTMLFormElement>('#update-form')!
-    const version = document.querySelector<HTMLInputElement>('#update-version')!
-    updateButton().click()
-    expect(form.hidden).toBe(false)
-    expect(version.value).toBe('1.0.0')
-    expect(document.activeElement).toBe(version)
-    expect(document.querySelector('#update-label')?.textContent).toBe(locale.messages.targetVersion.replace('{name}', 'example-plugin'))
-    expect(document.querySelector('#update-cancel')?.textContent).toBe(locale.messages.cancel)
-    if (language === 'en') {
-      expect({ label: document.querySelector('#update-label')?.textContent,
-        actions: [...form.querySelectorAll('button')].map(button => button.textContent) }).toMatchInlineSnapshot(`
-          {
-            "actions": [
-              "Update",
-              "Cancel",
-            ],
-            "label": "Enter the target version for example-plugin",
-          }
-        `)
-    }
-    document.querySelector<HTMLButtonElement>('#update-cancel')!.click()
-    expect(form.hidden).toBe(true)
-    expect(document.activeElement).toBe(updateButton())
-    expect(update).not.toHaveBeenCalled()
-    for (const target of ['1.0.0', '^2.0.0']) {
-      updateButton().click()
-      version.value = target
-      form.requestSubmit()
-      await expect.poll(() => document.querySelector('#status')?.textContent).toBe(locale.messages.operationComplete)
-      expect(update).toHaveBeenLastCalledWith('example-plugin', target)
-      expect(form.hidden).toBe(true)
-    }
-    expect(update).toHaveBeenCalledTimes(2)
-  } finally { dom.window.close() }
-})

+ 0 - 119
apps/desktop/tests/plugin-pnpm.spec.ts

@@ -1,119 +0,0 @@
-import { createHash } from 'node:crypto'
-import { execFileSync } from 'node:child_process'
-import { createServer } from 'node:http'
-import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { c } from 'tar'
-import { expect, it } from 'vitest'
-import { healIsolatedProfileModuleFallback, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
-import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts'
-import { resolveDesktopPaths } from '../src/paths.ts'
-import { runtimeFixture, writePackage } from './runtime-fixture.ts'
-
-it('installs a real pnpm graph and executes scripts approved by user configuration', async () => {
-  // pnpm resolves its cwd natively; use the same spelling for Windows 8.3 temp paths.
-  const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-')))
-  const server = createServer()
-  const archives = new Map<string, Buffer>()
-  try {
-    for (const name of ['fixture-plugin', 'fixture-script-dependency']) {
-      const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin'
-        ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } }
-        : { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"')
-      writeFileSync(join(path, 'bundle.yml'), '[]\n')
-      writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath}))')
-      const tarball = join(root, `${name}.tgz`)
-      await c({ file: tarball, cwd: join(path, '..'), gzip: true }, [name])
-      archives.set(name, readFileSync(tarball))
-    }
-    await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
-    const address = server.address()
-    if (address === null || typeof address === 'string') throw new Error('fixture registry has no TCP address')
-    const origin = `http://127.0.0.1:${address.port}`
-    server.on('request', (request, response) => {
-      const name = request.url?.slice(1).replace(/\.tgz$/u, '') ?? ''
-      const archive = archives.get(name)
-      if (archive === undefined) { response.writeHead(404); response.end(); return }
-      if (request.url?.endsWith('.tgz')) { response.end(archive); return }
-      response.setHeader('content-type', 'application/json')
-      response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {
-        name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` },
-        ...(name === 'fixture-plugin' ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : { scripts: { install: 'node install.cjs' } }),
-      } }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } }))
-    })
-    const dsh = join(root, 'dsh')
-    runtimeFixture(dsh)
-    const pnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs')
-    const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh })
-    const installAnchor = join(dsh, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
-    const hooks: DesktopProjectHooks = {
-      beforeChange: async () => {},
-      afterChange: async () => {
-        const profile = loadProfileDirectory('desktop test', manager.paths.profile, installAnchor)
-        healIsolatedProfileModuleFallback({ installAnchor, profile })
-      },
-    }
-    await manager.applyRelease()
-    writeFileSync(join(manager.paths.profile, '.npmrc'), `registry=${origin}\n`)
-    writeFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), `packages:\n  - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstoreDir: ${JSON.stringify(join(root, 'store'))}\nallowBuilds:\n  fixture-script-dependency: true\n`)
-    await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks)
-    expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }])
-    const builtPath = join(manager.paths.profile, 'node_modules/fixture-script-dependency/built.json')
-    if (!existsSync(builtPath)) {
-      const config = (name: string): string => execFileSync(process.execPath, [pnpm, 'config', 'get', name], {
-        cwd: manager.paths.profile, encoding: 'utf8',
-      }).trim()
-      throw new Error(`pnpm install script produced no output: ignoreScripts=${config('ignoreScripts')}, allowBuilds=${config('allowBuilds')}`)
-    }
-    const built = JSON.parse(readFileSync(builtPath, 'utf8')) as { node: string }
-    expect(realpathSync(built.node)).toBe(realpathSync(process.execPath))
-    const entry = join(dsh, 'identity.mjs')
-    writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`)
-    expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('true')
-    await manager.mutate({ type: 'plugin-update', name: 'fixture-plugin', version: '^1.0.0' }, hooks)
-    expect(manager.listPlugins()[0]?.version).toBe('1.0.0')
-    await manager.mutate({ type: 'plugin-remove', name: 'fixture-plugin' }, hooks)
-    expect(manager.listPlugins()).toEqual([])
-  } finally {
-    server.closeAllConnections()
-    if (server.listening) await new Promise<void>((resolve, reject) => {
-      server.close((error) => {
-        if (error !== undefined) reject(error)
-        else resolve()
-      })
-    })
-    rmSync(root, { recursive: true, force: true })
-  }
-}, 30_000)
-
-it.each(['directory', 'file', 'tarball', 'plain'] as const)('installs a %s source through pnpm', async (source) => {
-  const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-local-pnpm-')))
-  try {
-    const dsh = join(root, 'dsh')
-    runtimeFixture(dsh)
-    const packageDir = writePackage(join(root, 'local packages'), 'local-plugin', {
-      ...(source === 'plain' ? {} : { dsh: { bundle: { patch: 'missing.yml' } } }),
-      peerDependencies: { '@deepseek-ai/cordis': '^999.0.0' },
-    })
-    const tarball = join(root, 'local-plugin.tgz')
-    await c({ file: tarball, cwd: join(packageDir, '..'), gzip: true }, ['local-plugin'])
-    const spec = source === 'directory' ? packageDir : source === 'file' ? `file:${packageDir}` : tarball
-    const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), {
-      node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh,
-    })
-    const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} }
-    await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec }, hooks)
-    expect(manager.listPlugins()).toEqual([{ name: 'local-plugin', version: '1.0.0', enabled: source !== 'plain' }])
-    expect(existsSync(join(manager.paths.profile, 'node_modules/local-plugin/missing.yml'))).toBe(false)
-    await manager.disableAllPlugins()
-    expect(manager.listPlugins()[0]?.enabled).toBe(false)
-    await manager.mutate({ type: 'plugin-remove', name: 'local-plugin' }, hooks)
-    expect(manager.listPlugins()).toEqual([])
-    expect(existsSync(join(packageDir, 'package.json'))).toBe(true)
-  } finally {
-    rmSync(root, { recursive: true, force: true })
-  }
-}, 30_000)

+ 81 - 226
apps/desktop/tests/project-manager.spec.ts

@@ -1,73 +1,45 @@
-import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { resolveDesktopPaths } from '../src/paths.ts'
-import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts'
+import { DesktopProjectManager } from '../src/project-manager.ts'
+import { readProfilePlugins } from '@deepseek-ai/dsh-app-boot'
 import { runtimeFixture } from './runtime-fixture.ts'
 
 const roots: string[] = []
-const releaseWorkers: Array<() => Promise<void>> = []
 function temporaryRoot(): string {
   const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-'))
   roots.push(root)
   return root
 }
-function writeFakePnpm(root: string): string {
-  const path = join(root, 'pnpm.mjs')
-  writeFileSync(path, `
-import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
-const args = process.argv.slice(2)
-const project = process.cwd()
-const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value))
-appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY, userconfig: process.env.NPM_CONFIG_USERCONFIG, pnpmHome: process.env.PNPM_HOME, configHome: process.env.XDG_CONFIG_HOME}) + '\\n')
-if (command !== 'rebuild') {
-  const manifestPath = join(project, 'package.json')
-  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
-  if (command === 'add') {
-    const spec = args.includes('--') ? args[args.indexOf('--') + 1] : args[args.indexOf(command) + 1]
-    const index = spec.lastIndexOf('@')
-    const name = index > 0 ? spec.slice(0, index) : spec
-    manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0'
-  }
-  if (command === 'remove') delete manifest.dependencies[args[args.indexOf('--') + 1]]
-  writeFileSync(manifestPath, JSON.stringify(manifest))
-  rmSync(join(project, 'node_modules'), { recursive: true, force: true })
-  for (const [name, version] of Object.entries(manifest.dependencies)) {
-    const packageRoot = join(project, 'node_modules', name)
-    mkdirSync(packageRoot, { recursive: true })
-    writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version,
-      peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}}))
-    writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n')
+function seedPlugin(manager: DesktopProjectManager): void {
+  const path = join(manager.paths.profile, 'package.json')
+  const manifest = JSON.parse(readFileSync(path, 'utf8')) as {
+    dependencies: Record<string, string>
+    dsh: { profile: { bundles: string[] } }
   }
-  writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies))
+  manifest.dependencies.plugin = '1.0.0'
+  manifest.dsh.profile.bundles.push('plugin')
+  writeFileSync(path, JSON.stringify(manifest))
+  const directory = join(manager.paths.profile, 'node_modules/plugin')
+  mkdirSync(directory, { recursive: true })
+  writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'plugin', version: '1.0.0', dsh: { bundle: { patch: 'bundle.yml' } } }))
+  writeFileSync(join(directory, 'bundle.yml'), '[]\n')
 }
-`)
-  return path
-}
-function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
-  return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides }
+function plugins(manager: DesktopProjectManager) {
+  return readProfilePlugins({ binName: 'dsh', profileDir: manager.paths.profile,
+    installAnchor: join(manager.runtime.dsh, 'node_modules/@deepseek-ai/dsh/package.json') }).dependencies
+    .map(({ name, version, enabled }) => ({ name, version, enabled }))
 }
 function setup(): { root: string; manager: DesktopProjectManager } {
   const root = temporaryRoot()
   const dsh = join(root, 'resources', 'dsh')
   runtimeFixture(dsh)
-  return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) }
-}
-function calls(root: string): { args: string[]; registry: string }[] {
-  const path = join(root, 'pnpm-log.jsonl')
-  return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : []
+  return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { dsh }) }
 }
-afterEach(async () => {
-  const cleanups = releaseWorkers.splice(0)
-  const directories = roots.splice(0)
-  const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()))
-  for (const root of directories) rmSync(root, { recursive: true, force: true })
-  vi.unstubAllEnvs()
-  const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : [])
-  if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed')
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
 
 describe('desktop external plugin profile', () => {
@@ -85,7 +57,7 @@ describe('desktop external plugin profile', () => {
   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())
+    seedPlugin(manager)
     const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json')
     writeFileSync(manifest, '{broken')
     await expect(manager.applyRelease()).resolves.toBeUndefined()
@@ -95,9 +67,9 @@ describe('desktop external plugin profile', () => {
   })
 
   it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
-    const { root, manager } = setup()
+    const { manager } = setup()
     await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    seedPlugin(manager)
     const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml')
     unlinkSync(patch)
     await manager.disableAllPlugins()
@@ -105,14 +77,13 @@ describe('desktop external plugin profile', () => {
       dsh: { profile: { bundles: string[] } }
     }).dsh.profile.bundles).not.toContain('plugin')
     expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
-    expect(calls(root)).toHaveLength(1)
     await expect(manager.applyRelease()).resolves.toBeUndefined()
   })
 
   it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
     const { manager } = setup()
     await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    seedPlugin(manager)
     const patch = join(manager.paths.profile, 'cordis.patch.yml')
     writeFileSync(patch, ': broken')
     const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
@@ -151,47 +122,6 @@ describe('desktop external plugin profile', () => {
     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) => {
-    const { manager } = setup()
-    await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
-    const path = join(manager.paths.profile, 'node_modules/plugin/package.json')
-    if (damage === 'missing') unlinkSync(path)
-    else writeFileSync(path, damage === 'malformed' ? '{broken' : '{}')
-    expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
-    await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks())
-    expect(manager.listPlugins()[0]?.enabled).toBe(false)
-    await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
-    expect(manager.listPlugins()).toEqual([])
-  })
-
-  it('preserves custom profile metadata and bundle order during plugin changes', async () => {
-    const { manager } = setup()
-    await manager.applyRelease()
-    const path = join(manager.paths.profile, 'package.json')
-    writeFileSync(path, JSON.stringify({ name: 'custom', private: false, custom: true,
-      dependencies: {}, dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } }))
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
-    expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ name: 'custom', private: false, custom: true,
-      dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle', 'plugin'] } } })
-    await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
-    expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({
-      dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } })
-  })
-
-  it('passes user registry and pnpm configuration through to the bundled executable', async () => {
-    const { root, manager } = setup()
-    vi.stubEnv('NPM_CONFIG_REGISTRY', 'https://packages.example.test/')
-    vi.stubEnv('NPM_CONFIG_USERCONFIG', join(root, 'user.npmrc'))
-    vi.stubEnv('PNPM_HOME', join(root, 'user-pnpm'))
-    vi.stubEnv('XDG_CONFIG_HOME', join(root, 'user-config'))
-    await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@next' }, hooks())
-    expect(calls(root)[0]).toMatchObject({ registry: 'https://packages.example.test/',
-      userconfig: join(root, 'user.npmrc'), pnpmHome: join(root, 'user-pnpm'), configHome: join(root, 'user-config'),
-      args: ['add', '--', 'plugin@next'] })
-  })
-
   it('initializes the same pnpm settings as a Web profile without a Desktop build allowlist', async () => {
     const { manager } = setup()
     await manager.applyRelease()
@@ -215,8 +145,6 @@ describe('desktop external plugin profile', () => {
     writeFileSync(path, custom)
     await manager.applyRelease()
     expect(readFileSync(path, 'utf8')).toBe(custom)
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
-    expect(manager.listPlugins()[0]?.name).toBe('plugin')
   })
 
   it('reports damaged application metadata as a reinstall failure', async () => {
@@ -238,11 +166,10 @@ describe('desktop external plugin profile', () => {
   })
 
   it('initializes and restarts offline without executing pnpm', async () => {
-    const { root, manager } = setup()
+    const { manager } = setup()
     await expect(manager.applyRelease()).resolves.toBeUndefined()
     await expect(manager.applyRelease()).resolves.toBeUndefined()
-    expect(manager.listPlugins()).toEqual([])
-    expect(calls(root)).toEqual([])
+    expect(plugins(manager)).toEqual([])
     expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} })
   })
 
@@ -254,7 +181,7 @@ describe('desktop external plugin profile', () => {
   })
 
   it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
-    const { root, manager } = setup()
+    const { manager } = setup()
     if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}')
     if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
     if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
@@ -263,173 +190,101 @@ describe('desktop external plugin profile', () => {
     const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
     await expect(relaunched.applyRelease()).resolves.toBeUndefined()
     expect(existsSync(manager.paths.profile)).toBe(true)
-    expect(calls(root)).toEqual([])
-  })
-
-  it('installs plugins with pnpm script settings', async () => {
-    const { root, manager } = setup()
-    await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
-    expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }])
-    expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
-      ['add', '--', '@scope/plugin@2.0.0'],
-    ])
-    expect(calls(root).every(call => call.registry === process.env.NPM_CONFIG_REGISTRY)).toBe(true)
-    expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } })
-    await expect(manager.applyRelease()).resolves.toBeUndefined()
-    expect(calls(root)).toHaveLength(1)
-  })
-
-  it('retains pnpm-installed host package copies instead of rejecting their dependency placement', async () => {
-    const { manager } = setup()
-    await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis@2.0.0' }, hooks())
-    expect(manager.listPlugins()).toEqual([{ name: '@deepseek-ai/cordis', version: '2.0.0', enabled: false }])
-    await expect(manager.applyRelease()).resolves.toBeUndefined()
-    await manager.mutate({ type: 'plugin-remove', name: '@deepseek-ai/cordis' }, hooks())
-  })
-
-  it('retains disabled plugin versions through updates and enables them explicitly', async () => {
-    const { root, manager } = setup()
-    await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
-    await manager.disableAllPlugins()
-    expect(calls(root)).toHaveLength(1)
-    expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
-    await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks())
-    expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }])
-    await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks())
-    expect(manager.listPlugins()[0]?.enabled).toBe(true)
-    await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
-    expect(manager.listPlugins()).toEqual([])
   })
 
   it('keeps plugin files and patches through a compatible release and application relocation', async () => {
     const { root, manager } = setup()
     await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    seedPlugin(manager)
     writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n')
     const nextRoot = join(root, 'relocated', 'dsh')
     runtimeFixture(nextRoot, '1.1.0')
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot })
     await expect(next.applyRelease()).resolves.toBeUndefined()
-    expect(next.listPlugins()).toEqual(manager.listPlugins())
-    expect(next.dshVersion()).toBe('1.1.0')
+    expect(plugins(next)).toEqual(plugins(manager))
     expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
-    expect(calls(root)).toHaveLength(1)
     expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
   })
 
   it('preserves plugin files without running pnpm when bundled Node changes', async () => {
     const { root, manager } = setup()
     await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    seedPlugin(manager)
     const dsh = join(root, 'new-node')
     runtimeFixture(dsh, '1.1.0', '24.18.0')
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
     await next.applyRelease()
-    expect(calls(root)).toHaveLength(1)
-    expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
+    expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
   })
 
   it('allows peer version mismatches to reach Host startup and remain available for recovery', async () => {
     const { root, manager } = setup()
     await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
+    seedPlugin(manager)
     const dsh = join(root, 'next-major')
     runtimeFixture(dsh, '2.0.0')
     const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
     await expect(next.applyRelease()).resolves.toBeUndefined()
-    expect(next.dshVersion()).toBe('2.0.0')
     await next.disableAllPlugins()
-    expect(next.dshVersion()).toBe('2.0.0')
-    expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
+    expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
   })
+})
 
-  it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => {
+describe.each(['applyRelease', 'disableAllPlugins'] as const)('desktop profile lock during %s', (operation) => {
+  it('preserves a live owner lock and leaves the profile untouched', async () => {
     const { manager } = setup()
-    await manager.applyRelease()
-    let starts = 0
-    await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
-      beforeChange: async () => {
-        expect(manager.listPlugins()).toEqual([])
-        if (phase === 'before') throw new Error('before failed')
-      },
-      afterChange: async () => { starts++; throw new Error('after failed') },
-    }))).rejects.toThrow(`${phase} failed`)
-    expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }])
-    expect(starts).toBe(phase === 'before' ? 0 : 1)
+    mkdirSync(manager.paths.profile, { recursive: true })
+    const owner = `${String(process.pid)}\n`
+    writeFileSync(manager.paths.lock, owner)
+    await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
+    expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
+    expect(readdirSync(manager.paths.profile)).toEqual(['lock'])
   })
 
-  it('keeps partial package changes available for recovery after pnpm fails', async () => {
-    const { root, manager } = setup()
-    await manager.applyRelease()
-    const failingPnpm = join(root, 'failing.mjs')
-    writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`)
-    const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
-    await worker.applyRelease()
-    let starts = 0
-    await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
-      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(1)
-    expect(existsSync(manager.paths.lock)).toBe(false)
-    writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '')
-    await manager.applyRelease()
-    await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks({ afterChange: async () => { starts++ } }))
-    expect(starts).toBe(2)
-    await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
-    expect(manager.listPlugins()).toEqual([])
+  it('reclaims a stale owner lock and releases it after the operation', async () => {
+    const { manager } = setup()
+    mkdirSync(manager.paths.profile, { recursive: true })
+    writeFileSync(manager.paths.lock, `${String(process.pid)}\n`)
+    const probe = vi.spyOn(process, 'kill').mockImplementation(() => {
+      throw Object.assign(new Error('process absent'), { code: 'ESRCH' })
+    })
+    try {
+      await expect(manager[operation]()).resolves.toBeUndefined()
+      expect(probe).toHaveBeenCalledExactlyOnceWith(process.pid, 0)
+      expect(existsSync(manager.paths.lock)).toBe(false)
+    } finally {
+      probe.mockRestore()
+    }
   })
 
-  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.each(['invalid', '0', '-1', '9007199254740992'])('preserves a lock with invalid owner %s', async (owner) => {
+    const { manager } = setup()
+    mkdirSync(manager.paths.profile, { recursive: true })
+    writeFileSync(manager.paths.lock, owner)
+    await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
+    expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
   })
 
-  it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => {
+  it('rejects a directory at the lock path', async () => {
+    const { manager } = setup()
+    mkdirSync(manager.paths.lock, { recursive: true })
+    await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
+    expect(lstatSync(manager.paths.lock).isDirectory()).toBe(true)
+  })
+
+  it('rejects a linked lock without touching its target', async () => {
     const { root, manager } = setup()
-    await manager.applyRelease()
-    const ready = join(root, 'ready')
-    const release = join(root, 'release')
-    const blocker = join(root, 'blocking.mjs')
-    writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`)
-    const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker })
-    await worker.applyRelease()
-    const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
-    // Teardown observes failures even if the runner has abandoned the test body.
-    const completed = pending.then(value => ({ value }), (error: unknown) => ({ error }))
-    releaseWorkers.push(async () => {
-      writeFileSync(release, 'continue')
-      const outcome = await completed
-      if ('error' in outcome) throw outcome.error
-    })
+    mkdirSync(manager.paths.profile, { recursive: true })
+    const target = join(root, 'lock-target')
+    mkdirSync(target)
+    writeFileSync(join(target, 'sentinel'), 'retain')
+    symlinkSync(target, manager.paths.lock, process.platform === 'win32' ? 'junction' : 'dir')
     try {
-      // Child startup shares the test budget; an aborted poll must not resume ownership assertions.
-      await expect.poll(() => {
-        signal.throwIfAborted()
-        return existsSync(ready)
-      }, { timeout: task.timeout }).toBe(true)
-      signal.throwIfAborted()
-      expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8'))
-      await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u)
+      await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
+      expect(lstatSync(manager.paths.lock).isSymbolicLink()).toBe(true)
+      expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('retain')
     } finally {
-      writeFileSync(release, 'continue')
-      await pending
+      unlinkSync(manager.paths.lock)
     }
-    expect(existsSync(manager.paths.lock)).toBe(false)
   })
 })

+ 1 - 1
apps/desktop/tsdown.config.ts

@@ -12,7 +12,7 @@ export default defineConfig([
     clean: false,
     deps: { neverBundle: ['electron'] },
   },
-  ...(['preload', 'preload-app', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({
+  ...(['preload-app', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({
     // Sandboxed Electron preloads run as CommonJS even though the application package is ESM.
     entry: { [name]: `lib/types/${name}.js` },
     outDir: 'lib',

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