mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
refactor(desktop): remove standalone plugin manager
This commit is contained in:
1 parent
7e392f7616
commit
4b6474133a
46 files changed
+178
-1321
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 直接修改 Desktop profile
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-17
|
||||
|
||||
[English](2026-09-09-desktop-in-place-profile.md) | 中文
|
||||
|
||||
@@ -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",
|
||||
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
|
||||
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
|
||||
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",
|
||||
|
||||
+2
-2
@@ -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: c532db9c3078a8c0cc67935f88457bf06b850141
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: 216f3d122cb22d4af9b31371301bc9bb36586233
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 08d3246409ab4321d7c86e6b7feee46e727afeba
|
||||
+3
-3
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-08-25-electron-desktop-packaging-and-updates.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.
|
||||
|
||||
@@ -59,9 +59,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
|
||||
|
||||
## Installation and resolution
|
||||
|
||||
Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md).
|
||||
The shared Web plugin manager owns profile package operations. Electron retains startup preparation and native recovery; the [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns this separation.
|
||||
|
||||
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 package transaction; after that worker exits, the stale PID can be recovered.
|
||||
The process-lifetime Electron lock is the authoritative Desktop owner. Profile preparation and native recovery use an exclusive lock recording the Electron PID; a live owner blocks a competing operation, while a stale PID can be recovered. Web package operations use the shared plugin manager’s locking.
|
||||
|
||||
Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts.
|
||||
|
||||
|
||||
+3
-3
@@ -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 可执行文件的选择;本文其他决策仍然适用。
|
||||
|
||||
@@ -59,9 +59,9 @@ 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` 代码的桌面插件。
|
||||
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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: a61f3d185056e0e0599cef9eaa72574aeb4b4635
|
||||
2026-09-09-desktop-immediate-window-and-direct-start.zh.md: f6d984ac05f1ea627f6e750360d6cdcb52ea9620
|
||||
+3
-3
@@ -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,7 +16,7 @@ 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.
|
||||
|
||||
@@ -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.
|
||||
+3
-3
@@ -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,7 +16,7 @@ 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 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
|
||||
|
||||
@@ -30,4 +30,4 @@ Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-
|
||||
|
||||
用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。
|
||||
|
||||
验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。
|
||||
验证覆盖 Host 延迟时可见的加载页、新 profile 仅启动一次服务、同一窗口中的失败与重试、原生恢复,以及子进程启动时关闭。安装后 GUI 证据补充生命周期测试。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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: a9c26ce20840a73ceb9d3cd9c6206d2cf818c43f
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: 00ed91ebfa88463ea961cdcdfda311f4b585774a
|
||||
@@ -16,7 +16,7 @@ The first report claims presentation before awaiting the dialog. Later reports r
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Electron 在每个应用进程中提供一次原生致命错误对话框。明
|
||||
|
||||
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,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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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: 7763152e43cd44a588883b3e346bcaf69e453a40
|
||||
README.zh.md: a0758e0c5cc34c5cfd9883d54b490a9eab6f899f
|
||||
README.md: 42ceae1056acfaa747e576961b622e09cbfe42af
|
||||
README.zh.md: efd8c8914b11c379afad2204040677801ddf4326
|
||||
+10
-10
@@ -34,8 +34,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.
|
||||
@@ -44,13 +44,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. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
|
||||
The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Plugin management uses the Web application’s authenticated HTTP APIs; Electron exposes no plugin-management IPC or separate management document.
|
||||
|
||||
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. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus and native dialogs use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
|
||||
|
||||
Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
|
||||
Electron's application menu provides update checks and Quit. Plugin management uses the main application's Plugins page. The native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
|
||||
|
||||
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). Windows and Linux keep the application and Edit menus.
|
||||
|
||||
@@ -61,18 +61,18 @@ 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.
|
||||
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. There is no startup timeout heuristic.
|
||||
|
||||
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.
|
||||
Electron serializes profile preparation and native recovery through `$DSH_HOME/profiles/desktop/lock`. Web plugin operations use the shared manager’s package lock and lifecycle.
|
||||
|
||||
## Develop
|
||||
|
||||
|
||||
+10
-10
@@ -34,8 +34,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)负责发布身份、签名及更新验收。
|
||||
@@ -44,13 +44,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 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
|
||||
应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。
|
||||
|
||||
产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单、原生对话框和插件管理渲染器使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单和原生对话框使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。
|
||||
|
||||
Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
|
||||
Electron 应用菜单提供检查更新和退出操作。插件管理使用主应用的“插件”页面。原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
|
||||
|
||||
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Windows 和 Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
@@ -61,18 +61,18 @@ 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 启动失败都会进入原生恢复。不通过启动超时推断故障。
|
||||
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求不会触发恢复。不通过启动超时推断故障。
|
||||
|
||||
原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
|
||||
Electron 通过 `$DSH_HOME/profiles/desktop/lock` 串行执行 profile 准备和原生恢复。Web 插件操作使用共享管理器的包锁和生命周期。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -79,9 +79,8 @@ export function createElectronBuilderConfig(
|
||||
return true
|
||||
},
|
||||
files: [
|
||||
'lib/*.js',
|
||||
'lib/*.cjs',
|
||||
'renderer/**/*',
|
||||
'lib/main.js',
|
||||
'lib/preload-app.cjs',
|
||||
'package.json',
|
||||
{ from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
|
||||
// electron-builder excludes a source directory's root node_modules.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
+1
-37
@@ -1,52 +1,16 @@
|
||||
/** 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',
|
||||
nativeThemeSet: 'dsh-desktop:native-theme-set',
|
||||
} as const
|
||||
|
||||
/** Desktop release update state rendered by desktop-owned UI. */
|
||||
export interface DesktopUpdateState {
|
||||
readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error'
|
||||
readonly version?: string
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
/** 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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,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',
|
||||
unknownError: 'Unknown error',
|
||||
@@ -22,29 +21,6 @@ export const en = {
|
||||
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',
|
||||
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. */
|
||||
@@ -60,7 +36,6 @@ export const zh = {
|
||||
restartApplication: '重启',
|
||||
recoveryOperationFailed: '恢复操作失败',
|
||||
disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
|
||||
pluginsMenu: '桌面插件…',
|
||||
checkUpdatesMenu: '检查更新…',
|
||||
updateCheckFailedTitle: '更新检查失败',
|
||||
unknownError: '未知错误',
|
||||
@@ -72,42 +47,13 @@ export const zh = {
|
||||
installAndRestart: '安装并重启',
|
||||
later: '稍后',
|
||||
updateFailedTitle: '更新失败',
|
||||
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. */
|
||||
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 {
|
||||
export function resolveDesktopLocale(locale: string): DesktopMessages {
|
||||
return locale.toLowerCase().startsWith('zh')
|
||||
? { id: 'zh-CN', messages: zh }
|
||||
: { id: 'en', messages: en }
|
||||
? zh
|
||||
: en
|
||||
}
|
||||
|
||||
/** Replace named placeholders in one locale-owned message. */
|
||||
|
||||
+6
-121
@@ -1,7 +1,7 @@
|
||||
/** 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 { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
app,
|
||||
@@ -13,15 +13,14 @@ import {
|
||||
protocol,
|
||||
session,
|
||||
shell,
|
||||
type IpcMainInvokeEvent,
|
||||
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 } from './host-process.ts'
|
||||
import { installDesktopDirectoryPicker } from './directory-picker.ts'
|
||||
import { DesktopBackendController } from './backend-controller.ts'
|
||||
import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
|
||||
import { DESKTOP_IPC, SCHEME, assertDesktopSender } from './ipc.ts'
|
||||
import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
|
||||
import { claimDesktopSingleInstance } from './single-instance.ts'
|
||||
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
|
||||
@@ -32,7 +31,7 @@ let focusPrimaryWindow = (): void => {}
|
||||
let stopForRecovery = async (): Promise<void> => {}
|
||||
let shuttingDown = false
|
||||
const recovery = new DesktopFatalRecovery({
|
||||
messages: () => resolveDesktopLocale(app.getLocale()).messages,
|
||||
messages: () => resolveDesktopLocale(app.getLocale()),
|
||||
show: options => dialog.showMessageBox(options),
|
||||
stop: () => { shuttingDown = true; return stopForRecovery() },
|
||||
disablePlugins: async () => {
|
||||
@@ -62,13 +61,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
|
||||
@@ -159,26 +151,6 @@ function createWindow(preload: string, show = false): BrowserWindow {
|
||||
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 resources = runtimeResources()
|
||||
const paths = resolveDesktopPaths()
|
||||
@@ -188,13 +160,9 @@ async function main(): Promise<void> {
|
||||
let quitting = false
|
||||
let startup: Promise<void> | undefined
|
||||
let mainWindow: BrowserWindow | undefined
|
||||
let pluginWindow: BrowserWindow | undefined
|
||||
let shellInstallerOwnsQuit = false
|
||||
let updateState: DesktopUpdateState = { phase: 'idle' }
|
||||
const locale = resolveDesktopLocale(app.getLocale())
|
||||
const messages = locale.messages
|
||||
const messages = resolveDesktopLocale(app.getLocale())
|
||||
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
|
||||
@@ -235,19 +203,6 @@ async function main(): Promise<void> {
|
||||
if (state.phase === 'error') reportFatal(new Error(state.message))
|
||||
})
|
||||
|
||||
const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
|
||||
updateState = state
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send(DESKTOP_IPC.updatesState, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
const hooks: DesktopProjectHooks = {
|
||||
beforeChange: () => backend.stop(),
|
||||
afterChange: () => backend.start(async () => {}),
|
||||
}
|
||||
|
||||
stopForRecovery = () => backend.close()
|
||||
|
||||
const reconcileBackend = (): Promise<void> => {
|
||||
@@ -265,7 +220,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const updates = new DesktopUpdateCoordinator(
|
||||
publishUpdate,
|
||||
async () => {
|
||||
shellInstallerOwnsQuit = true
|
||||
await backend.stop()
|
||||
@@ -284,7 +238,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 }))
|
||||
})
|
||||
|
||||
@@ -319,60 +272,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'])
|
||||
await startup?.catch(() => undefined)
|
||||
if (recovery.active) throw new Error(messages.fatalSummary)
|
||||
try {
|
||||
await manager.mutate(mutation, hooks)
|
||||
} finally {
|
||||
if (backend.state.phase === 'ready') {
|
||||
navigation = undefined
|
||||
await navigateMain(applicationUrl).catch(reportFatal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
|
||||
assertDesktopSender(event, ['shell'])
|
||||
return locale
|
||||
})
|
||||
// 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'])
|
||||
return updates.check()
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => {
|
||||
assertDesktopSender(event, ['shell'])
|
||||
await updates.install()
|
||||
})
|
||||
|
||||
const checkAndPrompt = async (manual: boolean): Promise<void> => {
|
||||
const state = await updates.check()
|
||||
if (state.phase === 'error') {
|
||||
@@ -415,19 +319,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const openPluginWindow = (): void => {
|
||||
if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) {
|
||||
pluginWindow.focus()
|
||||
return
|
||||
}
|
||||
pluginWindow = createWindow(managementPreload)
|
||||
pluginWindow.setSize(900, 620)
|
||||
pluginWindow.setTitle(messages.pluginWindowTitle)
|
||||
pluginWindow.once('ready-to-show', () => { pluginWindow?.show() })
|
||||
pluginWindow.once('closed', () => { pluginWindow = undefined })
|
||||
void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
|
||||
}
|
||||
|
||||
// A custom application menu replaces Electron's default menu, so macOS needs
|
||||
// its standard menus and application hide commands declared explicitly.
|
||||
const darwin = process.platform === 'darwin'
|
||||
@@ -440,11 +331,6 @@ async function main(): Promise<void> {
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate([{
|
||||
label: darwin ? app.name : messages.application,
|
||||
submenu: [
|
||||
{
|
||||
label: messages.pluginsMenu,
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: openPluginWindow,
|
||||
},
|
||||
{ label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
|
||||
{ type: 'separator' },
|
||||
...hideCommands,
|
||||
@@ -507,7 +393,6 @@ async function main(): Promise<void> {
|
||||
if (mainWindow !== undefined && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
publishUpdate(updateState)
|
||||
setTimeout(() => { void checkAndPrompt(false) }, 10_000)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,10 +1,8 @@
|
||||
/** 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,
|
||||
@@ -15,7 +13,6 @@ import {
|
||||
writeSync,
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { desktopNodeEnvironment } from './node-environment.ts'
|
||||
import {
|
||||
DESKTOP_HOST_PACKAGE,
|
||||
desktopCorePackageOverrides,
|
||||
@@ -23,56 +20,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 })
|
||||
}
|
||||
@@ -99,28 +58,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.
|
||||
@@ -130,145 +78,20 @@ 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: 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 })
|
||||
let descriptor: number
|
||||
@@ -298,11 +121,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(this.paths.lock)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import electronUpdater, { type AppUpdater } from 'electron-updater'
|
||||
import type { DesktopUpdateState } from './ipc.ts'
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
/** Result of checking or installing a Desktop release. */
|
||||
export interface DesktopUpdateState {
|
||||
readonly phase: 'idle' | 'available' | 'ready' | 'error'
|
||||
readonly version?: string
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
/** Checks, downloads, and installs one complete Desktop release. */
|
||||
export class DesktopUpdateCoordinator {
|
||||
private availableVersion: string | undefined
|
||||
@@ -14,13 +20,11 @@ export class DesktopUpdateCoordinator {
|
||||
private installOperation: Promise<DesktopUpdateState> | undefined
|
||||
|
||||
/**
|
||||
* @param publish - state sink for every desktop window.
|
||||
* @param beforeRestart - stop application-owned processes before replacement.
|
||||
* @param updater - Electron artifact updater; replaceable for tests.
|
||||
* @param enabled - whether this packaged process carries updater configuration.
|
||||
*/
|
||||
constructor(
|
||||
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
|
||||
private readonly beforeRestart: () => Promise<void> = async () => {},
|
||||
private readonly updater: AppUpdater = autoUpdater,
|
||||
private readonly enabled: () => boolean = () => (
|
||||
@@ -50,24 +54,23 @@ export class DesktopUpdateCoordinator {
|
||||
}
|
||||
|
||||
private async doCheck(): Promise<DesktopUpdateState> {
|
||||
this.publish({ phase: 'checking' })
|
||||
try {
|
||||
if (!this.enabled()) {
|
||||
this.availableVersion = undefined
|
||||
return this.publish({ phase: 'idle' })
|
||||
return { 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' })
|
||||
: this.publish({ phase: 'available', version })
|
||||
? { phase: 'idle' }
|
||||
: { phase: 'available', version }
|
||||
} catch (error) {
|
||||
this.availableVersion = undefined
|
||||
return this.publish({
|
||||
return {
|
||||
phase: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,20 +79,18 @@ export class DesktopUpdateCoordinator {
|
||||
if (version === undefined) {
|
||||
throw new Error('desktop update: no verified update is available')
|
||||
}
|
||||
this.publish({ phase: 'installing', version })
|
||||
try {
|
||||
await this.updater.downloadUpdate()
|
||||
this.availableVersion = undefined
|
||||
const ready = this.publish({ phase: 'ready', version })
|
||||
await this.beforeRestart()
|
||||
this.updater.quitAndInstall(false, true)
|
||||
return ready
|
||||
return { phase: 'ready', version }
|
||||
} catch (error) {
|
||||
return this.publish({
|
||||
return {
|
||||
phase: 'error',
|
||||
version,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('')
|
||||
|
||||
@@ -7,7 +7,7 @@ function fixture(locale = 'en') {
|
||||
const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>()
|
||||
const stopped = Promise.withResolvers<undefined>()
|
||||
const operations = {
|
||||
messages: () => resolveDesktopLocale(locale).messages,
|
||||
messages: () => resolveDesktopLocale(locale),
|
||||
show: vi.fn((_options: MessageBoxOptions) => choice.promise),
|
||||
stop: vi.fn(() => stopped.promise),
|
||||
disablePlugins: vi.fn(async () => {}),
|
||||
|
||||
@@ -27,6 +27,9 @@ describe('installer preparation preserves application dependencies', () => {
|
||||
try {
|
||||
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
|
||||
const config = createElectronBuilderConfig(env, platform, 'x64')
|
||||
expect(config.files.filter(file => typeof file === 'string')).toEqual([
|
||||
'lib/main.js', 'lib/preload-app.cjs', 'package.json',
|
||||
])
|
||||
const packager = new Packager({ projectDir: tmpdir() })
|
||||
// A foreign source-build target avoids rebuilding modules; the real dependency ownership decision still runs.
|
||||
Object.defineProperties(packager, {
|
||||
|
||||
@@ -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')).toEqual(zh)
|
||||
expect(resolveDesktopLocale('en-US')).toEqual(en)
|
||||
expect(resolveDesktopLocale('fr-FR')).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([])
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,7 @@ const harness = await vi.hoisted(async () => {
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost,
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
popup,
|
||||
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Promise<Response>) => void>(),
|
||||
socketHeaders: vi.fn(),
|
||||
menu: {
|
||||
setApplicationMenu: vi.fn(),
|
||||
@@ -101,7 +102,6 @@ const harness = await vi.hoisted(async () => {
|
||||
},
|
||||
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'
|
||||
@@ -110,7 +110,6 @@ const harness = await vi.hoisted(async () => {
|
||||
get hostStarted() { return hostStarted }, get navigated() { return navigated },
|
||||
get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
|
||||
nextNavigation() { navigated = deferred(); return navigated.promise },
|
||||
nextHostStart() { hostStarted = deferred(); return hostStarted.promise },
|
||||
get pluginsEnabled() { return pluginsEnabled },
|
||||
set pluginsEnabled(value: boolean) { pluginsEnabled = value },
|
||||
reset() {
|
||||
@@ -136,26 +135,13 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
Menu: harness.menu,
|
||||
session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
|
||||
}))
|
||||
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) }))
|
||||
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()
|
||||
}
|
||||
|
||||
},
|
||||
}))
|
||||
@@ -165,7 +151,7 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn
|
||||
function invoke(channel: string, ...args: unknown[]): unknown {
|
||||
const handler = harness.handlers.get(channel)
|
||||
if (handler === undefined) throw new Error(`missing handler ${channel}`)
|
||||
return handler({ senderFrame: { url: channel === DESKTOP_IPC.boot ? 'dsh-app://app/' : 'dsh-app://shell/plugin-manager.html' } }, ...args)
|
||||
return handler({ senderFrame: { url: 'dsh-app://app/' } }, ...args)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -195,6 +181,16 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('desktop main startup', () => {
|
||||
it('exposes application IPC and rejects the removed shell document', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
expect([...harness.handlers.keys()].sort()).toEqual([
|
||||
DESKTOP_IPC.boot, DESKTOP_IPC.bootFailed, DESKTOP_IPC.directoryPick,
|
||||
].sort())
|
||||
const handler = harness.protocolHandle.mock.calls[0]![1]
|
||||
expect((await handler(new Request('dsh-app://shell/plugin-manager.html'))).status).toBe(404)
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32', 'linux'] as const)('limits native titlebar styling to macOS on %s', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
@@ -225,8 +221,8 @@ describe('desktop main startup', () => {
|
||||
: ['Application', 'editMenu'])
|
||||
const application = template[0]!.submenu as MenuItemConstructorOptions[]
|
||||
expect(application.map(describeItem)).toEqual(platform === 'darwin'
|
||||
? [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
|
||||
: [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'quit'])
|
||||
? [en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
|
||||
: [en.checkUpdatesMenu, 'separator', 'quit'])
|
||||
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -375,46 +371,6 @@ describe('desktop main startup', () => {
|
||||
expect(harness.dialog.showMessageBox.mock.calls[0]![0].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, '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, '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].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
|
||||
@@ -545,7 +501,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, 'example-plugin')).rejects.toThrow('The application could not start or stopped unexpectedly.')
|
||||
expect(harness.hosts).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
@@ -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)
|
||||
@@ -11,7 +11,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
|
||||
|
||||
it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => {
|
||||
it.each(['dsh-app://app/index.html', 'dsh-app://unowned/index.html'])('exposes the carrier marker to %s', async (url) => {
|
||||
vi.stubGlobal('location', new URL(url))
|
||||
await import('../src/preload-app.ts')
|
||||
expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
|
||||
@@ -39,7 +39,7 @@ it('exposes a directory picker only to the local application document', async ()
|
||||
electron.ipcRenderer.invoke.mockResolvedValue('/workspace')
|
||||
await expect(api.pick()).resolves.toBe('/workspace')
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick)
|
||||
for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) {
|
||||
for (const url of ['dsh-app://unowned/index.html', 'https://example.com/']) {
|
||||
vi.resetModules()
|
||||
electron.contextBridge.exposeInMainWorld.mockClear()
|
||||
vi.stubGlobal('location', new URL(url))
|
||||
|
||||
@@ -1,73 +1,45 @@
|
||||
import { existsSync, 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 { afterEach, describe, expect, it } 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'
|
||||
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[] } }
|
||||
}
|
||||
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')
|
||||
}
|
||||
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 }) }
|
||||
return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { 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 }) : []
|
||||
}
|
||||
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,44 @@ 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) => {
|
||||
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)
|
||||
})
|
||||
|
||||
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('retains both package and restart errors when the changed profile cannot start', async () => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
const failingPnpm = join(root, 'failing-restart.mjs')
|
||||
writeFileSync(failingPnpm, 'process.exitCode = 1')
|
||||
const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
|
||||
await worker.applyRelease()
|
||||
const afterChange = vi.fn(async () => { throw new Error('Host initialization failed') })
|
||||
const failure = await worker.mutate({ type: 'plugin-add', spec: 'missing' }, hooks({ afterChange }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect((failure as AggregateError).errors.map((error: Error) => error.message))
|
||||
.toEqual([expect.stringContaining('pnpm exited with 1'), 'Host initialization failed'])
|
||||
expect(afterChange).toHaveBeenCalledOnce()
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => {
|
||||
const { root, manager } = setup()
|
||||
await manager.applyRelease()
|
||||
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
|
||||
})
|
||||
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)
|
||||
} finally {
|
||||
writeFileSync(release, 'continue')
|
||||
await pending
|
||||
}
|
||||
expect(existsSync(manager.paths.lock)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppUpdater } from 'electron-updater'
|
||||
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
|
||||
import { parseDesktopRelease } from '../src/release.ts'
|
||||
import type { DesktopUpdateState } from '../src/ipc.ts'
|
||||
|
||||
vi.mock('electron', () => ({ app: { isPackaged: false } }))
|
||||
vi.mock('electron-updater', () => ({
|
||||
@@ -43,7 +42,6 @@ describe('desktop release metadata', () => {
|
||||
|
||||
describe('desktop update coordinator', () => {
|
||||
it('installs one Electron release and restarts after download', async () => {
|
||||
const states: DesktopUpdateState[] = []
|
||||
const downloadUpdate = vi.fn(async () => [])
|
||||
const quitAndInstall = vi.fn()
|
||||
const beforeRestart = vi.fn(async () => {})
|
||||
@@ -58,10 +56,6 @@ describe('desktop update coordinator', () => {
|
||||
quitAndInstall,
|
||||
} as unknown as AppUpdater
|
||||
const coordinator = new DesktopUpdateCoordinator(
|
||||
(state) => {
|
||||
states.push(state)
|
||||
return state
|
||||
},
|
||||
beforeRestart,
|
||||
updater,
|
||||
() => true,
|
||||
@@ -72,7 +66,6 @@ describe('desktop update coordinator', () => {
|
||||
expect(downloadUpdate).toHaveBeenCalledOnce()
|
||||
expect(beforeRestart).toHaveBeenCalledOnce()
|
||||
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 () => {
|
||||
@@ -88,7 +81,7 @@ describe('desktop update coordinator', () => {
|
||||
downloadUpdate,
|
||||
quitAndInstall: vi.fn(),
|
||||
} as unknown as AppUpdater
|
||||
const coordinator = new DesktopUpdateCoordinator(state => state, async () => {}, updater, () => true)
|
||||
const coordinator = new DesktopUpdateCoordinator( async () => {}, updater, () => true)
|
||||
|
||||
const checking = coordinator.check()
|
||||
const installing = coordinator.install()
|
||||
|
||||
@@ -12,9 +12,9 @@ export default defineConfig([
|
||||
clean: false,
|
||||
deps: { neverBundle: ['electron'] },
|
||||
},
|
||||
...(['preload', 'preload-app'] as const).map(name => ({
|
||||
{
|
||||
// Sandboxed Electron preloads run as CommonJS even though the application package is ESM.
|
||||
entry: { [name]: `lib/types/${name}.js` },
|
||||
entry: { 'preload-app': 'lib/types/preload-app.js' },
|
||||
outDir: 'lib',
|
||||
format: ['cjs'] as const,
|
||||
platform: 'node' as const,
|
||||
@@ -23,5 +23,5 @@ export default defineConfig([
|
||||
dts: false,
|
||||
clean: false,
|
||||
deps: { neverBundle: ['electron'] },
|
||||
})),
|
||||
},
|
||||
])
|
||||
Reference in new issue
Block a user