feat: 优化打包方式、启动速度

This commit is contained in:
07akioni committed 2026-09-08 16:39:17 +08:00
1 parent 189d96920c
commit 8e4d3bab9f
57 files changed
+2013 -2163

No files matched your search

@@ -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: 6d22777c8913911dbb1d89c09de9e891636873c8
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5108492ba6f029847735f570aa77c2cb505f981c
2026-08-25-electron-desktop-packaging-and-updates.md: fea2c419d73e4eff66de08483733cbc336b6f9af
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 88e2875cd35909c0111926377df83c9fe21841dc
@@ -10,13 +10,13 @@ DeepSeek Harness needs an Electron desktop application that reuses the Web UI, w
The desktop application and an npm-installed dsh share the `.dsh` data root, but they may have different dsh and plugin versions. They must share supported product data without sharing executable packages, lockfiles, `node_modules`, plugin activation, or package-manager configuration.
The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its pnpm-installed dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous.
The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its bundled dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous.
## Decision
Ship a small Electron shell with a bundled upstream Node.js executable and pinned pnpm. Electron starts the private Desktop Host package as an isolated child process; that package composes the installed dsh backend and matching client graph. Fetch metadata and bounded raw request and response chunks travel over two versioned framed byte pipes, Node IPC is reserved for readiness, fatal failure, and shutdown, and Electron serves validated assets through `dsh-app://`; it opens no listening port. Each frame carries a fixed marker, type, monotonic stream id, payload length, and validated payload. Serialized writers honor pipe drain, readers pause globally when a request or response stream applies backpressure, cancellation closes the matching stream, and late response frames for a retired stream stay inert. The Connection plugin provides its carrier-neutral RPC and Fetch registries without requiring `webServer`, while Client Modules provides the exact advertised combo-bundle responses to the shell-owned carrier; Web compositions attach their optional HTTP routes for both. The renderer keeps the same Fetch, RPC, and Remote-stream formats, while the child carrier avoids Base64 expansion and V8 serialization compatibility between Electron and the bundled upstream Node.js. Electron closes its request-pipe writer after sending shutdown, releasing an in-flight Windows pipe read before it waits for child exit. This follows the Electron reservation in the [GUI layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
Electron owns the reserved profile at `.dsh/profiles/desktop`. Its exact `@deepseek-ai/dsh` dependency supplies the backend and matching Web UI, while the matching private `@deepseek-ai/dsh-desktop-host` dependency supplies only the Electron child-process entry and composition overlay. The dsh release, private Host, and their first-party dependency closures use local npm tarballs packed from the same source build; the profile manifest lists every core package as a local `file:` dependency, and `pnpm-workspace.yaml` repeats the mapping as overrides. The Host remains outside the public CLI package and is never published to npm. Desktop plugins are additional registry npm dependencies and ordered `dsh.profile.bundles` entries in the same profile, and resolve from its one `node_modules`.
Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resource storage, external plugin dependencies, shared package links, and profile reconciliation. The private Desktop Host remains outside the public CLI package and is never published to npm.
One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged.
@@ -28,7 +28,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p
|---|---|
| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback |
| Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state |
| Desktop profile | One dependency graph, ordered bundle list, and `node_modules` for the desktop dsh package and desktop plugins |
| Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision |
| Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication |
| Installed dsh package | Backend, matching Web UI, boot manifest, client bundles, and product behavior |
| Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions |
@@ -55,15 +55,13 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
desktop-release.json
desktop-packages.json
desktop-packages/
desktop-runtime-state.json
node_modules/
sessions/
storages/
```
`.dsh/profiles/desktop` is the only active desktop profile. Its package manifest records the built-in and installed plugin bundle order; Electron alone mutates its dependencies, lockfile, and `node_modules`. Production startup rejects a bundle resolved outside this profile, including the CLI-maintained `.dsh/profiles/node_modules` fallback. Package content installed for the desktop profile uses `.dsh/desktop/pnpm/store`.
`.dsh/profiles/desktop` is the only active desktop profile. Its executable package ownership and allowed resolution directories follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Plugin package content uses `.dsh/desktop/pnpm/store`.
## Installation and resolution
@@ -71,35 +69,27 @@ The installer never mutates the active profile in place. It copies profile metad
The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered.
The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the union of the first-party package closures rooted at dsh and the private Desktop Host, lockfile, integrity inventory, and required store subset. Each `mac-arm64`, `mac-x64`, and `win-x64` build owns its packed packages, runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `.desktop-build/targets/<target>`; only the immutable, checksum-verified Node.js download cache is shared. The release build requires the Electron package, root dsh package, and private Host package to have the same version, creates final npm tarballs from the official source build, locally packs the private Host, selects the reachable dsh, Host, and vendored packages plus the Landlock entry, and verifies that the Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`. The Host `files` manifest contains only that runtime entry and overlay, and the package is never published to npm. Public package tarballs remain the official `pnpm pack` results governed by each package's publication manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The seed manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The target Node.js executes bundled pnpm, so pnpm's operating-system and CPU selection makes the materialized dependency graph and seed target-specific. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks the private Host entry and overlay. The build rejects any lockfile that resolves one of the local first-party names by registry version. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both Host files before copying the package set and after offline installation prevents a release whose process entry loads but cannot compose its required overlay from reaching application signing.
The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation stages every referenced Mach-O content-addressed object and runs at most four independent Developer ID signers concurrently with a secure timestamp and hardened runtime. A signer failure is observed only after every active signer exits and leaves the original CAS objects and package index unchanged. After all signers succeed, preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and verifies every embedded signature. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, replaces matching immutable store files, and transactionally merges each pnpm store version's SQLite `package_index` into `.dsh/desktop/pnpm/store`. Seed records replace matching keys while records downloaded for Desktop plugins remain. An interrupted file merge may leave valid immutable cache content, but each SQLite merge is atomic, and profile installation and activation still require pnpm integrity and the complete health check.
Startup requires the packaged release identity to equal Electron's application version, then compares `.dsh/profiles/desktop/desktop-release.json` plus the installed dsh and Desktop Host packages with that release before launching the backend. It installs the new seed manifest and lockfile with `pnpm install --offline --frozen-lockfile --trust-lockfile` in staging. After Electron replacement, it restores every plugin bundle recorded in the active profile at its exact installed version through one offline pnpm add from the existing desktop store and metadata cache. The complete graph must pass the same health check before activation.
The plugin GUI performs registry npm-package operations equivalent to `pnpm add <package> --save-exact`, `pnpm remove <package>`, and exact-version update in staging. Every mutation retains the local core-package descriptor, tarballs, dsh and Desktop Host dependencies, and complete override map. Electron validates the installed package manifest and updates the profile's dependency and ordered bundle entries; no renderer request can choose the registry, install directory, lifecycle policy, or arbitrary pnpm flags.
The backend and Loader use `.dsh/profiles/desktop/package.json` as their profile manifest and npm resolution anchor. The shared profile loader composes its ordered bundle entries, then the private Desktop Host applies its packaged overlay. The Host, dsh, Cordis, desktop plugins, plugin dependencies, and peer dependencies resolve through the ordinary pnpm `node_modules` graph. A desktop plugin contributing `dsh.client` code enters the boot manifest only after the complete profile passes health checking.
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). A desktop plugin contributing `dsh.client` code enters the client graph only after the complete profile passes health checking.
## Updates and recovery
Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release.
Before the new release opens a window, startup reconciles dsh from its packaged seed while retaining installed desktop plugins. The health check covers dependency resolution, native modules, shell API compatibility, backend startup and shutdown, Web assets, and the client boot graph. An incompatible plugin blocks activation and leaves the previous project available for rollback. Startup fails visibly rather than launching a shell and dsh version that do not match.
Before opening the product window, startup validates the current bundled runtime and reconciles external plugins according to the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Backend failure opens the Electron-owned recovery window instead of booting a mismatched dsh release.
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations.
## Security and release policy
Core dsh and the private Desktop Host come only from integrity-recorded local npm tarballs inside the signed Electron release; pnpm overrides prevent transitive core packages from falling back to a registry. Store archives are integrity-checked and fully validated in an isolated extraction directory before their files can enter writable package state. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, redacted diagnostics, and health checking are required before activation.
Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, and health checking are required before activation.
Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Seed preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, seed, or active project.
Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project.
The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the seed store subset. Architecture-specific builds must report actual component-level size deltas.
The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the dsh production tree. Architecture-specific builds must report actual component-level size deltas.
## Implementation
@@ -107,10 +97,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
|---|---|
| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. |
| Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. |
| Package state | The release seed and every later mutation run through bundled Node.js and pnpm with desktop-owned store, config, cache, state, and home paths; core packages resolve from release tarballs while plugins resolve from the fixed npm registry. |
| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. |
| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in Desktop staging. |
| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. |
`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, seed installation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation.
`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, runtime preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation.
## Alternatives considered
@@ -122,11 +112,11 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
**Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned.
**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other's dependency graph, Cordis version, plugin version, or native module. The desktop profile therefore owns a complete `node_modules` and rejects bundle resolution through the CLI profile fallback.
**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other’s dependency graph, Cordis version, plugin version, or native module. Desktop rejects package resolution through the CLI profile fallback.
**Install dsh and plugins into separate desktop projects.** This creates a second resolution anchor and peer-dependency fallback. One ordinary npm project already provides the required installation and resolution model.
**Separate core and plugin resolution without shared package ownership.** That permits duplicate host modules and uncontrolled peer fallback. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supplies explicit links and dependency validation for the separate resource and profile directories.
**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of seed space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout.
**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of runtime space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout.
**Export the Windows EV private key in a PFX file.** The externally supplied public leaf certificate lets SignTool construct the signature while `/csp` and `/kc` locate the hardware key. The EV private key remains non-exportable on the token.
@@ -136,9 +126,9 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
## Consequences
- A clean offline machine with no system Node.js or pnpm installs the seed into `.dsh/profiles/desktop` and starts a working dsh session.
- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, every Windows artifact has the configured hardware-backed EV signature, and the installed private store retains the ordinary pnpm layout.
- `.dsh/profiles/desktop/node_modules` contains and resolves the desktop dsh package and every GUI-installed desktop plugin.
- A clean offline machine without system Node.js or pnpm starts bundled dsh without installing core dependencies.
- The signed application inventories final runtime files; every macOS native file has the release Developer ID, secure timestamp, and hardened runtime, and every Windows artifact has the configured hardware-backed EV signature.
- `.dsh/profiles/desktop/node_modules` resolves shared host links and every GUI-installed desktop plugin.
- Every desktop pnpm operation uses the bundled executable and `.dsh/desktop/pnpm/store`; none reads user `PATH`, config, store, or profile `node_modules`.
- The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments.
- The backend and browser application cannot mutate desktop packages.
@@ -157,8 +147,8 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr
| Decision | Recommendation |
|---|---|
| First launch | Bundle an offline seed store subset and install it through pnpm |
| Desktop profile | One Electron-owned reserved profile containing exact dsh and plugin dependencies |
| First launch | Validate bundled production packages and create profile links without installing core dependencies |
| Desktop profile | One Electron-owned reserved profile for external plugins and shared package links |
| Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path |
| Activation | Staging project, complete health check, journaled directory replacement, one rollback copy |
| Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target |
@@ -175,3 +165,7 @@ An npm-installed dsh and desktop dsh may have different versions while sharing d
Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move.
Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification.
## Related proposals
The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supersedes this note’s offline runtime preparation and single-project package ownership. Release identity, signing, portless transport, process ownership, and Electron-only package authorization remain owned here.
@@ -10,13 +10,13 @@ DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用
桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。
当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。
当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 内置 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。
## 决策
交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留。
Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepseek-ai/dsh` 依赖提供后端与匹配的 Web UI,匹配的私有 `@deepseek-ai/dsh-desktop-host` 依赖则只提供 Electron 子进程入口与组合 overlay。dsh 发布、私有 Host 及其第一方依赖闭包使用同一次源码构建生成的本地 npm tarball;profile manifest 把每个核心包列为本地 `file:` 依赖,`pnpm-workspace.yaml` 再通过 overrides 重复该映射。Host 不进入公共 CLI 包,也不会发布到 npm。桌面插件既是同一 profile 中来自 registry 的其他 npm 依赖,也是有序的 `dsh.profile.bundles` 条目,并从该 profile 唯一的 `node_modules` 解析。
Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源存储、外部插件依赖、共享包链接和 profile 协调。私有 Desktop Host 保持独立于公共 CLI 包,且不会发布到 npm。
一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。
@@ -28,7 +28,7 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse
|---|---|
| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 |
| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 |
| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` |
| Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
| 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
| 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 |
| 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 |
@@ -55,15 +55,13 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
desktop-release.json
desktop-packages.json
desktop-packages/
desktop-runtime-state.json
node_modules/
sessions/
storages/
```
`.dsh/profiles/desktop` 是唯一活跃的 desktop profile。其 package manifest 记录内置与已安装插件 bundle 的顺序;只有 Electron 可以修改它的依赖、lockfile 和 `node_modules`。生产启动会拒绝解析到该 profile 之外的 bundle,包括 CLI 维护的 `.dsh/profiles/node_modules` fallback。desktop profile 安装的所有包内容都使用 `.dsh/desktop/pnpm/store`。
`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。插件包内容使用 `.dsh/desktop/pnpm/store`。
## 安装与解析
@@ -71,33 +69,25 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse
进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。
打包 seed 是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、分别以 dsh 和私有 Desktop Host 为根的第一方包闭包之并集的描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。每个 `mac-arm64`、`mac-x64` 和 `win-x64` 构建都在 `.desktop-build/targets/<target>` 下持有自己的打包输入、运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物;只有不可变且经过校验和验证的 Node.js 下载缓存会被共享。发布构建要求 Electron 包、根 dsh 包与私有 Host 包使用相同版本,从正式源码构建生成最终 npm tarball,在本地打包私有 Host,选择可达的 dsh、Host 与 vendored 包以及 Landlock 入口,并验证 Host tarball 中包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`。Host 的 `files` manifest 只包含该运行入口与 overlay,并且该包不会发布到 npm。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。seed manifest 把每个选中的包列为本地直接依赖,关闭 peer dependency 自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。目标 Node.js 执行内置 pnpm,因此 pnpm 的操作系统和 CPU 选择会使物化的依赖图与 seed 成为目标专用内容。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查私有 Host 的入口与 overlay。构建会拒绝任何通过 registry 版本解析本地第一方包名的 lockfile。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求这两个 Host 文件,可防止进程入口能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。
种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 会 staging 每个被引用的内容寻址 Mach-O 对象,最多并发四个独立的 Developer ID 签名进程,并带上安全时间戳与 hardened runtime。任一签名失败后,准备过程会等待已启动的签名进程全部退出,原始 CAS 对象与包索引保持不变。所有签名成功后,准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并验证每个内嵌签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,替换匹配的不可变 store 文件,并以事务方式把各 pnpm store 版本的 SQLite `package_index` 合并进 `.dsh/desktop/pnpm/store`。Seed 记录替换匹配的键,为 Desktop 插件下载的记录继续保留。中断的文件合并可能留下有效的不可变缓存内容,但每次 SQLite 合并都是原子的,profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。
启动过程先要求安装包内的发布身份等于 Electron 应用版本,再在启动后端前比较 `.dsh/profiles/desktop/desktop-release.json`、已安装 dsh 包、已安装 Desktop Host 包与该发布版本。它在 staging 中通过 `pnpm install --offline --frozen-lockfile --trust-lockfile` 安装新的 seed manifest 与 lockfile。Electron 替换后,启动过程再通过一次离线 pnpm add,从桌面端现有 store 与元数据缓存恢复活跃 profile 记录的每个插件 bundle 精确版本。完整依赖图必须通过同一套健康检查才能激活。
插件 GUI 执行等价于 `pnpm add <package> --save-exact`、`pnpm remove <package>` 和精确版本更新的 registry npm 包操作。每次修改都保留本地核心包描述文件、tarball、dsh 与 Desktop Host 依赖和完整 override 映射。Electron 验证已安装包 manifest,并更新 profile 的依赖与有序 bundle 条目;任何渲染进程请求都不能选择 registry、安装目录、生命周期策略或任意 pnpm flag。
后端与 Loader 把 `.dsh/profiles/desktop/package.json` 作为 profile manifest 和 npm 解析锚点。公共 profile loader 先组合其中的有序 bundle 条目,再由私有 Desktop Host 应用其打包的 overlay。Host、dsh、Cordis、桌面插件、插件依赖和 peer dependency 均通过普通 pnpm `node_modules` 图解析。贡献 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才进入启动 manifest。
核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。提供 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才会进入客户端依赖图。
## 更新与恢复
Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 dsh。
打开产品窗口前,启动过程会验证当前内置运行时,并根据[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)协调外部插件。后端失败会打开 Electron 拥有的恢复窗口,不会启动版本不匹配的 dsh。
`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。
## 安全与发布策略
核心 dsh 与私有 Desktop Host 只能来自签名 Electron 发布内经过完整性记录的本地 npm tarball;pnpm overrides 防止传递核心包回退到 registry。Store 归档经过完整性检查,并在隔离的解包目录中完成全部验证,归档文件随后才能进入可写包状态。插件安装接受桌面策略允许的 registry 包 spec,但绝不接受原始 pnpm 命令。激活前必须具备精确版本、lockfile 完整性、经过评审的 `allowBuilds` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。
核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合、仅限用户访问的目录权限和健康检查。
Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。Seed 准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。
打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。
在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。
@@ -107,10 +97,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool
|---|---|
| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 |
| 包状态 | 发布种子和后续每次修改都通过内置 Node.js 与 pnpm 执行,并使用桌面端拥有的 store、config、cache、state 和 home 路径;核心包从发布 tarball 解析,插件从固定 npm registry 解析。 |
| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop staging 中的外部插件依赖图。 |
| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、运行时准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
## 考虑过的替代方案
@@ -122,11 +112,11 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool
**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。
**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。
**让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。
**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。
**分离核心与插件解析,却不明确共享包归属。** 这会允许宿主模块重复以及不可控的 peer 回退。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)为分离的资源和 profile 目录提供明确链接及依赖验证。
**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。
**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 运行时空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。
**把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。
@@ -136,9 +126,9 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool
## 结果
- 没有系统 Node.js 或 pnpm 的干净离线机器把种子安装进 `.dsh/profiles/desktop`,并启动可工作的 dsh 会话。
- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,每个 Windows 产物都带有配置的硬件支持 EV 签名,安装后的私有 store 仍保持普通 pnpm 布局。
- `.dsh/profiles/desktop/node_modules` 包含并解析桌面 dsh 包和每个 GUI 安装的桌面插件。
- 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。
- 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。
- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。
- 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。
- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
- 后端与浏览器应用不能修改桌面包。
@@ -157,8 +147,8 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool
| 决策 | 建议 |
|---|---|
| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 |
| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile |
| 首次启动 | 验证内置生产包并创建 profile 链接,不安装核心依赖 |
| 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 |
| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 |
| 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
@@ -175,3 +165,7 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool
不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。
代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
## 相关提案
[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)取代本记录的离线 运行时准备与单项目包归属决策。发布身份、签名、无端口传输、进程归属及仅限 Electron 的包授权仍由本记录负责。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 8278107fb3c37aca909541886d9379a1bfd64bac
2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: b29eb1e6cbe19d4a5b4ea23a5d6159cfd3973bcd
@@ -0,0 +1,51 @@
# Agent Note: Bundle the Desktop runtime and retain external plugins
Status: implemented
English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)
## Problem
Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades.
Separate package directories can load duplicate Cordis or service modules. Retaining plugin files also does not prove compatibility with a new host API or Node runtime.
## Decision
[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. A bundled upstream Node process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`.
Desktop has not been released. This is its first installation format; there are no readers or migrations for the unpublished seed-based profile. This note supersedes core seed installation and single-project dependency ownership in the [Desktop packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That note continues to own release identity, signing, portless transport, process ownership, and Electron-only plugin authorization. No existing note is fully superseded or archived.
## Package ownership
The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization.
Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient.
The profile manifest records exact installed plugin dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates and validates them.
## Transactions and upgrades
First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, validates enabled peers, and boots the staged backend. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile.
Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction.
The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes.
An Electron-owned recovery window remains available when the backend cannot start. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity.
## Alternatives considered
- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions.
- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target.
- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable.
- **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths.
## Consequences
Core package installation is absent from first launch and compatible upgrades. Runtime integrity verification and a complete backend health check still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails.
The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them.
@@ -0,0 +1,51 @@
# Agent Note: 内置 Desktop 运行时并保留外部插件
Status: implemented
[English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文
## 问题
Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。
分离的包目录可能加载重复的 Cordis 或服务模块。仅保留插件文件也不能证明它与新的宿主 API 或 Node 运行时兼容。
## 决策
[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。内置上游 Node 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。
Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 seed profile 的读取器或迁移。本记录取代 [Desktop 打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的核心 seed 安装和单项目依赖归属部分。该记录继续负责发布身份、签名、无端口传输、进程归属和仅限 Electron 的插件授权。没有现有记录被完全取代或归档。
## 包归属
资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。
dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。
外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。
profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建并验证它们。
## 事务与升级
首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,验证已启用的 peer,并启动 staging 后端。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。
依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。
事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。
后端无法启动时,Electron 拥有的恢复窗口仍可使用。用户可以更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。
## 考虑过的替代方案
- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。
- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。
## 影响
首次启动和兼容升级不安装核心包。运行时完整性验证和完整后端健康检查仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。
[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7
2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571
@@ -0,0 +1,95 @@
# Agent Note: Uninstall Desktop while preserving the Harness home
Status: proposed
English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
## Problem
Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage.
The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory.
## Proposal
Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup.
Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup.
User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history.
### 1. Establish ownership and actual paths
Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths.
| Candidate | Windows | macOS | Required treatment |
|---|---|---|---|
| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. |
| Installer/update cache | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. |
| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `<appId>.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. |
| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. |
| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. |
| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. |
Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts.
### 2. Implement shared preservation and cleanup rules
Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location.
Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview.
Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup.
Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable.
### 3. Windows implementation
Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup.
Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`.
Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result.
Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged.
### 4. macOS implementation
Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied.
Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification.
Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned.
### 5. Verification and documentation
Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests.
Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed.
Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests.
## Alternatives considered
**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome.
**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here.
**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation.
**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations.
## Acceptance criteria
- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains.
- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact.
- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data.
- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash.
- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result.
- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion.
## Risks
Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention.
OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note.
@@ -0,0 +1,95 @@
# Agent Note: 卸载 Desktop 并保留 Harness 主目录
Status: proposed
[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
## 问题
用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。
[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。
## 提案
提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。
Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。
用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。
### 1. 确定归属和实际路径
使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。
| 候选项 | Windows | macOS | 处理要求 |
|---|---|---|---|
| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 |
| 安装器及更新缓存 | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 |
| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `<appId>.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 |
| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 |
| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 |
| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 |
建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。
### 2. 实现共用的保留与清理规则
按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。
拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。
阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。
不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。
### 3. Windows 实现
保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。
正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。
按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。
由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。
### 4. macOS 实现
在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。
使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。
把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。
### 5. 验证和文档
增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。
在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。
为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。
## 考虑过的替代方案
**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。
**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。
**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。
**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。
## 验收标准
- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。
- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。
- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。
- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。
- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。
- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。
## 风险
删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。
操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。
+19 -15
View File
@@ -149,12 +149,12 @@ function isProjectPath(projectDir: string, target: string): boolean {
return path === root || path.startsWith(root + sep)
}
function desktopPatches(projectDir: string, allowLinkedPackages: boolean): PatchOptions[] {
const dshRoot = dirname(packageManifestPath(projectDir, '@deepseek-ai/dsh'))
function desktopPatches(runtimeDir: string, projectDir: string, allowLinkedPackages: boolean): PatchOptions[] {
const dshRoot = dirname(packageManifestPath(runtimeDir, '@deepseek-ai/dsh'))
const profile = loadProfileDirectory('dsh desktop', projectDir, join(dshRoot, 'package.json'))
for (const layer of profile.layers) {
if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir)) {
throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the desktop profile`)
if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir) && !isProjectPath(runtimeDir, layer.packageDir)) {
throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the Desktop runtime and profile`)
}
}
const layers = [
@@ -176,14 +176,14 @@ function desktopPatches(projectDir: string, allowLinkedPackages: boolean): Patch
return layers.flat()
}
function dshVersion(projectDir: string): string {
const manifest = readManifest(packageManifestPath(projectDir, '@deepseek-ai/dsh'))
function dshVersion(runtimeDir: string): string {
const manifest = readManifest(packageManifestPath(runtimeDir, '@deepseek-ai/dsh'))
if (typeof manifest.version !== 'string') throw new Error('dsh desktop: installed dsh manifest has no version')
return manifest.version
}
function assetHandler(ctx: Context, projectDir: string): ConnectionFetchHandler {
const require = createRequire(join(projectDir, 'package.json'))
function assetHandler(ctx: Context, runtimeDir: string): ConnectionFetchHandler {
const require = createRequire(join(runtimeDir, 'package.json'))
const distIndex = require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')
const distRoot = realpathSync(dirname(distIndex))
const renderIndex = async (): Promise<Response> => {
@@ -270,12 +270,14 @@ interface NodeRequestInit extends RequestInit {
/**
* Boot one installed desktop npm project.
* @param runtimeDir - immutable dsh packages supplied by the Electron application.
* @param projectDir - active or staged Electron-owned desktop profile.
* @param writeResponse - serialized response-pipe writer that applies byte backpressure.
* @param options - development-only allowance for workspace-linked bundle packages.
* @returns controller after every Host and client-manifest row is active.
*/
export async function runDesktopHost(
runtimeDir: string,
projectDir: string,
writeResponse: (frame: Buffer) => Promise<void>,
options: { allowLinkedPackages?: boolean } = {},
@@ -287,6 +289,7 @@ export async function runDesktopHost(
const environment = loadLayeredEnv('dsh desktop')
let current: Context | undefined
const ctx = await boot('dsh desktop', rootConfig, structuredClone(desktopPatches(
resolve(runtimeDir),
absoluteProject,
options.allowLinkedPackages === true,
)), (hostCtx) => {
@@ -303,7 +306,7 @@ export async function runDesktopHost(
throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules')
}
const api = connection.createSharedFetchHandler('/api')
const assets = assetHandler(ctx, absoluteProject)
const assets = assetHandler(ctx, resolve(runtimeDir))
const streams = remoteStreamHandler(ctx)
const requests = new Map<number, AbortController>()
let disposing: Promise<void> | undefined
@@ -319,7 +322,7 @@ export async function runDesktopHost(
}
return {
dshVersion: dshVersion(absoluteProject),
dshVersion: dshVersion(resolve(runtimeDir)),
cancel(streamId) {
requests.get(streamId)?.abort()
},
@@ -374,11 +377,12 @@ export async function runDesktopHost(
}
async function main(): Promise<void> {
const projectDir = process.argv[2]
if (projectDir === undefined || process.send === undefined) {
throw new Error('dsh desktop: expected project directory, byte pipes, and a Node IPC channel')
const runtimeDir = process.argv[2]
const projectDir = process.argv[3]
if (runtimeDir === undefined || projectDir === undefined || process.send === undefined) {
throw new Error('dsh desktop: expected runtime and profile directories, byte pipes, and a Node IPC channel')
}
const option = process.argv[3]
const option = process.argv[4]
if (option !== undefined && option !== '--allow-linked-profile') {
throw new Error(`dsh desktop: unsupported internal option ${JSON.stringify(option)}`)
}
@@ -403,7 +407,7 @@ async function main(): Promise<void> {
if ((error as NodeJS.ErrnoException).code !== 'ERR_IPC_CHANNEL_CLOSED') throw error
}
}
const controller = await runDesktopHost(projectDir, writeResponse, { allowLinkedPackages: option !== undefined })
const controller = await runDesktopHost(runtimeDir, projectDir, writeResponse, { allowLinkedPackages: option !== undefined })
send({
type: 'ready',
protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
+2 -2
View File
@@ -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: cf350a9d5e9577f5cf52c4371b32f16e278133f9
README.zh.md: df48eb5903e21cae9ff041626c28d41d12e5c632
README.md: b2a51010dc37bcca7c7846819b72ababdc227496
README.zh.md: 241a1e1d900fef49042a7526d29fc473ef568088
+24 -34
View File
@@ -10,46 +10,36 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no
|---|---|---|
| Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
| Runtime | Electron's Node.js carries Electron patches, fuses, ABI, and lifecycle constraints, while system runtimes and package-manager state are uncontrolled. | dsh runs under the bundled upstream Node.js and every package operation uses the bundled pnpm. Electron's Node.js, system Node.js, system pnpm, and user package-manager configuration are outside the execution path. |
| Package sources | The exact dsh source build must be packageable before npm publication and install offline; plugins must remain ordinary user-selected npm packages. | The signed application carries locally packed first-party dsh packages and an offline seed store. Desktop plugins remain ordinary npm dependencies resolved from the fixed Desktop registry. |
| Seed transport | Apple notarization inspects code inside archives; shipping every pnpm store file separately would also make the application signature inventory tens of thousands of cache entries, while one compressed archive would amplify small package changes. | macOS packaging signs every Mach-O CAS object, rewrites its pnpm hashes, and proves another offline install before assigning store files to 16 deterministic uncompressed tar shards. The outer installer compresses them, and differential updates can reuse unchanged shards. |
| Package sources | Core installation at startup adds work even when offline. | `extraResources/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
| Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. |
| State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
| Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. |
| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes install in staging, boot a complete backend health check, and replace the active profile only after success; a journal and one rollback profile cover interrupted replacement. |
| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh seed, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
| 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, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions.
## Installation ownership
Electron owns the reserved profile at `$DSH_HOME/profiles/desktop`. Its manifest lists the built-in and installed plugin bundles in `dsh.profile.bundles`, while its `node_modules` contains the exact `@deepseek-ai/dsh` release, its matching private `@deepseek-ai/dsh-desktop-host`, and every desktop plugin. Keeping the Electron-only process entry and overlay in a private app package prevents Desktop implementation from becoming part of the public CLI package. The CLI cannot boot or mutate this profile. Electron always invokes its bundled Node.js and pnpm with the store at `$DSH_HOME/desktop/pnpm/store`; it never uses system pnpm or the caller's npm/pnpm configuration.
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `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/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
### Seed installation
### Runtime and plugin activation
The packaged seed is an installation kit, not a ready-to-run `node_modules` tree. Packaging creates the lockfile, materializes the production graph online with lifecycle scripts disabled, deletes `node_modules` and every temporary pnpm cache, config, and state directory, and proves one complete installation offline from the final store alone with the private Desktop Host entry and overlay present. A macOS build stages every Mach-O object from pnpm's content-addressed store, Developer ID signs at most four independent copies concurrently, and updates the affected SHA-512 index records only after all signers succeed. Another offline install proves the rewritten store before sharding; preparation then extracts the final archives and verifies every embedded signature. The signed seed retains the release identity, local first-party tarballs and their descriptor, project metadata, lockfile, integrity inventory, and pnpm store content required to repeat that installation on the user's machine.
The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup verifies the tree before creating host links. Core packages are never copied into profile storage or installed by pnpm at first launch.
| Seed content | Writable destination or use |
|---|---|
| `integrity.json` and `desktop-packages.json` | Verify every inventoried seed file, local tarball hash, and the bound dsh and Desktop Host versions before package state changes. |
| `store-archives.json` and `store-archives/*.tar` | Validate the deterministic uncompressed shards, extract them into a unique Desktop staging directory, replace matching immutable store files, and transactionally merge pnpm's versioned SQLite package index into `$DSH_HOME/desktop/pnpm/store` without removing packages already downloaded for Desktop plugins. |
| Project metadata and `desktop-packages/` | Copy into a unique `$DSH_HOME/desktop/staging/<transaction-id>/profile` project. |
| Lockfile and local package mappings | Drive the bundled pnpm installation without resolving a packaged core name from npm. |
1. A fresh profile creates its manifest and shared package links, then boots and stops the complete backend before activation.
2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, checks enabled plugins’ peer requirements, and runs the same backend health check. Plugin versions and lockfile remain unchanged; pnpm does not run.
3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again.
4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile.
5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. A failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell.
Startup installs or reconciles the seed as one serialized transaction:
The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts.
1. Recover an interrupted activation journal, verify the complete seed inventory and local package set, and require the seed version to equal Electron's application version.
2. If the active profile already contains that release plus the matching dsh and Desktop Host versions, verify its local package set and reuse it without reinstalling.
3. Otherwise validate every archive entry, extract all store shards into a temporary Desktop-owned staging directory, merge the package files and SQLite package-index records into the private store, create a staging profile, and run `pnpm install --offline --frozen-lockfile --trust-lockfile` through the bundled Node.js and pnpm. Seed records replace matching index keys while plugin-only records remain available.
4. During an Electron upgrade, read every plugin name and exact version from the old active profile and add those versions to staging with `--offline` from existing Desktop pnpm state. A first installation has no plugin-restore step.
5. Stop the active backend, boot and stop the complete staged backend as a health check, then restart the active backend before activation. This serialization prevents two desktop backends from sharing `$DSH_HOME`; installation or plugin incompatibility before activation deletes staging and leaves the active profile unchanged.
6. Persist each next activation phase before its directory move, move the active profile to `$DSH_HOME/desktop/rollback/profile`, and move staging into `$DSH_HOME/profiles/desktop`. Recovery combines the journal with the actual profile, rollback, and staging directories, so interruption in either write-to-move gap restores or retains a complete profile.
GUI plugin mutations use the same staging, health-check, activation, and rollback path after installing registry packages into the shared Desktop pnpm store.
The process-lifetime Electron lock is the primary desktop owner. The transaction lock is depth defense: it records Electron while preparing local state, records the spawned pnpm worker while that worker can still write, and returns ownership to Electron after the worker exits. A later process cannot treat a live orphaned worker as a stale transaction.
Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; hardlinks are unsuitable because they cannot represent package directories and would retain old file content after application replacement. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
## Develop
@@ -67,7 +57,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and
pnpm run start:desktop
```
Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, release seed, plugin installation, staging, and rollback paths.
Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation, staging, and rollback paths.
## Package
@@ -88,7 +78,7 @@ export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
pnpm run package:desktop
```
Release automation uses fixed target commands so runtime preparation, seed installation, and electron-builder receive the same platform and architecture:
Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture:
```sh
pnpm run package:desktop:mac:arm64
@@ -98,7 +88,7 @@ pnpm run package:desktop:win:x64
The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target.
Each target owns its packed package inputs, prepared runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets/<target>/`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state.
Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets/<target>/`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state.
### Upload updates
@@ -125,7 +115,7 @@ pnpm run upload:mac:arm64
Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Seed preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
### Windows EV signing
@@ -141,7 +131,7 @@ pnpm run package:desktop:win:x64
Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable.
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable.
Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
@@ -156,21 +146,21 @@ To inspect or troubleshoot the prepared host-target resources without invoking e
pnpm run prepare:desktop
```
This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or seed content.
This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
Every package command performs the official repository build, packs the dsh and vendored package families, locally packs the private Desktop Host package, and packs the Landlock entry before preparing release resources. `prepare:packages` selects the union of the first-party production closures rooted at `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host`, verifies that the private Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`, copies the selected tarballs into the seed input, and records their sizes and SHA-512 integrity. The Host package is never published to npm; its `files` manifest contains only that runtime entry and overlay. Public package tarballs remain the official `pnpm pack` outputs governed by each package's publication manifest, so Desktop adds no second filter, retains published declarations such as `lib/types`, and neither strips nor adds source maps independently. Registry packages likewise retain their published package bytes in pnpm's content-addressed store. The dsh release bump updates both private Desktop manifests together with the root and publishable workspaces; packaging also requires the root dsh package, Desktop Host package, and Electron package to have the same version. Neither dsh nor the private Host needs to be published to npm before the Desktop application is built. `prepare:runtime` downloads Node.js 24.17.0 from the official Node.js release service, verifies its SHA-256 entry before extraction, and executes the prepared target binary on a compatible build host to verify its reported version. It copies the pnpm version declared by the desktop package and records both runtime versions in the release seed. `prepare:seed` runs that target Node.js and bundled pnpm, so platform- and CPU-filtered optional dependencies make the pnpm store and seed target-specific. It generates local core-package mappings, disables the global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and all temporary pnpm cache, config, and state, proves the complete graph installs offline with the private Host entry and overlay, performs the macOS rewrite when applicable, proves the rewritten store with another offline installation, removes temporary pnpm project registrations, and replaces the loose store with 16 deterministic uncompressed tar shards. It extracts those final shards and verifies every embedded macOS signature before inventory generation. Later GUI plugin operations retain the local core mappings while resolving plugin packages and their external dependencies from the fixed Desktop npm registry. `electron-builder` emits each target's platform artifacts under `apps/desktop/.desktop-build/targets/<target>/artifacts`; a later version keeps differently named immutable installers and blockmaps while replacing that target's unpacked application, diagnostics, completion record, and channel metadata.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares target-specific Node and pnpm executables. `prepare:dsh` installs the production graph once at build time, copies materialized packages into `extraResources/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the copied inventory is checked before signing and again after signing. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
An unpacked artifact contains four independent size contributors: Electron, the offline seed store shards and local dsh tarballs, the upstream Node.js and pnpm runtime, and the small shell application. The shards are uncompressed so the outer DMG, ZIP, or NSIS compressor and differential updater can operate on stable ranges. Filesystem size is not installer download size, so measure both separately. First packaged startup also extracts the seed store into `$DSH_HOME/desktop/pnpm/store` before installing the writable profile, so release qualification must measure both application and Harness-home disk use.
An unpacked artifact contains Electron, the materialized dsh production tree, upstream Node.js and pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
## Updates
A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound seed before reopening the product window.
A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound runtime before reopening the product window.
Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The seed and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
## Low-level development overrides
`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_SEED_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`.
`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`.
## Known limitations
+24 -34
View File
@@ -10,46 +10,36 @@
|---|---|---|
| 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
| 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和用户包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。Electron 的 Node.js、系统 Node.js、系统 pnpm 与用户的包管理器配置都不进入执行路径。 |
| 包来源 | 必须能在发布到 npm 之前从同一次源码构建打包精确的 dsh,并支持离线安装;插件则需要保留为用户选择的普通 npm 包。 | 已签名应用携带本地打包的第一方 dsh 包与离线 seed store。桌面插件仍是从固定 Desktop registry 解析的普通 npm 依赖。 |
| Seed 传输 | Apple 公证会检查归档内的代码;把 pnpm store 的每个文件分别放入应用,还会让应用签名记录数万个缓存条目,而单个压缩归档会放大小幅包变更。 | macOS 打包先签署每个 Mach-O CAS 对象、重写其 pnpm 哈希并再次证明离线安装,再把 store 文件分配到 16 个确定性的未压缩 tar 分片。外层安装包负责压缩,差分更新可以复用未变化的分片。 |
| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
| 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 |
| 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
| 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 |
| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先安装到 staging,并启动完整后端执行健康检查;只有成功后才替换活跃 profile,中断替换由事务日志和一个 rollback profile 恢复。 |
| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh seed、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
[Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。
## 安装归属
Electron 拥有保留 profile `$DSH_HOME/profiles/desktop`。其 manifest 通过 `dsh.profile.bundles` 列出内置与已安装插件 bundle,`node_modules` 则同时包含精确版本的 `@deepseek-ai/dsh`、与之匹配的私有 `@deepseek-ai/dsh-desktop-host` 和所有桌面插件。把 Electron 专用进程入口与 overlay 放入私有应用包,可以避免 Desktop 实现成为公共 CLI 包的一部分。CLI 不能启动或修改该 profile。Electron 始终调用自身内置的 Node.js 与 pnpm,并把 store 固定在 `$DSH_HOME/desktop/pnpm/store`;它绝不使用系统 pnpm 或调用方的 npm/pnpm 配置。
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
### Seed 安装
### 运行时与插件激活
安装包内的 seed 是安装工具包,不是可以直接运行的 `node_modules` 目录。打包过程会生成锁文件,在禁用生命周期脚本的情况下在线物化生产依赖图,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 完成一次完整离线安装,并验证私有 Desktop Host 的入口与 overlay 均存在。macOS 构建随后从 pnpm 内容寻址 store staging 每个 Mach-O 对象,最多并发四个 Developer ID 签名进程,并且只在所有签名成功后才更新受影响的 SHA-512 索引记录。再一次离线安装会在分片前证明重写后的 store;准备过程随后解包最终归档,并验证每个内嵌签名。签名 seed 保留发布身份、本地第一方 tarball 及其描述文件、项目元数据、锁文件、完整性清单,以及在用户机器上重复该安装所需的 pnpm store 内容。
签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动会先验证依赖树,再创建宿主链接。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
| Seed 内容 | 可写目标或用途 |
|---|---|
| `integrity.json` 与 `desktop-packages.json` | 在修改包状态前验证清单记录的每个 seed 文件、本地 tarball 哈希以及绑定的 dsh 与 Desktop Host 版本。 |
| `store-archives.json` 与 `store-archives/*.tar` | 验证确定性的未压缩分片,把它们解包到唯一的 Desktop staging 目录,替换匹配的不可变 store 文件,并以事务方式把 pnpm 的版本化 SQLite 包索引合并进 `$DSH_HOME/desktop/pnpm/store`,且不移除已经为 Desktop 插件下载的包。 |
| 项目元数据与 `desktop-packages/` | 复制到唯一的 `$DSH_HOME/desktop/staging/<transaction-id>/profile` 项目。 |
| 锁文件与本地包映射 | 驱动内置 pnpm 完成安装,且不会从 npm 解析已打包的核心包名。 |
1. 新 profile 创建 manifest 和共享包链接,然后启动并停止完整后端,通过后才激活。
2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,检查已启用插件的 peer 要求,并执行相同的后端健康检查。插件版本和锁文件保持不变;不运行 pnpm。
3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。
4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。
5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。
启动过程把 seed 安装或校准为一个串行事务:
后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。
1. 恢复中断的激活事务日志,验证完整 seed 清单与本地包集,并要求 seed 版本等于 Electron 应用版本。
2. 如果活跃 profile 已包含该发布及匹配的 dsh 与 Desktop Host 版本,则验证其中的本地包集并直接复用,不重新安装。
3. 否则验证每个归档条目,把全部 store 分片解包到 Desktop 拥有的临时 staging 目录,将包文件与 SQLite 包索引记录合并进私有 store,再创建 staging profile,并通过内置 Node.js 与 pnpm 执行 `pnpm install --offline --frozen-lockfile --trust-lockfile`。Seed 记录替换匹配的索引键,插件专属记录继续保留。
4. Electron 升级时,从旧活跃 profile 读取每个插件的名称和精确版本,再通过现有 Desktop pnpm 状态以 `--offline` 把这些版本加入 staging。首次安装不执行插件恢复。
5. 停止活跃后端,启动并停止完整的 staging 后端执行健康检查,再在激活前重新启动活跃后端。这种串行方式避免两个桌面后端共享 `$DSH_HOME`;安装错误或插件不兼容会删除 staging,并保持活跃 profile 不变。
6. 在每次目录移动前先持久化下一个激活阶段,把活跃 profile 移到 `$DSH_HOME/desktop/rollback/profile`,再把 staging 移到 `$DSH_HOME/profiles/desktop`。恢复过程同时检查日志与真实的 profile、rollback 和 staging 目录,因此在任一个写入与移动间隙中断后仍会恢复或保留一个完整 profile。
GUI 插件修改会在把 registry 包安装到共享 Desktop pnpm store 后,使用相同的 staging、健康检查、激活与 rollback 路径。
进程生命周期 Electron 锁是桌面端的主要 owner。事务锁用于纵深防御:准备本地状态时记录 Electron,在 pnpm worker 仍可能写入时记录该 worker,worker 退出后再把 owner 交还 Electron。后续进程不会把仍然存活的孤儿 worker 误判为陈旧事务。
包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;硬链接无法表示包目录,而且应用替换后仍会保留旧文件内容,因此不适合此用途。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。
## 开发
@@ -67,7 +57,7 @@ pnpm run dev:desktop
pnpm run start:desktop
```
Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、发布 seed、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。
Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。
## 打包
@@ -88,7 +78,7 @@ export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
pnpm run package:desktop
```
发布自动化使用固定目标命令,确保运行时准备、seed 安装与 electron-builder 接收相同的平台和架构:
发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构:
```sh
pnpm run package:desktop:mac:arm64
@@ -98,7 +88,7 @@ pnpm run package:desktop:win:x64
macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Desktop 尚不支持 Linux 发布目标。
每个目标都在 `apps/desktop/.desktop-build/targets/<target>/` 下持有自己的打包输入、已准备运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。
每个目标都在 `apps/desktop/.desktop-build/targets/<target>/` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。
### 上传更新
@@ -125,7 +115,7 @@ pnpm run upload:mac:arm64
生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证 hook 成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。Seed 准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
### Windows EV 签名
@@ -141,7 +131,7 @@ pnpm run package:desktop:win:x64
打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 打包会失败,不会生成未签名产物。
PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。
PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。
使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
@@ -156,21 +146,21 @@ pnpm run package:desktop:mac:arm64:dir
pnpm run prepare:desktop
```
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 seed 内容。
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
每条打包命令都会先执行仓库的正式构建,打包 dsh 与 vendored 包族,在本地打包私有 Desktop Host 包,并打包 Landlock 入口,然后再准备发布资源。`prepare:packages` 选择分别以 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-desktop-host` 为根的第一方生产依赖闭包之并集,验证私有 Host tarball 同时包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`,把选中的 tarball 复制到 seed 输入,并记录其大小与 SHA-512 完整性。Host 包不会发布到 npm;它的 `files` manifest 只包含该运行入口与 overlay。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 输出,因此 Desktop 不增加第二套过滤规则,会保留 `lib/types` 等已发布声明,也不会独立删除或增加 source map。Registry 包同样在 pnpm 内容寻址 store 中保留其发布的包字节。dsh 发布版本更新会同步更新两个私有 Desktop manifest、仓库根与可发布 workspace;打包还会要求根 dsh 包、Desktop Host 包与 Electron 包使用同一版本。构建 Desktop 应用前不要求 dsh 或私有 Host 已发布到 npm。`prepare:runtime` 从 Node.js 官方发行服务下载 Node.js 24.17.0,在解压前验证其 SHA-256 条目,并在兼容的构建宿主上执行准备完成的目标二进制文件以验证其报告版本。它复制桌面包声明的 pnpm 版本,并把两个运行时版本记录进发布 seed。`prepare:seed` 运行该目标 Node.js 与内置 pnpm,因此按平台和 CPU 过滤的可选依赖会使 pnpm store 与 seed 成为目标专用内容。它生成本地核心包映射、禁用全局 virtual store、从 npm 物化外部生产依赖并禁用生命周期脚本、删除 `node_modules` 以及所有临时 pnpm cache、config 和 state,证明完整依赖图可以离线安装并包含私有 Host 的入口与 overlay,在适用时执行 macOS 重写,再通过一次离线安装证明重写后的 store,删除临时 pnpm 项目注册,然后把松散 store 替换为 16 个确定性的未压缩 tar 分片。它会解包这些最终分片,并在生成清单前验证每个内嵌 macOS 签名。后续 GUI 插件操作保留本地核心包映射,同时从固定的 Desktop npm registry 解析插件包及其外部依赖。`electron-builder` 把各目标的平台产物写到 `apps/desktop/.desktop-build/targets/<target>/artifacts`;后续版本会保留不同名称的不可变安装包与 blockmap,但会替换该目标的未打包应用、诊断文件、完成记录与频道元数据。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Node 与 pnpm 可执行文件。`prepare:dsh` 在构建时安装一次生产依赖图,把物化包复制到 `extraResources/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;复制后的清单在签名前及签名后分别验证。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
未压缩产物包含四块相互独立的体积:Electron、离线 seed store 分片与本地 dsh tarball、上游 Node.js 与 pnpm 运行时,以及很小的桌面壳应用。分片不压缩,使外层 DMG、ZIP 或 NSIS 压缩器与差分更新器可以处理稳定的数据区间。文件系统占用不等于安装包下载大小,因此必须分别测量。打包应用首次启动时还会先把 seed store 解包到 `$DSH_HOME/desktop/pnpm/store`,再安装可写 profile,因此发布验证必须同时测量应用与 Harness home 的磁盘占用。
未压缩产物包含 Electron、物化后的 dsh 生产依赖树、上游 Node.js 与 pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
## 更新
打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。
打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的运行时,再重新打开产品窗口。
Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。Seed 与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
## 底层开发覆盖项
`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_SEED_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。
`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。
## 已知限制
+3 -1
View File
@@ -6,12 +6,14 @@ export interface DesktopElectronBuilderConfig {
}
readonly extraResources: readonly [
{ readonly from: string, readonly to: 'runtime' },
{ readonly from: string, readonly to: 'seed' },
{ readonly from: string, readonly to: 'dsh' },
{ readonly from: string, readonly to: 'dsh/node_modules' },
]
readonly mac: {
readonly identity: string | undefined
readonly forceCodeSigning: boolean
readonly notarize: boolean
readonly signIgnore: readonly string[]
}
readonly dmg: {
readonly sign: boolean
+15 -2
View File
@@ -1,3 +1,4 @@
import { join } from 'node:path'
import {
resolveDesktopAppId,
resolveMacOSNotarizationEnvironment,
@@ -59,13 +60,17 @@ export function createElectronBuilderConfig(
],
extraResources: [
{ from: buildPaths.runtime, to: 'runtime' },
{ from: buildPaths.seed, to: 'seed' },
{ from: buildPaths.dsh, to: 'dsh' },
// electron-builder excludes a source directory's root node_modules.
{ from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules' },
],
mac: {
category: 'public.app-category.developer-tools',
identity: macOSSigning?.signingIdentity,
forceCodeSigning: true,
hardenedRuntime: true,
// Runtime files are signed before their integrity inventory is sealed.
signIgnore: ['/Contents/Resources/dsh(?:/|$)'],
notarize: true,
target: ['dmg', 'zip'],
},
@@ -73,8 +78,16 @@ export function createElectronBuilderConfig(
sign: true,
writeUpdateInfo: false,
},
afterSign: context => {
afterPack: async context => {
const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'),
context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
},
afterSign: async context => {
if (context.electronPlatformName !== 'darwin') return
const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js')
verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'),
context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch })
verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
},
artifactBuildCompleted: artifact => {
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for an isolated pnpm-installed dsh runtime",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.1.3-alpha.2",
"private": true,
"license": "MIT",
@@ -12,7 +12,7 @@
"start": "tsx scripts/dev.ts --skip-build",
"prepare:runtime": "tsx scripts/prepare-runtime.ts",
"prepare:packages": "tsx scripts/prepare-package-set.ts",
"prepare:seed": "tsx scripts/prepare-seed.ts",
"prepare:dsh": "tsx scripts/prepare-dsh.ts",
"prepare:package": "tsx scripts/package-target.ts --prepare-only",
"verify:mac-signature": "node scripts/verify-macos-signature.mjs",
"package": "tsx scripts/package-target.ts",
@@ -43,7 +43,6 @@
"electron-builder": "^26.15.3",
"extract-zip": "^2.0.1",
"js-yaml": "^4.2.0",
"msgpackr": "2.0.4",
"pnpm": "11.7.0",
"tar": "^7.5.0",
"typescript": "^6.0.3"
@@ -16,6 +16,12 @@
</div>
<button id="refresh" class="quiet" type="button"></button>
</header>
<section id="recovery" hidden>
<p id="recovery-description"></p>
<p id="startup-error" role="alert"></p>
<button id="retry" type="button"></button>
<button id="disable-all" type="button"></button>
</section>
<form id="install-form">
<label id="package-label" for="package-spec"></label>
<div class="install-row">
+17 -2
View File
@@ -14,6 +14,10 @@ async function main() {
document.querySelector('#installed-heading').textContent = messages.installed
document.querySelector('#empty').textContent = messages.noPlugins
document.querySelector('#recovery-description').textContent = messages.recoveryDescription
document.querySelector('#retry').textContent = messages.retry
document.querySelector('#disable-all').textContent = messages.disableAll
const list = document.querySelector('#plugins')
const empty = document.querySelector('#empty')
const status = document.querySelector('#status')
@@ -27,13 +31,16 @@ async function main() {
}
async function render() {
const backend = await api.backend.status()
document.querySelector('#recovery').hidden = backend.ready
document.querySelector('#startup-error').textContent = backend.error ?? ''
const plugins = await api.plugins.list()
list.replaceChildren(...plugins.map(plugin => {
const item = document.createElement('li')
const identity = document.createElement('span')
const version = document.createElement('span')
version.className = 'package-version'
version.textContent = plugin.version
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'
@@ -52,7 +59,13 @@ async function main() {
})
const actions = document.createElement('span')
actions.className = 'package-actions'
actions.append(update, remove)
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
}))
@@ -93,6 +106,8 @@ async function main() {
input.value = ''
}, message('installing', { spec }))
})
document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
await load(messages.loadingPlugins, '')
@@ -6,8 +6,8 @@ export interface DesktopTargetBuildPaths {
readonly artifacts: string
readonly runtime: string
readonly packageSet: string
readonly seed: string
readonly seedPnpm: string
readonly dsh: string
readonly dshPnpm: string
readonly nodeExtract: string
readonly packedDsh: string
readonly packedVendor: string
+3 -3
View File
@@ -31,7 +31,7 @@ export function resolveDesktopBuildTarget(
/**
* Return the mutable preparation and artifact directories owned by one release target.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
* @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, seed: string, seedPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
* @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
*/
export function desktopTargetBuildPaths(target) {
if (!SUPPORTED_TARGETS.has(target)) {
@@ -44,8 +44,8 @@ export function desktopTargetBuildPaths(target) {
artifacts: join(root, 'artifacts'),
runtime: join(root, 'runtime'),
packageSet: join(root, 'package-set'),
seed: join(root, 'seed'),
seedPnpm: join(root, 'seed-pnpm'),
dsh: join(root, 'dsh'),
dshPnpm: join(root, 'dsh-pnpm'),
nodeExtract: join(root, 'node-extract'),
packedDsh: join(packed, 'dsh'),
packedVendor: join(packed, 'vendor'),
+43
View File
@@ -0,0 +1,43 @@
/** Sign final native runtime files before the enclosing Desktop application is signed. */
import { createHash } from 'node:crypto'
import { closeSync, openSync, readSync } from 'node:fs'
import { join } from 'node:path'
import { inventoryDesktopRuntime } from '../src/runtime-tree.ts'
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs'
const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca'])
function isMachO(path: string): boolean {
const descriptor = openSync(path, 'r')
try {
const header = Buffer.alloc(4)
return readSync(descriptor, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex'))
} finally { closeSync(descriptor) }
}
/**
* Sign and verify every materialized Mach-O file, awaiting all signers on failure.
* @param root - Self-contained production runtime without symlinks.
* @param appId - Release application identifier.
* @param expected - Required signing identity.
* @returns Number of signed native files.
*/
export async function signMacOSRuntime(root: string, appId: string, expected: MacOSSigningEnvironment): Promise<number> {
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => isMachO(join(root, path)))
let next = 0
const workers = Array.from({ length: Math.min(4, files.length) }, async () => {
for (;;) {
const path = files[next++]
if (path === undefined) return
const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}`
await signMacOSRuntimeCode(join(root, path), identifier, expected)
verifyMacOSRuntimeCode(join(root, path), expected)
}
})
const results = await Promise.allSettled(workers)
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
if (errors.length > 0) throw new AggregateError(errors, 'desktop runtime: native signing failed')
return files.length
}
-413
View File
@@ -1,413 +0,0 @@
/** Sign Mach-O content in a pnpm CAS without invalidating the store index. */
import { createHash } from 'node:crypto'
import {
chmodSync,
closeSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
readdirSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { availableParallelism, tmpdir } from 'node:os'
import { basename, dirname, join, relative, sep } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { Packr } from 'msgpackr'
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
import { signMacOSSeedCode, verifyMacOSSeedCode } from './verify-macos-signature.mjs'
const MACH_O_MAGICS = new Set([
'cafebabe',
'cafebabf',
'cefaedfe',
'cffaedfe',
'feedface',
'feedfacf',
'bebafeca',
'bfbafeca',
])
const CAS_PATH_PATTERN = /^([0-9a-f]{2})\/([0-9a-f]{126})(-exec)?$/u
const MAX_CONCURRENT_CODE_SIGNERS = 4
const packr = new Packr({ moreTypes: true, useRecords: true })
interface PnpmStoreFileRecord {
checkedAt: number
digest: string
mode: number
size: number
}
interface PnpmSideEffectsRecord {
readonly added?: Map<string, PnpmStoreFileRecord>
}
interface PnpmPackageIndexRecord {
readonly algo?: string
readonly files?: Map<string, PnpmStoreFileRecord>
readonly sideEffects?: Map<string, PnpmSideEffectsRecord>
}
interface DecodedIndexRow {
readonly key: string
readonly value: PnpmPackageIndexRecord
changed: boolean
}
interface CasFile {
readonly path: string
readonly digest: string
readonly executable: boolean
}
interface FileReference {
readonly row: DecodedIndexRow
readonly record: PnpmStoreFileRecord
}
interface SigningWork {
readonly file: CasFile
readonly references: readonly FileReference[]
readonly temporaryPath: string
}
/** Summary of native code rewritten in one pnpm store. */
export interface MacOSSeedStoreSigningResult {
readonly signedFiles: number
readonly prunedOrphans: number
readonly updatedIndexRows: number
}
/** A signer used to make one writable Mach-O copy release-valid. */
export type MacOSSeedCodeSigner = (path: string, identifier: string) => Promise<void>
/** A verifier used to check one Mach-O file after packaging transport. */
export type MacOSSeedCodeVerifier = (path: string) => void
/** Optional execution controls for seed-store code signing. */
export interface MacOSSeedStoreSigningOptions {
readonly signer?: MacOSSeedCodeSigner
readonly concurrency?: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isStoreFileRecord(value: unknown): value is PnpmStoreFileRecord {
if (!isRecord(value)) return false
return typeof value.checkedAt === 'number'
&& typeof value.digest === 'string'
&& /^[0-9a-f]{128}$/u.test(value.digest)
&& Number.isSafeInteger(value.mode)
&& Number.isSafeInteger(value.size)
}
function packageFileMaps(value: unknown, key: string): readonly Map<string, PnpmStoreFileRecord>[] {
if (!isRecord(value)) throw new Error(`desktop seed signing: invalid pnpm index record ${key}`)
const record = value as PnpmPackageIndexRecord
if (record.algo !== undefined && record.algo !== 'sha512') {
throw new Error(`desktop seed signing: unsupported pnpm index algorithm in ${key}`)
}
const maps: Map<string, PnpmStoreFileRecord>[] = []
if (record.files !== undefined) {
if (!(record.files instanceof Map)) throw new Error(`desktop seed signing: invalid pnpm file map in ${key}`)
maps.push(record.files)
}
if (record.sideEffects !== undefined) {
if (!(record.sideEffects instanceof Map)) {
throw new Error(`desktop seed signing: invalid pnpm side-effects map in ${key}`)
}
for (const effect of record.sideEffects.values()) {
if (!isRecord(effect)) throw new Error(`desktop seed signing: invalid pnpm side effect in ${key}`)
if (effect.added === undefined) continue
if (!(effect.added instanceof Map)) {
throw new Error(`desktop seed signing: invalid pnpm side-effect file map in ${key}`)
}
maps.push(effect.added)
}
}
for (const files of maps) {
for (const file of files.values()) {
if (!isStoreFileRecord(file)) throw new Error(`desktop seed signing: invalid pnpm file record in ${key}`)
}
}
return maps
}
function isExecutableMode(mode: number): boolean {
return (mode & 0o111) !== 0
}
function referenceKey(digest: string, executable: boolean): string {
return `${digest}:${executable ? 'exec' : 'nonexec'}`
}
function isMachO(path: string): boolean {
const descriptor = openSync(path, 'r')
try {
const header = Buffer.alloc(4)
return readSync(descriptor, header, 0, header.length, 0) === header.length
&& MACH_O_MAGICS.has(header.toString('hex'))
} finally {
closeSync(descriptor)
}
}
function visitFiles(root: string): readonly string[] {
const files: string[] = []
const visit = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
if (entry.isSymbolicLink()) {
throw new Error(`desktop seed signing: pnpm store contains a symbolic link: ${relative(root, path)}`)
}
if (entry.isDirectory()) visit(path)
else if (entry.isFile()) files.push(path)
else throw new Error(`desktop seed signing: unsupported pnpm store entry: ${relative(root, path)}`)
}
}
visit(root)
return files.sort((left, right) => left.localeCompare(right))
}
function versionRoots(storeRoot: string): readonly string[] {
return readdirSync(storeRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && /^v\d+$/u.test(entry.name))
.map(entry => join(storeRoot, entry.name))
.filter(root => existsSync(join(root, 'files')))
.sort((left, right) => left.localeCompare(right))
}
function casFiles(versionRoot: string): readonly CasFile[] {
const filesRoot = join(versionRoot, 'files')
const result: CasFile[] = []
for (const path of visitFiles(filesRoot)) {
if (!isMachO(path)) continue
const normalized = relative(filesRoot, path).split(sep).join('/')
const match = CAS_PATH_PATTERN.exec(normalized)
if (match === null) {
throw new Error(`desktop seed signing: Mach-O content has an unsupported pnpm CAS path: ${normalized}`)
}
result.push({
path,
digest: `${match[1]}${match[2]}`,
executable: match[3] !== undefined,
})
}
return result
}
function readIndexRows(database: DatabaseSync): readonly DecodedIndexRow[] {
const rows: DecodedIndexRow[] = []
for (const row of database.prepare('SELECT key, data FROM package_index').iterate() as Iterable<{
key: string
data: Uint8Array
}>) {
rows.push({ key: row.key, value: packr.unpack(row.data) as PnpmPackageIndexRecord, changed: false })
}
return rows
}
function fileReferences(rows: readonly DecodedIndexRow[]): ReadonlyMap<string, readonly FileReference[]> {
const references = new Map<string, FileReference[]>()
for (const row of rows) {
for (const files of packageFileMaps(row.value, row.key)) {
for (const record of files.values()) {
const key = referenceKey(record.digest, isExecutableMode(record.mode))
const values = references.get(key) ?? []
values.push({ row, record })
references.set(key, values)
}
}
}
return references
}
function writeCasFile(path: string, body: Buffer, mode: number): void {
mkdirSync(dirname(path), { recursive: true })
try {
writeFileSync(path, body, { flag: 'wx', mode })
} catch (error) {
if (!isRecord(error) || error.code !== 'EEXIST' || !readFileSync(path).equals(body)) throw error
}
chmodSync(path, mode)
}
function signedCasPath(versionRoot: string, digest: string, executable: boolean): string {
return join(
versionRoot,
'files',
digest.slice(0, 2),
`${digest.slice(2)}${executable ? '-exec' : ''}`,
)
}
async function runConcurrent<T>(
values: readonly T[],
concurrency: number,
run: (value: T) => Promise<void>,
): Promise<void> {
let next = 0
const failure: { error?: unknown; failed: boolean } = { failed: false }
const worker = async (): Promise<void> => {
while (!failure.failed) {
const index = next
if (index >= values.length) return
next += 1
try {
await run(values[index] as T)
} catch (error) {
if (!failure.failed) {
failure.failed = true
failure.error = error
}
}
}
}
const workers = Array.from(
{ length: Math.min(concurrency, values.length) },
async () => worker(),
)
await Promise.all(workers)
if (failure.failed) throw failure.error
}
async function rewriteVersionStore(
versionRoot: string,
appId: string,
signer: MacOSSeedCodeSigner,
concurrency: number,
): Promise<MacOSSeedStoreSigningResult> {
const databasePath = join(versionRoot, 'index.db')
if (!existsSync(databasePath)) {
throw new Error(`desktop seed signing: pnpm store has no package index: ${databasePath}`)
}
const database = new DatabaseSync(databasePath)
const workRoot = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-'))
const obsoleteFiles = new Set<string>()
let prunedOrphans = 0
let rows: readonly DecodedIndexRow[] = []
try {
rows = readIndexRows(database)
const references = fileReferences(rows)
const signingWork: SigningWork[] = []
for (const file of casFiles(versionRoot)) {
const body = readFileSync(file.path)
const actualDigest = createHash('sha512').update(body).digest('hex')
if (actualDigest !== file.digest) {
throw new Error(`desktop seed signing: pnpm CAS digest mismatch at ${file.path}`)
}
const fileReferences = references.get(referenceKey(file.digest, file.executable)) ?? []
if (fileReferences.length === 0) {
obsoleteFiles.add(file.path)
prunedOrphans += 1
continue
}
const temporary = join(workRoot, `${signingWork.length.toString().padStart(4, '0')}-${basename(file.path)}`)
copyFileSync(file.path, temporary)
chmodSync(temporary, 0o755)
signingWork.push({ file, references: fileReferences, temporaryPath: temporary })
}
await runConcurrent(signingWork, concurrency, async (work) => {
await signer(work.temporaryPath, `${appId}.seed.${work.file.digest.slice(0, 32)}`)
})
for (const work of signingWork) {
const signedBody = readFileSync(work.temporaryPath)
if (!isMachO(work.temporaryPath)) {
throw new Error(`desktop seed signing: signer produced non-Mach-O content for ${work.file.path}`)
}
const signedDigest = createHash('sha512').update(signedBody).digest('hex')
const mode = work.file.executable ? 0o755 : 0o644
const destination = signedCasPath(versionRoot, signedDigest, work.file.executable)
writeCasFile(destination, signedBody, mode)
const checkedAt = Date.now()
for (const reference of work.references) {
reference.record.checkedAt = checkedAt
reference.record.digest = signedDigest
reference.record.mode = mode
reference.record.size = signedBody.length
reference.row.changed = true
}
if (destination !== work.file.path) obsoleteFiles.add(work.file.path)
}
const changedRows = rows.filter(row => row.changed)
database.exec('BEGIN IMMEDIATE')
let committed = false
try {
const statement = database.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)')
for (const row of changedRows) statement.run(row.key, packr.pack(row.value))
database.exec('COMMIT')
committed = true
} finally {
if (!committed) database.exec('ROLLBACK')
}
for (const path of obsoleteFiles) unlinkSync(path)
database.exec('VACUUM')
return { signedFiles: signingWork.length, prunedOrphans, updatedIndexRows: changedRows.length }
} finally {
database.close()
rmSync(workRoot, { recursive: true, force: true })
}
}
/**
* Replace every Mach-O CAS object with a Developer ID signed object and update pnpm's SHA-512 index.
* A signer rejection leaves the original CAS objects and package index unchanged.
* @param storeRoot - Loose pnpm store prepared for the packaged seed.
* @param appId - Electron application ID used as the signing identifier prefix.
* @param expected - Company Developer ID identity and Team ID.
* @param options - Optional signer and worker bound used by focused tests.
* @returns Counts for release diagnostics after every signer completes and the index transaction commits.
*/
export async function signMacOSSeedStore(
storeRoot: string,
appId: string,
expected: MacOSSigningEnvironment,
options: MacOSSeedStoreSigningOptions = {},
): Promise<MacOSSeedStoreSigningResult> {
const roots = versionRoots(storeRoot)
if (roots.length === 0) throw new Error(`desktop seed signing: no pnpm store versions found in ${storeRoot}`)
const concurrency = options.concurrency ?? Math.min(MAX_CONCURRENT_CODE_SIGNERS, availableParallelism())
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
throw new Error(`desktop seed signing: concurrency must be a positive integer; received ${String(concurrency)}`)
}
const signer = options.signer ?? (async (path, identifier) => {
await signMacOSSeedCode(path, identifier, expected)
})
const results: MacOSSeedStoreSigningResult[] = []
for (const root of roots) results.push(await rewriteVersionStore(root, appId, signer, concurrency))
return results.reduce((total, current) => ({
signedFiles: total.signedFiles + current.signedFiles,
prunedOrphans: total.prunedOrphans + current.prunedOrphans,
updatedIndexRows: total.updatedIndexRows + current.updatedIndexRows,
}), { signedFiles: 0, prunedOrphans: 0, updatedIndexRows: 0 })
}
/**
* Verify that every Mach-O CAS object has the expected Developer ID, timestamp, and hardened runtime.
* @param storeRoot - Loose or extracted pnpm store.
* @param expected - Company Developer ID identity and Team ID.
* @param verifier - Injectable signature verifier used by focused tests.
* @returns Number of verified Mach-O files.
*/
export function verifyMacOSSeedStore(
storeRoot: string,
expected: MacOSSigningEnvironment,
verifier: MacOSSeedCodeVerifier = (path) => { verifyMacOSSeedCode(path, expected) },
): number {
let count = 0
for (const root of versionRoots(storeRoot)) {
for (const file of casFiles(root)) {
verifier(file.path)
count += 1
}
}
return count
}
+2 -2
View File
@@ -1,4 +1,4 @@
/** Build one release target with matching Electron, Node.js, and seed architecture. */
/** Build one release target with matching Electron, Node.js, and dsh architecture. */
import { spawn } from 'node:child_process'
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
@@ -276,7 +276,7 @@ async function main(): Promise<void> {
], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'prepare:runtime'], targetEnv)
await runPnpm(['run', 'prepare:packages'], targetEnv)
await runPnpm(['run', 'prepare:seed'], targetEnv)
await runPnpm(['run', 'prepare:dsh'], targetEnv)
if (invocation.prepareOnly) return
await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
+150
View File
@@ -0,0 +1,150 @@
/** Materialize the complete production runtime before publishing Desktop resources. */
import { spawn } from 'node:child_process'
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join, resolve } from 'node:path'
import { createRuntimeProjectMetadata } from '../src/project-manager.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts'
import {
DESKTOP_HOST_PACKAGE,
DESKTOP_HOST_RUNTIME_FILES,
DESKTOP_PACKAGES_DIR,
DESKTOP_PACKAGE_SET_FILE,
readDesktopCorePackageSet,
verifyDesktopCoreLockfile,
} from '../src/core-package-set.ts'
import { smokeDesktopRuntime } from './smoke-runtime.ts'
import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
import {
resolveDesktopAppId,
resolveMacOSSigningEnvironment,
} from './desktop-release-environment.mjs'
import {
signMacOSRuntime,
} from './macos-runtime.ts'
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = resolve(import.meta.dirname, '..')
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh
const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-'))
const STORE_ROOT = join(BUILD_ROOT, 'store')
const RUNTIME_ROOT = BUILD_PATHS.runtime
const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm
const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
function manifestVersion(path: string, subject: string): string {
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
if (typeof manifest.version !== 'string') throw new Error(`desktop runtime: ${subject} has no version`)
return manifest.version
}
function desktopRelease(): DesktopRelease {
const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package')
const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package')
if (version !== dshVersion) {
throw new Error(`desktop runtime: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`)
}
const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record<string, unknown>
return parseDesktopRelease({
schemaVersion: 1,
version,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: runtime.node,
pnpmVersion: runtime.pnpm,
})
}
function runPnpm(args: readonly string[]): Promise<void> {
return new Promise((resolvePromise, reject) => {
const [command, ...commandArgs] = args
if (command === undefined) throw new Error('desktop runtime: pnpm command is required')
const config = join(PNPM_BUILD_STATE, 'config')
const userConfig = join(config, 'npmrc')
mkdirSync(config, { recursive: true })
writeFileSync(userConfig, '')
const child = spawn(NODE, [
PNPM,
'--config.registry=https://registry.npmjs.org/',
`--config.store-dir=${STORE_ROOT}`,
'--config.enable-global-virtual-store=false',
`--config.userconfig=${userConfig}`,
command,
...commandArgs,
], {
cwd: BUILD_ROOT,
env: {
...Object.fromEntries(Object.entries(process.env).filter(([name]) => (
name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
))),
NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
NPM_CONFIG_STORE_DIR: STORE_ROOT,
NPM_CONFIG_USERCONFIG: userConfig,
PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`,
XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'),
XDG_CONFIG_HOME: config,
XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'),
},
stdio: 'inherit',
})
child.once('error', reject)
child.once('close', (code, signal) => {
if (code === 0) resolvePromise()
else reject(new Error(`desktop runtime: pnpm exited with ${String(code ?? signal)}`))
})
})
}
async function main(): Promise<void> {
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
mkdirSync(STORE_ROOT, { recursive: true })
try {
const release = desktopRelease()
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
createRuntimeProjectMetadata(BUILD_ROOT, release)
await runPnpm(['install', '--lockfile-only'])
verifyDesktopCoreLockfile(
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
readDesktopCorePackageSet(BUILD_ROOT, release.version),
)
await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile'])
const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version)
mkdirSync(DSH_OUTPUT_ROOT, { recursive: true })
cpSync(join(BUILD_ROOT, 'node_modules'), join(DSH_OUTPUT_ROOT, 'node_modules'), {
recursive: true, dereference: true,
filter: source => !source.split(/[\\/]/u).some(part => part === '.bin' || part === '.pnpm' || part === '.modules.yaml' || part === '.pnpm-workspace-state-v1.json'),
})
writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({
name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module',
dependencies: Object.fromEntries(packageSet.packages.map(entry => [entry.name, entry.version])),
}, undefined, 2)}\n`)
for (const file of DESKTOP_HOST_RUNTIME_FILES) {
if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', DESKTOP_HOST_PACKAGE, file))) {
throw new Error(`desktop runtime: missing private Host file ${file}`)
}
}
if (process.platform === 'darwin') {
await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
}
const targetName = resolveDesktopBuildTarget()
const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
const descriptor = verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor)
verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target)
} catch (error) {
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
throw error
} finally {
rmSync(BUILD_ROOT, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
}
}
await main()
-200
View File
@@ -1,200 +0,0 @@
/** Build the release seed through the same embedded pnpm used on first launch. */
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join, relative, resolve, sep } from 'node:path'
import { createSeedMetadata } from '../src/project-manager.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts'
import {
DESKTOP_HOST_PACKAGE,
DESKTOP_HOST_RUNTIME_FILES,
DESKTOP_PACKAGES_DIR,
DESKTOP_PACKAGE_SET_FILE,
readDesktopCorePackageSet,
verifyDesktopCoreLockfile,
} from '../src/core-package-set.ts'
import {
archivePnpmStore,
extractPnpmStoreArchives,
removePnpmProjectRegistrations,
} from '../src/seed-store.ts'
import {
resolveDesktopAppId,
resolveMacOSSigningEnvironment,
} from './desktop-release-environment.mjs'
import {
signMacOSSeedStore,
verifyMacOSSeedStore,
} from './macos-seed-store.ts'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = resolve(import.meta.dirname, '..')
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
const SEED_OUTPUT_ROOT = BUILD_PATHS.seed
const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-'))
const STORE_ROOT = join(SEED_ROOT, 'store')
const RUNTIME_ROOT = BUILD_PATHS.runtime
const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm
const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
function manifestVersion(path: string, subject: string): string {
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
if (typeof manifest.version !== 'string') throw new Error(`desktop seed: ${subject} has no version`)
return manifest.version
}
function desktopRelease(): DesktopRelease {
const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package')
const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package')
if (version !== dshVersion) {
throw new Error(`desktop seed: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`)
}
const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record<string, unknown>
return parseDesktopRelease({
schemaVersion: 1,
version,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: runtime.node,
pnpmVersion: runtime.pnpm,
})
}
function runPnpm(args: readonly string[]): Promise<void> {
return new Promise((resolvePromise, reject) => {
const [command, ...commandArgs] = args
if (command === undefined) throw new Error('desktop seed: pnpm command is required')
const config = join(PNPM_BUILD_STATE, 'config')
const userConfig = join(config, 'npmrc')
mkdirSync(config, { recursive: true })
writeFileSync(userConfig, '')
const child = spawn(NODE, [
PNPM,
'--config.registry=https://registry.npmjs.org/',
`--config.store-dir=${STORE_ROOT}`,
'--config.enable-global-virtual-store=false',
`--config.userconfig=${userConfig}`,
command,
...commandArgs,
], {
cwd: SEED_ROOT,
env: {
...Object.fromEntries(Object.entries(process.env).filter(([name]) => (
!/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
))),
NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
NPM_CONFIG_STORE_DIR: STORE_ROOT,
NPM_CONFIG_USERCONFIG: userConfig,
PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`,
XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'),
XDG_CONFIG_HOME: config,
XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'),
},
stdio: 'inherit',
})
child.once('error', reject)
child.once('close', (code, signal) => {
if (code === 0) resolvePromise()
else reject(new Error(`desktop seed: pnpm exited with ${String(code ?? signal)}`))
})
})
}
function inventory(root: string): readonly { path: string; bytes: number; sha256: string }[] {
const files: string[] = []
const visit = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) visit(path)
else if (entry.isFile()) files.push(path)
else throw new Error(`desktop seed: unsupported filesystem entry ${relative(root, path)}`)
}
}
visit(root)
return files.sort().map((path) => {
const body = readFileSync(path)
return {
path: relative(root, path).split(sep).join('/'),
bytes: statSync(path).size,
sha256: createHash('sha256').update(body).digest('hex'),
}
})
}
async function verifyOfflineInstallation(release: DesktopRelease): Promise<void> {
const installedModules = join(SEED_ROOT, 'node_modules')
try {
await runPnpm(['install', '--offline', '--frozen-lockfile', '--trust-lockfile'])
const hostRoot = join(installedModules, ...DESKTOP_HOST_PACKAGE.split('/'))
for (const file of DESKTOP_HOST_RUNTIME_FILES) {
if (!existsSync(join(hostRoot, file))) {
throw new Error(`desktop seed: local ${DESKTOP_HOST_PACKAGE}@${release.version} does not contain ${file}`)
}
}
} finally {
rmSync(installedModules, { recursive: true, force: true })
}
}
async function main(): Promise<void> {
rmSync(SEED_OUTPUT_ROOT, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
mkdirSync(STORE_ROOT, { recursive: true })
try {
const release = desktopRelease()
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(SEED_ROOT, DESKTOP_PACKAGE_SET_FILE))
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(SEED_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
createSeedMetadata(SEED_ROOT, release)
await runPnpm(['install', '--lockfile-only'])
verifyDesktopCoreLockfile(
readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'),
readDesktopCorePackageSet(SEED_ROOT, release.version),
)
const installedModules = join(SEED_ROOT, 'node_modules')
await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts'])
rmSync(installedModules, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
await verifyOfflineInstallation(release)
const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform
let signedMachOFiles: number | undefined
let macOSSigning: ReturnType<typeof resolveMacOSSigningEnvironment> | undefined
if (targetPlatform === 'darwin') {
macOSSigning = resolveMacOSSigningEnvironment(process.env)
const signing = await signMacOSSeedStore(
STORE_ROOT,
resolveDesktopAppId(process.env),
macOSSigning,
)
signedMachOFiles = signing.signedFiles
process.stdout.write(
`desktop seed: signed ${signing.signedFiles} Mach-O files, updated ${signing.updatedIndexRows} pnpm index records, and pruned ${signing.prunedOrphans} native orphans\n`,
)
await verifyOfflineInstallation(release)
}
removePnpmProjectRegistrations(STORE_ROOT)
archivePnpmStore(SEED_ROOT, STORE_ROOT)
if (macOSSigning !== undefined && signedMachOFiles !== undefined) {
const extractedStore = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-verification-'))
try {
extractPnpmStoreArchives(SEED_ROOT, extractedStore)
const verified = verifyMacOSSeedStore(extractedStore, macOSSigning)
if (verified !== signedMachOFiles) {
throw new Error(`desktop seed: archived store contains ${verified} signed Mach-O files; expected ${signedMachOFiles}`)
}
} finally {
rmSync(extractedStore, { recursive: true, force: true })
}
}
const records = inventory(SEED_ROOT).filter(entry => entry.path !== 'integrity.json')
writeFileSync(join(SEED_ROOT, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files: records }, undefined, 2)}\n`)
cpSync(SEED_ROOT, SEED_OUTPUT_ROOT, { recursive: true })
} finally {
rmSync(SEED_ROOT, { recursive: true, force: true })
}
}
await main()
+58
View File
@@ -0,0 +1,58 @@
/** Boot the materialized target runtime without access to a user's Harness profile. */
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DesktopHostProcess } from '../src/host-process.ts'
import { createPluginProfile } from '../src/project-manager.ts'
import { linkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts'
import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
/**
* Prove the final resource tree boots and serves its matching Web frontend.
* @param root - Materialized dsh resources.
* @param node - Prepared target Node executable.
* @param runtime - Verified resource descriptor.
*/
export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise<void> {
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-'))
const profile = join(home, 'profiles', 'desktop')
const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home })
try {
createPluginProfile(profile)
const pluginName = 'desktop-runtime-smoke-plugin'
const plugin = join(profile, 'node_modules', pluginName)
mkdirSync(plugin, { recursive: true })
const cordis = runtime.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis')
if (cordis === undefined) throw new Error('desktop runtime: missing shared Cordis package')
writeFileSync(join(plugin, 'package.json'), JSON.stringify({
name: pluginName, version: '1.0.0', type: 'module', exports: './index.js',
peerDependencies: { '@deepseek-ai/cordis': cordis.version }, dsh: { bundle: { patch: './bundle.yml' } },
}))
writeFileSync(join(plugin, 'index.js'), `
import { Context } from '@deepseek-ai/cordis'
export function apply(ctx) {
if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance')
}
`)
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n')
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as {
dependencies: Record<string, string>
dsh: { profile: { bundles: string[] } }
}
manifest.dependencies[pluginName] = '1.0.0'
manifest.dsh.profile.bundles.push(pluginName)
writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest))
linkDesktopHostPackages(profile, root, runtime)
validateDesktopPluginGraph(profile, root, runtime, [pluginName])
const ready = await host.start()
if (ready.dshVersion !== runtime.release.version) throw new Error('desktop runtime: Host reported another dsh release')
const response = await host.fetch(new Request('dsh-app://app/'))
if (response.status !== 200 || !(await response.text()).includes('<html')) {
throw new Error('desktop runtime: packaged frontend smoke failed')
}
} finally {
await host.stop()
rmSync(home, { recursive: true, force: true })
}
}
@@ -8,31 +8,31 @@ import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
export function assertMacOSSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
/**
* Require the signature properties Apple validates for executable seed content.
* Require the signature properties Apple validates for executable runtime content.
* @param details - Output from `codesign --display --verbose=4`.
* @param expected - Public release identity.
*/
export function assertMacOSSeedSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
export function assertMacOSRuntimeSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
/**
* Sign one Mach-O file embedded in the seed store.
* Sign one Mach-O file embedded in the runtime tree.
* @param path - Writable standalone Mach-O file.
* @param identifier - Stable code-signing identifier derived from the release app ID and CAS digest.
* @param expected - Public release identity.
* @returns Resolves after codesign exits successfully.
*/
export function signMacOSSeedCode(
export function signMacOSRuntimeCode(
path: string,
identifier: string,
expected: MacOSSigningEnvironment,
): Promise<void>
/**
* Verify one Mach-O file embedded in the seed store.
* Verify one Mach-O file embedded in the runtime tree.
* @param path - Mach-O file to inspect.
* @param expected - Public release identity.
*/
export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void
export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvironment): void
/**
* Verify the full application signature and its release owner.
+11 -11
View File
@@ -1,4 +1,4 @@
/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */
/** Sign runtime code and verify that packaged macOS artifacts carry the company release identity. */
import { spawn, spawnSync } from 'node:child_process'
import { resolve } from 'node:path'
@@ -21,19 +21,19 @@ export function assertMacOSSignatureDetails(details, expected) {
}
/**
* Require the signature properties Apple validates for executable seed content.
* Require the signature properties Apple validates for executable runtime content.
* @param {string} details - Output from `codesign --display --verbose=4`.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {void}
*/
export function assertMacOSSeedSignatureDetails(details, expected) {
export function assertMacOSRuntimeSignatureDetails(details, expected) {
assertMacOSSignatureDetails(details, expected)
const fields = details.split(/\r?\n/u).map(line => line.trim())
if (!fields.some(line => /^Timestamp=.+/u.test(line))) {
throw new Error('desktop macOS signing: seed signature has no secure timestamp')
throw new Error('desktop macOS signing: runtime signature has no secure timestamp')
}
if (!fields.some(line => /\bflags=0x[0-9a-f]+\(runtime\)(?:\s|$)/iu.test(line))) {
throw new Error('desktop macOS signing: seed signature does not enable hardened runtime')
throw new Error('desktop macOS signing: runtime signature does not enable hardened runtime')
}
}
@@ -60,7 +60,7 @@ function runAppleCommand(command, args, label) {
}
/**
* Execute one Apple release tool without blocking other independent seed signers.
* Execute one Apple release tool without blocking other independent runtime signers.
* @param {string} command - Absolute executable path.
* @param {readonly string[]} args - Tool arguments.
* @param {string} label - Stable diagnostic name.
@@ -106,13 +106,13 @@ function runCodeSign(args) {
}
/**
* Sign one Mach-O file embedded in the seed store.
* Sign one Mach-O file embedded in the runtime tree.
* @param {string} path - Writable standalone Mach-O file.
* @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {Promise<void>} Resolves after codesign exits successfully.
*/
export async function signMacOSSeedCode(path, identifier, expected) {
export async function signMacOSRuntimeCode(path, identifier, expected) {
await runAppleCommandAsync('/usr/bin/codesign', [
'--force',
'--sign', expected.signingIdentity,
@@ -124,15 +124,15 @@ export async function signMacOSSeedCode(path, identifier, expected) {
}
/**
* Verify one Mach-O file embedded in the seed store.
* Verify one Mach-O file embedded in the runtime tree.
* @param {string} path - Mach-O file to inspect.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {void}
*/
export function verifyMacOSSeedCode(path, expected) {
export function verifyMacOSRuntimeCode(path, expected) {
runCodeSign(['--verify', '--strict', '--verbose=2', path])
const details = runCodeSign(['--display', '--verbose=4', path])
assertMacOSSeedSignatureDetails(details, expected)
assertMacOSRuntimeSignatureDetails(details, expected)
}
/**
+1 -1
View File
@@ -124,7 +124,7 @@ export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string
/**
* Verify every local tarball and reject extra package files before pnpm executes them.
* @param projectDir - Seed or profile directory containing the package set.
* @param projectDir - Build directory containing the package set.
* @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron.
* @returns The verified package set.
*/
+9 -4
View File
@@ -86,28 +86,33 @@ export class DesktopHostProcess {
/**
* @param node - absolute bundled upstream Node.js executable.
* @param projectDir - active or staged desktop npm project.
* @param runtimeDir - immutable packages carried by the current application.
* @param projectDir - active or staged desktop plugin profile.
* @param inspectPort - optional loopback inspector port for workspace development.
* @param environment - Child environment; runtime and package-manager overrides are removed.
*/
constructor(
private readonly node: string,
private readonly runtimeDir: string,
private readonly projectDir: string,
private readonly inspectPort?: number,
private readonly environment: NodeJS.ProcessEnv = process.env,
) {}
/** Start the child once and resolve only after its complete composition is active. */
async start(): Promise<DesktopHostReady> {
if (this.child !== undefined) return this.readyPromise
const entry = join(this.projectDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
const child = spawn(this.node, [
...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
entry,
this.runtimeDir,
this.projectDir,
...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']),
], {
cwd: this.projectDir,
env: Object.fromEntries(Object.entries(process.env).filter(([name]) => (
name !== 'NODE_OPTIONS' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
env: Object.fromEntries(Object.entries(this.environment).filter(([name]) => (
name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
))),
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'],
})
+10
View File
@@ -10,6 +10,10 @@ export const DESKTOP_IPC = {
pluginsAdd: 'dsh-desktop:plugins-add',
pluginsRemove: 'dsh-desktop:plugins-remove',
pluginsUpdate: 'dsh-desktop:plugins-update',
pluginsToggle: 'dsh-desktop:plugins-toggle',
pluginsDisableAll: 'dsh-desktop:plugins-disable-all',
backendStatus: 'dsh-desktop:backend-status',
backendRetry: 'dsh-desktop:backend-retry',
updatesCheck: 'dsh-desktop:updates-check',
updatesInstall: 'dsh-desktop:updates-install',
updatesState: 'dsh-desktop:updates-state',
@@ -31,6 +35,12 @@ export interface DshDesktopApi {
add(spec: string): Promise<void>
remove(name: string): Promise<void>
update(name: string, version: string): Promise<void>
toggle(name: string, enabled: boolean): Promise<void>
disableAll(): Promise<void>
}
readonly backend: {
status(): Promise<{ readonly ready: boolean; readonly error?: string }>
retry(): Promise<void>
}
readonly updates: {
check(): Promise<DesktopUpdateState>
+14
View File
@@ -20,6 +20,13 @@ export const en = {
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',
retry: 'Retry startup',
disableAll: 'Disable all plugins and retry',
recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.',
changingActivation: 'Changing plugin activation…',
npmPackage: 'npm package',
install: 'Install',
installed: 'Installed',
@@ -59,6 +66,13 @@ export const zh = {
pluginWindowTitle: 'DeepSeek Harness — 桌面插件',
pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。',
refresh: '刷新',
enable: '启用',
disable: '禁用',
disabled: '已禁用',
retry: '重试启动',
disableAll: '禁用全部插件并重试',
recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。',
changingActivation: '正在更改插件启用状态…',
npmPackage: 'npm 包',
install: '安装',
installed: '已安装',
+48 -17
View File
@@ -49,7 +49,7 @@ const MIME: Readonly<Record<string, string>> = {
interface RuntimeResources {
readonly node: string
readonly pnpm: string
readonly seed: string
readonly dsh: string
}
function runtimeResources(): RuntimeResources {
@@ -58,8 +58,8 @@ function runtimeResources(): RuntimeResources {
?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node')
const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined)
?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')
const seed = (development ? process.env.DSH_DESKTOP_SEED_DIR : undefined) ?? join(process.resourcesPath, 'seed')
return { node, pnpm, seed }
const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? join(process.resourcesPath, 'dsh')
return { node, pnpm, dsh }
}
function developmentProject(): string | undefined {
@@ -137,8 +137,8 @@ async function main(): Promise<void> {
const activeProject = development ?? paths.profile
const hostInspectPort = developmentHostInspectPort(development !== undefined)
const manager = new DesktopProjectManager(paths, resources)
if (development === undefined) manager.recover()
let host: DesktopHostProcess | undefined
let startupError: string | undefined
let mainWindow: BrowserWindow | undefined
let pluginWindow: BrowserWindow | undefined
let shellInstallerOwnsQuit = false
@@ -157,9 +157,15 @@ async function main(): Promise<void> {
}
const startHost = async (projectDir = activeProject): Promise<DesktopHostProcess> => {
const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort)
await next.start()
return next
if (development === undefined) manager.assertProfileRuntime(projectDir)
const next = new DesktopHostProcess(resources.node, development ?? resources.dsh, projectDir, hostInspectPort)
try {
await next.start()
return next
} catch (error) {
await next.stop()
throw error
}
}
const hooks: DesktopProjectHooks = {
healthCheck: async (projectDir) => {
@@ -202,14 +208,18 @@ async function main(): Promise<void> {
},
}
if (development === undefined) {
await manager.applyRelease(resources.seed, app.getVersion(), {
...hooks,
beforeActivate: async () => {},
afterActivate: async () => {},
})
const reconcileBackend = async (): Promise<void> => {
try {
if (development === undefined) {
await manager.applyRelease(app.getVersion(), { ...hooks, beforeActivate: async () => {}, afterActivate: async () => {} })
}
host = await startHost()
startupError = undefined
} catch (error) {
startupError = errorOf(error, messages.startupFailed).message
throw error
}
}
host = await startHost()
const updates = new DesktopUpdateCoordinator(
publishUpdate,
@@ -236,7 +246,9 @@ async function main(): Promise<void> {
throw new Error('dsh desktop: plugin package changes require a packaged application')
}
await manager.mutate(mutation, hooks)
startupError = undefined
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload()
else focusPrimaryWindow()
}
ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
assertDesktopSender(event, ['shell'])
@@ -261,6 +273,20 @@ async function main(): Promise<void> {
}
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.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' }))
ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => {
assertDesktopSender(event, ['shell'])
return { ready: host !== undefined, ...(startupError === undefined ? {} : { error: startupError }) }
})
ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
assertDesktopSender(event, ['shell'])
if (host === undefined) await reconcileBackend()
focusPrimaryWindow()
})
ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
assertDesktopSender(event, ['shell'])
return updates.check()
@@ -348,6 +374,7 @@ async function main(): Promise<void> {
return window
}
focusPrimaryWindow = () => {
if (host === undefined) { openPluginWindow(); return }
const window = mainWindow
if (window === undefined || window.isDestroyed()) {
const replacement = createMainWindow()
@@ -359,9 +386,13 @@ async function main(): Promise<void> {
window.focus()
}
mainWindow = createMainWindow()
await mainWindow.loadURL(`${SCHEME}://app/index.html`)
if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
await reconcileBackend().catch(() => undefined)
if (host === undefined) openPluginWindow()
else {
mainWindow = createMainWindow()
await mainWindow.loadURL(`${SCHEME}://app/index.html`)
}
if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
mainWindow.webContents.openDevTools({ mode: 'detach' })
}
publishUpdate(updateState)
+6
View File
@@ -10,8 +10,14 @@ const api: DshDesktopApi = {
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>,
disableAll: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise<void>,
},
backend: {
status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopApi['backend']['status']>,
retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise<void>,
},
updates: {
check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise<DesktopUpdateState>,
install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise<void>,
+254
View File
@@ -0,0 +1,254 @@
/** Desktop-owned host links and validation of the external plugin dependency graph. */
import { createHash } from 'node:crypto'
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { satisfies } from 'semver'
import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
/** Applied runtime identity and the only links Desktop may replace. */
export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json'
/** Durable ownership of a package-directory link. */
export interface DesktopPackageLink {
readonly name: string
readonly target: string
}
/** Facts from the last successfully validated profile. */
export interface DesktopProfileState {
readonly schemaVersion: 1
readonly runtimeId: string
readonly version: string
readonly nodeVersion: string
readonly platform: string
readonly arch: string
readonly lockHash: string
readonly links: readonly DesktopPackageLink[]
}
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stat(path: string): ReturnType<typeof lstatSync> | undefined {
try { return lstatSync(path) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
}
}
function inside(root: string, path: string): boolean {
const child = relative(root, path)
return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`))
}
/**
* Read profile state without interpreting an unpublished predecessor format.
* @param profile - Desktop profile directory.
* @returns Validated state, or undefined for an uninitialized profile.
*/
export function readDesktopProfileState(profile: string): DesktopProfileState | undefined {
const path = join(profile, DESKTOP_PROFILE_STATE)
if (!existsSync(path)) return undefined
const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (!record(value) || value.schemaVersion !== 1 || typeof value.runtimeId !== 'string'
|| !/^[a-f0-9]{64}$/u.test(value.runtimeId) || typeof value.version !== 'string'
|| typeof value.nodeVersion !== 'string' || typeof value.platform !== 'string' || typeof value.arch !== 'string'
|| typeof value.lockHash !== 'string' || !Array.isArray(value.links)) {
throw new Error('desktop profile: invalid runtime state')
}
const links = value.links.map((link: unknown): DesktopPackageLink => {
if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name)
|| typeof link.target !== 'string' || !isAbsolute(link.target)) {
throw new Error('desktop profile: invalid managed link')
}
return { name: link.name, target: link.target }
})
if (new Set(links.map(link => link.name)).size !== links.length) throw new Error('desktop profile: duplicate managed link')
return { schemaVersion: 1, runtimeId: value.runtimeId, version: value.version, nodeVersion: value.nodeVersion,
platform: value.platform, arch: value.arch, lockHash: value.lockHash, links }
}
/**
* Hash the plugin lockfile, including the empty-profile case.
* @param profile - Desktop profile directory.
* @returns Lockfile content identity.
*/
export function desktopPluginLockHash(profile: string): string {
const lock = join(profile, 'pnpm-lock.yaml')
return createHash('sha256').update(existsSync(lock) ? readFileSync(lock) : '').digest('hex')
}
/**
* Remove only recorded host links, without following even broken targets.
* @param profile - Active or copied profile.
*/
export function unlinkDesktopHostPackages(profile: string): void {
for (const link of readDesktopProfileState(profile)?.links ?? []) {
const path = join(profile, 'node_modules', link.name)
const entry = stat(path)
if (entry === undefined) continue
if (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target)) {
throw new Error(`desktop profile: refusing to replace unowned package ${link.name}`)
}
unlinkSync(path)
}
}
/**
* Copy plugin files without copying host packages or sharing writable hardlinks.
* @param source - Active Desktop profile.
* @param target - Empty transaction profile directory.
*/
export function copyDesktopProfile(source: string, target: string): void {
const owned = readDesktopProfileState(source)?.links ?? []
for (const link of owned) {
const path = join(source, 'node_modules', link.name)
const entry = stat(path)
if (entry !== undefined && (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target))) {
throw new Error(`desktop profile: refusing to copy unowned package ${link.name}`)
}
}
const links = new Set(owned.map(link => join(source, 'node_modules', link.name)))
cpSync(source, target, { recursive: true, verbatimSymlinks: true, force: false, errorOnExist: true,
filter: path => !links.has(path) })
}
/**
* Bind an external profile to this application's real package directories.
* @param profile - Candidate profile.
* @param root - Current immutable runtime directory.
* @param runtime - Verified release descriptor.
*/
export function linkDesktopHostPackages(profile: string, root: string, runtime: DesktopRuntimeDescriptor): void {
unlinkDesktopHostPackages(profile)
const links = runtime.sharedPackages.map(entry => ({ name: entry.name, target: runtimePath(root, entry.path) }))
for (const link of links) {
const path = join(profile, 'node_modules', link.name)
if (stat(path) !== undefined) throw new Error(`desktop profile: plugin installed reserved host package ${link.name}`)
mkdirSync(dirname(path), { recursive: true })
symlinkSync(link.target, path, process.platform === 'win32' ? 'junction' : 'dir')
}
const state: DesktopProfileState = { schemaVersion: 1, runtimeId: desktopRuntimeId(runtime), version: runtime.release.version,
nodeVersion: runtime.release.nodeVersion, platform: runtime.platform, arch: runtime.arch,
lockHash: desktopPluginLockHash(profile), links }
writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 })
}
interface PackageManifest {
readonly name: string
readonly version: string
readonly dependencies: Readonly<Record<string, string>>
readonly optionalDependencies: Readonly<Record<string, string>>
readonly peerDependencies: Readonly<Record<string, string>>
readonly optionalPeers: ReadonlySet<string>
}
function manifest(path: string): PackageManifest {
const value: unknown = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8'))
if (!record(value) || typeof value.name !== 'string' || typeof value.version !== 'string') {
throw new Error(`desktop profile: invalid package manifest ${path}`)
}
const dependencies = (key: string): Record<string, string> => {
const field = value[key]
if (field === undefined) return {}
if (!record(field) || Object.entries(field).some(([name, spec]) => !PACKAGE_NAME.test(name) || typeof spec !== 'string')) {
throw new Error(`desktop profile: invalid ${key} in ${path}`)
}
return field as Record<string, string>
}
const optionalPeers = new Set<string>()
if (record(value.peerDependenciesMeta)) {
for (const [name, meta] of Object.entries(value.peerDependenciesMeta)) {
if (record(meta) && meta.optional === true) optionalPeers.add(name)
}
}
return { name: value.name, version: value.version, dependencies: dependencies('dependencies'),
optionalDependencies: dependencies('optionalDependencies'), peerDependencies: dependencies('peerDependencies'), optionalPeers }
}
function packageFrom(anchor: string, name: string): string | undefined {
if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`)
for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) {
const path = join(modules, name)
if (existsSync(join(path, 'package.json'))) return realpathSync(path)
}
return undefined
}
/**
* Prove active plugin dependencies stay local and share the host's exact package instances.
* @param profile - Profile with its generated host links present.
* @param root - Immutable runtime directory.
* @param runtime - Verified shared package inventory.
* @param activePlugins - Explicit enabled plugin roots whose peer compatibility is required.
*/
export function validateDesktopPluginGraph(
profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[],
): void {
const profileRoot = realpathSync(profile)
const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync(runtimePath(root, entry.path))]))
for (const [name, path] of shared) {
if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`)
}
const scanned = new Set<string>()
const scan = (modules: string): void => {
if (!existsSync(modules)) return
if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`)
const directory = realpathSync(modules)
if (scanned.has(directory)) return
scanned.add(directory)
for (const entry of readdirSync(modules, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue
const path = join(modules, entry.name)
if (entry.name.startsWith('@')) { scan(path); continue }
if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`)
const canonical = realpathSync(path)
const info = manifest(canonical)
const host = shared.get(info.name)
if (host !== undefined) {
if (canonical !== host || path !== join(profile, 'node_modules', info.name)) {
throw new Error(`desktop profile: duplicate or aliased host package ${info.name} at ${path}`)
}
continue
}
if (entry.isSymbolicLink()) throw new Error(`desktop profile: linked private package ${path}`)
if (!inside(profileRoot, canonical)) throw new Error(`desktop profile: package resolves outside profile: ${path}`)
scan(join(path, 'node_modules'))
}
}
scan(join(profile, 'node_modules'))
const visited = new Set<string>()
const visit = (path: string, chain: string): void => {
if (visited.has(path)) return
visited.add(path)
const info = manifest(path)
const deps = { ...info.dependencies, ...info.optionalDependencies }
for (const [name, range] of Object.entries({ ...deps, ...info.peerDependencies })) {
const peer = name in info.peerDependencies
const optional = peer ? info.optionalPeers.has(name) : name in info.optionalDependencies
const target = packageFrom(path, name)
if (target === undefined && optional) continue
if (target === undefined) throw new Error(`desktop profile: ${chain} requires missing ${name}@${range}`)
const host = shared.get(name)
if (host !== undefined && name in deps) throw new Error(`desktop profile: ${chain} must declare ${name} as a peer dependency`)
if (host !== undefined ? target !== host : !inside(profileRoot, target)) {
throw new Error(`desktop profile: ${chain} resolves ${name} outside its owned packages`)
}
const dependency = manifest(target)
if (peer && !satisfies(dependency.version, range)) {
throw new Error(`desktop profile: ${chain} requires ${name}@${range}, found ${dependency.version}`)
}
if (host === undefined) visit(target, `${chain} -> ${name}`)
}
}
for (const name of activePlugins) {
const path = packageFrom(profile, name)
if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`)
visit(path, name)
}
}
+157 -191
View File
@@ -1,11 +1,9 @@
/** Transactional owner of the reserved desktop profile and its private pnpm state. */
import { valid } from 'semver'
import { spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { randomUUID } from 'node:crypto'
import {
constants,
copyFileSync,
cpSync,
existsSync,
fsyncSync,
ftruncateSync,
@@ -13,7 +11,6 @@ import {
mkdirSync,
openSync,
closeSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
@@ -23,31 +20,23 @@ import {
} from 'node:fs'
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import {
DESKTOP_PACKAGES_DIR,
DESKTOP_PACKAGE_SET_FILE,
DESKTOP_HOST_PACKAGE,
desktopCorePackageOverrides,
desktopDshPackageSpec,
readDesktopCorePackageSet,
verifyDesktopCorePackageSet,
} from './core-package-set.ts'
import type { DesktopPaths } from './paths.ts'
import { parseDesktopRelease, type DesktopRelease } from './release.ts'
import { extractPnpmStoreArchives, mergePnpmStore } from './seed-store.ts'
/** Files the package transaction copies between active and staging projects. */
const DESKTOP_PROJECT_FILES = [
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'desktop-release.json',
DESKTOP_PACKAGE_SET_FILE,
] as const
import type { DesktopRelease } from './release.ts'
import { desktopRuntimeId, verifyDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
import {
copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState,
} from './profile-packages.ts'
/** Desktop plugin record derived from the installed profile. */
export interface DesktopPluginRecord {
readonly name: string
readonly version: string
readonly enabled: boolean
}
/** Installed desktop project manifest slice. */
@@ -68,6 +57,8 @@ interface DesktopPendingTransaction {
readonly schemaVersion: 1
readonly id: string
readonly stagingProfile: string
readonly fromRuntimeId: string | null
readonly toRuntimeId: string
readonly step: 'prepared' | 'active-moved' | 'staging-activated'
}
@@ -75,6 +66,7 @@ interface DesktopPendingTransaction {
export interface DesktopRuntimeExecutables {
readonly node: string
readonly pnpm: string
readonly dsh: string
}
/** Hooks that bind project replacement to backend lifecycle and health. */
@@ -92,12 +84,8 @@ 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 }
interface DesktopSeedIntegrityRecord {
readonly path: string
readonly bytes: number
readonly sha256: string
}
| { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean }
| { readonly type: 'plugins-disable-all' }
const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
const DSH_PACKAGE = '@deepseek-ai/dsh'
@@ -133,10 +121,6 @@ function workspaceFile(overrides: Readonly<Record<string, string>> = {}): string
return `packages:\n - .\n\n${overrideSection}${WORKSPACE_SETTINGS}allowBuilds:\n node-pty: true\n koffi: true\n fs-ext: true\n ${JSON.stringify(coreBuildKey)}: true\n '@google/genai': false\n protobufjs: false\n node-addon-require-builtin: false\n`
}
function releaseFile(projectDir: string): DesktopRelease {
return parseDesktopRelease(readJson(join(projectDir, 'desktop-release.json')))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
@@ -190,83 +174,20 @@ function removeOwnedDirectory(path: string): void {
rmSync(path, { recursive: true })
}
function copyMetadata(source: string, target: string): void {
mkdirSync(target, { recursive: true, mode: 0o700 })
for (const filename of DESKTOP_PROJECT_FILES) {
const from = join(source, filename)
if (existsSync(from)) copyFileSync(from, join(target, filename), constants.COPYFILE_EXCL)
}
cpSync(join(source, DESKTOP_PACKAGES_DIR), join(target, DESKTOP_PACKAGES_DIR), {
recursive: true,
force: false,
errorOnExist: true,
})
}
function seedFiles(root: string): readonly DesktopSeedIntegrityRecord[] {
const files: DesktopSeedIntegrityRecord[] = []
const visit = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
const relativePath = path.slice(root.length + 1).split(sep).join('/')
if (relativePath === 'integrity.json') continue
if (entry.isSymbolicLink()) throw new Error(`desktop seed: symbolic link is not allowed: ${relativePath}`)
if (entry.isDirectory()) {
visit(path)
continue
}
if (!entry.isFile()) throw new Error(`desktop seed: unsupported file type: ${relativePath}`)
const body = readFileSync(path)
files.push({
path: relativePath,
bytes: body.byteLength,
sha256: createHash('sha256').update(body).digest('hex'),
})
}
}
visit(root)
return files.sort((left, right) => left.path.localeCompare(right.path))
}
/** Verify the packaged offline seed before any content enters writable desktop state. */
export function verifySeedIntegrity(seedDir: string): void {
const integrityPath = join(seedDir, 'integrity.json')
const integrity = readJson(integrityPath)
if (!isRecord(integrity) || integrity.schemaVersion !== 2 || !Array.isArray(integrity.files)) {
throw new Error(`desktop seed: invalid integrity inventory ${integrityPath}`)
}
const expected: DesktopSeedIntegrityRecord[] = integrity.files.map((record) => {
if (!isRecord(record) || typeof record.path !== 'string' || record.path === '' || record.path.startsWith('/')
|| record.path.split('/').includes('..') || typeof record.bytes !== 'number'
|| !Number.isSafeInteger(record.bytes) || record.bytes < 0
|| typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(record.sha256)) {
throw new Error(`desktop seed: invalid integrity record in ${integrityPath}`)
}
return { path: record.path, bytes: record.bytes, sha256: record.sha256 }
}).sort((left, right) => left.path.localeCompare(right.path))
const actual = seedFiles(seedDir)
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error('desktop seed: integrity verification failed')
}
}
function projectManifest(projectDir: string): DesktopProjectManifest {
const path = join(projectDir, 'package.json')
const value = readJson(path)
const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined
const profile = isRecord(dsh?.profile) ? dsh.profile : undefined
if (!isRecord(value) || value.name !== PROJECT_NAME || value.private !== true
|| typeof value.version !== 'string' || !isRecord(value.dependencies)
|| typeof value.version !== 'string' || (value.dependencies !== undefined && !isRecord(value.dependencies))
|| !Array.isArray(profile?.bundles) || !profile.bundles.every(bundle => typeof bundle === 'string')) {
throw new Error(`desktop project: invalid desktop profile manifest ${path}`)
}
const manifest = value as unknown as DesktopProjectManifest
const packageSet = readDesktopCorePackageSet(projectDir, releaseFile(projectDir).version)
const expectedOverrides = desktopCorePackageOverrides(packageSet)
if (manifest.dependencies[DSH_PACKAGE] !== desktopDshPackageSpec(packageSet)
|| Object.entries(expectedOverrides).some(([name, spec]) => manifest.dependencies[name] !== spec)
|| readFileSync(join(projectDir, 'pnpm-workspace.yaml'), 'utf8') !== workspaceFile(expectedOverrides)) {
throw new Error(`desktop project: core package mapping does not match ${DESKTOP_PACKAGE_SET_FILE}`)
const manifest = { ...value, dependencies: value.dependencies ?? {} } as unknown as DesktopProjectManifest
if (Object.entries(manifest.dependencies).some(([name, version]) => !PACKAGE_NAME_PATTERN.test(name)
|| typeof version !== 'string' || valid(version) !== version)) {
throw new Error('desktop project: plugin dependencies must use exact registry versions')
}
return manifest
}
@@ -285,7 +206,7 @@ function profilePluginNames(projectDir: string): readonly string[] {
}
function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] {
return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name))
return Object.keys(projectManifest(projectDir).dependencies).sort().map(name => inspectPlugin(projectDir, name))
}
function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void {
@@ -296,7 +217,7 @@ function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPlugin
...manifest.dsh,
profile: {
...manifest.dsh.profile,
bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)],
bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.filter(plugin => plugin.enabled).map(plugin => plugin.name)],
},
},
} satisfies DesktopProjectManifest)
@@ -322,12 +243,13 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin
if ((patchPath !== packageDir && !patchPath.startsWith(packageDir + sep)) || !existsSync(patchPath)) {
throw new Error(`desktop project: ${requestedName}@${manifest.version} declares an invalid bundle patch`)
}
return { name: requestedName, version: manifest.version }
return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) }
}
/** Transactional desktop npm project manager. */
export class DesktopProjectManager {
private lockDescriptor: number | undefined
private descriptor: DesktopRuntimeDescriptor | undefined
/**
* @param paths - Electron-owned package state and reserved desktop profile paths.
@@ -345,6 +267,9 @@ export class DesktopProjectManager {
if (!isRecord(value) || value.schemaVersion !== 1
|| typeof value.id !== 'string' || typeof value.stagingProfile !== 'string'
|| !isDescendant(this.paths.staging, value.stagingProfile)
|| value.stagingProfile !== join(this.paths.staging, value.id, 'profile')
|| (value.fromRuntimeId !== null && (typeof value.fromRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fromRuntimeId)))
|| typeof value.toRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.toRuntimeId)
|| (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) {
throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`)
}
@@ -352,9 +277,20 @@ export class DesktopProjectManager {
schemaVersion: 1,
id: value.id,
stagingProfile: value.stagingProfile,
fromRuntimeId: value.fromRuntimeId,
toRuntimeId: value.toRuntimeId,
step: value.step,
}
if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) {
if (pending.step === 'staging-activated' && !existsSync(pending.stagingProfile) && existsSync(this.paths.profile)) {
if (readDesktopProfileState(this.paths.profile)?.runtimeId !== pending.toRuntimeId) {
throw new Error('desktop project: activated profile does not match its journal')
}
removeOwnedDirectory(this.paths.profile)
}
if (!existsSync(this.paths.profile) && pending.fromRuntimeId !== null && existsSync(this.paths.rollback)) {
if (readDesktopProfileState(this.paths.rollback)?.runtimeId !== pending.fromRuntimeId) {
throw new Error('desktop project: rollback profile does not match its journal')
}
mkdirSync(dirname(this.paths.profile), { recursive: true })
renameSync(this.paths.rollback, this.paths.profile)
}
@@ -368,64 +304,59 @@ export class DesktopProjectManager {
return pluginRecords(this.paths.profile)
}
/** Read the exact dsh version installed in the active desktop project. */
/** Read the dsh version supplied by this application's verified resources. */
dshVersion(): string {
if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
return this.installedPackageVersion(DSH_PACKAGE)
return this.currentRuntime().release.version
}
private installedPackageVersion(packageName: string): string {
const manifestPath = join(this.paths.profile, 'node_modules', ...packageName.split('/'), 'package.json')
const manifest = readJson(manifestPath)
if (!isRecord(manifest) || typeof manifest.version !== 'string') {
throw new Error(`desktop project: installed ${packageName} package has no version`)
}
assertVersion(manifest.version)
return manifest.version
}
/** Read the release version applied to the active desktop project. */
/** Read the release most recently applied to the active profile. */
releaseVersion(): string {
if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
return releaseFile(this.paths.profile).version
const state = readDesktopProfileState(this.paths.profile)
if (state === undefined) throw new Error('desktop project: active profile has no runtime state')
return state.version
}
/** Install or reconcile the active project to the Electron package's exact release. */
async applyRelease(seedDir: string, electronVersion: string, hooks: DesktopProjectHooks): Promise<boolean> {
/** Reject a profile whose dependency links were prepared for another runtime. */
assertProfileRuntime(projectDir: string): void {
if (readDesktopProfileState(projectDir)?.runtimeId !== desktopRuntimeId(this.currentRuntime())) {
throw new Error('desktop project: profile does not match this application runtime')
}
}
private currentRuntime(): DesktopRuntimeDescriptor {
if (this.descriptor === undefined) throw new Error('desktop project: runtime has not been verified')
return this.descriptor
}
private prepareProfile(projectDir: string): void {
const runtime = this.currentRuntime()
linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime)
validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir))
}
/** Verify this release and reconcile its external profile without installing core packages. */
async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise<boolean> {
return this.withLock(async () => {
this.recover()
verifySeedIntegrity(seedDir)
const target = releaseFile(seedDir)
verifyDesktopCorePackageSet(seedDir, target.version)
if (target.version !== electronVersion) {
throw new Error(`desktop project: seed ${target.version} does not match Electron ${electronVersion}`)
const target = verifyDesktopRuntime(this.runtime.dsh, electronVersion)
this.descriptor = target
const previous = readDesktopProfileState(this.paths.profile)
if (existsSync(this.paths.profile) && previous === undefined) {
throw new Error('desktop project: existing profile is not a Desktop plugin profile')
}
if (existsSync(this.paths.profile) && this.releaseVersion() === target.version
&& this.dshVersion() === target.version
&& this.installedPackageVersion(DESKTOP_HOST_PACKAGE) === target.version) {
verifyDesktopCorePackageSet(this.paths.profile, target.version)
if (previous?.runtimeId === desktopRuntimeId(target)
&& previous.lockHash === desktopPluginLockHash(this.paths.profile)
&& previous.links.length === target.sharedPackages.length
&& previous.links.every(link => link.target === join(this.runtime.dsh, 'node_modules', link.name)
&& existsSync(join(this.paths.profile, 'node_modules', link.name)))) {
validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile))
return false
}
this.mergeSeedPnpmState(seedDir)
const stagingProfile = this.newStagingProfile()
try {
if (existsSync(this.paths.profile)) {
const plugins = pluginRecords(this.paths.profile)
copyMetadata(seedDir, stagingProfile)
await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile'])
if (plugins.length > 0) {
await this.runPnpm(stagingProfile, [
'add',
...plugins.map(plugin => `${plugin.name}@${plugin.version}`),
'--save-exact',
'--offline',
])
writeProfilePlugins(stagingProfile, plugins)
}
} else {
copyMetadata(seedDir, stagingProfile)
await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile'])
}
if (previous === undefined) createPluginProfile(stagingProfile)
else copyDesktopProfile(this.paths.profile, stagingProfile)
await this.reconcileProfile(stagingProfile, previous)
await hooks.healthCheck(stagingProfile)
await this.activate(stagingProfile, hooks)
return true
@@ -436,16 +367,18 @@ export class DesktopProjectManager {
})
}
/** Apply one exact dependency mutation through a staging project. */
/** Apply an exact plugin dependency or activation change through a staging project. */
async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise<void> {
await this.withLock(async () => {
this.recover()
this.currentRuntime()
if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
verifyDesktopCorePackageSet(this.paths.profile, this.releaseVersion())
const stagingProfile = this.newStagingProfile()
try {
copyMetadata(this.paths.profile, stagingProfile)
copyDesktopProfile(this.paths.profile, stagingProfile)
await this.applyMutation(stagingProfile, mutation)
await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile),
mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all')
await hooks.healthCheck(stagingProfile)
await this.activate(stagingProfile, hooks)
} catch (error) {
@@ -455,6 +388,25 @@ export class DesktopProjectManager {
})
}
private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise<void> {
const target = this.currentRuntime()
this.prepareProfile(projectDir)
const rebuild = previous !== undefined && pluginRecords(projectDir).length > 0
&& (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch)
if (rebuild) {
unlinkDesktopHostPackages(projectDir)
removeOwnedDirectory(join(projectDir, 'node_modules'))
await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts'])
}
if (rebuild || packagesChanged) await this.finishPackageOperation(projectDir)
}
private async finishPackageOperation(projectDir: string): Promise<void> {
this.prepareProfile(projectDir)
await this.runPnpm(projectDir, ['rebuild', '--pending'])
this.prepareProfile(projectDir)
}
private newStagingProfile(): string {
const path = join(this.paths.staging, randomUUID(), 'profile')
mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -466,8 +418,11 @@ export class DesktopProjectManager {
case 'plugin-add': {
const requestedName = packageNameFromSpec(mutation.spec)
if (requestedName === undefined) throw new Error('desktop project: plugin package name is required')
await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact'])
const installed = inspectPlugin(projectDir, requestedName)
if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) {
throw new Error(`desktop project: cannot install host-owned package ${requestedName}`)
}
await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact', '--ignore-scripts'])
const installed = { ...inspectPlugin(projectDir, requestedName), enabled: true }
const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name)
writeProfilePlugins(
projectDir,
@@ -477,21 +432,21 @@ export class DesktopProjectManager {
}
case 'plugin-remove': {
assertPackageName(mutation.name)
if (!profilePluginNames(projectDir).includes(mutation.name)) {
if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) {
throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`)
}
const remaining = pluginRecords(projectDir).filter(plugin => plugin.name !== mutation.name)
await this.runPnpm(projectDir, ['remove', mutation.name])
await this.runPnpm(projectDir, ['remove', mutation.name, '--config.ignore-scripts=true'])
writeProfilePlugins(projectDir, remaining)
return
}
case 'plugin-update':
assertPackageName(mutation.name)
assertVersion(mutation.version)
if (!profilePluginNames(projectDir).includes(mutation.name)) {
if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) {
throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`)
}
await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact'])
await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact', '--ignore-scripts'])
{
const installed = inspectPlugin(projectDir, mutation.name)
writeProfilePlugins(
@@ -500,33 +455,37 @@ export class DesktopProjectManager {
)
}
return
case 'plugins-disable-all':
writeProfilePlugins(projectDir, pluginRecords(projectDir).map(plugin => ({ ...plugin, enabled: false })))
return
case 'plugin-toggle': {
assertPackageName(mutation.name)
const plugins = pluginRecords(projectDir)
if (!plugins.some(plugin => plugin.name === mutation.name)) throw new Error(`desktop project: plugin ${mutation.name} is not installed`)
writeProfilePlugins(projectDir, plugins.map(plugin => (
plugin.name === mutation.name ? { ...plugin, enabled: mutation.enabled } : plugin
)))
return
}
default:
mutation satisfies never
}
}
private mergeSeedPnpmState(seedDir: string): void {
const transactionRoot = join(this.paths.staging, randomUUID())
const extractedStore = join(transactionRoot, 'store')
try {
extractPnpmStoreArchives(seedDir, extractedStore)
mergePnpmStore(extractedStore, this.paths.pnpm.store)
} finally {
removeOwnedDirectory(transactionRoot)
}
}
private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise<void> {
const pending: DesktopPendingTransaction = {
schemaVersion: 1,
id: basename(dirname(stagingProfile)),
stagingProfile,
fromRuntimeId: readDesktopProfileState(this.paths.profile)?.runtimeId ?? null,
toRuntimeId: desktopRuntimeId(this.currentRuntime()),
step: 'prepared',
}
writeJson(this.paths.pending, pending)
await hooks.beforeActivate()
let activeMoved = false
let stagingActivated = false
try {
await hooks.beforeActivate()
removeOwnedDirectory(this.paths.rollback)
mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 })
writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction)
@@ -537,10 +496,11 @@ export class DesktopProjectManager {
mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 })
writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction)
renameSync(stagingProfile, this.paths.profile)
stagingActivated = true
await hooks.afterActivate()
unlinkSync(this.paths.pending)
} catch (error) {
if (existsSync(this.paths.profile)) removeOwnedDirectory(this.paths.profile)
if (stagingActivated) removeOwnedDirectory(this.paths.profile)
if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile)
if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending)
await hooks.afterActivate().catch(() => undefined)
@@ -558,7 +518,7 @@ export class DesktopProjectManager {
const npmrc = join(this.paths.pnpm.config, 'npmrc')
if (!existsSync(npmrc)) writeFileSync(npmrc, '', { mode: 0o600 })
const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
!/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
)))
await new Promise<void>((settle, reject) => {
const child = spawn(this.runtime.node, [
@@ -585,19 +545,7 @@ export class DesktopProjectManager {
},
stdio: ['ignore', 'pipe', 'pipe'],
})
const childPid = child.pid
if (childPid === undefined) {
child.kill('SIGKILL')
reject(new Error('desktop project: pnpm did not report a process id'))
return
}
try {
this.writeLockOwner(childPid)
} catch (error) {
child.kill('SIGKILL')
reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm'))
return
}
let failure: Error | undefined
let diagnostics = ''
let completed = false
const appendDiagnostics = (chunk: string): void => {
@@ -618,9 +566,10 @@ export class DesktopProjectManager {
}
settleChild()
}
child.once('error', (error) => { complete(() => { reject(error) }) })
child.once('error', (error) => { failure = error })
child.once('close', (code, signal) => {
complete(() => {
if (failure !== undefined) { reject(failure); return }
if (code === 0) {
settle()
return
@@ -630,6 +579,13 @@ export class DesktopProjectManager {
))
})
})
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')
}
})
}
@@ -682,10 +638,10 @@ export class DesktopProjectManager {
}
}
/** Create seed metadata for one exact Electron and dsh release. */
export function createSeedMetadata(seedDir: string, release: DesktopRelease): void {
mkdirSync(seedDir, { recursive: true, mode: 0o700 })
const packageSet = verifyDesktopCorePackageSet(seedDir, release.version)
/** Create build-only project metadata for materializing the signed runtime. */
export function createRuntimeProjectMetadata(projectDir: string, release: DesktopRelease): void {
mkdirSync(projectDir, { recursive: true, mode: 0o700 })
const packageSet = verifyDesktopCorePackageSet(projectDir, release.version)
const manifest: DesktopProjectManifest = {
name: PROJECT_NAME,
private: true,
@@ -693,13 +649,13 @@ export function createSeedMetadata(seedDir: string, release: DesktopRelease): vo
dependencies: desktopCorePackageOverrides(packageSet),
dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
}
writeJson(join(seedDir, 'package.json'), manifest)
writeJson(join(projectDir, 'package.json'), manifest)
writeFileSync(
join(seedDir, 'pnpm-workspace.yaml'),
join(projectDir, 'pnpm-workspace.yaml'),
workspaceFile(desktopCorePackageOverrides(packageSet)),
{ mode: 0o600 },
)
writeJson(join(seedDir, 'desktop-release.json'), release)
writeJson(join(projectDir, 'desktop-release.json'), release)
}
/**
@@ -723,3 +679,13 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De
writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
writeJson(join(projectDir, 'desktop-release.json'), release)
}
/** Create the first external plugin profile without running a package manager. */
export function createPluginProfile(projectDir: string): void {
mkdirSync(projectDir, { recursive: true, mode: 0o700 })
writeJson(join(projectDir, 'package.json'), {
name: PROJECT_NAME, private: true, version: '0.0.0', dependencies: {},
dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
} satisfies DesktopProjectManifest)
writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
}
+2 -2
View File
@@ -1,9 +1,9 @@
/** Immutable version identity shared by one Electron shell and its dsh seed. */
/** Immutable version identity shared by one Electron shell and its bundled dsh runtime. */
import { valid } from 'semver'
import { DESKTOP_HOST_PROTOCOL_VERSION } from './host-protocol.ts'
/** Release facts embedded in the seed and copied into the active desktop project. */
/** Release facts embedded in the bundled runtime descriptor. */
export interface DesktopRelease {
readonly schemaVersion: 1
/** Exact version used by both Electron and `@deepseek-ai/dsh`. */
+180
View File
@@ -0,0 +1,180 @@
/** Relocatable, integrity-recorded production packages carried by one Desktop release. */
import { createHash } from 'node:crypto'
import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { isAbsolute, join, relative, sep } from 'node:path'
import { valid } from 'semver'
import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts'
import { parseDesktopRelease, type DesktopRelease } from './release.ts'
/** Descriptor at the root of the immutable Desktop resource tree. */
export const DESKTOP_RUNTIME_FILE = 'desktop-runtime.json'
/** Host-owned package available to external plugins through a directory link. */
export interface DesktopSharedPackage {
readonly name: string
readonly version: string
readonly path: string
}
/** Final bytes and executable permissions of a runtime file. */
export interface DesktopRuntimeFile {
readonly path: string
readonly bytes: number
readonly sha256: string
readonly executable: boolean
}
/** One signed application's production dependency tree. */
export interface DesktopRuntimeDescriptor {
readonly schemaVersion: 1
readonly release: DesktopRelease
readonly platform: NodeJS.Platform
readonly arch: string
readonly sharedPackages: readonly DesktopSharedPackage[]
readonly files: readonly DesktopRuntimeFile[]
}
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Resolve one portable resource path without permitting traversal or absolute paths.
* @param root - Runtime root.
* @param path - Slash-separated relative path from durable metadata.
* @returns Absolute resource path.
*/
export function runtimePath(root: string, path: string): string {
if (path === '' || isAbsolute(path) || path.includes('\\') || path.includes(':')
|| path.split('/').some(part => part === '' || part === '.' || part === '..')) {
throw new Error(`desktop runtime: invalid relative path ${JSON.stringify(path)}`)
}
return join(root, ...path.split('/'))
}
/**
* Inventory a materialized runtime without following links or including its descriptor.
* @param root - Self-contained runtime directory.
* @returns Sorted final-file inventory.
*/
export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] {
const files: DesktopRuntimeFile[] = []
const visit = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
const name = relative(root, path).split(sep).join('/')
if (name === DESKTOP_RUNTIME_FILE) continue
if (entry.isDirectory()) visit(path)
else if (entry.isFile()) {
const body = readFileSync(path)
files.push({ path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'),
executable: (lstatSync(path).mode & 0o111) !== 0 })
} else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`)
}
}
visit(root)
return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
}
/**
* Seal the final runtime tree after materialization and native signing.
* @param root - Runtime output directory.
* @param release - Matching shell, dsh, Host, and executable versions.
* @param sharedNames - Release-owned packages supplied to plugins.
* @param target - Platform and architecture selected by runtime preparation.
* @returns Descriptor written beside the production packages.
*/
export function writeDesktopRuntime(
root: string, release: DesktopRelease, sharedNames: readonly string[],
target: { platform: NodeJS.Platform; arch: string } = process,
): DesktopRuntimeDescriptor {
const sharedPackages = [...new Set(sharedNames)].sort().map((name) => {
if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`)
const path = `node_modules/${name}`
const manifest = JSON.parse(readFileSync(join(runtimePath(root, path), 'package.json'), 'utf8')) as unknown
if (!record(manifest) || manifest.name !== name || typeof manifest.version !== 'string' || valid(manifest.version) === null) {
throw new Error(`desktop runtime: invalid shared package manifest ${name}`)
}
return { name, version: manifest.version, path }
})
const descriptor: DesktopRuntimeDescriptor = {
schemaVersion: 1, release, platform: target.platform, arch: target.arch,
sharedPackages, files: inventoryDesktopRuntime(root),
}
writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`)
return descriptor
}
/**
* Verify the runtime before linking any of its packages into writable state.
* @param root - Current application's runtime resources.
* @param electronVersion - Expected shell version.
* @param target - Required execution target; defaults to the current process.
* @returns Validated runtime descriptor.
*/
export function verifyDesktopRuntime(
root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
): DesktopRuntimeDescriptor {
const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8'))
if (!record(value) || value.schemaVersion !== 1 || value.platform !== target.platform || value.arch !== target.arch
|| !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) {
throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture')
}
const release = parseDesktopRelease(value.release)
if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`)
const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => {
if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name)
|| typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) {
throw new Error('desktop runtime: invalid shared package record')
}
return { name: entry.name, version: entry.version, path: entry.path }
})
if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) {
throw new Error('desktop runtime: duplicate shared package')
}
const files = value.files.map((entry: unknown): DesktopRuntimeFile => {
if (!record(entry) || typeof entry.path !== 'string' || typeof entry.bytes !== 'number'
|| !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || typeof entry.executable !== 'boolean'
|| typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.sha256)) {
throw new Error('desktop runtime: invalid file inventory')
}
runtimePath(root, entry.path)
return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable }
}).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
const actual = inventoryDesktopRuntime(root)
// Windows has no portable Unix executable permission bits.
const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'
? items.map(({ executable: _executable, ...item }) => item) : items
if (JSON.stringify(comparable(files)) !== JSON.stringify(comparable(actual))) {
throw new Error('desktop runtime: integrity verification failed')
}
for (const entry of sharedPackages) {
const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8'))
if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) {
throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`)
}
}
for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) {
if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) {
throw new Error(`desktop runtime: missing or mismatched ${name}`)
}
}
for (const file of DESKTOP_HOST_RUNTIME_FILES) {
if (!files.some(entry => entry.path === `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`)) {
throw new Error(`desktop runtime: missing Host file ${file}`)
}
}
return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files }
}
/**
* Identify exact runtime content independently of its installation path.
* @param descriptor - Validated runtime metadata.
* @returns SHA-256 runtime identity.
*/
export function desktopRuntimeId(descriptor: DesktopRuntimeDescriptor): string {
return createHash('sha256').update(JSON.stringify(descriptor)).digest('hex')
}
-271
View File
@@ -1,271 +0,0 @@
/** Deterministic archive transport for the desktop seed's pnpm store. */
import { createHash } from 'node:crypto'
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { join, relative, sep } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { create, extract, list } from 'tar'
/** Directory containing the seed's uncompressed pnpm store archives. */
export const SEED_STORE_ARCHIVE_DIR = 'store-archives'
/** Manifest describing the deterministic pnpm store archive set. */
export const SEED_STORE_ARCHIVE_MANIFEST = 'store-archives.json'
const DEFAULT_SHARD_COUNT = 16
const ARCHIVE_NAME_PATTERN = /^store-[0-9a-f]{2}\.tar$/u
const STORE_VERSION_PATTERN = /^v\d+$/u
interface SeedStoreArchiveRecord {
readonly file: string
readonly entries: number
}
interface SeedStoreArchiveManifest {
readonly schemaVersion: 1
readonly shardCount: number
readonly archives: readonly SeedStoreArchiveRecord[]
}
function storeFiles(storeRoot: string): readonly string[] {
const files: string[] = []
const visit = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
if (entry.isSymbolicLink()) {
throw new Error(`desktop seed: pnpm store contains a symbolic link: ${relative(storeRoot, path)}`)
}
if (entry.isDirectory()) {
visit(path)
continue
}
if (!entry.isFile()) {
throw new Error(`desktop seed: pnpm store contains an unsupported file: ${relative(storeRoot, path)}`)
}
files.push(relative(storeRoot, path).split(sep).join('/'))
}
}
visit(storeRoot)
return files.sort((left, right) => left.localeCompare(right))
}
function shardFor(path: string, shardCount: number): number {
return createHash('sha256').update(path).digest().readUInt32BE(0) % shardCount
}
function readArchiveManifest(seedRoot: string): SeedStoreArchiveManifest {
const path = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST)
const value = JSON.parse(readFileSync(path, 'utf8')) as unknown
if (typeof value !== 'object' || value === null) {
throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`)
}
const candidate = value as Record<string, unknown>
if (candidate.schemaVersion !== 1 || !Number.isSafeInteger(candidate.shardCount)
|| (candidate.shardCount as number) < 1 || (candidate.shardCount as number) > 256
|| !Array.isArray(candidate.archives) || candidate.archives.length === 0) {
throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`)
}
const names = new Set<string>()
const archives = candidate.archives.map((entry): SeedStoreArchiveRecord => {
if (typeof entry !== 'object' || entry === null) {
throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`)
}
const record = entry as Record<string, unknown>
if (typeof record.file !== 'string' || !ARCHIVE_NAME_PATTERN.test(record.file)
|| names.has(record.file) || !Number.isSafeInteger(record.entries) || (record.entries as number) < 1) {
throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`)
}
const shard = Number.parseInt(record.file.slice('store-'.length, -'.tar'.length), 16)
if (shard >= (candidate.shardCount as number)) {
throw new Error(`desktop seed: pnpm store archive shard is outside the manifest range in ${path}`)
}
names.add(record.file)
return { file: record.file, entries: record.entries as number }
})
return {
schemaVersion: 1,
shardCount: candidate.shardCount as number,
archives,
}
}
function assertArchivePath(path: string): void {
if (path === '' || path.startsWith('/') || path.includes('\\') || path.includes('\0')
|| path.split('/').some(part => part === '' || part === '.' || part === '..')) {
throw new Error(`desktop seed: unsafe pnpm store archive path ${JSON.stringify(path)}`)
}
}
/**
* Remove pnpm's registrations for projects that populated the seed store.
* @param storeRoot - pnpm store directory included in the desktop seed.
*/
export function removePnpmProjectRegistrations(storeRoot: string): void {
for (const entry of readdirSync(storeRoot, { withFileTypes: true })) {
if (!entry.isDirectory() || !/^v\d+$/u.test(entry.name)) continue
rmSync(join(storeRoot, entry.name, 'projects'), { recursive: true, force: true })
}
}
function mergeStoreIndex(source: string, destination: string): void {
if (!existsSync(destination)) {
copyFileSync(source, destination)
return
}
const database = new DatabaseSync(destination)
let attached = false
try {
database.exec('PRAGMA busy_timeout=5000')
database.prepare('ATTACH DATABASE ? AS seed').run(source)
attached = true
database.exec('BEGIN IMMEDIATE')
let committed = false
try {
database.exec('INSERT OR REPLACE INTO package_index (key, data) SELECT key, data FROM seed.package_index')
database.exec('COMMIT')
committed = true
} finally {
if (!committed) database.exec('ROLLBACK')
}
} finally {
if (attached) database.exec('DETACH DATABASE seed')
database.close()
}
}
/**
* Merge a completely extracted seed store into Desktop's persistent pnpm store.
* @param source - Verified temporary store extraction.
* @param destination - Desktop-owned persistent pnpm store.
*/
export function mergePnpmStore(source: string, destination: string): void {
mkdirSync(destination, { recursive: true, mode: 0o700 })
const indexPaths = readdirSync(source, { withFileTypes: true })
.filter(entry => entry.isDirectory() && STORE_VERSION_PATTERN.test(entry.name)
&& existsSync(join(source, entry.name, 'index.db')))
.map(entry => `${entry.name}/index.db`)
const indexes = new Set(indexPaths)
cpSync(source, destination, {
recursive: true,
force: true,
filter: path => !indexes.has(relative(source, path).split(sep).join('/')),
})
for (const path of indexPaths) {
mergeStoreIndex(join(source, ...path.split('/')), join(destination, ...path.split('/')))
}
}
/**
* Replace a prepared loose pnpm store with deterministic uncompressed archive shards.
* @param seedRoot - seed directory that owns the archive output.
* @param storeRoot - populated pnpm store to archive and remove after success.
* @param shardCount - stable shard count used to limit update churn.
*/
export function archivePnpmStore(
seedRoot: string,
storeRoot: string,
shardCount = DEFAULT_SHARD_COUNT,
): void {
if (!Number.isSafeInteger(shardCount) || shardCount < 1 || shardCount > 256) {
throw new Error(`desktop seed: invalid pnpm store shard count ${shardCount}`)
}
const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR)
const manifestPath = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST)
rmSync(archiveRoot, { recursive: true, force: true })
rmSync(manifestPath, { force: true })
mkdirSync(archiveRoot, { recursive: true })
const shards = Array.from({ length: shardCount }, (): string[] => [])
for (const path of storeFiles(storeRoot)) (shards[shardFor(path, shardCount)] as string[]).push(path)
const archives: SeedStoreArchiveRecord[] = []
for (const [index, paths] of shards.entries()) {
if (paths.length === 0) continue
const file = `store-${index.toString(16).padStart(2, '0')}.tar`
create({
cwd: storeRoot,
file: join(archiveRoot, file),
noDirRecurse: true,
noMtime: true,
portable: true,
sync: true,
}, paths)
chmodSync(join(archiveRoot, file), 0o644)
archives.push({ file, entries: paths.length })
}
if (archives.length === 0) throw new Error('desktop seed: pnpm store is empty')
writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, shardCount, archives }, undefined, 2)}\n`)
rmSync(storeRoot, { recursive: true })
}
/**
* Validate and extract a packaged pnpm store archive set into an empty directory.
* @param seedRoot - verified packaged seed directory.
* @param destination - empty Desktop-owned temporary extraction directory.
*/
export function extractPnpmStoreArchives(seedRoot: string, destination: string): void {
const manifest = readArchiveManifest(seedRoot)
const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR)
const actualFiles = readdirSync(archiveRoot, { withFileTypes: true }).map((entry) => {
if (!entry.isFile() || entry.isSymbolicLink()) {
throw new Error(`desktop seed: invalid pnpm store archive entry ${entry.name}`)
}
return entry.name
}).sort()
const expectedFiles = manifest.archives.map(archive => archive.file).sort()
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error('desktop seed: pnpm store archive set does not match its manifest')
}
if (existsSync(destination) && readdirSync(destination).length !== 0) {
throw new Error(`desktop seed: pnpm store extraction directory is not empty: ${destination}`)
}
mkdirSync(destination, { recursive: true, mode: 0o700 })
const paths = new Set<string>()
for (const archive of manifest.archives) {
const archivePath = join(archiveRoot, archive.file)
const archiveShard = Number.parseInt(archive.file.slice('store-'.length, -'.tar'.length), 16)
let entries = 0
list({
file: archivePath,
onReadEntry: (entry) => {
if (entry.type !== 'File' && entry.type !== 'OldFile') {
throw new Error(`desktop seed: unsupported pnpm store archive entry type ${entry.type}`)
}
assertArchivePath(entry.path)
if (shardFor(entry.path, manifest.shardCount) !== archiveShard) {
throw new Error(`desktop seed: pnpm store path is assigned to the wrong archive shard: ${entry.path}`)
}
if (paths.has(entry.path)) {
throw new Error(`desktop seed: duplicate pnpm store archive path ${entry.path}`)
}
paths.add(entry.path)
entries += 1
},
strict: true,
sync: true,
})
if (entries !== archive.entries) {
throw new Error(`desktop seed: pnpm store archive ${archive.file} has an unexpected entry count`)
}
}
for (const archive of manifest.archives) {
extract({
chmod: true,
cwd: destination,
file: join(archiveRoot, archive.file),
noMtime: true,
preservePaths: false,
processUmask: 0,
strict: true,
sync: true,
})
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
/** One Electron release stream for the version-bound shell and dsh seed. */
/** One Electron release stream for the version-bound shell and bundled dsh runtime. */
import { existsSync } from 'node:fs'
import { join } from 'node:path'
@@ -15,8 +15,8 @@ describe('desktop build paths', () => {
'artifacts',
'runtime',
'packageSet',
'seed',
'seedPnpm',
'dsh',
'dshPnpm',
'nodeExtract',
'packedDsh',
'packedVendor',
@@ -27,7 +27,7 @@ describe('desktop build paths', () => {
expect(new Set([arm64[key], x64[key], windows[key]]).size).toBe(3)
}
expect(arm64.artifacts).toContain(join('targets', 'mac-arm64', 'artifacts'))
expect(x64.seed).toContain(join('targets', 'mac-x64', 'seed'))
expect(x64.dsh).toContain(join('targets', 'mac-x64', 'dsh'))
expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime'))
})
+28 -7
View File
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -75,6 +75,27 @@ afterEach(() => {
})
describe('desktop host process', () => {
it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => {
const runtime = projectWithHost(`
process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' })
function onRequestFrame(frame) {
if (frame.type !== 1) return
responseStart(frame.streamId)
responseData(frame.streamId, JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH}))
responseEnd(frame.streamId)
}
`)
const profile = mkdtempSync(join(tmpdir(), 'desktop-external-profile-'))
roots.push(profile)
const host = new DesktopHostProcess(process.execPath, runtime, profile, undefined, {
...process.env, NODE_OPTIONS: '--invalid-desktop-test-option', NODE_PATH: '/unowned',
})
try {
const response = await host.fetch(new Request('dsh-app://app/environment'))
expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile) })
} finally { await host.stop() }
})
it('carries raw request and response bytes and shuts the child down cleanly', async () => {
const project = projectWithHost(`
const bodies = new Map()
@@ -98,7 +119,7 @@ function answer(streamId) {
`)
const previous = process.env.NODE_OPTIONS
process.env.NODE_OPTIONS = '--require /path/that-must-not-reach-the-child'
const host = new DesktopHostProcess(process.execPath, project)
const host = new DesktopHostProcess(process.execPath, project, project)
try {
await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' })
const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' }))
@@ -124,7 +145,7 @@ function onRequestFrame(frame) {
responseEnd(frame.streamId)
}
`)
const host = new DesktopHostProcess(process.execPath, project)
const host = new DesktopHostProcess(process.execPath, project, project)
try {
const response = await host.fetch(new Request('dsh-app://app/large'))
const body = new Uint8Array(await response.arrayBuffer())
@@ -151,7 +172,7 @@ function onRequestFrame(frame) {
start(controller) { controller.enqueue(Buffer.from('first')) },
cancel() { canceled = true },
})
const host = new DesktopHostProcess(process.execPath, project)
const host = new DesktopHostProcess(process.execPath, project, project)
try {
const request = new Request('dsh-app://app/early', {
method: 'POST',
@@ -184,7 +205,7 @@ function onRequestFrame(frame) {
}
}
`)
const host = new DesktopHostProcess(process.execPath, project)
const host = new DesktopHostProcess(process.execPath, project, project)
try {
const canceled = await host.fetch(new Request('dsh-app://app/cancel'))
await canceled.body?.cancel()
@@ -202,7 +223,7 @@ process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' })
function onRequestFrame(frame) {
if (frame.type === 1) responsePipe.write(Buffer.alloc(13))
}
`))
`), projectWithHost(''))
await invalid.start()
await expect(invalid.fetch(new Request('dsh-app://app/invalid'))).rejects.toThrow(/invalid Host response frame marker/u)
await invalid.stop().catch(() => undefined)
@@ -210,7 +231,7 @@ function onRequestFrame(frame) {
const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(`
function onRequestFrame() {}
process.exit(0)
`))
`), projectWithHost(''))
await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { signMacOSRuntime } from '../scripts/macos-runtime.ts'
import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs'
vi.mock('../scripts/verify-macos-signature.mjs', () => ({ signMacOSRuntimeCode: vi.fn(), verifyMacOSRuntimeCode: vi.fn() }))
const roots: string[] = []
function root(): string {
const path = mkdtempSync(join(tmpdir(), 'desktop-signing-'))
roots.push(path)
return path
}
const identity = { signingIdentity: 'Example (TEAMID1234)', teamId: 'TEAMID1234' }
afterEach(() => {
vi.resetAllMocks()
for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true })
})
it('signs Mach-O files in their final locations and verifies each signature', async () => {
const path = root()
writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex'))
writeFileSync(join(path, 'source.js'), 'export {}')
await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1)
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity)
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity)
})
it('awaits other signers before rejecting and permitting output cleanup', async () => {
const path = root()
for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex'))
let release!: () => void
const barrier = new Promise<void>((resolve) => { release = resolve })
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
vi.mocked(signMacOSRuntimeCode).mockImplementation(async (file) => {
if (file.endsWith('a.node')) throw new Error('sign failure')
started()
await barrier
})
let completed = false
const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error })
try {
await ready
expect(completed).toBe(false)
} finally { release() }
expect(await result).toBeInstanceOf(AggregateError)
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity)
})
-245
View File
@@ -1,245 +0,0 @@
import { createHash } from 'node:crypto'
import {
appendFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { Packr } from 'msgpackr'
import { afterEach, describe, expect, it } from 'vitest'
import {
signMacOSSeedStore,
verifyMacOSSeedStore,
} from '../scripts/macos-seed-store.ts'
const temporaryRoots: string[] = []
const packr = new Packr({ moreTypes: true, useRecords: true })
const SIGNING_ENVIRONMENT = {
signingIdentity: 'Example Company (TEAMID1234)',
teamId: 'TEAMID1234',
}
function temporaryRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-test-'))
temporaryRoots.push(root)
return root
}
function casPath(store: string, body: Buffer, executable = false): { digest: string; path: string } {
const digest = createHash('sha512').update(body).digest('hex')
return {
digest,
path: join(store, 'v11', 'files', digest.slice(0, 2), `${digest.slice(2)}${executable ? '-exec' : ''}`),
}
}
function createStoreFile(path: string, body: Buffer): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, body)
}
afterEach(() => {
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('desktop macOS seed store signing', () => {
it('rehashes signed Mach-O content, rewrites every package reference, and prunes native orphans', async () => {
const store = temporaryRoot()
const native = Buffer.concat([Buffer.from('cffaedfe', 'hex'), Buffer.from('native-code')])
const nativeCas = casPath(store, native)
createStoreFile(nativeCas.path, native)
const orphan = Buffer.concat([Buffer.from('cafebabe', 'hex'), Buffer.from('orphan')])
const orphanCas = casPath(store, orphan)
createStoreFile(orphanCas.path, orphan)
const plain = Buffer.from('plain package content')
const plainCas = casPath(store, plain)
createStoreFile(plainCas.path, plain)
const database = new DatabaseSync(join(store, 'v11', 'index.db'))
database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID')
const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)')
for (const key of ['package-a', 'package-b']) {
insert.run(key, packr.pack({
algo: 'sha512',
files: new Map([
['native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }],
['index.js', { checkedAt: 1, digest: plainCas.digest, mode: 0o644, size: plain.length }],
]),
sideEffects: key === 'package-b'
? new Map([['build', { added: new Map([
['built/native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }],
]) }]])
: undefined,
}))
}
database.close()
const result = await signMacOSSeedStore(
store,
'com.example.desktop',
SIGNING_ENVIRONMENT,
{
signer: async (path, identifier) => {
expect(identifier).toBe(`com.example.desktop.seed.${nativeCas.digest.slice(0, 32)}`)
appendFileSync(path, 'signed')
},
},
)
expect(result).toEqual({ signedFiles: 1, prunedOrphans: 1, updatedIndexRows: 2 })
expect(existsSync(nativeCas.path)).toBe(false)
expect(existsSync(orphanCas.path)).toBe(false)
expect(readFileSync(plainCas.path)).toEqual(plain)
const updated = new DatabaseSync(join(store, 'v11', 'index.db'), { readOnly: true })
const digests = [...updated.prepare('SELECT data FROM package_index').iterate() as Iterable<{ data: Uint8Array }>]
.flatMap((row) => {
const record = packr.unpack(row.data) as {
files: Map<string, { digest: string }>
sideEffects?: Map<string, { added: Map<string, { digest: string }> }>
}
return [
record.files.get('native.node')?.digest,
...[...(record.sideEffects?.values() ?? [])].map(effect => effect.added.get('built/native.node')?.digest),
].filter((digest): digest is string => digest !== undefined)
})
updated.close()
expect(new Set(digests).size).toBe(1)
expect(digests).toHaveLength(3)
expect(digests[0]).not.toBe(nativeCas.digest)
const verified: string[] = []
expect(verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, (path) => { verified.push(path) })).toBe(1)
expect(verified).toHaveLength(1)
})
it('bounds concurrent signing while allowing independent Mach-O files to overlap', async () => {
const store = temporaryRoot()
const files = new Map<string, { checkedAt: number; digest: string; mode: number; size: number }>()
for (let index = 0; index < 6; index += 1) {
const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)])
const nativeCas = casPath(store, body)
createStoreFile(nativeCas.path, body)
files.set(`native-${index}.node`, {
checkedAt: 1,
digest: nativeCas.digest,
mode: 0o644,
size: body.length,
})
}
const database = new DatabaseSync(join(store, 'v11', 'index.db'))
database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID')
database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)')
.run('package', packr.pack({ algo: 'sha512', files }))
database.close()
let started = 0
let active = 0
let maximumActive = 0
let releaseSigning = (): void => {}
const signingReleased = new Promise<void>((resolve) => { releaseSigning = resolve })
let markFirstWaveReady = (): void => {}
const firstWaveReady = new Promise<void>((resolve) => { markFirstWaveReady = resolve })
const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, {
concurrency: 4,
signer: async () => {
started += 1
active += 1
maximumActive = Math.max(maximumActive, active)
if (started === 4) markFirstWaveReady()
try {
await signingReleased
} finally {
active -= 1
}
},
})
await firstWaveReady
expect({ started, active, maximumActive }).toEqual({ started: 4, active: 4, maximumActive: 4 })
releaseSigning()
await expect(signing).resolves.toMatchObject({ signedFiles: 6, updatedIndexRows: 1 })
expect({ started, active, maximumActive }).toEqual({ started: 6, active: 0, maximumActive: 4 })
})
it('awaits active signers and preserves the store when one signer fails', async () => {
const store = temporaryRoot()
const originals: { digest: string; path: string }[] = []
const files = new Map<string, { checkedAt: number; digest: string; mode: number; size: number }>()
for (let index = 0; index < 2; index += 1) {
const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)])
const nativeCas = casPath(store, body)
originals.push(nativeCas)
createStoreFile(nativeCas.path, body)
files.set(`native-${index}.node`, {
checkedAt: 1,
digest: nativeCas.digest,
mode: 0o644,
size: body.length,
})
}
const databasePath = join(store, 'v11', 'index.db')
const database = new DatabaseSync(databasePath)
database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID')
database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)')
.run('package', packr.pack({ algo: 'sha512', files }))
database.close()
const originalIndex = readFileSync(databasePath)
let started = 0
let settled = 0
let releaseSigning = (): void => {}
const signingReleased = new Promise<void>((resolve) => { releaseSigning = resolve })
let markBothReady = (): void => {}
const bothReady = new Promise<void>((resolve) => { markBothReady = resolve })
const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, {
concurrency: 2,
signer: async () => {
started += 1
const call = started
if (started === 2) markBothReady()
try {
await signingReleased
if (call === 1) throw new Error('signing failed')
} finally {
settled += 1
}
},
})
await bothReady
releaseSigning()
await expect(signing).rejects.toThrow(/signing failed/u)
expect({ started, settled }).toEqual({ started: 2, settled: 2 })
expect(readFileSync(databasePath)).toEqual(originalIndex)
for (const original of originals) expect(existsSync(original.path)).toBe(true)
})
it('rejects an invalid signing worker bound before starting a signer', async () => {
const store = temporaryRoot()
mkdirSync(join(store, 'v11', 'files'), { recursive: true })
await expect(signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, {
concurrency: 0,
signer: async () => {},
})).rejects.toThrow(/positive integer/u)
})
it('propagates a signature-verification failure', () => {
const store = temporaryRoot()
const native = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from('native-code')])
const nativeCas = casPath(store, native)
createStoreFile(nativeCas.path, native)
expect(() => {
verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, () => {
throw new Error('invalid signature')
})
}).toThrow(/invalid signature/u)
})
})
+41 -8
View File
@@ -1,3 +1,10 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { createRequire } from 'node:module'
import { FileMatcher } from 'app-builder-lib/out/fileMatcher.js'
import { runtimeFixture } from './runtime-fixture.ts'
import { verifyDesktopRuntime } from '../src/runtime-tree.ts'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import type { NotarizeOptions } from '@electron/notarize'
import {
@@ -7,10 +14,15 @@ import {
} from '../scripts/desktop-release-environment.mjs'
import { notarizeMacOSDiskImageArtifact } from '../scripts/notarize-macos-disk-images.mjs'
import {
assertMacOSSeedSignatureDetails,
assertMacOSRuntimeSignatureDetails,
assertMacOSSignatureDetails,
} from '../scripts/verify-macos-signature.mjs'
// app-builder-lib omits this internal copier from its declarations; the regression exercises its actual file filter.
const { copyFiles } = createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js') as {
copyFiles: (matchers: FileMatcher[]) => Promise<void>
}
const RELEASE_ENVIRONMENT = {
DSH_DESKTOP_APP_ID: 'com.example.desktop',
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
@@ -40,17 +52,18 @@ describe('desktop macOS release signature', () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
expect(config.extraResources).toHaveLength(2)
expect(config.extraResources).toHaveLength(3)
expect(config.extraResources[0]?.to).toBe('runtime')
expect(config.extraResources[1]?.to).toBe('seed')
expect(config.extraResources[1]?.to).toBe('dsh')
expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime')
expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/seed')
expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/dsh')
expect(config).toMatchObject({
appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
mac: {
identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY,
forceCodeSigning: true,
notarize: true,
signIgnore: ['/Contents/Resources/dsh(?:/|$)'],
},
dmg: {
sign: true,
@@ -64,6 +77,26 @@ describe('desktop macOS release signature', () => {
expect(typeof config.artifactBuildCompleted).toBe('function')
})
it('copies the complete runtime despite electron-builder excluding root node_modules', async () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
const root = mkdtempSync(join(tmpdir(), 'desktop-resource-copy-'))
try {
const source = join(root, 'source')
const destination = join(root, 'resources')
runtimeFixture(source)
const sourceRoot = config.extraResources[1].from
const matchers = config.extraResources.slice(1).map(entry => new FileMatcher(
join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value,
))
await copyFiles(matchers.slice(0, 1))
expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).toThrow(/integrity/u)
rmSync(destination, { recursive: true })
await copyFiles(matchers)
expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).not.toThrow()
} finally { rmSync(root, { recursive: true, force: true }) }
})
it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
expect(() => createElectronBuilderConfig({
@@ -82,7 +115,7 @@ describe('desktop macOS release signature', () => {
}).not.toThrow()
})
it('requires a secure timestamp and hardened runtime for seed code', () => {
it('requires a secure timestamp and hardened runtime for runtime code', () => {
const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT)
const details = [
`Authority=Developer ID Application: ${expected.signingIdentity}`,
@@ -90,12 +123,12 @@ describe('desktop macOS release signature', () => {
'Timestamp=31 Aug 2026 at 20:00:00',
'CodeDirectory v=20500 size=773 flags=0x10000(runtime) hashes=13+7 location=embedded',
].join('\n')
expect(() => { assertMacOSSeedSignatureDetails(details, expected) }).not.toThrow()
expect(() => { assertMacOSRuntimeSignatureDetails(details, expected) }).not.toThrow()
expect(() => {
assertMacOSSeedSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected)
assertMacOSRuntimeSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected)
}).toThrow(/secure timestamp/u)
expect(() => {
assertMacOSSeedSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected)
assertMacOSRuntimeSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected)
}).toThrow(/hardened runtime/u)
})
+1 -1
View File
@@ -56,7 +56,7 @@ describe('desktop package target', () => {
expect(desktopElectronBuilderArguments(target, true)).toContain('--dir')
})
it('keeps Windows signing fields out of build and seed preparation subprocesses', () => {
it('keeps Windows signing fields out of build and runtime preparation subprocesses', () => {
expect(withoutWindowsSigningEnvironment({
DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer',
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
+33
View File
@@ -0,0 +1,33 @@
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 and offers recovery without a running backend', async () => {
const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
let enabled = true
let ready = false
const disableAll = vi.fn(async () => { enabled = false; ready = true })
const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active })
const api = {
locale: async () => resolveDesktopLocale('en'),
backend: { status: async () => ({ ready, error: ready ? undefined : 'plugin requires Cordis ^2.0.0' }), retry: vi.fn() },
plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle },
}
Object.defineProperty(dom.window, 'dshDesktop', { value: api })
try {
runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
const document = dom.window.document
await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin')
expect(document.querySelector<HTMLElement>('#recovery')?.hidden).toBe(false)
expect(document.querySelector('#startup-error')?.textContent).toBe('plugin requires Cordis ^2.0.0')
document.querySelector<HTMLButtonElement>('#disable-all')?.click()
await expect.poll(() => document.querySelector<HTMLElement>('#recovery')?.hidden).toBe(true)
expect(disableAll).toHaveBeenCalledOnce()
expect(document.querySelector('#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() }
})
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { createServer } from 'node:http'
import { 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 { 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, then executes approved scripts with the shared host instance', async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-')))
const server = createServer()
const archives = new Map<string, Buffer>()
try {
for (const name of ['fixture-plugin', 'node-pty']) {
const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin'
? { dependencies: { 'node-pty': '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, host:require.resolve("@deepseek-ai/cordis")}))')
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: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : {}),
} }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } }))
})
const dsh = join(root, 'dsh')
runtimeFixture(dsh)
const pnpm = join(root, 'pnpm.mjs')
const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs')
writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`)
const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh })
const hooks: DesktopProjectHooks = { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {} }
await manager.applyRelease('1.0.0', hooks)
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks)
expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }])
const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string; host: string }
expect(realpathSync(built.node)).toBe(realpathSync(process.execPath))
expect(built.host).toBe(join(dsh, 'node_modules/@deepseek-ai/cordis/index.js'))
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-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)
@@ -0,0 +1,81 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, 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, expect, it } from 'vitest'
import { createPluginProfile } from '../src/project-manager.ts'
import { copyDesktopProfile, linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts'
import { runtimeFixture, writePackage } from './runtime-fixture.ts'
const roots: string[] = []
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'desktop-profile-'))
roots.push(root)
const dsh = join(root, 'dsh')
const runtime = runtimeFixture(dsh)
const profile = join(root, 'profile')
createPluginProfile(profile)
linkDesktopHostPackages(profile, dsh, runtime)
return { root, dsh, runtime, profile }
}
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
it('loads one shared ESM instance from both host and external plugin while keeping ordinary dependencies private', () => {
const { dsh, runtime, profile } = fixture()
writePackage(join(dsh, 'node_modules'), 'ordinary', {}, 'export default "host"')
writePackage(join(profile, 'node_modules'), 'ordinary', {}, 'export default "plugin"')
const plugin = writePackage(join(profile, 'node_modules'), 'plugin', {
peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dependencies: { ordinary: '1.0.0' },
}, 'export { identity } from "@deepseek-ai/cordis"; export { default as ordinary } from "ordinary"')
validateDesktopPluginGraph(profile, dsh, runtime, ['plugin'])
const entry = join(dsh, 'check.mjs')
writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import ordinary from 'ordinary'; import * as plugin from ${JSON.stringify(pathToFileURL(join(plugin, 'index.js')).href)}; console.log(JSON.stringify({same:identity===plugin.identity, host:ordinary, plugin:plugin.ordinary}))`)
const output = execFileSync(process.execPath, [entry], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } })
expect(JSON.parse(output)).toEqual({ same: true, host: 'host', plugin: 'plugin' })
})
it.each(['nested', 'alias'])('rejects a %s second copy of a host package', (placement) => {
const { dsh, runtime, profile } = fixture()
const plugin = writePackage(join(profile, 'node_modules'), 'plugin')
if (placement === 'nested') writePackage(join(plugin, 'node_modules'), '@deepseek-ai/cordis')
else writePackage(join(profile, 'node_modules'), 'alias', { name: '@deepseek-ai/cordis' })
expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/duplicate or aliased/u)
})
it('rejects a host package declared as an ordinary dependency', () => {
const { dsh, runtime, profile } = fixture()
writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { '@deepseek-ai/cordis': '^1.0.0' } })
expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/peer dependency/u)
})
it('rejects incompatible peers only when the plugin is enabled', () => {
const { dsh, runtime, profile } = fixture()
writePackage(join(profile, 'node_modules'), 'plugin', { peerDependencies: { '@deepseek-ai/cordis': '^2.0.0' } })
expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/found 1.0.0/u)
expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, []) }).not.toThrow()
})
it('refuses to satisfy a plugin dependency from an ancestor CLI project', () => {
const { root, dsh, runtime, profile } = fixture()
writePackage(join(root, 'node_modules'), 'ambient')
writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { ambient: '1.0.0' } })
expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/outside its owned packages/u)
})
it('copies writable plugin bytes independently and removes broken owned links without following them', () => {
const { root, profile } = fixture()
writePackage(join(profile, 'node_modules'), 'plugin')
copyDesktopProfile(profile, join(root, 'copy'))
writeFileSync(join(root, 'copy/node_modules/plugin/index.js'), 'changed')
expect(readFileSync(join(profile, 'node_modules/plugin/index.js'), 'utf8')).toContain('identity')
rmSync(join(root, 'dsh'), { recursive: true })
expect(() =>{ unlinkDesktopHostPackages(profile) }).not.toThrow()
})
it('refuses to replace an unowned package at a managed name', () => {
const { profile } = fixture()
unlinkSync(join(profile, 'node_modules/@deepseek-ai/cordis'))
writePackage(join(profile, 'node_modules'), '@deepseek-ai/cordis')
expect(() =>{ unlinkDesktopHostPackages(profile) }).toThrow(/unowned package/u)
})
it('rejects private package links instead of following cycles or old transaction paths', () => {
const { dsh, runtime, profile } = fixture()
const plugin = writePackage(join(profile, 'node_modules'), 'plugin')
symlinkSync(plugin, join(profile, 'node_modules/alias'), process.platform === 'win32' ? 'junction' : 'dir')
expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/linked private package/u)
})
+181 -356
View File
@@ -1,392 +1,217 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, relative, sep } from 'node:path'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveDesktopPaths } from '../src/paths.ts'
import {
createSeedMetadata,
DesktopProjectManager,
packageNameFromSpec,
verifySeedIntegrity,
type DesktopProjectHooks,
} from '../src/project-manager.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { DESKTOP_PACKAGES_DIR, DESKTOP_PACKAGE_SET_FILE } from '../src/core-package-set.ts'
import type { DesktopRelease } from '../src/release.ts'
import { archivePnpmStore } from '../src/seed-store.ts'
import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts'
import { readDesktopProfileState } from '../src/profile-packages.ts'
import { runtimeFixture } from './runtime-fixture.ts'
const roots: string[] = []
function temporaryRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-'))
roots.push(root)
return root
}
function writeIntegrity(seed: string): void {
const paths: string[] = []
const visit = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
if (entry.isDirectory()) visit(path)
else if (entry.name !== 'integrity.json') paths.push(path)
}
}
visit(seed)
const files = paths.sort().map((path) => {
const body = readFileSync(path)
return {
path: relative(seed, path).split(sep).join('/'),
bytes: statSync(path).size,
sha256: createHash('sha256').update(body).digest('hex'),
}
})
writeFileSync(join(seed, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files })}\n`)
}
function archiveStore(seed: string): void {
const store = join(seed, 'store')
mkdirSync(store, { recursive: true })
if (readdirSync(store).length === 0) writeFileSync(join(store, 'test-entry'), 'content')
archivePnpmStore(seed, store)
}
function writeCorePackageSet(seed: string, version: string): void {
const packages = [
{ name: '@deepseek-ai/dsh', file: `deepseek-ai-dsh-${version}.tgz`, body: Buffer.from(`dsh-${version}`) },
{
name: '@deepseek-ai/dsh-desktop-host',
file: `deepseek-ai-dsh-desktop-host-${version}.tgz`,
body: Buffer.from(`desktop-host-${version}`),
},
]
mkdirSync(join(seed, DESKTOP_PACKAGES_DIR), { recursive: true })
for (const entry of packages) writeFileSync(join(seed, DESKTOP_PACKAGES_DIR, entry.file), entry.body)
writeFileSync(join(seed, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({
schemaVersion: 1,
packages: packages.map(({ name, file, body }) => ({
name,
version,
file,
bytes: body.byteLength,
integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`,
})),
})}\n`)
}
function createTestSeedMetadata(seed: string, desktopRelease: DesktopRelease): void {
writeCorePackageSet(seed, desktopRelease.version)
createSeedMetadata(seed, desktopRelease)
}
function writeFakePnpm(root: string): string {
const path = join(root, 'pnpm.mjs')
writeFileSync(path, String.raw`
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
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 => value === 'install' || value === 'add' || value === 'remove')
const manifestPath = join(project, 'package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const packageName = spec => spec.startsWith('@')
? spec.slice(0, spec.indexOf('@', spec.indexOf('/') + 1) === -1 ? undefined : spec.indexOf('@', spec.indexOf('/') + 1))
: spec.split('@')[0]
const packageVersion = spec => {
const index = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@')
return index === -1 ? '1.0.0' : spec.slice(index + 1)
}
if (command === 'add') {
const spec = args[args.indexOf('add') + 1]
manifest.dependencies[packageName(spec)] = packageVersion(spec)
}
if (command === 'remove') delete manifest.dependencies[args[args.indexOf('remove') + 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.split('/'))
mkdirSync(packageRoot, { recursive: true })
const core = name === '@deepseek-ai/dsh' || name === '@deepseek-ai/dsh-desktop-host'
const plugin = !core
const installedVersion = plugin
? version
: JSON.parse(readFileSync(join(project, 'desktop-release.json'), 'utf8')).version
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
name, version: installedVersion,
...(plugin ? { dsh: { bundle: { patch: './bundle.yml' } } } : {}),
}))
if (plugin) writeFileSync(join(packageRoot, 'bundle.yml'), '[]\n')
else if (name === '@deepseek-ai/dsh-desktop-host') {
mkdirSync(join(packageRoot, 'lib'), { recursive: true })
writeFileSync(join(packageRoot, 'lib', 'index.js'), '')
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}) + '\\n')
if (command !== 'rebuild') {
const manifestPath = join(project, 'package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (command === 'add') {
const spec = args[args.indexOf(command) + 1]
const index = spec.lastIndexOf('@')
const name = index > 0 ? spec.slice(0, index) : spec
manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0'
}
if (command === 'remove') delete manifest.dependencies[args[args.indexOf(command) + 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))
}
writeFileSync(join(project, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
if (process.env.TEST_PNPM_LOG) writeFileSync(process.env.TEST_PNPM_LOG, JSON.stringify({ args, env: process.env }))
`)
return path
}
function writeBlockingFakePnpm(root: string, ready: string, release: string): string {
const path = join(root, 'blocking-pnpm.mjs')
const delegate = writeFakePnpm(root)
writeFileSync(path, `
import { existsSync, writeFileSync } from 'node:fs'
import { setTimeout as sleep } from 'node:timers/promises'
writeFileSync(${JSON.stringify(ready)}, String(process.pid))
while (!existsSync(${JSON.stringify(release)})) await sleep(10)
await import(${JSON.stringify(pathToFileURL(delegate).href)})
`)
return path
}
function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
return {
healthCheck: async () => {},
beforeActivate: async () => {},
afterActivate: async () => {},
...overrides,
}
return { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides }
}
function release(version = '1.0.0'): DesktopRelease {
return {
schemaVersion: 1,
version,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
}
function setup(): { root: string; manager: DesktopProjectManager } {
const root = temporaryRoot()
const dsh = join(root, 'resources', 'dsh')
runtimeFixture(dsh)
return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) }
}
function calls(root: string): { args: string[]; registry: string }[] {
const path = join(root, 'pnpm-log.jsonl')
return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : []
}
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('desktop package policy', () => {
it('accepts registry package specs but rejects alternate sources and flags', () => {
describe('desktop external plugin profile', () => {
it('accepts registry names and tags but rejects alternate sources and flags', () => {
expect(packageNameFromSpec('@scope/[email protected]')).toBe('@scope/plugin')
expect(packageNameFromSpec('plugin@next')).toBe('plugin')
expect(() => packageNameFromSpec('file:../plugin')).toThrow(/unsupported npm package spec/u)
expect(() => packageNameFromSpec('--registry=evil')).toThrow(/unsupported npm package spec/u)
expect(() => packageNameFromSpec('https://example.test/plugin.tgz')).toThrow(/unsupported npm package spec/u)
})
it('rejects any seed content changed after release inventory generation', () => {
const seed = join(temporaryRoot(), 'seed')
createTestSeedMetadata(seed, release())
writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
writeIntegrity(seed)
expect(() => { verifySeedIntegrity(seed) }).not.toThrow()
writeFileSync(join(seed, 'package.json'), '{}\n')
expect(() => { verifySeedIntegrity(seed) }).toThrow(/integrity verification failed/u)
})
})
describe('desktop project transactions', () => {
it('installs the offline seed and reconciles a mismatched private Host', async () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
const log = join(root, 'pnpm-log.json')
createTestSeedMetadata(seed, release())
writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
mkdirSync(join(seed, 'store'), { recursive: true })
writeFileSync(join(seed, 'store', 'seed-entry'), 'content')
archiveStore(seed)
writeIntegrity(seed)
const paths = resolveDesktopPaths(join(root, '.dsh'))
const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
const previousLog = process.env.TEST_PNPM_LOG
const previousRegistry = process.env.npm_config_registry
process.env.TEST_PNPM_LOG = log
process.env.npm_config_registry = 'https://user-registry.invalid'
try {
await expect(manager.applyRelease(seed, '2.0.0', hooks())).rejects.toThrow(/does not match Electron/u)
await manager.applyRelease(seed, '1.0.0', hooks())
writeFileSync(
join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'),
'{"name":"@deepseek-ai/dsh-desktop-host","version":"0.9.0"}\n',
)
await expect(manager.applyRelease(seed, '1.0.0', hooks())).resolves.toBe(true)
} finally {
if (previousLog === undefined) delete process.env.TEST_PNPM_LOG
else process.env.TEST_PNPM_LOG = previousLog
if (previousRegistry === undefined) delete process.env.npm_config_registry
else process.env.npm_config_registry = previousRegistry
for (const spec of ['file:../plugin', '--registry=evil', 'https://example.test/plugin.tgz']) {
expect(() => packageNameFromSpec(spec)).toThrow(/unsupported npm package spec/u)
}
expect(manager.dshVersion()).toBe('1.0.0')
expect(manager.releaseVersion()).toBe('1.0.0')
expect(paths.profile).toBe(join(root, '.dsh', 'profiles', 'desktop'))
expect(existsSync(join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(true)
const installedHost = JSON.parse(readFileSync(
join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'),
'utf8',
)) as { version: string }
expect(installedHost.version).toBe('1.0.0')
expect(existsSync(join(paths.profile, 'desktop-plugins.json'))).toBe(false)
expect(readFileSync(join(paths.pnpm.store, 'seed-entry'), 'utf8')).toBe('content')
const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record<string, string> }
expect(invocation.args).toContain('--offline')
expect(invocation.args).toContain('--trust-lockfile')
expect(invocation.args).toContain(`--config.store-dir=${paths.pnpm.store}`)
expect(invocation.args).toContain('--config.enable-global-virtual-store=false')
expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/')
expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/')
expect(invocation.env.NPM_CONFIG_STORE_DIR).toBe(paths.pnpm.store)
expect(invocation.env.NPM_CONFIG_USERCONFIG).toBe(join(paths.pnpm.config, 'npmrc'))
expect(invocation.env.npm_config_registry).toBeUndefined()
})
it('restores the active project when the replacement backend cannot start', async () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
createTestSeedMetadata(seed, release())
writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
archiveStore(seed)
writeIntegrity(seed)
const paths = resolveDesktopPaths(join(root, '.dsh'))
const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
await manager.applyRelease(seed, '1.0.0', hooks())
let starts = 0
await expect(manager.mutate({ type: 'plugin-add', spec: '@scope/[email protected]' }, hooks({
afterActivate: async () => {
starts += 1
if (starts === 1) throw new Error('backend rejected staged graph')
},
}))).rejects.toThrow(/backend rejected staged graph/u)
it('initializes and restarts offline without executing pnpm', async () => {
const { root, manager } = setup()
await expect(manager.applyRelease('2.0.0', hooks())).rejects.toThrow(/does not match Electron/u)
await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true)
await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(false)
expect(manager.listPlugins()).toEqual([])
expect(manager.dshVersion()).toBe('1.0.0')
expect(starts).toBe(2)
expect(calls(root)).toEqual([])
expect(existsSync(manager.paths.pnpm.store)).toBe(false)
expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis')))
expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} })
})
it('restores rollback when the active move completed before its journal update', async () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
createTestSeedMetadata(seed, release())
writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
archiveStore(seed)
writeIntegrity(seed)
const paths = resolveDesktopPaths(join(root, '.dsh'))
const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
await manager.applyRelease(seed, '1.0.0', hooks())
it('repairs a removed managed link without running pnpm', async () => {
const { root, manager } = setup()
await manager.applyRelease('1.0.0', hooks())
unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))
await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true)
expect(calls(root)).toEqual([])
})
it('installs only plugins and checks the graph before running lifecycle scripts', async () => {
const { root, manager } = setup()
await manager.applyRelease('1.0.0', hooks())
await manager.mutate({ type: 'plugin-add', spec: '@scope/[email protected]' }, hooks())
const stagingProfile = join(paths.staging, 'interrupted', 'profile')
mkdirSync(stagingProfile, { recursive: true })
writeFileSync(join(stagingProfile, 'marker'), 'staging')
rmSync(paths.rollback, { recursive: true, force: true })
mkdirSync(dirname(paths.rollback), { recursive: true })
renameSync(paths.profile, paths.rollback)
writeFileSync(paths.pending, `${JSON.stringify({
schemaVersion: 1,
id: 'interrupted',
stagingProfile,
step: 'prepared',
})}\n`)
manager.recover()
expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }])
expect(existsSync(stagingProfile)).toBe(false)
expect(existsSync(paths.pending)).toBe(false)
})
it('records the live pnpm worker as transaction owner until it exits', async () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
const ready = join(root, 'pnpm-ready')
const releaseWorker = join(root, 'pnpm-release')
createTestSeedMetadata(seed, release())
writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
archiveStore(seed)
writeIntegrity(seed)
const paths = resolveDesktopPaths(join(root, '.dsh'))
const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) }
const manager = new DesktopProjectManager(paths, runtime)
const installing = manager.applyRelease(seed, '1.0.0', hooks())
await expect.poll(() => existsSync(ready)).toBe(true)
const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10)
expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`)
const competing = new DesktopProjectManager(paths, runtime)
await expect(competing.applyRelease(seed, '1.0.0', hooks())).rejects.toThrow(/another package transaction is active/u)
writeFileSync(releaseWorker, 'continue')
await expect(installing).resolves.toBe(true)
expect(existsSync(paths.lock)).toBe(false)
})
it('keeps core packages local while installing plugins from the desktop registry', async () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
const log = join(root, 'pnpm-log.json')
createTestSeedMetadata(seed, release())
writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
archiveStore(seed)
writeIntegrity(seed)
const paths = resolveDesktopPaths(join(root, '.dsh'))
const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
await manager.applyRelease(seed, '1.0.0', hooks())
const previousLog = process.env.TEST_PNPM_LOG
process.env.TEST_PNPM_LOG = log
try {
await manager.mutate({ type: 'plugin-add', spec: '@scope/[email protected]' }, hooks())
} finally {
if (previousLog === undefined) delete process.env.TEST_PNPM_LOG
else process.env.TEST_PNPM_LOG = previousLog
}
const manifest = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as {
dependencies: Record<string, string>
}
const coreSpec = manifest.dependencies['@deepseek-ai/dsh']
expect(coreSpec).toMatch(/^file:\.\/desktop-packages\//u)
expect(readFileSync(join(paths.profile, 'pnpm-workspace.yaml'), 'utf8'))
.toContain(`${JSON.stringify('@deepseek-ai/dsh')}: ${JSON.stringify(coreSpec)}`)
expect(manifest.dependencies['@scope/plugin']).toBe('2.0.0')
const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record<string, string> }
expect(invocation.args).toContain('add')
expect(invocation.args).toContain('@scope/[email protected]')
expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/')
expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/')
})
it('reconciles dsh to the packaged release without removing desktop plugins', async () => {
const root = temporaryRoot()
const paths = resolveDesktopPaths(join(root, '.dsh'))
const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
const firstSeed = join(root, 'seed-1')
createTestSeedMetadata(firstSeed, release('1.0.0'))
writeFileSync(join(firstSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
mkdirSync(join(firstSeed, 'store'), { recursive: true })
writeFileSync(join(firstSeed, 'store', 'release-1'), 'one')
archiveStore(firstSeed)
writeIntegrity(firstSeed)
await manager.applyRelease(firstSeed, '1.0.0', hooks())
await manager.mutate({ type: 'plugin-add', spec: '@scope/[email protected]' }, hooks())
const nextSeed = join(root, 'seed-2')
createTestSeedMetadata(nextSeed, release('1.1.0'))
writeFileSync(join(nextSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
mkdirSync(join(nextSeed, 'store'), { recursive: true })
writeFileSync(join(nextSeed, 'store', 'release-2'), 'two')
archiveStore(nextSeed)
writeIntegrity(nextSeed)
await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(true)
expect(manager.releaseVersion()).toBe('1.1.0')
expect(manager.dshVersion()).toBe('1.1.0')
expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }])
const profile = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as {
dsh: { profile: { bundles: string[] } }
}
expect(profile.dsh.profile.bundles).toEqual([
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-web-app',
'@scope/plugin',
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/[email protected]', '--save-exact', '--ignore-scripts'], ['rebuild', '--pending'],
])
expect(readFileSync(join(paths.pnpm.store, 'release-1'), 'utf8')).toBe('one')
expect(readFileSync(join(paths.pnpm.store, 'release-2'), 'utf8')).toBe('two')
await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(false)
expect(calls(root).every(call => call.registry === 'https://registry.npmjs.org/')).toBe(true)
expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } })
await expect(manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis' }, hooks())).rejects.toThrow(/host-owned/u)
})
it('retains disabled plugin versions through updates and enables them explicitly', async () => {
const { root, manager } = setup()
await manager.applyRelease('1.0.0', hooks())
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
await manager.mutate({ type: 'plugins-disable-all' }, hooks())
expect(calls(root)).toHaveLength(2)
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('1.0.0', hooks())
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
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('1.1.0', hooks())).resolves.toBe(true)
expect(next.listPlugins()).toEqual(manager.listPlugins())
expect(next.releaseVersion()).toBe('1.1.0')
expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
expect(calls(root)).toHaveLength(2)
expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis')))
writeFileSync(join(manager.paths.rollback, 'node_modules/plugin/bundle.yml'), 'rollback only')
expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
})
it('reinstalls the locked plugin graph when bundled Node changes', async () => {
const { root, manager } = setup()
await manager.applyRelease('1.0.0', hooks())
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
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('1.1.0', hooks())
expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'],
])
expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
})
it('allows incompatible plugins to be disabled in recovery without deleting them', async () => {
const { root, manager } = setup()
await manager.applyRelease('1.0.0', hooks())
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
const dsh = join(root, 'next-major')
runtimeFixture(dsh, '2.0.0')
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
await expect(next.applyRelease('2.0.0', hooks())).rejects.toThrow(/requires @deepseek-ai\/cordis/u)
expect(next.releaseVersion()).toBe('1.0.0')
expect(() =>{ next.assertProfileRuntime(manager.paths.profile) }).toThrow(/does not match/u)
await next.mutate({ type: 'plugins-disable-all' }, hooks())
expect(next.releaseVersion()).toBe('2.0.0')
expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
})
it.each(['health', 'before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => {
const { manager } = setup()
await manager.applyRelease('1.0.0', hooks())
let starts = 0
await expect(manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks({
healthCheck: async () => { if (phase === 'health') throw new Error('health failed') },
beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') },
afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') },
}))).rejects.toThrow(`${phase} failed`)
expect(manager.listPlugins()).toEqual([])
expect(existsSync(manager.paths.pending)).toBe(false)
})
it('recovers a directory move using recorded runtime identities', async () => {
const { manager } = setup()
await manager.applyRelease('1.0.0', hooks())
const id = readDesktopProfileState(manager.paths.profile)?.runtimeId
const stagingProfile = join(manager.paths.staging, 'interrupted', 'profile')
mkdirSync(stagingProfile, { recursive: true })
mkdirSync(dirname(manager.paths.rollback), { recursive: true })
renameSync(manager.paths.profile, manager.paths.rollback)
writeFileSync(manager.paths.pending, JSON.stringify({ schemaVersion: 1, id: 'interrupted', stagingProfile, fromRuntimeId: id, toRuntimeId: id, step: 'active-moved' }))
manager.recover()
expect(manager.releaseVersion()).toBe('1.0.0')
expect(existsSync(stagingProfile)).toBe(false)
expect(existsSync(manager.paths.pending)).toBe(false)
})
it('holds the transaction lock until the pnpm worker exits', async () => {
const { root, manager } = setup()
await manager.applyRelease('1.0.0', hooks())
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('1.0.0', hooks())
const pending = worker.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
try {
await expect.poll(() => existsSync(ready)).toBe(true)
expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8'))
await expect(manager.applyRelease('1.0.0', hooks())).rejects.toThrow(/another package transaction/u)
} finally {
writeFileSync(release, 'continue')
await pending
}
expect(existsSync(manager.paths.lock)).toBe(false)
})
})
+42
View File
@@ -0,0 +1,42 @@
/** Temporary materialized packages for Desktop resource and profile behavior tests. */
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
/**
* Write a package fixture with explicit runtime exports.
* @param modules - Owning node_modules directory.
* @param name - Package name.
* @param fields - Manifest fields.
* @param source - ESM entry contents.
* @returns Installed package directory.
*/
export function writePackage(modules: string, name: string, fields: Record<string, unknown> = {}, source = 'export const identity = {}\n'): string {
const path = join(modules, name)
mkdirSync(path, { recursive: true })
writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.js', ...fields }))
writeFileSync(join(path, 'index.js'), source)
return path
}
/**
* Seal a minimal release containing Host entry files and a shared Cordis package.
* @param root - New runtime directory.
* @param version - Shell and dsh version.
* @param nodeVersion - Bundled Node version used for native rebuild selection.
* @returns Sealed runtime metadata.
*/
export function runtimeFixture(root: string, version = '1.0.0', nodeVersion = '24.17.0'): DesktopRuntimeDescriptor {
const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/cordis']
for (const name of names) writePackage(join(root, 'node_modules'), name, { version })
for (const file of DESKTOP_HOST_RUNTIME_FILES) {
const path = join(root, 'node_modules', '@deepseek-ai/dsh-desktop-host', file)
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, '')
}
writeFileSync(join(root, 'package.json'), '{"type":"module"}\n')
return writeDesktopRuntime(root, { schemaVersion: 1, version, nodeVersion, pnpmVersion: '11.7.0', hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }, names)
}
+46
View File
@@ -0,0 +1,46 @@
import { cpSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
import { runtimeFixture } from './runtime-fixture.ts'
const roots: string[] = []
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-'))
roots.push(root)
runtimeFixture(join(root, 'dsh'))
return root
}
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
it('verifies a runtime after relocation without depending on build paths', () => {
const root = fixture()
const before = verifyDesktopRuntime(join(root, 'dsh'), '1.0.0')
cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true })
expect(desktopRuntimeId(verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before))
})
it.each(['changed', 'extra', 'missing'])('rejects %s runtime bytes', (operation) => {
const dsh = join(fixture(), 'dsh')
if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}')
if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '')
if (operation === 'missing') rmSync(join(dsh, 'package.json'))
expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/integrity/u)
})
it('rejects filesystem links and incompatible targets', () => {
const dsh = join(fixture(), 'dsh')
expect(() => verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u)
symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir')
expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/unsupported filesystem/u)
})
it('rejects a descriptor that maps a shared package outside node_modules', () => {
const dsh = join(fixture(), 'dsh')
const path = join(dsh, DESKTOP_RUNTIME_FILE)
const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { sharedPackages: { path: string }[] }
descriptor.sharedPackages[0]!.path = '../outside'
writeFileSync(path, JSON.stringify(descriptor))
expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package record/u)
})
it.each(['../outside', '/absolute', 'C:/absolute', 'a\\b', 'a//b', './a'])('rejects nonportable path %s', (path) => {
expect(() => runtimePath('/runtime', path)).toThrow(/invalid relative path/u)
})
-167
View File
@@ -1,167 +0,0 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import {
archivePnpmStore,
extractPnpmStoreArchives,
mergePnpmStore,
removePnpmProjectRegistrations,
SEED_STORE_ARCHIVE_DIR,
SEED_STORE_ARCHIVE_MANIFEST,
} from '../src/seed-store.ts'
const temporaryRoots: string[] = []
function temporaryRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-store-'))
temporaryRoots.push(root)
return root
}
function archiveBytes(seed: string): readonly { path: string; body: Buffer }[] {
return [SEED_STORE_ARCHIVE_MANIFEST, ...readdirSync(join(seed, SEED_STORE_ARCHIVE_DIR))]
.map(path => ({
path,
body: readFileSync(path === SEED_STORE_ARCHIVE_MANIFEST
? join(seed, path)
: join(seed, SEED_STORE_ARCHIVE_DIR, path)),
}))
}
afterEach(() => {
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('desktop seed store cleanup', () => {
it('removes project registrations without removing package data', () => {
const storeRoot = temporaryRoot()
mkdirSync(join(storeRoot, 'v11', 'projects', 'temporary-project'), { recursive: true })
mkdirSync(join(storeRoot, 'v12', 'projects'), { recursive: true })
mkdirSync(join(storeRoot, 'metadata', 'projects'), { recursive: true })
writeFileSync(join(storeRoot, 'v11', 'package-data'), 'package')
removePnpmProjectRegistrations(storeRoot)
expect(existsSync(join(storeRoot, 'v11', 'projects'))).toBe(false)
expect(existsSync(join(storeRoot, 'v12', 'projects'))).toBe(false)
expect(existsSync(join(storeRoot, 'v11', 'package-data'))).toBe(true)
expect(existsSync(join(storeRoot, 'metadata', 'projects'))).toBe(true)
})
})
describe('desktop seed store merge', () => {
it('preserves installed package records while the verified seed replaces matching records and files', () => {
const root = temporaryRoot()
const source = join(root, 'source')
const destination = join(root, 'destination')
for (const store of [source, destination]) {
mkdirSync(join(store, 'v11', 'files'), { recursive: true })
const database = new DatabaseSync(join(store, 'v11', 'index.db'))
database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID')
const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)')
if (store === source) {
insert.run('seed-only', Buffer.from('seed'))
insert.run('shared', Buffer.from('new'))
} else {
insert.run('plugin-only', Buffer.from('plugin'))
insert.run('shared', Buffer.from('old'))
}
database.close()
}
writeFileSync(join(source, 'v11', 'files', 'shared'), 'new')
writeFileSync(join(destination, 'v11', 'files', 'shared'), 'old')
writeFileSync(join(destination, 'v11', 'files', 'plugin'), 'plugin')
mergePnpmStore(source, destination)
const database = new DatabaseSync(join(destination, 'v11', 'index.db'), { readOnly: true })
const records = database.prepare('SELECT key, data FROM package_index ORDER BY key').all() as {
key: string
data: Uint8Array
}[]
database.close()
expect(records.map(record => [record.key, Buffer.from(record.data).toString()])).toEqual([
['plugin-only', 'plugin'],
['seed-only', 'seed'],
['shared', 'new'],
])
expect(readFileSync(join(destination, 'v11', 'files', 'shared'), 'utf8')).toBe('new')
expect(readFileSync(join(destination, 'v11', 'files', 'plugin'), 'utf8')).toBe('plugin')
})
})
describe('desktop seed store archives', () => {
it('extracts package bytes and executable modes without retaining loose seed files', () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
const store = join(seed, 'store')
const executable = join(store, 'v10', 'files', 'native-addon')
mkdirSync(join(store, 'v10', 'files'), { recursive: true })
writeFileSync(executable, 'native')
chmodSync(executable, 0o755)
writeFileSync(join(store, 'v10', 'files', 'package-data'), 'package')
archivePnpmStore(seed, store)
const destination = join(root, 'extracted')
extractPnpmStoreArchives(seed, destination)
expect(existsSync(store)).toBe(false)
expect(readFileSync(join(destination, 'v10', 'files', 'package-data'), 'utf8')).toBe('package')
if (process.platform !== 'win32') {
expect(statSync(join(destination, 'v10', 'files', 'native-addon')).mode & 0o111).toBe(0o111)
}
})
it('produces identical shards for identical paths, bytes, and modes', () => {
const root = temporaryRoot()
const seeds = [join(root, 'first'), join(root, 'second')]
for (const [index, seed] of seeds.entries()) {
const store = join(seed, 'store')
mkdirSync(join(store, 'nested'), { recursive: true })
const paths = index === 0 ? ['alpha', 'nested/beta'] : ['nested/beta', 'alpha']
for (const path of paths) {
const target = join(store, path)
writeFileSync(target, path)
utimesSync(target, new Date(index * 10_000), new Date(index * 20_000))
}
archivePnpmStore(seed, store)
}
const first = archiveBytes(seeds[0] as string)
const second = archiveBytes(seeds[1] as string)
expect(second.map(entry => entry.path)).toEqual(first.map(entry => entry.path))
expect(second.map(entry => entry.body)).toEqual(first.map(entry => entry.body))
})
it('rejects an archive whose entry count differs from the manifest', () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
const store = join(seed, 'store')
mkdirSync(store, { recursive: true })
writeFileSync(join(store, 'package-data'), 'package')
archivePnpmStore(seed, store)
const manifestPath = join(seed, SEED_STORE_ARCHIVE_MANIFEST)
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
archives: { entries: number }[]
}
const archive = manifest.archives[0]
if (archive === undefined) throw new Error('test seed has no archive')
archive.entries += 1
writeFileSync(manifestPath, JSON.stringify(manifest))
expect(() => { extractPnpmStoreArchives(seed, join(root, 'extracted')) }).toThrow(/unexpected entry count/u)
})
})
+2 -2
View File
@@ -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 docs/architecture.md
architecture.md: a77dfd06c61cbb12d7008133a7e8d515a3449a02
architecture.zh.md: 83fa958609500cc85af9c0d1f7e339104f6c18c7
architecture.md: cdd9686261221ffcba7e4991ec4f93bc4e4559f1
architecture.zh.md: 2cd1b0781e1a56f05d53ba9035542102b15971a9
+2 -2
View File
@@ -48,9 +48,9 @@ The Python SDK follows the same application architecture. Its runtime wheel pack
## Desktop application
The [Electron desktop application](../apps/desktop/README.md) owns the reserved `$DSH_HOME/profiles/desktop` npm project. Each signed Electron release binds one exact dsh version and carries a first-party offline seed; startup installs that version into the writable profile with the bundled pnpm, while retaining exact desktop-plugin versions from the previous profile. CLI profiles share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules` with Desktop.
The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed application resources. The reserved `$DSH_HOME/profiles/desktop` contains external plugins and links to host-owned packages; compatible upgrades retain plugin files and refresh these links without installing core dependencies. CLI profiles share supported product data under `$DSH_HOME`, while executable packages, plugin activation, lockfiles, and package-manager state remain separate.
Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the installed dsh backend and matching client graph from the reserved profile. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`.
Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the bundled dsh backend and matching client graph together with enabled profile plugins. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`.
## Core packages
+2 -2
View File
@@ -48,9 +48,9 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
## 桌面应用
[Electron 桌面应用](../apps/desktop/README.zh.md)持有保留的 `$DSH_HOME/profiles/desktop` npm 项目。每个签名 Electron 发行版绑定一个确切 dsh 版本并携带第一方离线 seed;启动时通过内置 pnpm 把该版本安装进可写 profile,同时保留旧 profile 中桌面插件的确切版本。CLI profile 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、lockfile 或 `node_modules`。
[Electron 桌面应用](../apps/desktop/README.zh.md)在签名应用资源中携带精确版本的 dsh 生产运行时。保留的 `$DSH_HOME/profiles/desktop` 保存外部插件和指向宿主拥有包的链接;兼容升级保留插件文件并刷新这些链接,无需安装核心依赖。CLI profile 共享 `$DSH_HOME` 下受支持的产品数据,而可执行包、插件激活、锁文件和包管理器状态保持独立。
Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包从保留 profile 加载已安装的 dsh 后端与匹配的客户端图。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。
Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包加载内置 dsh 后端、匹配的客户端图和已启用的 profile 插件。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。
## 核心包
+3 -86
View File
@@ -525,9 +525,6 @@ importers:
js-yaml:
specifier: ^4.2.0
version: 4.3.1
msgpackr:
specifier: 2.0.4
version: 2.0.4
pnpm:
specifier: 11.7.0
version: 11.7.0
@@ -12688,36 +12685,6 @@ packages:
resolution: {integrity: sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==}
hasBin: true
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
cpu: [x64]
os: [win32]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
peerDependencies:
@@ -16331,13 +16298,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]:
resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==}
[email protected]:
resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==}
@@ -16438,10 +16398,6 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
[email protected]:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -17149,13 +17105,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
[email protected]:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
[email protected]:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -19433,24 +19389,6 @@ snapshots:
- supports-color
- zod
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
dependencies:
'@emnapi/core': 1.10.0
@@ -23321,22 +23259,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
optional: true
[email protected]:
optionalDependencies:
msgpackr-extract: 3.0.4
[email protected]:
dependencies:
once: 1.4.0
@@ -23425,11 +23347,6 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
[email protected]:
dependencies:
detect-libc: 2.1.2
optional: true
[email protected]:
dependencies:
env-paths: 2.2.1