Преглед на файлове

fix(desktop): address lifecycle review findings

07akioni преди 1 месец
родител
ревизия
f90d57f389

+ 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: b677bca44ff075d3b5897a5a466a8933730633de
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 76604ea51f70d2f0b10013eed79560934060d822
+2026-08-25-electron-desktop-packaging-and-updates.md: 483e0cc3bfd0af3fd7d7cde523099561d3c65fd0
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 30b573aea8456ac55409de3e01f59ed61d372d48

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

@@ -20,7 +20,7 @@ Electron owns the reserved profile at `.dsh/profiles/desktop`. Its exact `@deeps
 
 One Desktop release number identifies both the Electron artifact and its exact `@deepseek-ai/dsh` dependency. A release cannot select a different dsh version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged.
 
-The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm.
+The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before project recovery or Host startup; later launches focus or recreate the primary window without touching profile state. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm.
 
 ## Ownership
 
@@ -33,7 +33,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p
 | Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions |
 | npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state |
 
-The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments.
+The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, locale, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron selects a typed English or Chinese dictionary from its application locale and falls back to English; menus, native dialogs, and the plugin-management renderer use that locale-owned copy.
 
 ## Filesystem layout
 
@@ -66,7 +66,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
 
 ## Installation and resolution
 
-The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory, applies an exact dependency change with the bundled pnpm, performs a full health check, stops the backend, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. `pending.json` journals the filesystem moves so startup can complete or reverse an interrupted replacement.
+The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile.
+
+The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered.
 
 The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the first-party package closure rooted at dsh, lockfile, integrity inventory, and required store subset. The release build requires the Electron package and root dsh package to have the same version, creates final npm tarballs from the official source build, selects the reachable dsh and vendored packages plus the Landlock entry, and verifies the dsh tarball's `lib/desktop-host.js` entry and `config/desktop.cordis.patch.yml` overlay. The overlay is the only CLI configuration file published specifically for Desktop; example configurations remain outside the tarball. These tarballs remain the official `pnpm pack` results governed by each package's `files` manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The build rejects any lockfile that resolves one of those names by registry version. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks both Desktop Host files. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both files before copying the package set and after offline installation prevents a release whose Host entry loads but cannot compose its required overlay from reaching application signing.
 
@@ -80,7 +82,7 @@ The backend and Loader use `.dsh/profiles/desktop/package.json` as their profile
 
 ## Updates and recovery
 
-Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. The update dialog downloads and installs the Electron artifact, then restarts into the new release.
+Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release.
 
 Before the new release opens a window, startup reconciles dsh from its packaged seed while retaining installed desktop plugins. The health check covers dependency resolution, native modules, shell API compatibility, backend startup and shutdown, Web assets, and the client boot graph. An incompatible plugin blocks activation and leaves the previous project available for rollback. Startup fails visibly rather than launching a shell and dsh version that do not match.
 

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

@@ -20,7 +20,7 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse
 
 一个 Desktop 发布号同时标识 Electron 产物及其精确 `@deepseek-ai/dsh` 依赖。发布不能在构建或运行时选择不同的 dsh 版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。
 
-浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。
+浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。
 
 ## 归属
 
@@ -33,7 +33,7 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse
 | 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 |
 | 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 |
 
-渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。
+渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。
 
 ## 文件系统布局
 
@@ -66,7 +66,9 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse
 
 ## 安装与解析
 
-安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,使用内置 pnpm 应用精确依赖变更,执行完整健康检查,停止后端,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。`pending.json` 记录文件系统移动,使启动过程可以完成或反转中断的替换。
+安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。
+
+进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。
 
 打包种子是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、以 dsh 为根的第一方包闭包描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。发布构建要求 Electron 包与根 dsh 包使用相同版本,从正式源码构建生成最终 npm tarball,选择可达的 dsh 与 vendored 包以及 Landlock 入口,并验证 dsh tarball 中的 `lib/desktop-host.js` 入口与 `config/desktop.cordis.patch.yml` overlay。该 overlay 是唯一为了 Desktop 而发布的 CLI 配置文件;示例配置仍留在 tarball 之外。这些 tarball 保持为由各包 `files` manifest 决定内容的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。manifest 把每个选中的包列为本地直接依赖,关闭对等依赖自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。构建会拒绝任何通过 registry 版本解析这些包名的 lockfile。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查两个 Desktop Host 文件。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求两个文件,可防止 Host 入口本身能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。
 
@@ -80,7 +82,7 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse
 
 ## 更新与恢复
 
-Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
+Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
 
 新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 dsh。
 

+ 2 - 0
apps/cli/package.json

@@ -23,6 +23,8 @@
   },
   "files": [
     "lib/*.js",
+    "lib/types/desktop-host.d.ts",
+    "lib/types/desktop-host-wire.d.ts",
     "config/desktop.cordis.patch.yml"
   ],
   "dsh": {

+ 1 - 1
apps/cli/src/args.ts

@@ -61,7 +61,7 @@ interface BootOptions {
 const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
 
 function rejectElectronProfile(program: Command, profile: string): void {
-  if (profile === 'desktop') {
+  if (profile.toLowerCase() === 'desktop') {
     program.error('error: profile "desktop" is managed exclusively by the Electron application')
   }
 }

+ 15 - 1
apps/cli/src/desktop-host.ts

@@ -411,6 +411,7 @@ async function main(): Promise<void> {
   const decoder = new DesktopHostRequestDecoder()
   const requestBodies = new Map<number, ReadableStreamDefaultController<Uint8Array>>()
   const blockedRequests = new Set<number>()
+  const discardedRequestBodies = new Set<number>()
   const runs = new Set<Promise<void>>()
   let lastStreamId = 0
   let requestedExitCode = 0
@@ -429,6 +430,7 @@ async function main(): Promise<void> {
       for (const body of requestBodies.values()) body.error(stopped)
       requestBodies.clear()
       blockedRequests.clear()
+      discardedRequestBodies.clear()
       requestPipe.destroy()
       closeSync(DESKTOP_REQUEST_PIPE_FD)
       await controller.dispose()
@@ -483,7 +485,16 @@ async function main(): Promise<void> {
       },
     }, body)
     runs.add(run)
-    void run.catch(failTransport).finally(() => { runs.delete(run) })
+    void run.catch(failTransport).finally(() => {
+      runs.delete(run)
+      const openBody = requestBodies.get(frame.streamId)
+      if (openBody === undefined) return
+      openBody.error(new Error('dsh desktop: response completed before the request body ended'))
+      requestBodies.delete(frame.streamId)
+      blockedRequests.delete(frame.streamId)
+      discardedRequestBodies.add(frame.streamId)
+      resumeRequestPipe()
+    })
   }
 
   const handleRequestFrame = (frame: DesktopHostRequestFrame): void => {
@@ -494,6 +505,7 @@ async function main(): Promise<void> {
       case 'data': {
         const body = requestBodies.get(frame.streamId)
         if (body === undefined) {
+          if (discardedRequestBodies.has(frame.streamId)) return
           throw new Error(`dsh desktop: Electron sent body data for inactive stream ${String(frame.streamId)}`)
         }
         body.enqueue(frame.data)
@@ -506,6 +518,7 @@ async function main(): Promise<void> {
       case 'end': {
         const body = requestBodies.get(frame.streamId)
         if (body === undefined) {
+          if (discardedRequestBodies.delete(frame.streamId)) return
           throw new Error(`dsh desktop: Electron ended inactive body stream ${String(frame.streamId)}`)
         }
         body.close()
@@ -522,6 +535,7 @@ async function main(): Promise<void> {
         body?.error(new Error('dsh desktop: Electron canceled the request'))
         requestBodies.delete(frame.streamId)
         blockedRequests.delete(frame.streamId)
+        discardedRequestBodies.delete(frame.streamId)
         controller.cancel(frame.streamId)
         resumeRequestPipe()
         return

+ 3 - 0
apps/cli/tests/args.spec.ts

@@ -96,8 +96,11 @@ describe('parseDshArgs', () => {
     expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
     expect(exitCode(['plugin', '--profile', ''])).toBe(1)
     expect(exitCode(['--profile', 'desktop'])).toBe(1)
+    expect(exitCode(['--profile', 'Desktop'])).toBe(1)
+    expect(exitCode(['--profile', 'DESKTOP'])).toBe(1)
     expect(exitCode(['--profile', 'desktop', '--dump-config'])).toBe(1)
     expect(exitCode(['plugin', '--profile', 'desktop', 'add', 'x'])).toBe(1)
+    expect(exitCode(['plugin', '--profile', 'Desktop', 'add', 'x'])).toBe(1)
     expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
   })
 

+ 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: aa35c3fca95f3dd9d9409ca8b2446dbcdc58eab2
-README.zh.md: 27848b17fe1f8aa4b0afbec106a323eb354eb4d5
+README.md: 4590896b14aa0be7a626cb11d9056dcc051ffe13
+README.zh.md: 373730fcaf088a9b11592153e6e4f9333b0192fb

+ 8 - 4
apps/desktop/README.md

@@ -12,7 +12,7 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no
 | Runtime | Electron's Node.js carries Electron patches, fuses, ABI, and lifecycle constraints, while system runtimes and package-manager state are uncontrolled. | dsh runs under the bundled upstream Node.js and every package operation uses the bundled pnpm. Electron's Node.js, system Node.js, system pnpm, and user package-manager configuration are outside the execution path. |
 | Package sources | The exact dsh source build must be packageable before npm publication and install offline; plugins must remain ordinary user-selected npm packages. | The signed application carries locally packed first-party dsh packages and an offline seed store. Desktop plugins remain ordinary npm dependencies resolved from the fixed Desktop registry. |
 | Seed transport | Apple notarization inspects code inside archives; shipping every pnpm store file separately would also make the application signature inventory tens of thousands of cache entries, while one compressed archive would amplify small package changes. | macOS packaging signs every Mach-O CAS object, rewrites its pnpm hashes, and proves another offline install before assigning store files to 16 deterministic uncompressed tar shards. The outer installer compresses them, and differential updates can reuse unchanged shards. |
-| State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions. | Electron exclusively owns `$DSH_HOME/profiles/desktop` and its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
+| State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
 | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. |
 | Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes install in staging, boot a complete backend health check, and replace the active profile only after success; a journal and one rollback profile cover interrupted replacement. |
 | 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 seed, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
@@ -25,6 +25,8 @@ Electron owns the reserved profile at `$DSH_HOME/profiles/desktop`. Its manifest
 
 The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
+Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
+
 ### Seed installation
 
 The packaged seed is an installation kit, not a ready-to-run `node_modules` tree. Packaging creates the lockfile, materializes the production graph online with lifecycle scripts disabled, deletes `node_modules` and every temporary pnpm cache, config, and state directory, and proves one complete installation offline from the final store alone with both Desktop Host files. A macOS build then Developer ID signs every Mach-O object in pnpm's content-addressed store, updates every affected SHA-512 index record, and proves the rewritten store with another offline install before deleting `node_modules`. The signed seed retains the release identity, local first-party tarballs and their descriptor, project metadata, lockfile, integrity inventory, and pnpm store content required to repeat that installation on the user's machine.
@@ -42,11 +44,13 @@ Startup installs or reconciles the seed as one serialized transaction:
 2. If the active profile already contains that release and dsh version, verify its local package set and reuse it without reinstalling.
 3. Otherwise validate every archive entry, extract all store shards into a temporary Desktop-owned staging directory, merge the package files and SQLite package-index records into the private store, create a staging profile, and run `pnpm install --offline --frozen-lockfile --trust-lockfile` through the bundled Node.js and pnpm. Seed records replace matching index keys while plugin-only records remain available.
 4. During an Electron upgrade, read every plugin name and exact version from the old active profile and add those versions to staging with `--offline` from existing Desktop pnpm state. A first installation has no plugin-restore step.
-5. Boot the complete staged backend as a health check. Installation or plugin incompatibility before activation deletes staging and leaves the active profile unchanged.
-6. Journal the directory replacement, move the active profile to `$DSH_HOME/desktop/rollback/profile`, and move staging into `$DSH_HOME/profiles/desktop`. A failed replacement restores the old profile immediately; the next launch recovers an interrupted replacement from the journal.
+5. Stop the active backend, boot and stop the complete staged backend as a health check, then restart the active backend before activation. This serialization prevents two desktop backends from sharing `$DSH_HOME`; installation or plugin incompatibility before activation deletes staging and leaves the active profile unchanged.
+6. Persist each next activation phase before its directory move, move the active profile to `$DSH_HOME/desktop/rollback/profile`, and move staging into `$DSH_HOME/profiles/desktop`. Recovery combines the journal with the actual profile, rollback, and staging directories, so interruption in either write-to-move gap restores or retains a complete profile.
 
 GUI plugin mutations use the same staging, health-check, activation, and rollback path after installing registry packages into the shared Desktop pnpm store.
 
+The process-lifetime Electron lock is the primary desktop owner. The transaction lock is depth defense: it records Electron while preparing local state, records the spawned pnpm worker while that worker can still write, and returns ownership to Electron after the worker exits. A later process cannot treat a live orphaned worker as a stale transaction.
+
 ## Develop
 
 `dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI package and its workspace dependencies into a disposable desktop npm project, and launches Electron without downloading the packaged Node.js runtime or resolving dsh from npm:
@@ -133,7 +137,7 @@ An unpacked artifact contains four independent size contributors: Electron, the
 
 ## Updates
 
-A packaged application checks its configured release stream ten seconds after the main window opens; the **检查更新…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound seed before reopening the product window. A build without updater configuration performs no network update request and reports that it is current.
+A packaged application checks its configured release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound seed before reopening the product window. A build without updater configuration performs no network update request and reports that it is current.
 
 Release builds set `DSH_DESKTOP_SHELL_UPDATE_URL` to the generic update server used by electron-updater. With this setting, electron-builder emits the channel metadata that must be published with the update blockmaps and installers; an unconfigured local build omits that metadata. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The seed and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
 

+ 8 - 4
apps/desktop/README.zh.md

@@ -12,7 +12,7 @@
 | 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和用户包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。Electron 的 Node.js、系统 Node.js、系统 pnpm 与用户的包管理器配置都不进入执行路径。 |
 | 包来源 | 必须能在发布到 npm 之前从同一次源码构建打包精确的 dsh,并支持离线安装;插件则需要保留为用户选择的普通 npm 包。 | 已签名应用携带本地打包的第一方 dsh 包与离线 seed store。桌面插件仍是从固定 Desktop registry 解析的普通 npm 依赖。 |
 | Seed 传输 | Apple 公证会检查归档内的代码;把 pnpm store 的每个文件分别放入应用,还会让应用签名记录数万个缓存条目,而单个压缩归档会放大小幅包变更。 | macOS 打包先签署每个 Mach-O CAS 对象、重写其 pnpm 哈希并再次证明离线安装,再把 store 文件分配到 16 个确定性的未压缩 tar 分片。外层安装包负责压缩,差分更新可以复用未变化的分片。 |
-| 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本。 | Electron 独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
+| 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
 | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 |
 | 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先安装到 staging,并启动完整后端执行健康检查;只有成功后才替换活跃 profile,中断替换由事务日志和一个 rollback profile 恢复。 |
 | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh seed、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
@@ -25,6 +25,8 @@ Electron 拥有保留 profile `$DSH_HOME/profiles/desktop`。其 manifest 通过
 
 dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
 
+Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
+
 ### Seed 安装
 
 安装包内的 seed 是安装工具包,不是可以直接运行的 `node_modules` 目录。打包过程会生成锁文件,在禁用生命周期脚本的情况下在线物化生产依赖图,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 完成一次完整离线安装,并验证两个 Desktop Host 文件。macOS 构建随后用 Developer ID 签署 pnpm 内容寻址 store 中的每个 Mach-O 对象,更新所有受影响的 SHA-512 索引记录,再用一次离线安装证明重写后的 store,最后删除 `node_modules`。签名 seed 保留发布身份、本地第一方 tarball 及其描述文件、项目元数据、锁文件、完整性清单,以及在用户机器上重复该安装所需的 pnpm store 内容。
@@ -42,11 +44,13 @@ dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构
 2. 如果活跃 profile 已包含该发布与 dsh 版本,则验证其中的本地包集并直接复用,不重新安装。
 3. 否则验证每个归档条目,把全部 store 分片解包到 Desktop 拥有的临时 staging 目录,将包文件与 SQLite 包索引记录合并进私有 store,再创建 staging profile,并通过内置 Node.js 与 pnpm 执行 `pnpm install --offline --frozen-lockfile --trust-lockfile`。Seed 记录替换匹配的索引键,插件专属记录继续保留。
 4. Electron 升级时,从旧活跃 profile 读取每个插件的名称和精确版本,再通过现有 Desktop pnpm 状态以 `--offline` 把这些版本加入 staging。首次安装不执行插件恢复。
-5. 启动完整的 staging 后端执行健康检查。在激活前发生安装错误或插件不兼容时,删除 staging 并保持活跃 profile 不变。
-6. 记录目录替换事务,把活跃 profile 移到 `$DSH_HOME/desktop/rollback/profile`,再把 staging 移到 `$DSH_HOME/profiles/desktop`。替换失败时立即恢复旧 profile;替换中断时,下次启动会根据事务日志恢复。
+5. 停止活跃后端,启动并停止完整的 staging 后端执行健康检查,再在激活前重新启动活跃后端。这种串行方式避免两个桌面后端共享 `$DSH_HOME`;安装错误或插件不兼容会删除 staging,并保持活跃 profile 不变。
+6. 在每次目录移动前先持久化下一个激活阶段,把活跃 profile 移到 `$DSH_HOME/desktop/rollback/profile`,再把 staging 移到 `$DSH_HOME/profiles/desktop`。恢复过程同时检查日志与真实的 profile、rollback 和 staging 目录,因此在任一个写入与移动间隙中断后仍会恢复或保留一个完整 profile。
 
 GUI 插件修改会在把 registry 包安装到共享 Desktop pnpm store 后,使用相同的 staging、健康检查、激活与 rollback 路径。
 
+进程生命周期 Electron 锁是桌面端的主要 owner。事务锁用于纵深防御:准备本地状态时记录 Electron,在 pnpm worker 仍可能写入时记录该 worker,worker 退出后再把 owner 交还 Electron。后续进程不会把仍然存活的孤儿 worker 误判为陈旧事务。
+
 ## 开发
 
 `dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不下载安装包内的 Node.js,也不从 npm 解析 dsh:
@@ -133,7 +137,7 @@ pnpm run prepare:desktop
 
 ## 更新
 
-打包应用会在主窗口打开十秒后检查已配置的发布流;**检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。没有 updater 配置的构建不会发起网络更新请求,并会报告当前已是最新版本。
+打包应用会在主窗口打开十秒后检查已配置的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。没有 updater 配置的构建不会发起网络更新请求,并会报告当前已是最新版本。
 
 发布构建通过 `DSH_DESKTOP_SHELL_UPDATE_URL` 配置 electron-updater 使用的 generic 更新服务。设置该变量后,electron-builder 会生成需要与更新 blockmap 和安装包一起发布的频道元数据;未配置的本地构建不会生成该元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。Seed 与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
 

+ 9 - 9
apps/desktop/renderer/plugin-manager.html

@@ -1,33 +1,33 @@
 <!doctype html>
-<html lang="zh-CN">
+<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>桌面插件</title>
+    <title id="page-title"></title>
     <link rel="stylesheet" href="plugin-manager.css">
   </head>
   <body>
     <main>
       <header>
         <div>
-          <h1>桌面插件</h1>
-          <p>插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。</p>
+          <h1 id="title"></h1>
+          <p id="description"></p>
         </div>
-        <button id="refresh" class="quiet" type="button">刷新</button>
+        <button id="refresh" class="quiet" type="button"></button>
       </header>
       <form id="install-form">
-        <label for="package-spec">npm 包</label>
+        <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 type="submit">安装</button>
+          <button id="install" type="submit"></button>
         </div>
       </form>
       <p id="status" role="status" aria-live="polite"></p>
       <section aria-labelledby="installed-heading">
-        <h2 id="installed-heading">已安装</h2>
+        <h2 id="installed-heading"></h2>
         <ul id="plugins"></ul>
-        <p id="empty">还没有安装桌面插件。</p>
+        <p id="empty"></p>
       </section>
     </main>
     <script src="plugin-manager.js"></script>

+ 92 - 74
apps/desktop/renderer/plugin-manager.js

@@ -1,83 +1,101 @@
 const api = window.dshDesktop
-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')
 
-function setBusy(busy, message = '') {
-  for (const control of document.querySelectorAll('button, input')) control.disabled = busy
-  status.textContent = message
-}
+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
 
-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.version
-    identity.append(document.createTextNode(plugin.name), version)
-    const remove = document.createElement('button')
-    remove.type = 'button'
-    remove.textContent = '移除'
-    remove.addEventListener('click', () => void run(
-      () => api.plugins.remove(plugin.name),
-      `正在移除 ${plugin.name}…`,
-    ))
-    const update = document.createElement('button')
-    update.type = 'button'
-    update.textContent = '更新'
-    update.addEventListener('click', () => {
-      const next = window.prompt(`输入 ${plugin.name} 的目标版本`, plugin.version)?.trim()
-      if (next === undefined || next === '' || next === plugin.version) return
-      void run(() => api.plugins.update(plugin.name, next), `正在更新 ${plugin.name}…`)
-    })
-    const actions = document.createElement('span')
-    actions.className = 'package-actions'
-    actions.append(update, remove)
-    item.append(identity, actions)
-    return item
-  }))
-  empty.hidden = plugins.length !== 0
-}
+  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')
 
-async function run(operation, message) {
-  setBusy(true, message)
-  try {
-    await operation()
-    await render()
-    status.textContent = '操作完成,桌面后端已重新启动。'
-  } catch (error) {
-    status.textContent = error instanceof Error ? error.message : String(error)
-  } finally {
-    setBusy(false, status.textContent)
+  function setBusy(busy, statusMessage = '') {
+    for (const control of document.querySelectorAll('button, input')) control.disabled = busy
+    status.textContent = statusMessage
   }
-}
 
-async function load(message, success) {
-  setBusy(true, message)
-  try {
-    await render()
-    status.textContent = success
-  } catch (error) {
-    status.textContent = error instanceof Error ? error.message : String(error)
-  } finally {
-    setBusy(false, status.textContent)
+  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.version
+      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', () => {
+        const next = window.prompt(message('targetVersion', { name: plugin.name }), plugin.version)?.trim()
+        if (next === undefined || next === '' || next === plugin.version) return
+        void run(() => api.plugins.update(plugin.name, next), message('updating', { name: plugin.name }))
+      })
+      const actions = document.createElement('span')
+      actions.className = 'package-actions'
+      actions.append(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 = ''
-  }, `正在安装 ${spec}…`)
-})
-refresh.addEventListener('click', () => void load('正在刷新…', '插件列表已刷新。'))
+  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 }))
+  })
+  refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
+
+  await load(messages.loadingPlugins, '')
+}
 
-void load('正在读取桌面插件…', '')
+void main()

+ 18 - 8
apps/desktop/src/host-process.ts

@@ -46,6 +46,19 @@ function errorOf(reason: unknown, fallback: string): Error {
   return reason instanceof Error ? reason : new Error(fallback)
 }
 
+async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
+  let timer: ReturnType<typeof setTimeout> | undefined
+  const timeout = new Promise<false>((resolve) => {
+    timer = setTimeout(() => { resolve(false) }, milliseconds)
+    timer.unref()
+  })
+  try {
+    return await Promise.race([exit.then(() => true), timeout])
+  } finally {
+    if (timer !== undefined) clearTimeout(timer)
+  }
+}
+
 /** Ready facts reported by one installed dsh child. */
 export interface DesktopHostReady {
   readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
@@ -194,15 +207,12 @@ export class DesktopHostProcess {
     // Closing the parent-owned write end releases the Host's pending Windows pipe read.
     this.requestPipe?.destroy()
     const exited = this.exitPromise ?? Promise.resolve()
-    const wait = (milliseconds: number): Promise<'timeout'> => new Promise((resolve) => {
-      const timer = setTimeout(() => { resolve('timeout') }, milliseconds)
-      timer.unref()
-    })
-    if (await Promise.race([exited.then(() => 'exit' as const), wait(10_000)]) === 'timeout') child.kill('SIGTERM')
-    if (await Promise.race([exited.then(() => 'exit' as const), wait(5_000)]) === 'timeout') {
+    if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
+    if (!await exitsWithin(exited, 5_000)) {
       child.kill('SIGKILL')
-      this.child = undefined
-      throw new Error('dsh desktop host did not stop after termination')
+      if (!await exitsWithin(exited, 5_000)) {
+        throw new Error('dsh desktop host did not exit after SIGKILL')
+      }
     }
     this.child = undefined
     this.requestPipe = undefined

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

@@ -1,9 +1,11 @@
 /** Typed preload operations exposed only by the Electron shell. */
 
 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',
   pluginsList: 'dsh-desktop:plugins-list',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
@@ -23,6 +25,7 @@ export interface DesktopUpdateState {
 /** 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>

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

@@ -0,0 +1,97 @@
+/** Typed English and Chinese copy owned by the Electron shell. */
+
+export const en = {
+  application: 'Application',
+  startupFailed: 'DeepSeek Harness could not start',
+  pluginsMenu: 'Desktop Plugins…',
+  pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)',
+  checkUpdatesMenu: 'Check for Updates…',
+  updateCheckFailedTitle: 'Update Check Failed',
+  unknownError: 'Unknown error',
+  updateCheckTitle: 'Check for Updates',
+  updateCurrent: 'You already have the latest version.',
+  updateTitle: 'DeepSeek Harness Update',
+  updateAvailable: 'An update is available',
+  updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.',
+  installAndRestart: 'Install and Restart',
+  later: 'Later',
+  updateFailedTitle: 'Update Failed',
+  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',
+  npmPackage: 'npm package',
+  install: 'Install',
+  installed: 'Installed',
+  noPlugins: 'No Desktop plugins are installed.',
+  remove: 'Remove',
+  update: 'Update',
+  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. */
+export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
+
+export const zh = {
+  application: '应用',
+  startupFailed: 'DeepSeek Harness 无法启动',
+  pluginsMenu: '桌面插件…',
+  pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)',
+  checkUpdatesMenu: '检查更新…',
+  updateCheckFailedTitle: '更新检查失败',
+  unknownError: '未知错误',
+  updateCheckTitle: '检查更新',
+  updateCurrent: '当前已是最新版本。',
+  updateTitle: 'DeepSeek Harness 更新',
+  updateAvailable: '发现可用更新',
+  updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。',
+  installAndRestart: '安装并重启',
+  later: '稍后',
+  updateFailedTitle: '更新失败',
+  pluginManagerTitle: '桌面插件',
+  pluginWindowTitle: 'DeepSeek Harness — 桌面插件',
+  pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。',
+  refresh: '刷新',
+  npmPackage: 'npm 包',
+  install: '安装',
+  installed: '已安装',
+  noPlugins: '还没有安装桌面插件。',
+  remove: '移除',
+  update: '更新',
+  targetVersion: '输入 {name} 的目标版本',
+  removing: '正在移除 {name}…',
+  updating: '正在更新 {name}…',
+  installing: '正在安装 {spec}…',
+  operationComplete: '操作完成,桌面后端已重新启动。',
+  refreshing: '正在刷新…',
+  refreshed: '插件列表已刷新。',
+  loadingPlugins: '正在读取桌面插件…',
+} as const satisfies DesktopMessages
+
+/** Locale payload exposed to the Desktop-owned renderer. */
+export interface DesktopLocale {
+  readonly id: 'en' | 'zh-CN'
+  readonly messages: DesktopMessages
+}
+
+/** Resolve Electron's locale to one shipped Desktop dictionary. */
+export function resolveDesktopLocale(locale: string): DesktopLocale {
+  return locale.toLowerCase().startsWith('zh')
+    ? { id: 'zh-CN', messages: zh }
+    : { id: 'en', messages: en }
+}
+
+/** Replace named placeholders in one locale-owned message. */
+export function formatDesktopMessage(
+  message: string,
+  values: Readonly<Record<string, string>>,
+): string {
+  return message.replaceAll(/\{([^{}]+)\}/gu, (placeholder, key: string) => values[key] ?? placeholder)
+}

+ 94 - 24
apps/desktop/src/main.ts

@@ -16,9 +16,16 @@ import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
 import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
+import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
+import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
 
 const SCHEME = 'dsh-app'
+let focusPrimaryWindow = (): void => {}
+
+function errorOf(reason: unknown, fallback: string): Error {
+  return reason instanceof Error ? reason : new Error(fallback)
+}
 
 protocol.registerSchemesAsPrivileged([{
   scheme: SCHEME,
@@ -136,6 +143,8 @@ async function main(): Promise<void> {
   let pluginWindow: BrowserWindow | undefined
   let shellInstallerOwnsQuit = false
   let updateState: DesktopUpdateState = { phase: 'idle' }
+  const locale = resolveDesktopLocale(app.getLocale())
+  const messages = locale.messages
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
 
@@ -154,8 +163,34 @@ async function main(): Promise<void> {
   }
   const hooks: DesktopProjectHooks = {
     healthCheck: async (projectDir) => {
-      const probe = await startHost(projectDir)
-      await probe.stop()
+      const active = host
+      host = undefined
+      await active?.stop()
+      let healthFailure: unknown
+      let probe: DesktopHostProcess | undefined
+      try {
+        probe = await startHost(projectDir)
+        await probe.stop()
+      } catch (error) {
+        healthFailure = error
+        await probe?.stop().catch(() => undefined)
+      }
+      let restartFailure: unknown
+      if (active !== undefined) {
+        try {
+          host = await startHost()
+        } catch (error) {
+          restartFailure = error
+        }
+      }
+      if (healthFailure !== undefined && restartFailure !== undefined) {
+        throw new AggregateError([
+          errorOf(healthFailure, 'desktop project: staged health check failed'),
+          errorOf(restartFailure, 'desktop project: active backend restart failed'),
+        ], 'desktop project: staged health check and active backend restart failed')
+      }
+      if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed')
+      if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed')
     },
     beforeActivate: async () => {
       const active = host
@@ -201,8 +236,12 @@ async function main(): Promise<void> {
       throw new Error('dsh desktop: plugin package changes require a packaged application')
     }
     await manager.mutate(mutation, hooks)
-    mainWindow?.webContents.reload()
+    if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload()
   }
+  ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
+    assertDesktopSender(event, ['shell'])
+    return locale
+  })
   ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => {
     assertDesktopSender(event, ['shell'])
     if (development !== undefined) return []
@@ -234,26 +273,42 @@ async function main(): Promise<void> {
   const checkAndPrompt = async (manual: boolean): Promise<void> => {
     const state = await updates.check()
     if (state.phase === 'error') {
-      if (manual) await dialog.showMessageBox({ type: 'error', title: '更新检查失败', message: state.message ?? '未知错误' })
+      if (manual) {
+        await dialog.showMessageBox({
+          type: 'error',
+          title: messages.updateCheckFailedTitle,
+          message: state.message ?? messages.unknownError,
+        })
+      }
       return
     }
     if (state.phase !== 'available') {
-      if (manual) await dialog.showMessageBox({ type: 'info', title: '检查更新', message: state.message ?? '当前已是最新版本。' })
+      if (manual) {
+        await dialog.showMessageBox({
+          type: 'info',
+          title: messages.updateCheckTitle,
+          message: state.message ?? messages.updateCurrent,
+        })
+      }
       return
     }
     const result = await dialog.showMessageBox({
       type: 'info',
-      title: 'DeepSeek Harness 更新',
-      message: '发现可用更新',
-      detail: `DeepSeek Harness ${state.version ?? ''}\n\n新版本绑定匹配的 dsh,安装后将重新启动。`,
-      buttons: ['安装并重启', '稍后'],
+      title: messages.updateTitle,
+      message: messages.updateAvailable,
+      detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
+      buttons: [messages.installAndRestart, messages.later],
       defaultId: 0,
       cancelId: 1,
     })
     if (result.response !== 0) return
     const installed = await updates.install()
     if (installed.phase === 'error') {
-      await dialog.showMessageBox({ type: 'error', title: '更新失败', message: installed.message ?? '未知错误' })
+      await dialog.showMessageBox({
+        type: 'error',
+        title: messages.updateFailedTitle,
+        message: installed.message ?? messages.unknownError,
+      })
     }
   }
 
@@ -264,30 +319,47 @@ async function main(): Promise<void> {
     }
     pluginWindow = createWindow(managementPreload)
     pluginWindow.setSize(900, 620)
-    pluginWindow.setTitle('DeepSeek Harness 桌面插件')
+    pluginWindow.setTitle(messages.pluginWindowTitle)
     pluginWindow.once('ready-to-show', () => { pluginWindow?.show() })
     pluginWindow.once('closed', () => { pluginWindow = undefined })
     void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
   }
 
   Menu.setApplicationMenu(Menu.buildFromTemplate([{
-    label: process.platform === 'darwin' ? app.name : '应用',
+    label: process.platform === 'darwin' ? app.name : messages.application,
     submenu: [
       {
-        label: development === undefined ? '桌面插件…' : '桌面插件…(打包应用中可用)',
+        label: development === undefined ? messages.pluginsMenu : messages.pluginsMenuPackagedOnly,
         accelerator: 'CmdOrCtrl+,',
         enabled: development === undefined,
         click: openPluginWindow,
       },
-      { label: '检查更新…', click: () => { void checkAndPrompt(true) } },
+      { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
       { type: 'separator' },
       { role: 'quit' },
     ],
   }]))
 
-  mainWindow = createWindow(appPreload)
-  mainWindow.once('ready-to-show', () => { mainWindow?.show() })
-  mainWindow.on('closed', () => { mainWindow = undefined })
+  const createMainWindow = (): BrowserWindow => {
+    const window = createWindow(appPreload)
+    mainWindow = window
+    window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
+    window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
+    return window
+  }
+  focusPrimaryWindow = () => {
+    const window = mainWindow
+    if (window === undefined || window.isDestroyed()) {
+      const replacement = createMainWindow()
+      void replacement.loadURL(`${SCHEME}://app/index.html`)
+      return
+    }
+    if (window.isMinimized()) window.restore()
+    window.show()
+    window.focus()
+  }
+
+  mainWindow = createMainWindow()
   await mainWindow.loadURL(`${SCHEME}://app/index.html`)
   if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
     mainWindow.webContents.openDevTools({ mode: 'detach' })
@@ -296,11 +368,7 @@ async function main(): Promise<void> {
   setTimeout(() => { void checkAndPrompt(false) }, 10_000)
 
   app.on('activate', () => {
-    if (BrowserWindow.getAllWindows().length === 0) {
-      mainWindow = createWindow(appPreload)
-      mainWindow.once('ready-to-show', () => { mainWindow?.show() })
-      void mainWindow.loadURL(`${SCHEME}://app/index.html`)
-    }
+    if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
   })
   app.on('window-all-closed', () => {
     if (process.platform !== 'darwin') app.quit()
@@ -315,13 +383,15 @@ async function main(): Promise<void> {
   })
 }
 
-void app.whenReady().then(main).catch(async (error: unknown) => {
+const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })
+
+if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unknown) => {
   const message = error instanceof Error ? error.message : String(error)
   console.error(error)
   const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE
   if (diagnosticFile !== undefined) {
     await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
   }
-  dialog.showErrorBox('DeepSeek Harness 无法启动', message)
+  dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message)
   app.exit(1)
 })

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

@@ -5,6 +5,7 @@ import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.
 
 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>,

+ 62 - 30
apps/desktop/src/project-manager.ts

@@ -7,6 +7,8 @@ import {
   copyFileSync,
   cpSync,
   existsSync,
+  fsyncSync,
+  ftruncateSync,
   lstatSync,
   mkdirSync,
   openSync,
@@ -17,6 +19,7 @@ import {
   rmSync,
   unlinkSync,
   writeFileSync,
+  writeSync,
 } from 'node:fs'
 import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
 import {
@@ -75,7 +78,7 @@ export interface DesktopRuntimeExecutables {
 
 /** Hooks that bind project replacement to backend lifecycle and health. */
 export interface DesktopProjectHooks {
-  /** Prove the staged dependency graph before the active backend stops. */
+  /** Prove the staged dependency graph while the active backend is stopped. */
   healthCheck(projectDir: string): Promise<void>
   /** Stop the active backend and await process exit before directory moves. */
   beforeActivate(): Promise<void>
@@ -105,6 +108,10 @@ const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u
 const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024
 const DESKTOP_REGISTRY = 'https://registry.npmjs.org/'
 
+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 })
 }
@@ -319,6 +326,8 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin
 
 /** Transactional desktop npm project manager. */
 export class DesktopProjectManager {
+  private lockDescriptor: number | undefined
+
   /**
    * @param paths - Electron-owned package state and reserved desktop profile paths.
    * @param runtime - absolute bundled Node.js and pnpm entry paths.
@@ -344,26 +353,11 @@ export class DesktopProjectManager {
       stagingProfile: value.stagingProfile,
       step: value.step,
     }
-    switch (pending.step) {
-      case 'prepared':
-        removeOwnedDirectory(pending.stagingProfile)
-        break
-      case 'active-moved':
-        if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) {
-          mkdirSync(dirname(this.paths.profile), { recursive: true })
-          renameSync(this.paths.rollback, this.paths.profile)
-        }
-        removeOwnedDirectory(pending.stagingProfile)
-        break
-      case 'staging-activated':
-        if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) {
-          mkdirSync(dirname(this.paths.profile), { recursive: true })
-          renameSync(this.paths.rollback, this.paths.profile)
-        }
-        break
-      default:
-        pending.step satisfies never
+    if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) {
+      mkdirSync(dirname(this.paths.profile), { recursive: true })
+      renameSync(this.paths.rollback, this.paths.profile)
     }
+    removeOwnedDirectory(pending.stagingProfile)
     unlinkSync(this.paths.pending)
   }
 
@@ -529,14 +523,14 @@ export class DesktopProjectManager {
     try {
       removeOwnedDirectory(this.paths.rollback)
       mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 })
+      writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction)
       if (existsSync(this.paths.profile)) {
         renameSync(this.paths.profile, this.paths.rollback)
         activeMoved = true
       }
-      writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction)
       mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 })
-      renameSync(stagingProfile, this.paths.profile)
       writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction)
+      renameSync(stagingProfile, this.paths.profile)
       await hooks.afterActivate()
       unlinkSync(this.paths.pending)
     } catch (error) {
@@ -585,7 +579,21 @@ export class DesktopProjectManager {
         },
         stdio: ['ignore', 'pipe', 'pipe'],
       })
+      const childPid = child.pid
+      if (childPid === undefined) {
+        child.kill('SIGKILL')
+        reject(new Error('desktop project: pnpm did not report a process id'))
+        return
+      }
+      try {
+        this.writeLockOwner(childPid)
+      } catch (error) {
+        child.kill('SIGKILL')
+        reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm'))
+        return
+      }
       let diagnostics = ''
+      let completed = false
       const appendDiagnostics = (chunk: string): void => {
         diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES)
       }
@@ -593,19 +601,41 @@ export class DesktopProjectManager {
       child.stdout.on('data', appendDiagnostics)
       child.stderr.setEncoding('utf8')
       child.stderr.on('data', appendDiagnostics)
-      child.once('error', reject)
-      child.once('close', (code, signal) => {
-        if (code === 0) {
-          settle()
+      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
         }
-        reject(new Error(
-          `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`,
-        ))
+        settleChild()
+      }
+      child.once('error', (error) => { complete(() => { reject(error) }) })
+      child.once('close', (code, signal) => {
+        complete(() => {
+          if (code === 0) {
+            settle()
+            return
+          }
+          reject(new Error(
+            `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`,
+          ))
+        })
       })
     })
   }
 
+  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: () => Promise<T>): Promise<T> {
     mkdirSync(this.paths.root, { recursive: true, mode: 0o700 })
     let descriptor: number
@@ -635,9 +665,11 @@ export class DesktopProjectManager {
       }
     }
     try {
-      writeFileSync(descriptor, `${String(process.pid)}\n`)
+      this.lockDescriptor = descriptor
+      this.writeLockOwner(process.pid)
       return await operation()
     } finally {
+      this.lockDescriptor = undefined
       closeSync(descriptor)
       unlinkSync(this.paths.lock)
     }

+ 26 - 0
apps/desktop/src/single-instance.ts

@@ -0,0 +1,26 @@
+/** Electron single-instance ownership before any Desktop profile lifecycle begins. */
+
+/** Minimal Electron application operations needed for instance ownership. */
+export interface DesktopSingleInstanceApplication {
+  requestSingleInstanceLock(): boolean
+  quit(): void
+  on(event: 'second-instance', listener: () => void): unknown
+}
+
+/**
+ * Claim the process-lifetime Desktop lock and route later launches to the owner.
+ * @param application - Electron application singleton.
+ * @param focusOwner - focus or recreate the primary window after a later launch.
+ * @returns true only in the process that may access the Desktop profile.
+ */
+export function claimDesktopSingleInstance(
+  application: DesktopSingleInstanceApplication,
+  focusOwner: () => void,
+): boolean {
+  if (!application.requestSingleInstanceLock()) {
+    application.quit()
+    return false
+  }
+  application.on('second-instance', focusOwner)
+  return true
+}

+ 15 - 10
apps/desktop/src/update-coordinator.ts

@@ -10,7 +10,8 @@ const { autoUpdater } = electronUpdater
 /** Checks, downloads, and installs one complete Desktop release. */
 export class DesktopUpdateCoordinator {
   private availableVersion: string | undefined
-  private operation: Promise<DesktopUpdateState> | undefined
+  private checkOperation: Promise<DesktopUpdateState> | undefined
+  private installOperation: Promise<DesktopUpdateState> | undefined
 
   /**
    * @param publish - state sink for every desktop window.
@@ -32,16 +33,20 @@ export class DesktopUpdateCoordinator {
 
   /** Check the configured Desktop release stream and retain an available version. */
   async check(): Promise<DesktopUpdateState> {
-    if (this.operation !== undefined) return this.operation
-    this.operation = this.doCheck().finally(() => { this.operation = undefined })
-    return this.operation
+    if (this.installOperation !== undefined) return this.installOperation
+    if (this.checkOperation !== undefined) return this.checkOperation
+    this.checkOperation = this.doCheck().finally(() => { this.checkOperation = undefined })
+    return this.checkOperation
   }
 
-  /** Download and install the retained Desktop release. */
+  /** Wait for an in-flight check, then download and install its retained release. */
   async install(): Promise<DesktopUpdateState> {
-    if (this.operation !== undefined) return this.operation
-    this.operation = this.doInstall().finally(() => { this.operation = undefined })
-    return this.operation
+    if (this.installOperation !== undefined) return this.installOperation
+    this.installOperation = (async () => {
+      await this.checkOperation
+      return this.doInstall()
+    })().finally(() => { this.installOperation = undefined })
+    return this.installOperation
   }
 
   private async doCheck(): Promise<DesktopUpdateState> {
@@ -49,13 +54,13 @@ export class DesktopUpdateCoordinator {
     try {
       if (!this.enabled()) {
         this.availableVersion = undefined
-        return this.publish({ phase: 'idle', message: '当前已是最新版本。' })
+        return this.publish({ phase: 'idle' })
       }
       const result = await this.updater.checkForUpdates()
       const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined
       this.availableVersion = version
       return version === undefined
-        ? this.publish({ phase: 'idle', message: '当前已是最新版本。' })
+        ? this.publish({ phase: 'idle' })
         : this.publish({ phase: 'available', version })
     } catch (error) {
       this.availableVersion = undefined

+ 23 - 0
apps/desktop/tests/locale.spec.ts

@@ -0,0 +1,23 @@
+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 })
+  })
+
+  it('formats named values without consuming unknown placeholders', () => {
+    expect(formatDesktopMessage('{name}@{version} {missing}', { name: 'plugin', version: '1.2.3' }))
+      .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([])
+  })
+})

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

@@ -1,7 +1,7 @@
 import { createHash } from 'node:crypto'
-import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join, relative, sep } from 'node:path'
+import { dirname, join, relative, sep } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { resolveDesktopPaths } from '../src/paths.ts'
 import {
@@ -91,6 +91,7 @@ const packageVersion = spec => {
   const index = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@')
   return index === -1 ? '1.0.0' : spec.slice(index + 1)
 }
+
 if (command === 'add') {
   const spec = args[args.indexOf('add') + 1]
   manifest.dependencies[packageName(spec)] = packageVersion(spec)
@@ -121,6 +122,19 @@ if (process.env.TEST_PNPM_LOG) writeFileSync(process.env.TEST_PNPM_LOG, JSON.str
   return path
 }
 
+function writeBlockingFakePnpm(root: string, ready: string, release: string): string {
+  const path = join(root, 'blocking-pnpm.mjs')
+  const delegate = writeFakePnpm(root)
+  writeFileSync(path, `
+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(delegate)})
+`)
+  return path
+}
+
 function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
   return {
     healthCheck: async () => {},
@@ -230,6 +244,60 @@ describe('desktop project transactions', () => {
     expect(starts).toBe(2)
   })
 
+  it('restores rollback when the active move completed before its journal update', async () => {
+    const root = temporaryRoot()
+    const seed = join(root, 'seed')
+    createTestSeedMetadata(seed, release())
+    writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
+    archiveStore(seed)
+    writeIntegrity(seed)
+    const paths = resolveDesktopPaths(join(root, '.dsh'))
+    const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
+    await manager.applyRelease(seed, '1.0.0', hooks())
+    await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
+    const stagingProfile = join(paths.staging, 'interrupted', 'profile')
+    mkdirSync(stagingProfile, { recursive: true })
+    writeFileSync(join(stagingProfile, 'marker'), 'staging')
+    rmSync(paths.rollback, { recursive: true, force: true })
+    mkdirSync(dirname(paths.rollback), { recursive: true })
+    renameSync(paths.profile, paths.rollback)
+    writeFileSync(paths.pending, `${JSON.stringify({
+      schemaVersion: 1,
+      id: 'interrupted',
+      stagingProfile,
+      step: 'prepared',
+    })}\n`)
+
+    manager.recover()
+
+    expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }])
+    expect(existsSync(stagingProfile)).toBe(false)
+    expect(existsSync(paths.pending)).toBe(false)
+  })
+
+  it('records the live pnpm worker as transaction owner until it exits', async () => {
+    const root = temporaryRoot()
+    const seed = join(root, 'seed')
+    const ready = join(root, 'pnpm-ready')
+    const releaseWorker = join(root, 'pnpm-release')
+    createTestSeedMetadata(seed, release())
+    writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
+    archiveStore(seed)
+    writeIntegrity(seed)
+    const paths = resolveDesktopPaths(join(root, '.dsh'))
+    const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) }
+    const manager = new DesktopProjectManager(paths, runtime)
+    const installing = manager.applyRelease(seed, '1.0.0', hooks())
+    await expect.poll(() => existsSync(ready)).toBe(true)
+    const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10)
+    expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`)
+    const competing = new DesktopProjectManager(paths, runtime)
+    await expect(competing.applyRelease(seed, '1.0.0', hooks())).rejects.toThrow(/another package transaction is active/u)
+    writeFileSync(releaseWorker, 'continue')
+    await expect(installing).resolves.toBe(true)
+    expect(existsSync(paths.lock)).toBe(false)
+  })
+
   it('keeps core packages local while installing plugins from the desktop registry', async () => {
     const root = temporaryRoot()
     const seed = join(root, 'seed')

+ 32 - 0
apps/desktop/tests/single-instance.spec.ts

@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest'
+import { claimDesktopSingleInstance, type DesktopSingleInstanceApplication } from '../src/single-instance.ts'
+
+describe('desktop single-instance ownership', () => {
+  it('quits a second process without registering lifecycle work', () => {
+    const quit = vi.fn()
+    const on = vi.fn()
+    const application = {
+      requestSingleInstanceLock: () => false,
+      quit,
+      on,
+    } satisfies DesktopSingleInstanceApplication
+
+    expect(claimDesktopSingleInstance(application, vi.fn())).toBe(false)
+    expect(quit).toHaveBeenCalledOnce()
+    expect(on).not.toHaveBeenCalled()
+  })
+
+  it('routes a later launch to the primary process', () => {
+    let secondInstance: (() => void) | undefined
+    const focus = vi.fn()
+    const application = {
+      requestSingleInstanceLock: () => true,
+      quit: vi.fn(),
+      on: vi.fn((_event: 'second-instance', listener: () => void) => { secondInstance = listener }),
+    } satisfies DesktopSingleInstanceApplication
+
+    expect(claimDesktopSingleInstance(application, focus)).toBe(true)
+    secondInstance?.()
+    expect(focus).toHaveBeenCalledOnce()
+  })
+})

+ 25 - 0
apps/desktop/tests/update-coordinator.spec.ts

@@ -74,4 +74,29 @@ describe('desktop update coordinator', () => {
     expect(quitAndInstall).toHaveBeenCalledWith(false, true)
     expect(states.map(state => state.phase)).toEqual(['checking', 'available', 'installing', 'ready'])
   })
+
+  it('queues install behind an in-flight check instead of returning the check result', async () => {
+    const checked = Promise.withResolvers<{
+      isUpdateAvailable: true
+      updateInfo: { version: string }
+    }>()
+    const downloadUpdate = vi.fn(async () => [])
+    const updater = {
+      autoDownload: true,
+      autoInstallOnAppQuit: true,
+      checkForUpdates: vi.fn(() => checked.promise),
+      downloadUpdate,
+      quitAndInstall: vi.fn(),
+    } as unknown as AppUpdater
+    const coordinator = new DesktopUpdateCoordinator(state => state, async () => {}, updater, () => true)
+
+    const checking = coordinator.check()
+    const installing = coordinator.install()
+    expect(downloadUpdate).not.toHaveBeenCalled()
+    checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } })
+
+    await expect(checking).resolves.toEqual({ phase: 'available', version: '1.2.0' })
+    await expect(installing).resolves.toEqual({ phase: 'ready', version: '1.2.0' })
+    expect(downloadUpdate).toHaveBeenCalledOnce()
+  })
 })

+ 6 - 1
scripts/check-workspace-constraints.ts

@@ -58,7 +58,12 @@ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|app
 const desktopApplicationDirectory = 'apps/desktop'
 const localArtifactDirs = new Set(['node_modules'])
 const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
-  '@deepseek-ai/dsh': ['lib/*.js', 'config/desktop.cordis.patch.yml'],
+  '@deepseek-ai/dsh': [
+    'lib/*.js',
+    'lib/types/desktop-host.d.ts',
+    'lib/types/desktop-host-wire.d.ts',
+    'config/desktop.cordis.patch.yml',
+  ],
   // Sourcemaps stay out by payload policy; the worker-preview surface
   // (dist/preview.html and dist/preview/) backs private experimental
   // packages and is not published.

+ 16 - 0
scripts/verify-client-ui-i18n.spec.ts

@@ -62,4 +62,20 @@ describe('Client UI i18n source check', () => {
       'export const en = { title: "Hard-coded by design" }',
     )).toEqual([])
   })
+
+  it('rejects Electron dialog, title, prompt, and DOM copy outside locale owners', () => {
+    const source = `
+      dialog.showMessageBox({ title: 'Update available', message: 'Install it now?' })
+      window.setTitle('Desktop plugins')
+      window.prompt('Target version')
+      status.textContent = 'Finished'
+    `
+    expect(findUiI18nViolations('apps/desktop/src/main.ts', source).map(row => row.text)).toEqual([
+      'Update available',
+      'Install it now?',
+      'Desktop plugins',
+      'Target version',
+      'Finished',
+    ])
+  })
 })

+ 18 - 2
scripts/verify-client-ui-i18n.ts

@@ -111,7 +111,7 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi
     sourceText,
     ts.ScriptTarget.Latest,
     true,
-    file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
+    file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS,
   )
   const violations = new Map<number, UiI18nViolation>()
 
@@ -251,13 +251,27 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi
       && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))
     ) collectExpression(node.expression, 'JSX child')
 
-    if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) {
+    if ((file.endsWith('.tsx') || file.startsWith('apps/desktop/')) && ts.isPropertyAssignment(node)) {
       const name = propertyName(node.name)
       if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
         collectExpression(node.initializer, `${name} property`)
       }
     }
 
+    if (file.startsWith('apps/desktop/') && ts.isBinaryExpression(node)
+      && node.operatorToken.kind === ts.SyntaxKind.EqualsToken
+      && ts.isPropertyAccessExpression(node.left)
+      && (node.left.name.text === 'textContent' || node.left.name.text === 'innerText')) {
+      collectExpression(node.right, `${node.left.name.text} assignment`)
+    }
+
+    if (file.startsWith('apps/desktop/') && ts.isCallExpression(node)
+      && ts.isPropertyAccessExpression(node.expression)
+      && (node.expression.name.text === 'setTitle' || node.expression.name.text === 'prompt')) {
+      const copy = node.arguments[0]
+      if (copy !== undefined) collectExpression(copy, `${node.expression.name.text} argument`)
+    }
+
     if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
       const name = propertyName(node.name)
       if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
@@ -311,6 +325,8 @@ function sourceFiles(): string[] {
     ...[...clientComponentRoots].flatMap(clientRoot =>
       globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })),
     ...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }),
+    ...globSync('apps/desktop/src/{main,update-coordinator}.{ts,tsx}', { cwd: root }),
+    ...globSync('apps/desktop/renderer/*.js', { cwd: root }),
   ])]
     .map(file => file.replaceAll('\\', '/'))
     .filter(file => !file.endsWith('.d.ts'))