Merge origin/master into feat/remove-str-replace-editor-from-minimal

This commit is contained in:
fz committed 2026-09-07 23:25:03 +08:00
commit 707d0e7e52
279 files changed
+14310 -1229

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-07-31-claimed-pre-step-inbox-lifecycle.md
2026-07-31-claimed-pre-step-inbox-lifecycle.md: 73768e1eee8957f8976d40812b0a31a2961f0825
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 343816abaf394b8f64924cf36b753c6b1b2e34ca
2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888
2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18
@@ -12,17 +12,19 @@ Occurrence-local inbox wrappers also duplicated the identity already carried by
## Decision
Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the waterfall with that exclusive batch and `{ turn, step, signal }`.
Before every proposed step, the loop's package-internal `ReactLoopInbox` atomically claims the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome, emits `agent/inbox/claimed { message, turn }` once per claimed message, and returns the exclusive batch for the loop's waterfall with `{ turn, step, signal }`.
`PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before `step/start` likewise close a balanced no-step turn. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` extension point.
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields.
The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields.
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change.
`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes.
The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value.
Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued.
The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection.
The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.
## Alternatives considered
@@ -34,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026
## Verification
Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new waterfall and payloads.
Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
## Consequences
@@ -12,17 +12,19 @@ Status: implemented
## 决策
每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。
每个拟议步骤之前,循环包内部的 `ReactLoopInbox` 会原子领取完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`,针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并把独占批次返回给循环,由后者用 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。
`PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 `step/start` 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` 扩展点。
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除通过 `Inbox.splice()` 或其他 Inbox 变更方法处理,从而让同一投影记录所有变化。
`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数。
两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。
必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。
已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。
已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。
## 曾考虑的替代方案
@@ -34,7 +36,7 @@ Status: implemented
## 验证
agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
## 后果
@@ -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-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
@@ -0,0 +1,177 @@
# Agent Note: Package and update the Electron desktop application
Status: implemented
English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md)
## Problem
DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow.
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.
## 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`.
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.
The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before project recovery or Host startup; later launches focus or recreate the primary window without touching profile state. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm.
## Ownership
| Owner | Responsibility |
|---|---|
| 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 |
| 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 |
| npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state |
The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, locale, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron selects a typed English or Chinese dictionary from its application locale and falls back to English; menus, native dialogs, and the plugin-management renderer use that locale-owned copy.
## Filesystem layout
```text
~/.dsh/
desktop/
staging/<transaction-id>/profile/
rollback/profile/
pending.json
lock
pnpm/
store/
cache/
state/
config/
profiles/
desktop/
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
desktop-release.json
desktop-packages.json
desktop-packages/
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`.
## Installation and resolution
The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile.
The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered.
The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the 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.
## 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.
`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.
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.
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.
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.
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.
## Implementation
| Surface | Implementation |
|---|---|
| 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. |
`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.
## Alternatives considered
**Use Electron's Node.js for dsh.** This saves package size but couples dsh to Electron's Node patches, fuses, native ABI, TLS behavior, and process lifecycle. A bundled upstream Node.js keeps dsh on its supported runtime.
**Carry Fetch bodies through JSON IPC as Base64.** JSON IPC keeps one message mechanism but expands every request and response body, constructs large strings in both processes, buffers each request before dispatch, and double-encodes image bytes already represented as Base64 inside RPC JSON. Raw framed pipes retain an explicit versioned protocol without relying on Electron and upstream Node.js to share V8 serialization behavior.
**Bake the product Web UI into Electron.** Independent UI and backend updates would require a new versioned compatibility program. Installing backend and Web UI from the same dsh package preserves the current release binding.
**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.
**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.
**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.
**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.
**Commit a credential-bearing signing script or persist the Token Password.** A credential-bearing CMD file, `.env`, or Windows user or system environment variable leaves the Token Password recoverable at rest. The checked-in CMD contains only environment-variable references, and the packaging step accepts the password as an ephemeral runner secret.
**Let electron-builder or a general directory sync publish directly.** A direct publisher can expose channel metadata before every referenced artifact exists, mix stale or cross-target files into a release, and cannot prove that the completed signed build still matches the current dsh version. A target-specific validated upload keeps publication ordering and release identity explicit.
## 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.
- 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.
- npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`.
- The active backend and Web UI report the same dsh version and a compatible shell API before the product window opens.
- Failed installation, health checking, or update leaves the current profile usable or restores `rollback/profile` after restart.
- One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart.
- Shared `.dsh` data rejects incompatible readers before migration or mutation.
- No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs.
- Workspace development runs current built code without downloading release resources, while unpacked-package verification retains the production installation path.
- Windows release packaging requires the validated SignTool, EV token, matching public leaf certificate, Token Password, and explicit key container; it never falls back to an unsigned artifact or an exportable key file.
- A target update cannot expose new channel metadata until the completed signed build and every referenced artifact pass release validation; retained historical artifacts remain available for differential updates.
- Signed installed artifacts update successfully from the previous supported release on each release-blocking platform.
## Review decisions
| 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 |
| 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 |
| Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation |
## Risks
Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships.
Updating the bound dsh can invalidate plugin peer dependencies or native modules. pnpm resolution and full-project health checking must reject the staged project before replacing the active one.
An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing.
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.
@@ -0,0 +1,177 @@
# Agent Note: 打包并更新 Electron 桌面应用
Status: implemented
[English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文
## 问题
DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。
桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。
当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 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` 解析。
一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。
浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。
## 归属
| Owner | 职责 |
|---|---|
| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 |
| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 |
| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` |
| 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
| 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 |
| 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 |
| 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 |
渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。
## 文件系统布局
```text
~/.dsh/
desktop/
staging/<transaction-id>/profile/
rollback/profile/
pending.json
lock
pnpm/
store/
cache/
state/
config/
profiles/
desktop/
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
desktop-release.json
desktop-packages.json
desktop-packages/
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`。
## 安装与解析
安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。
进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。
打包 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。
## 更新与恢复
Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。
新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 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` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。
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 中。
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 中。
打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。
在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。
## 实现
| 表面 | 实现 |
|---|---|
| 壳 | `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 录制仍是发布环境门槛。 |
`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
## 考虑过的替代方案
**使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。
**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** JSON IPC 可以只保留一种消息机制,但会膨胀每个请求与响应消息体、在两个进程中构造大字符串、在分派前缓冲完整请求,还会再次编码 RPC JSON 中已经表示为 Base64 的图片字节。原始分帧管道保留明确的带版本协议,同时不要求 Electron 与上游 Node.js 共享 V8 序列化行为。
**把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。
**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。
**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。
**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。
**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。
**把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。
**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收。
**让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。
## 结果
- 没有系统 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 安装的桌面插件。
- 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。
- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
- 后端与浏览器应用不能修改桌面包。
- npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。
- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。
- 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
- 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
- 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。
- Workspace 开发无需下载发布资源即可运行当前已构建代码,未封装安装器的应用验证仍保留生产安装路径。
- Windows 发布打包要求已验证的 SignTool、EV Token、匹配的公开叶证书、Token Password 和明确的密钥容器,绝不会回退到未签名产物或可导出的密钥文件。
- 目标更新只有在已完成签名的构建及其引用的每个产物通过发布校验后才能暴露新频道元数据;保留的历史产物继续供差分更新使用。
- 每个发布阻断平台上的签名已安装产物均能从上一个受支持版本成功更新。
## 评审决策
| 决策 | 建议 |
|---|---|
| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 |
| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile |
| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 |
| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 |
| 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 |
| 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh |
## 风险
插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。
更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目。
通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。
代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
+3 -3
View File
@@ -1,10 +1,10 @@
# The repo's canonical text form is LF, enforced at checkout too: no smudge
# boundary between working tree and repo, so byte-level gates (verify-*
# comparisons, blob hashing, coverage offsets) see one form on every host.
# If a file class ever genuinely needs CRLF in the working tree (.bat/.cmd
# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the
# in-repo form stays LF; CRLF becomes checkout-time presentation only.
# Windows command scripts require CRLF in the working tree for cmd.exe; the
# override after the default keeps the in-repo form normalized as text.
* text=auto eol=lf
*.cmd text eol=crlf
*.pdf binary
+2
View File
@@ -27,6 +27,8 @@ tmp/
.idea
mise.toml
dist-exe/
/dist/
apps/desktop/.desktop-build/
python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*
python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/
python/**/__pycache__/
+12
View File
@@ -68,6 +68,7 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`compression`](https://github.com/expressjs/compression) | MIT |
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
| [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
| [`fs-ext`](https://github.com/baudehlo/node-fs-ext) | MIT |
@@ -98,6 +99,7 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`react-dom`](https://github.com/facebook/react) | MIT |
| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT |
| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT |
| [`semver`](https://github.com/npm/node-semver) | ISC |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
@@ -140,7 +142,9 @@ External packages **directly declared** only by repository tooling, test infrast
| Package | License |
| --- | --- |
| [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3) | Apache-2.0 |
| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
| [`@electron/notarize`](https://github.com/electron/notarize) | MIT |
| [`@lexical/headless`](https://github.com/facebook/lexical) | MIT |
| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
@@ -158,6 +162,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/semver`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
@@ -166,13 +171,17 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT |
| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
| [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT |
| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
| [`debug`](https://github.com/debug-js/debug) | MIT |
| [`electron`](https://github.com/electron/electron) | MIT |
| [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT |
| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
| [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause |
| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
| [`http-server`](https://github.com/http-party/http-server) | MIT |
| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause |
@@ -181,12 +190,15 @@ External packages **directly declared** only by repository tooling, test infrast
| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
| [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`publint`](https://github.com/publint/publint) | MIT |
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 |
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
| [`vite`](https://github.com/vitejs/vite) | MIT |
+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/cli/README.md
README.md: 850a9371c1515d1e0219e9a685b638063c498ff4
README.zh.md: 02de213d7a7158971b445511a00661183b37234c
README.md: adab66bb1d7ed46039248a57c8722964f5aebd33
README.zh.md: 4887a67872a569a0a54f0378aaceffe51cdc43c1
+1 -1
View File
@@ -16,7 +16,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
| `dsh web` | Alias of `--profile web`. |
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`.
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it.
## App arguments
+1 -1
View File
@@ -16,7 +16,7 @@
| `dsh web` | `--profile web` 的别名。 |
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI 会拒绝针对它的启动、配置 dump 和插件管理请求。
## 应用参数
+2
View File
@@ -104,6 +104,8 @@
"@agentclientprotocol/sdk": "1.4.0",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
+8
View File
@@ -60,6 +60,12 @@ interface BootOptions {
*/
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
function rejectElectronProfile(program: Command, profile: string): void {
if (profile.toLowerCase() === 'desktop') {
program.error('error: profile "desktop" is managed exclusively by the Electron application')
}
}
/** The launcher's own help text; each app prints its own. */
const HELP_EXAMPLES = `
Examples:
@@ -141,6 +147,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
}
const profile = options.profile
if (profile === '') program.error('error: --profile needs a name')
rejectElectronProfile(program, profile)
resolved = resolveBoot(program, profile, options, args)
})
@@ -176,6 +183,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
.action((args: string[], options: { profile: string }) => {
rejectParentOptions('plugin')
if (options.profile === '') program.error('error: --profile needs a name')
rejectElectronProfile(plugin, options.profile)
if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
resolved = { mode: 'plugin', profile: options.profile, args }
})
+6
View File
@@ -95,6 +95,12 @@ describe('parseDshArgs', () => {
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
expect(exitCode(['--profile', 'desktop'])).toBe(1)
expect(exitCode(['--profile', 'Desktop'])).toBe(1)
expect(exitCode(['--profile', 'DESKTOP'])).toBe(1)
expect(exitCode(['--profile', 'desktop', '--dump-config'])).toBe(1)
expect(exitCode(['plugin', '--profile', 'desktop', 'add', 'x'])).toBe(1)
expect(exitCode(['plugin', '--profile', 'Desktop', 'add', 'x'])).toBe(1)
expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
})
+3 -2
View File
@@ -1,10 +1,11 @@
import { fileURLToPath } from 'node:url'
import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-tools'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const overlayPath = process.argv[2]
if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path')
@@ -23,7 +24,7 @@ try {
id: agentId,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
inbox: unsupportedInbox(),
status: 'idle',
send: () => {},
followup: () => {},
@@ -2,7 +2,6 @@ import { Context } from '@deepseek-ai/cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
@@ -56,7 +55,6 @@ export interface CodingHarnessOptions {
export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { personaPrefix: options.personaPrefix ?? '' },
})
@@ -0,0 +1,34 @@
# Electron reuses the browser composition without its network and browser-launch rows.
- id: web-startup
disabled: true
- id: webserver
disabled: true
- id: web-runtime
disabled: true
- id: client-hmr
disabled: true
- id: open-in-app
disabled: true
- id: ui-open-in-app
disabled: true
- id: directory-picker
disabled: true
- id: connection
inject:
- credentials
config: {}
- insert:
- id: directory-picker-native
name: '@deepseek-ai/dsh-host-directory-picker-native'
- id: ui-directory-picker-native
name: '@deepseek-ai/dsh-client-ui-directory-picker-native'
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private upstream-Node host process for the Electron desktop application",
"version": "0.1.3-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
"main": "lib/index.js",
"files": [
"lib/index.js",
"config/desktop.cordis.patch.yml"
],
"dependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/dsh": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-web-frontend": "workspace:^"
}
}
+587
View File
@@ -0,0 +1,587 @@
/**
* Electron child-process entry: boots the desktop project without a listening
* socket and carries API plus validated Web assets over framed byte pipes.
* @module @deepseek-ai/dsh-desktop-host
*/
import { createRequire } from 'node:module'
import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { once } from 'node:events'
import { readFile } from 'node:fs/promises'
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import {
boot,
composeEntries,
loadLayeredEnv,
loadProfileDirectory,
loadOverlayPatches,
} from '@deepseek-ai/dsh-app-boot'
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
import type {} from '@deepseek-ai/dsh-api-gateway'
import type { ConnectionFetchHandler } from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-client-modules'
import { renderIndexInjections, type IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import {
DESKTOP_HOST_PROTOCOL_VERSION,
DESKTOP_PIPE_CHUNK_BYTES,
DESKTOP_REQUEST_PIPE_FD,
DESKTOP_RESPONSE_PIPE_FD,
DesktopHostRequestDecoder,
encodeDesktopResponseData,
encodeDesktopResponseEnd,
encodeDesktopResponseError,
encodeDesktopResponseStart,
type DesktopHostRequestFrame,
} from './wire.ts'
export { DESKTOP_HOST_PROTOCOL_VERSION } from './wire.ts'
/** One request forwarded from Electron's `dsh-app://` handler. */
export interface DesktopHostFetchCommand {
readonly streamId: number
readonly request: {
readonly url: string
readonly method: string
readonly headers: readonly [string, string][]
}
}
/** Commands accepted by the desktop child process. */
export type DesktopHostCommand = {
readonly type: 'shutdown'
}
/** Events emitted by the desktop child process. */
export type DesktopHostEvent = {
readonly type: 'ready'
readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
readonly dshVersion: string
} | {
readonly type: 'fatal'
readonly message: string
}
/** Controller returned to tests and the self-executing process entry. */
export interface DesktopHostController {
/** Installed dsh version carried by this host. */
readonly dshVersion: string
/** Dispatch one custom-protocol request and stream its response to the response pipe. */
fetch(command: DesktopHostFetchCommand, body: ReadableStream<Uint8Array> | null): Promise<void>
/** Abort one in-flight request. */
cancel(streamId: number): void
/** Stop accepting messages and await complete host teardown. */
dispose(): Promise<void>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isDesktopHostCommand(message: unknown): message is DesktopHostCommand {
return typeof message === 'object' && message !== null && 'type' in message
&& (message as Record<string, unknown>).type === 'shutdown'
}
interface PackageManifest {
readonly name?: string
readonly version?: string
}
const DESKTOP_PATCH = fileURLToPath(new URL('../config/desktop.cordis.patch.yml', import.meta.url))
const ROOT_CONFIG = '# Electron desktop composition root; package transactions own this file.\n[]\n'
const ROOT_CONFIG_FILENAME = 'desktop.cordis.yml'
const DESKTOP_STREAM_PATH = '/.dsh/remote-stream'
const DESKTOP_TRANSPORT_SCRIPT = `globalThis.__DSH_TRANSPORT__={
ownsHost:true,
async *openStream(endpoint,payload,signal){
const response=await fetch(${JSON.stringify(DESKTOP_STREAM_PATH)},{
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({endpoint,payload}),signal
})
if(!response.ok||response.body===null)throw new Error('desktop stream transport failed: HTTP '+response.status)
const reader=response.body.getReader(),decoder=new TextDecoder()
let pending=''
for(;;){
const {done,value}=await reader.read()
pending+=decoder.decode(value,{stream:!done})
let newline
while((newline=pending.indexOf('\\n'))!==-1){
const line=pending.slice(0,newline);pending=pending.slice(newline+1)
if(line!=='')yield JSON.parse(line)
}
if(done)break
}
if(pending!=='')yield JSON.parse(pending)
}
}`
const MIME: Readonly<Record<string, string>> = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.webmanifest': 'application/manifest+json',
}
function readManifest(path: string): PackageManifest {
const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (!isRecord(value)) throw new Error(`dsh desktop: ${path} must contain a package manifest`)
return {
...(typeof value.name === 'string' ? { name: value.name } : {}),
...(typeof value.version === 'string' ? { version: value.version } : {}),
}
}
function packageManifestPath(projectDir: string, packageName: string): string {
const path = join(projectDir, 'node_modules', ...packageName.split('/'), 'package.json')
if (!existsSync(path)) throw new Error(`dsh desktop: installed package ${JSON.stringify(packageName)} has no manifest`)
return path
}
function isProjectPath(projectDir: string, target: string): boolean {
const root = realpathSync(projectDir)
const path = realpathSync(target)
return path === root || path.startsWith(root + sep)
}
function desktopPatches(projectDir: string, allowLinkedPackages: boolean): PatchOptions[] {
const dshRoot = dirname(packageManifestPath(projectDir, '@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`)
}
}
const layers = [
...profile.layers.map(layer => layer.patches),
profile.patches,
loadOverlayPatches('dsh desktop', DESKTOP_PATCH),
]
const rows = new Map(composeEntries(layers).flatMap(row => typeof row.id === 'string' ? [[row.id, row] as const] : []))
const agentPresets = rows.get('agent-presets')
if (agentPresets !== undefined) {
layers.push([{
id: 'agent-presets',
config: {
...(agentPresets.config ?? {}) as Record<string, unknown>,
roots: [{ path: join(dshRoot, 'config', 'agent-presets'), trust: 'system' }],
},
}])
}
return layers.flat()
}
function dshVersion(projectDir: string): string {
const manifest = readManifest(packageManifestPath(projectDir, '@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'))
const distIndex = require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')
const distRoot = realpathSync(dirname(distIndex))
const renderIndex = async (): Promise<Response> => {
const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: DESKTOP_TRANSPORT_SCRIPT }]
ctx.emit('webserver/index-inject', rows)
const body = renderIndexInjections(await readFile(distIndex, 'utf8'), rows)
return new Response(body, { headers: { 'content-type': MIME['.html'] ?? 'text/html; charset=utf-8' } })
}
return {
requestBodyMode: () => 'buffered',
async fetch(request): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
const url = new URL(request.url)
if (url.pathname.startsWith('/plugins/')) return ctx.clientModules.fetchBundle(request)
let pathname: string
try {
pathname = decodeURIComponent(url.pathname)
} catch {
return new Response(null, { status: 400 })
}
if (pathname === '/' || pathname === '/index.html') return renderIndex()
const target = resolve(normalize(join(distRoot, pathname)))
if (target !== distRoot && !target.startsWith(distRoot + sep)) return new Response(null, { status: 403 })
try {
const realTarget = realpathSync(target)
if (realTarget !== distRoot && !realTarget.startsWith(distRoot + sep)) return new Response(null, { status: 403 })
return new Response(request.method === 'HEAD' ? null : await readFile(realTarget), {
headers: { 'content-type': MIME[extname(realTarget)] ?? 'application/octet-stream' },
})
} catch {
return renderIndex()
}
},
}
}
function remoteStreamHandler(ctx: Context): ConnectionFetchHandler {
return {
requestBodyMode: () => 'buffered',
async fetch(request): Promise<Response> {
if (request.method !== 'POST') return new Response(null, { status: 405 })
const gateway = ctx.get('typertGateway')
if (gateway === undefined) return new Response('gateway unavailable', { status: 503 })
let body: unknown
try {
body = await request.json()
} catch {
return new Response('body is not JSON', { status: 400 })
}
if (!isRecord(body) || typeof body.endpoint !== 'string') {
return new Response('invalid stream request', { status: 400 })
}
const abort = new AbortController()
const cancel = (): void => { abort.abort(request.signal.reason) }
request.signal.addEventListener('abort', cancel, { once: true })
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
const values = await gateway.wireStream.open(body.endpoint as string, body.payload, abort.signal)
for await (const value of values) {
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
}
controller.close()
} catch (error) {
controller.error(error)
} finally {
request.signal.removeEventListener('abort', cancel)
}
},
cancel(reason) {
abort.abort(reason)
request.signal.removeEventListener('abort', cancel)
},
})
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } })
},
}
}
interface NodeRequestInit extends RequestInit {
readonly duplex?: 'half'
}
/**
* Boot one installed desktop npm project.
* @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(
projectDir: string,
writeResponse: (frame: Buffer) => Promise<void>,
options: { allowLinkedPackages?: boolean } = {},
): Promise<DesktopHostController> {
const absoluteProject = resolve(projectDir)
mkdirSync(absoluteProject, { recursive: true })
const rootConfig = join(absoluteProject, ROOT_CONFIG_FILENAME)
writeFileSync(rootConfig, ROOT_CONFIG)
const environment = loadLayeredEnv('dsh desktop')
let current: Context | undefined
const ctx = await boot('dsh desktop', rootConfig, structuredClone(desktopPatches(
absoluteProject,
options.allowLinkedPackages === true,
)), (hostCtx) => {
current = hostCtx
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment)
provideCmdline(hostCtx, { args: [], exit: () => {} })
})
current = ctx
const connection = ctx.get('connection')
const clientModules = ctx.get('clientModules')
const gateway = ctx.get('typertGateway')
if (connection === undefined || clientModules === undefined || gateway === undefined) {
await ctx.fiber.dispose()
throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules')
}
const api = connection.createSharedFetchHandler('/api')
const assets = assetHandler(ctx, absoluteProject)
const streams = remoteStreamHandler(ctx)
const requests = new Map<number, AbortController>()
let disposing: Promise<void> | undefined
const dispose = async (): Promise<void> => {
disposing ??= (async () => {
for (const controller of requests.values()) controller.abort()
requests.clear()
await current?.fiber.dispose()
current = undefined
})()
await disposing
}
return {
dshVersion: dshVersion(absoluteProject),
cancel(streamId) {
requests.get(streamId)?.abort()
},
async fetch(command, body) {
if (disposing !== undefined) throw new Error('dsh desktop: host is disposing')
const controller = new AbortController()
requests.set(command.streamId, controller)
try {
const url = new URL(command.request.url)
const init: NodeRequestInit = {
method: command.request.method,
headers: new Headers(command.request.headers.map(([name, value]) => [name, value] as [string, string])),
...(body === null ? {} : { body, duplex: 'half' }),
signal: controller.signal,
}
const request = new Request(url, init)
const response = url.pathname === DESKTOP_STREAM_PATH
? await streams.fetch(request)
: url.pathname.startsWith('/api/')
? await api.fetch(request)
: await assets.fetch(request)
await writeResponse(encodeDesktopResponseStart(command.streamId, {
status: response.status,
headers: [...response.headers.entries()],
hasBody: response.body !== null,
}))
if (response.body !== null) {
for await (const chunk of response.body) {
const bytes = Buffer.from(chunk)
for (let offset = 0; offset < bytes.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) {
await writeResponse(encodeDesktopResponseData(
command.streamId,
bytes.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES),
))
}
}
}
await writeResponse(encodeDesktopResponseEnd(command.streamId))
} catch (error) {
if (!controller.signal.aborted) {
await writeResponse(encodeDesktopResponseError(
command.streamId,
error instanceof Error ? error.message : String(error),
))
}
} finally {
requests.delete(command.streamId)
}
},
dispose,
}
}
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 option = process.argv[3]
if (option !== undefined && option !== '--allow-linked-profile') {
throw new Error(`dsh desktop: unsupported internal option ${JSON.stringify(option)}`)
}
const requestPipe = createReadStream('', { fd: DESKTOP_REQUEST_PIPE_FD, autoClose: false })
const responsePipe = createWriteStream('', { fd: DESKTOP_RESPONSE_PIPE_FD, autoClose: false })
let responseWriteTail: Promise<void> = Promise.resolve()
const writeResponse = (frame: Buffer): Promise<void> => {
const write = responseWriteTail.then(async () => {
if (responsePipe.destroyed) throw new Error('dsh desktop: Electron response pipe is unavailable')
if (!responsePipe.write(frame)) await once(responsePipe, 'drain')
})
responseWriteTail = write.catch(() => undefined)
return write
}
const send = (event: DesktopHostEvent): void => {
if (process.send === undefined || !process.connected) return
try {
process.send(event)
} catch (error) {
// A concurrent parent disconnect owns teardown; only that closed-channel
// condition is safe to discard while streamed responses unwind.
if ((error as NodeJS.ErrnoException).code !== 'ERR_IPC_CHANNEL_CLOSED') throw error
}
}
const controller = await runDesktopHost(projectDir, writeResponse, { allowLinkedPackages: option !== undefined })
send({
type: 'ready',
protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
dshVersion: controller.dshVersion,
})
const decoder = new DesktopHostRequestDecoder()
const requestBodies = new Map<number, ReadableStreamDefaultController<Uint8Array>>()
const blockedRequests = new Set<number>()
const discardedRequestBodies = new Set<number>()
const runs = new Set<Promise<void>>()
let lastStreamId = 0
let requestedExitCode = 0
let stopping: Promise<void> | undefined
const resumeRequestPipe = (): void => {
if (blockedRequests.size === 0) requestPipe.resume()
}
const stop = (exitCode = 0): Promise<void> => {
requestedExitCode = Math.max(requestedExitCode, exitCode)
stopping ??= (async () => {
requestPipe.pause()
requestPipe.removeAllListeners('data')
const stopped = new Error('dsh desktop: Host is stopping')
for (const body of requestBodies.values()) body.error(stopped)
requestBodies.clear()
blockedRequests.clear()
discardedRequestBodies.clear()
requestPipe.destroy()
closeSync(DESKTOP_REQUEST_PIPE_FD)
await controller.dispose()
await Promise.allSettled([...runs])
await responseWriteTail.catch(() => undefined)
if (!responsePipe.destroyed) {
await new Promise<void>((resolvePromise) => { responsePipe.end(resolvePromise) })
responsePipe.destroy()
}
closeSync(DESKTOP_RESPONSE_PIPE_FD)
if (process.connected) process.disconnect()
process.exitCode = requestedExitCode
})()
return stopping
}
const failTransport = (error: unknown): void => {
const message = error instanceof Error ? error.message : String(error)
send({ type: 'fatal', message })
void stop(1)
}
const beginRequest = (frame: Extract<DesktopHostRequestFrame, { type: 'start' }>): void => {
if (frame.streamId <= lastStreamId) {
throw new Error(`dsh desktop: Electron reused or reordered request stream ${String(frame.streamId)}`)
}
lastStreamId = frame.streamId
let body: ReadableStream<Uint8Array> | null = null
if (frame.hasBody) {
body = new ReadableStream<Uint8Array>({
start(controllerOfBody) {
requestBodies.set(frame.streamId, controllerOfBody)
},
pull() {
blockedRequests.delete(frame.streamId)
resumeRequestPipe()
},
cancel() {
requestBodies.delete(frame.streamId)
blockedRequests.delete(frame.streamId)
controller.cancel(frame.streamId)
resumeRequestPipe()
},
})
}
const run = controller.fetch({
streamId: frame.streamId,
request: {
url: frame.url,
method: frame.method,
headers: frame.headers,
},
}, body)
runs.add(run)
void run.catch(failTransport).finally(() => {
runs.delete(run)
const openBody = requestBodies.get(frame.streamId)
if (openBody === undefined) return
openBody.error(new Error('dsh desktop: response completed before the request body ended'))
requestBodies.delete(frame.streamId)
blockedRequests.delete(frame.streamId)
discardedRequestBodies.add(frame.streamId)
resumeRequestPipe()
})
}
const handleRequestFrame = (frame: DesktopHostRequestFrame): void => {
switch (frame.type) {
case 'start':
beginRequest(frame)
return
case 'data': {
const body = requestBodies.get(frame.streamId)
if (body === undefined) {
if (discardedRequestBodies.has(frame.streamId)) return
throw new Error(`dsh desktop: Electron sent body data for inactive stream ${String(frame.streamId)}`)
}
body.enqueue(frame.data)
if ((body.desiredSize ?? 0) <= 0) {
blockedRequests.add(frame.streamId)
requestPipe.pause()
}
return
}
case 'end': {
const body = requestBodies.get(frame.streamId)
if (body === undefined) {
if (discardedRequestBodies.delete(frame.streamId)) return
throw new Error(`dsh desktop: Electron ended inactive body stream ${String(frame.streamId)}`)
}
body.close()
requestBodies.delete(frame.streamId)
blockedRequests.delete(frame.streamId)
resumeRequestPipe()
return
}
case 'cancel': {
if (frame.streamId > lastStreamId) {
throw new Error(`dsh desktop: Electron canceled unknown stream ${String(frame.streamId)}`)
}
const body = requestBodies.get(frame.streamId)
body?.error(new Error('dsh desktop: Electron canceled the request'))
requestBodies.delete(frame.streamId)
blockedRequests.delete(frame.streamId)
discardedRequestBodies.delete(frame.streamId)
controller.cancel(frame.streamId)
resumeRequestPipe()
return
}
default:
frame satisfies never
}
}
requestPipe.on('data', (chunk: string | Buffer) => {
try {
for (const frame of decoder.push(Buffer.from(chunk))) handleRequestFrame(frame)
} catch (error) {
failTransport(error)
}
})
requestPipe.once('end', () => {
if (stopping !== undefined) return
try {
decoder.finish()
failTransport(new Error('dsh desktop: Electron request pipe ended'))
} catch (error) {
failTransport(error)
}
})
requestPipe.once('error', failTransport)
responsePipe.once('error', failTransport)
process.on('message', (message: unknown) => {
if (!isDesktopHostCommand(message)) {
send({ type: 'fatal', message: 'dsh desktop: invalid Electron IPC command' })
void stop(1)
return
}
void stop()
})
process.once('disconnect', () => { void stop() })
process.once('SIGTERM', () => { void stop() })
process.once('SIGINT', () => { void stop() })
}
if (import.meta.main) {
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
if (process.send !== undefined) process.send({ type: 'fatal', message } satisfies DesktopHostEvent)
else process.stderr.write(`dsh desktop: ${message}\n`)
process.exitCode = 1
})
}
+184
View File
@@ -0,0 +1,184 @@
/** Framed request and response bytes for the Electron Desktop Host transport. */
/** Protocol version shared with the Electron shell. */
export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const
/** Child descriptor that receives Electron request frames. */
export const DESKTOP_REQUEST_PIPE_FD = 3
/** Child descriptor that emits Host response frames. */
export const DESKTOP_RESPONSE_PIPE_FD = 4
/** Maximum raw body bytes carried by one data frame. */
export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024
const FRAME_MAGIC = 0x44534833
const FRAME_HEADER_BYTES = 13
const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024
const REQUEST_FRAME_START = 1
const REQUEST_FRAME_DATA = 2
const REQUEST_FRAME_END = 3
const REQUEST_FRAME_CANCEL = 4
const RESPONSE_FRAME_START = 1
const RESPONSE_FRAME_DATA = 2
const RESPONSE_FRAME_END = 3
const RESPONSE_FRAME_ERROR = 4
type ResponseFrameType = typeof RESPONSE_FRAME_START | typeof RESPONSE_FRAME_DATA
| typeof RESPONSE_FRAME_END | typeof RESPONSE_FRAME_ERROR
/** One validated request-pipe frame. */
export type DesktopHostRequestFrame = {
readonly type: 'start'
readonly streamId: number
readonly url: string
readonly method: string
readonly headers: readonly [string, string][]
readonly hasBody: boolean
} | {
readonly type: 'data'
readonly streamId: number
readonly data: Buffer
} | {
readonly type: 'end' | 'cancel'
readonly streamId: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isHeaders(value: unknown): value is readonly [string, string][] {
return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2
&& typeof header[0] === 'string' && typeof header[1] === 'string')
}
function assertStreamId(streamId: number): void {
if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) {
throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`)
}
}
function encodeFrame(type: ResponseFrameType, streamId: number, payload: Buffer): Buffer {
assertStreamId(streamId)
const limit = type === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
if (payload.byteLength > limit) {
throw new Error(`dsh desktop: response pipe frame exceeds the ${String(limit)}-byte limit`)
}
const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength)
frame.writeUInt32BE(FRAME_MAGIC, 0)
frame.writeUInt8(type, 4)
frame.writeUInt32BE(streamId, 5)
frame.writeUInt32BE(payload.byteLength, 9)
payload.copy(frame, FRAME_HEADER_BYTES)
return frame
}
function encodeJsonFrame(type: ResponseFrameType, streamId: number, value: unknown): Buffer {
return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8'))
}
/** Encode response metadata before any body frames. */
export function encodeDesktopResponseStart(
streamId: number,
response: {
readonly status: number
readonly headers: readonly [string, string][]
readonly hasBody: boolean
},
): Buffer {
return encodeJsonFrame(RESPONSE_FRAME_START, streamId, response)
}
/** Encode one bounded raw response-body chunk. */
export function encodeDesktopResponseData(streamId: number, data: Uint8Array): Buffer {
return encodeFrame(RESPONSE_FRAME_DATA, streamId, Buffer.from(data))
}
/** Encode normal response completion. */
export function encodeDesktopResponseEnd(streamId: number): Buffer {
return encodeFrame(RESPONSE_FRAME_END, streamId, Buffer.alloc(0))
}
/** Encode one response failure without exposing an Error object across processes. */
export function encodeDesktopResponseError(streamId: number, message: string): Buffer {
return encodeJsonFrame(RESPONSE_FRAME_ERROR, streamId, { message })
}
/** Incrementally decode validated request frames from the Electron byte pipe. */
export class DesktopHostRequestDecoder {
private buffer: Buffer = Buffer.alloc(0)
/**
* Append bytes and return every complete request frame.
* @param chunk - next bytes read from the Electron request pipe.
* @returns complete frames in pipe order.
*/
push(chunk: Buffer): DesktopHostRequestFrame[] {
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk])
const frames: DesktopHostRequestFrame[] = []
for (;;) {
const frame = this.next()
if (frame === undefined) return frames
frames.push(frame)
}
}
/** Reject EOF that splits a frame. */
finish(): void {
if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Electron request pipe ended inside a frame')
}
private next(): DesktopHostRequestFrame | undefined {
if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined
if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Electron request frame marker')
const rawType = this.buffer.readUInt8(4)
const streamId = this.buffer.readUInt32BE(5)
const payloadLength = this.buffer.readUInt32BE(9)
assertStreamId(streamId)
const limit = rawType === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
if (payloadLength > limit) {
throw new Error(`dsh desktop: Electron request frame exceeds the ${String(limit)}-byte limit`)
}
const frameLength = FRAME_HEADER_BYTES + payloadLength
if (this.buffer.byteLength < frameLength) return undefined
const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength)
this.buffer = this.buffer.subarray(frameLength)
switch (rawType) {
case REQUEST_FRAME_START:
return this.parseStart(streamId, payload)
case REQUEST_FRAME_DATA:
return { type: 'data', streamId, data: payload }
case REQUEST_FRAME_END:
if (payloadLength !== 0) throw new Error('dsh desktop: Electron request end frame carried a payload')
return { type: 'end', streamId }
case REQUEST_FRAME_CANCEL:
if (payloadLength !== 0) throw new Error('dsh desktop: Electron request cancel frame carried a payload')
return { type: 'cancel', streamId }
default:
throw new Error(`dsh desktop: unknown Electron request frame type ${String(rawType)}`)
}
}
private parseStart(streamId: number, payload: Buffer): DesktopHostRequestFrame {
let value: unknown
try {
value = JSON.parse(payload.toString('utf8')) as unknown
} catch (error) {
throw new Error(`dsh desktop: Electron request start payload is not JSON: ${error instanceof Error ? error.message : String(error)}`)
}
if (!isRecord(value) || typeof value.url !== 'string' || typeof value.method !== 'string'
|| !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') {
throw new Error('dsh desktop: invalid Electron request start payload')
}
return {
type: 'start',
streamId,
url: value.url,
method: value.method,
headers: value.headers,
hasBody: value.hasBody,
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../vendor/cordis" },
{ "path": "../../vendor/include" },
{ "path": "../../packages/api/gateway/tsconfig.host.json" },
{ "path": "../../packages/boot/app-boot" },
{ "path": "../../packages/boot/cmdline" },
{ "path": "../../packages/client/connection/tsconfig.host.json" },
{ "path": "../../packages/client/modules" },
{ "path": "../../packages/host/webserver" },
{ "path": "../../packages/util/launch-environment" }
]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})
+6
View File
@@ -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 apps/desktop/README.md
README.md: cf350a9d5e9577f5cf52c4371b32f16e278133f9
README.zh.md: df48eb5903e21cae9ff041626c28d41d12e5c632
+180
View File
@@ -0,0 +1,180 @@
# DeepSeek Harness Desktop
English | [中文](README.zh.md)
The desktop application is an Electron shell around the dsh Web UI. It opens no listening port: a bundled upstream Node.js child boots the installed dsh project, versioned framed byte pipes carry Fetch requests and streaming responses without an outer Base64 envelope, Node IPC carries lifecycle control, and `dsh-app://` serves the matching client assets.
## Key technical decisions
| Decision | Why | Direct consequence |
|---|---|---|
| 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. |
| 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. |
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.
The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
### Seed installation
The packaged seed is an installation kit, not a ready-to-run `node_modules` tree. Packaging creates the lockfile, materializes the production graph online with lifecycle scripts disabled, deletes `node_modules` and every temporary pnpm cache, config, and state directory, and proves one complete installation offline from the final store alone with 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.
| 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. |
Startup installs or reconciles the seed as one serialized transaction:
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.
## Develop
`dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without downloading the packaged Node.js runtime or resolving dsh from npm:
```sh
pnpm run dev:desktop
```
Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed.
After an explicit build, `start:desktop` reconstructs the disposable project and launches the existing artifacts without building again:
```sh
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.
## Package
The normal packaging path is one complete command. It performs release preparation before creating the host platform's installers and update metadata. Every target requires a reverse-DNS `DSH_DESKTOP_APP_ID`. macOS targets additionally require the electron-builder certificate qualifier in `DSH_DESKTOP_MACOS_SIGNING_IDENTITY`, its 10-character Apple Team ID in `DSH_DESKTOP_MACOS_TEAM_ID`, and one complete notarytool credential strategy. The App Store Connect API-key strategy uses these variables:
```sh
export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
export APPLE_API_KEY='<absolute path to the .p8 file>'
export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
```
`prepare:desktop` is not a prerequisite:
```sh
pnpm run package:desktop
```
Release automation uses fixed target commands so runtime preparation, seed installation, and electron-builder receive the same platform and architecture:
```sh
pnpm run package:desktop:mac:arm64
pnpm run package:desktop:mac:x64
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.
### Upload updates
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`, while the production origin remains `https://download.deepseek.com`. Upload additionally requires the selected deployment's COS bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. The target path is `_/harness/desktop/stable/<target>/`, where `target` is `mac-arm64`, `mac-x64`, or `win-x64`.
The update destination and upload credentials follow the selected deployment:
| Environment | Public origin | COS bucket | COS credentials |
|---|---|---|---|
| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` |
| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` |
Package and upload one target under the same environment. For example, the default test deployment uses:
```sh
export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
pnpm run package:desktop:mac:arm64
export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
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>`.
### Windows EV signing
Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target:
```powershell
$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
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.
Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
```sh
pnpm run package:desktop:dir
pnpm run package:desktop:mac:arm64:dir
```
To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation:
```sh
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.
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.
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.
## 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.
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.
## 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`.
## Known limitations
- The Web "Open In..." action is disabled in Desktop because its host plugin requires HTTP routes; Desktop does not provide a `webServer`.
- Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
- Desktop plugins with dependency lifecycle scripts are rejected unless their package appears in the desktop project's reviewed `allowBuilds` policy.
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, lockfiles, and package-manager state remain separate.
+180
View File
@@ -0,0 +1,180 @@
# DeepSeek Harness 桌面端
[English](README.md) | 中文
桌面应用是包裹 dsh Web UI 的 Electron 壳。它不打开监听端口:内置的上游 Node.js 子进程启动已安装的 dsh 项目,带版本的分帧字节管道在没有外层 Base64 信封的情况下承载 Fetch 请求与流式响应,Node IPC 承载生命周期控制,`dsh-app://` 则提供与后端版本匹配的客户端资源。
## 关键技术决策
| 决策 | 原因 | 直接结果 |
|---|---|---|
| 发布身份 | 桌面壳 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 分片。外层安装包负责压缩,差分更新可以复用未变化的分片。 |
| 状态归属 | 共享可执行依赖图会让 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 发布。 |
[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 配置。
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 内容。
| 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 解析已打包的核心包名。 |
启动过程把 seed 安装或校准为一个串行事务:
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 误判为陈旧事务。
## 开发
`dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不下载安装包内的 Node.js,也不从 npm 解析 dsh:
```sh
pnpm run dev:desktop
```
开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。
显式构建完成后,`start:desktop` 会重新生成一次性项目,并跳过构建直接启动已有产物:
```sh
pnpm run start:desktop
```
Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、发布 seed、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。
## 打包
正常打包只需执行一条完整命令。该命令会先准备发布资源,再生成宿主平台的安装包与更新元数据。所有目标都要求通过 `DSH_DESKTOP_APP_ID` 提供反向域名形式的应用 ID。macOS 目标还要求通过 `DSH_DESKTOP_MACOS_SIGNING_IDENTITY` 提供 electron-builder 证书限定名,通过 `DSH_DESKTOP_MACOS_TEAM_ID` 提供对应的 10 字符 Apple Team ID,并提供一套完整的 notarytool 凭据。App Store Connect API Key 方式使用以下变量:
```sh
export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
export APPLE_API_KEY='<absolute path to the .p8 file>'
export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
```
无需提前执行 `prepare:desktop`:
```sh
pnpm run package:desktop
```
发布自动化使用固定目标命令,确保运行时准备、seed 安装与 electron-builder 接收相同的平台和架构:
```sh
pnpm run package:desktop:mac:arm64
pnpm run package:desktop:mac:x64
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` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。
### 上传更新
`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包时写入的更新 URL 与后续 COS 上传目标,可取 `test` 或 `production`;未设置时使用 `test`。测试打包必须通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin,生产 origin 仍为 `https://download.deepseek.com`。上传还必须通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选环境的 COS bucket。目标路径为 `_/harness/desktop/stable/<target>/`,其中 `target` 为 `mac-arm64`、`mac-x64` 或 `win-x64`。
更新目标与上传凭据都与所选环境对应:
| 环境 | 公开 origin | COS bucket | COS 凭据 |
|---|---|---|---|
| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` |
| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` |
同一目标必须在同一环境下完成打包与上传。例如,默认测试环境使用:
```sh
export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
pnpm run package:desktop:mac:arm64
export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
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 身份变量。
### Windows EV 签名
Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入:
```powershell
$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
$env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
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 用户或系统环境变量。
使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
```sh
pnpm run package:desktop:dir
pnpm run package:desktop:mac:arm64:dir
```
需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止:
```sh
pnpm run prepare:desktop
```
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 seed 内容。
每条打包命令都会先执行仓库的正式构建,打包 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,但会替换该目标的未打包应用、诊断文件、完成记录与频道元数据。
未压缩产物包含四块相互独立的体积:Electron、离线 seed store 分片与本地 dsh tarball、上游 Node.js 与 pnpm 运行时,以及很小的桌面壳应用。分片不压缩,使外层 DMG、ZIP 或 NSIS 压缩器与差分更新器可以处理稳定的数据区间。文件系统占用不等于安装包下载大小,因此必须分别测量。打包应用首次启动时还会先把 seed store 解包到 `$DSH_HOME/desktop/pnpm/store`,再安装可写 profile,因此发布验证必须同时测量应用与 Harness home 的磁盘占用。
## 更新
打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。
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 发布环境选择构建所验证的应用身份与平台签名身份。
## 底层开发覆盖项
`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_SEED_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。
## 已知限制
- Desktop 禁用 Web 的“在本地应用中打开”操作,因为其 Host 插件依赖 HTTP 路由,而 Desktop 不提供 `webServer`。
- 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
- 依赖包含 lifecycle script 的桌面插件,只有其包名进入桌面项目经过评审的 `allowBuilds` 策略后才能安装。
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活、锁文件与包管理器状态彼此隔离。
@@ -0,0 +1,39 @@
/** Electron-builder fields asserted by the Desktop release tests. */
export interface DesktopElectronBuilderConfig {
readonly appId: string
readonly directories: {
readonly output: string
}
readonly extraResources: readonly [
{ readonly from: string, readonly to: 'runtime' },
{ readonly from: string, readonly to: 'seed' },
]
readonly mac: {
readonly identity: string | undefined
readonly forceCodeSigning: boolean
readonly notarize: boolean
}
readonly dmg: {
readonly sign: boolean
readonly writeUpdateInfo: boolean
}
readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }]
}
/**
* Create electron-builder configuration from one release environment.
* @param env - Packaging environment.
* @param hostPlatform - Build-host platform used when no explicit target is present.
* @param hostArch - Build-host architecture used when no explicit target is present.
* @returns electron-builder configuration.
*/
export function createElectronBuilderConfig(
env?: NodeJS.ProcessEnv,
hostPlatform?: NodeJS.Platform,
hostArch?: string,
): DesktopElectronBuilderConfig
declare const electronBuilderConfig: DesktopElectronBuilderConfig
export default electronBuilderConfig
+109
View File
@@ -0,0 +1,109 @@
import {
resolveDesktopAppId,
resolveMacOSNotarizationEnvironment,
resolveMacOSSigningEnvironment,
} from './scripts/desktop-release-environment.mjs'
import { notarizeMacOSDiskImageArtifact } from './scripts/notarize-macos-disk-images.mjs'
import { verifyMacOSSignatureAfterSign } from './scripts/verify-macos-signature.mjs'
import {
createWindowsTokenSigner,
installWindowsNsisBootstrapSigner,
} from './scripts/windows-sign.mjs'
import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs'
import { desktopTargetBuildPaths } from './scripts/desktop-build-paths.mjs'
/**
* Create electron-builder configuration from one release environment.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present.
* @param {string} hostArch - Build-host architecture used when no explicit target is present.
* @returns {object} electron-builder configuration.
*/
export function createElectronBuilderConfig(
env = process.env,
hostPlatform = process.platform,
hostArch = process.arch,
) {
const appId = resolveDesktopAppId(env)
const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM
const resolvedPlatform = targetPlatform ?? hostPlatform
const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch
const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin')
const packagesWindows = targetPlatform === 'win32'
const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
const windowsSigner = packagesWindows
? createWindowsTokenSigner({
certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
})
: undefined
if (windowsSigner !== undefined) {
installWindowsNsisBootstrapSigner({ sign: windowsSigner })
}
const update = resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
const buildPaths = desktopTargetBuildPaths(update.target)
return {
appId,
productName: 'DeepSeek Harness',
artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
directories: { output: buildPaths.artifacts },
asar: true,
files: [
'lib/*.js',
'lib/*.cjs',
'renderer/**/*',
'package.json',
],
extraResources: [
{ from: buildPaths.runtime, to: 'runtime' },
{ from: buildPaths.seed, to: 'seed' },
],
mac: {
category: 'public.app-category.developer-tools',
identity: macOSSigning?.signingIdentity,
forceCodeSigning: true,
hardenedRuntime: true,
notarize: true,
target: ['dmg', 'zip'],
},
dmg: {
sign: true,
writeUpdateInfo: false,
},
afterSign: context => {
if (context.electronPlatformName !== 'darwin') return
verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
},
artifactBuildCompleted: artifact => {
if (!artifact.file.endsWith('.dmg')) return
return notarizeMacOSDiskImageArtifact(
artifact,
env,
macOSSigning ?? resolveMacOSSigningEnvironment(env),
)
},
win: {
forceCodeSigning: true,
signtoolOptions: {
sign: windowsSigner,
signingHashAlgorithms: ['sha256'],
},
target: ['nsis'],
},
linux: {
category: 'Development',
target: ['AppImage'],
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
differentialPackage: true,
},
publish: [{ provider: 'generic', url: update.publicUrl }],
}
}
export default createElectronBuilderConfig()
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for an isolated pnpm-installed dsh runtime",
"version": "0.1.3-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
"main": "lib/main.js",
"scripts": {
"build": "tsc -b && tsdown",
"dev": "tsx scripts/dev.ts",
"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:package": "tsx scripts/package-target.ts --prepare-only",
"verify:mac-signature": "node scripts/verify-macos-signature.mjs",
"package": "tsx scripts/package-target.ts",
"package:dir": "tsx scripts/package-target.ts --dir",
"package:mac:arm64": "tsx scripts/package-target.ts mac-arm64",
"package:mac:arm64:dir": "tsx scripts/package-target.ts mac-arm64 --dir",
"package:mac:x64": "tsx scripts/package-target.ts mac-x64",
"package:mac:x64:dir": "tsx scripts/package-target.ts mac-x64 --dir",
"package:win:x64": "tsx scripts/package-target.ts win-x64",
"package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir",
"upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64",
"upload:mac:x64": "tsx scripts/upload-target.ts mac-x64",
"upload:win:x64": "tsx scripts/upload-target.ts win-x64"
},
"dependencies": {
"electron-updater": "^6.8.9",
"semver": "^7.8.5"
},
"devDependencies": {
"@aws-sdk/client-s3": "3.1067.0",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@electron/notarize": "2.5.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.20.0",
"@types/semver": "^7.8.0",
"app-builder-lib": "26.15.3",
"electron": "^44.0.0",
"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"
}
}
+108
View File
@@ -0,0 +1,108 @@
:root {
color-scheme: light dark;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: Canvas;
color: CanvasText;
}
body {
margin: 0;
}
main {
max-width: 680px;
margin: 0 auto;
padding: 32px;
}
header,
.install-row,
li {
display: flex;
align-items: center;
gap: 12px;
}
header {
justify-content: space-between;
}
h1,
h2,
p {
margin-top: 0;
}
header p {
color: GrayText;
}
form,
section {
margin-top: 28px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: 1px solid ButtonBorder;
border-radius: 7px;
background: Field;
color: FieldText;
}
button {
padding: 9px 14px;
border: 1px solid ButtonBorder;
border-radius: 7px;
background: AccentColor;
color: AccentColorText;
cursor: pointer;
}
button.quiet,
li button {
background: ButtonFace;
color: ButtonText;
}
button:disabled,
input:disabled {
opacity: 0.55;
cursor: wait;
}
#status {
min-height: 1.5em;
margin-top: 16px;
color: GrayText;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid ButtonBorder;
}
.package-version {
color: GrayText;
margin-left: 8px;
}
.package-actions {
display: flex;
gap: 8px;
}
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self' data:">
<title id="page-title"></title>
<link rel="stylesheet" href="plugin-manager.css">
</head>
<body>
<main>
<header>
<div>
<h1 id="title"></h1>
<p id="description"></p>
</div>
<button id="refresh" class="quiet" type="button"></button>
</header>
<form id="install-form">
<label id="package-label" for="package-spec"></label>
<div class="install-row">
<input id="package-spec" name="package-spec" autocomplete="off" placeholder="@scope/[email protected]" required>
<button id="install" type="submit"></button>
</div>
</form>
<p id="status" role="status" aria-live="polite"></p>
<section aria-labelledby="installed-heading">
<h2 id="installed-heading"></h2>
<ul id="plugins"></ul>
<p id="empty"></p>
</section>
</main>
<script src="plugin-manager.js"></script>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
const api = window.dshDesktop
async function main() {
const locale = await api.locale()
const messages = locale.messages
const message = (key, values = {}) => messages[key].replaceAll(/\{([^{}]+)\}/gu, (placeholder, name) => values[name] ?? placeholder)
document.documentElement.lang = locale.id
document.querySelector('#page-title').textContent = messages.pluginManagerTitle
document.querySelector('#title').textContent = messages.pluginManagerTitle
document.querySelector('#description').textContent = messages.pluginManagerDescription
document.querySelector('#refresh').textContent = messages.refresh
document.querySelector('#package-label').textContent = messages.npmPackage
document.querySelector('#install').textContent = messages.install
document.querySelector('#installed-heading').textContent = messages.installed
document.querySelector('#empty').textContent = messages.noPlugins
const list = document.querySelector('#plugins')
const empty = document.querySelector('#empty')
const status = document.querySelector('#status')
const form = document.querySelector('#install-form')
const input = document.querySelector('#package-spec')
const refresh = document.querySelector('#refresh')
function setBusy(busy, statusMessage = '') {
for (const control of document.querySelectorAll('button, input')) control.disabled = busy
status.textContent = statusMessage
}
async function render() {
const plugins = await api.plugins.list()
list.replaceChildren(...plugins.map(plugin => {
const item = document.createElement('li')
const identity = document.createElement('span')
const version = document.createElement('span')
version.className = 'package-version'
version.textContent = plugin.version
identity.append(document.createTextNode(plugin.name), version)
const remove = document.createElement('button')
remove.type = 'button'
remove.textContent = messages.remove
remove.addEventListener('click', () => void run(
() => api.plugins.remove(plugin.name),
message('removing', { name: plugin.name }),
))
const update = document.createElement('button')
update.type = 'button'
update.textContent = messages.update
update.addEventListener('click', () => {
const next = window.prompt(message('targetVersion', { name: plugin.name }), plugin.version)?.trim()
if (next === undefined || next === '' || next === plugin.version) return
void run(() => api.plugins.update(plugin.name, next), message('updating', { name: plugin.name }))
})
const actions = document.createElement('span')
actions.className = 'package-actions'
actions.append(update, remove)
item.append(identity, actions)
return item
}))
empty.hidden = plugins.length !== 0
}
async function run(operation, statusMessage) {
setBusy(true, statusMessage)
try {
await operation()
await render()
status.textContent = messages.operationComplete
} catch (error) {
status.textContent = error instanceof Error ? error.message : String(error)
} finally {
setBusy(false, status.textContent)
}
}
async function load(statusMessage, success) {
setBusy(true, statusMessage)
try {
await render()
status.textContent = success
} catch (error) {
status.textContent = error instanceof Error ? error.message : String(error)
} finally {
setBusy(false, status.textContent)
}
}
form.addEventListener('submit', (event) => {
event.preventDefault()
const spec = input.value.trim()
if (spec === '') return
void run(async () => {
await api.plugins.add(spec)
input.value = ''
}, message('installing', { spec }))
})
refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
await load(messages.loadingPlugins, '')
}
void main()
@@ -0,0 +1,90 @@
/** Environment variable that selects the Desktop update deployment. */
export const DESKTOP_AUTO_UPDATE_ENV: 'DSH_DESKTOP_AUTO_UPDATE_ENV'
/** Supported Desktop update deployment. */
export type DesktopAutoUpdateEnvironment = 'test' | 'production'
/** Directory name of one supported Desktop release target. */
export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64'
/** Public updater URL for one release target. */
export interface DesktopAutoUpdateConfig {
readonly environment: DesktopAutoUpdateEnvironment
readonly target: DesktopAutoUpdateTarget
readonly origin: string
readonly publicUrl: string
readonly keyPrefix: string
}
/** Public updater URL and private COS destination for one upload target. */
export interface DesktopUploadConfig extends DesktopAutoUpdateConfig {
readonly bucket: string
readonly secretIdEnvName: string
readonly secretKeyEnvName: string
}
/**
* Resolve the update deployment, defaulting local release work to test.
* @param env - Packaging or upload environment.
* @returns Validated deployment name.
*/
export function resolveDesktopAutoUpdateEnvironment(
env: NodeJS.ProcessEnv,
): DesktopAutoUpdateEnvironment
/**
* Resolve one supported platform and architecture to its update directory.
* @param platform - Target Node.js platform.
* @param arch - Target Node.js architecture.
* @returns Update target directory.
*/
export function resolveDesktopAutoUpdateTarget(
platform: NodeJS.Platform,
arch: string,
): DesktopAutoUpdateTarget
/**
* Return the local completion record filename for one packaged target.
* @param target - Supported release target.
* @returns Filename stored beside electron-builder artifacts.
*/
export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): string
/**
* Return the electron-builder channel metadata filename for an application version.
* @param version - Desktop semantic version.
* @param platform - Target platform.
* @returns Channel metadata filename emitted for the target.
*/
export function desktopUpdateMetadataFilename(
version: string,
platform: NodeJS.Platform,
): string
/**
* Resolve the public updater URL for one release target.
* @param env - Packaging or upload environment.
* @param platform - Target Node.js platform.
* @param arch - Target Node.js architecture.
* @returns Resolved updater configuration.
* @throws When the test deployment lacks a valid HTTPS origin.
*/
export function resolveDesktopAutoUpdateConfig(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
arch: string,
): DesktopAutoUpdateConfig
/**
* Resolve the public updater URL and private COS destination for one upload target.
* @param env - Upload environment.
* @param platform - Target Node.js platform.
* @param arch - Target Node.js architecture.
* @returns Resolved upload configuration.
* @throws When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS.
*/
export function resolveDesktopUploadConfig(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
arch: string,
): DesktopUploadConfig
@@ -0,0 +1,169 @@
/** Resolve the Desktop auto-update channel and its Tencent COS destination. */
import { prerelease, valid } from 'semver'
/** Environment variable that selects the Desktop update deployment. */
export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV'
const UPDATE_ENVIRONMENTS = {
test: {
originEnvName: 'DOWNLOAD_TEST_ORIGIN',
fixedOrigin: undefined,
bucketEnvName: 'DOWNLOAD_TEST_COS_BUCKET',
secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID',
secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY',
},
production: {
originEnvName: undefined,
fixedOrigin: 'https://download.deepseek.com',
bucketEnvName: 'DOWNLOAD_PROD_COS_BUCKET',
secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID',
secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY',
},
}
const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64'])
/**
* Resolve the update deployment, defaulting local release work to test.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
* @returns {'test' | 'production'} Validated deployment name.
*/
export function resolveDesktopAutoUpdateEnvironment(env) {
const value = env[DESKTOP_AUTO_UPDATE_ENV]?.trim() || 'test'
if (value !== 'test' && value !== 'production') {
throw new Error(`desktop auto-update: ${DESKTOP_AUTO_UPDATE_ENV} must be "test" or "production"`)
}
return value
}
/**
* Resolve one supported platform and architecture to its update directory.
* @param {NodeJS.Platform} platform - Target Node.js platform.
* @param {string} arch - Target Node.js architecture.
* @returns {'mac-arm64' | 'mac-x64' | 'win-x64'} Update target directory.
*/
export function resolveDesktopAutoUpdateTarget(platform, arch) {
const os = platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform
const target = `${os}-${arch}`
if (!UPDATE_TARGETS.has(target)) {
throw new Error(`desktop auto-update: unsupported target ${target}`)
}
return target
}
/**
* Return the local completion record filename for one packaged target.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported release target.
* @returns {string} Filename stored beside electron-builder artifacts.
*/
export function desktopBuildRecordFilename(target) {
if (!UPDATE_TARGETS.has(target)) {
throw new Error(`desktop auto-update: unsupported target ${target}`)
}
return `${target}-release.json`
}
/**
* Return the electron-builder channel metadata filename for an application version.
* @param {string} version - Desktop semantic version.
* @param {NodeJS.Platform} platform - Target platform.
* @returns {string} Channel metadata filename emitted for the target.
*/
export function desktopUpdateMetadataFilename(version, platform) {
if (valid(version) === null) {
throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`)
}
if (platform !== 'darwin' && platform !== 'win32') {
throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`)
}
const release = prerelease(version)
const channel = release === null ? 'latest' : String(release[0])
return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml`
}
/**
* Read one required release setting without accepting whitespace-only values.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
* @param {string} name - Environment variable to read.
* @returns {string} Trimmed setting.
*/
function requiredEnvironmentValue(env, name) {
const value = env[name]?.trim()
if (value === undefined || value === '') {
throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`)
}
return value
}
/**
* Normalize an HTTPS origin and reject paths or credentials.
* @param {string} value - Candidate origin.
* @param {string} name - Environment variable used in diagnostics.
* @returns {string} Normalized HTTPS origin without a trailing slash.
*/
function httpsOrigin(value, name) {
let parsed
try {
parsed = new URL(value)
}
catch {
throw new Error(`desktop auto-update: ${name} must be an absolute HTTPS origin`)
}
if (parsed.protocol !== 'https:'
|| parsed.username !== ''
|| parsed.password !== ''
|| parsed.pathname !== '/'
|| parsed.search !== ''
|| parsed.hash !== '') {
throw new Error(`desktop auto-update: ${name} must be an absolute HTTPS origin without a path, credentials, query, or fragment`)
}
return parsed.origin
}
/**
* Resolve the public updater URL for one release target.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
* @param {NodeJS.Platform} platform - Target Node.js platform.
* @param {string} arch - Target Node.js architecture.
* @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string }} Resolved updater configuration.
* @throws {Error} When the test deployment lacks a valid HTTPS origin.
*/
export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
const environment = resolveDesktopAutoUpdateEnvironment(env)
const target = resolveDesktopAutoUpdateTarget(platform, arch)
const deployment = UPDATE_ENVIRONMENTS[environment]
let origin = deployment.fixedOrigin
if (origin === undefined) {
const { originEnvName } = deployment
if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin')
origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName)
}
const keyPrefix = `_/harness/desktop/stable/${target}`
return {
environment,
target,
origin,
keyPrefix,
publicUrl: `${origin}/${keyPrefix}/`,
}
}
/**
* Resolve the public updater URL and private COS destination for one upload target.
* @param {NodeJS.ProcessEnv} env - Upload environment.
* @param {NodeJS.Platform} platform - Target Node.js platform.
* @param {string} arch - Target Node.js architecture.
* @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration.
* @throws {Error} When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS.
*/
export function resolveDesktopUploadConfig(env, platform, arch) {
const update = resolveDesktopAutoUpdateConfig(env, platform, arch)
const deployment = UPDATE_ENVIRONMENTS[update.environment]
return {
...update,
bucket: requiredEnvironmentValue(env, deployment.bucketEnvName),
secretIdEnvName: deployment.secretIdEnvName,
secretKeyEnvName: deployment.secretKeyEnvName,
}
}
@@ -0,0 +1,49 @@
import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
/** Mutable target directories plus the shared immutable download cache. */
export interface DesktopTargetBuildPaths {
readonly root: string
readonly artifacts: string
readonly runtime: string
readonly packageSet: string
readonly seed: string
readonly seedPnpm: string
readonly nodeExtract: string
readonly packedDsh: string
readonly packedVendor: string
readonly packedLandlock: string
readonly downloads: string
}
/**
* Resolve the fixed build target selected by a packaging environment.
* @param env - Packaging environment.
* @param hostPlatform - Build-host platform used when no target override exists.
* @param hostArch - Build-host architecture used when no target override exists.
* @returns Supported Desktop target name.
*/
export function resolveDesktopBuildTarget(
env?: NodeJS.ProcessEnv,
hostPlatform?: NodeJS.Platform,
hostArch?: string,
): DesktopAutoUpdateTarget
/**
* Return the mutable preparation and artifact directories owned by one release target.
* @param target - Supported Desktop target name.
* @returns Target paths plus the shared immutable download cache.
*/
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
/**
* Resolve the paths owned by the target selected in a packaging environment.
* @param env - Packaging environment.
* @param hostPlatform - Build-host platform used when no target override exists.
* @param hostArch - Build-host architecture used when no target override exists.
* @returns Selected target paths.
*/
export function resolveDesktopTargetBuildPaths(
env?: NodeJS.ProcessEnv,
hostPlatform?: NodeJS.Platform,
hostArch?: string,
): DesktopTargetBuildPaths
@@ -0,0 +1,70 @@
/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */
import { join, resolve } from 'node:path'
const APP_ROOT = resolve(import.meta.dirname, '..')
const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64'])
/**
* Resolve the fixed build target selected by a packaging environment.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists.
* @param {string} hostArch - Build-host architecture used when no target override exists.
* @returns {'mac-arm64' | 'mac-x64' | 'win-x64'} Supported Desktop target name.
*/
export function resolveDesktopBuildTarget(
env = process.env,
hostPlatform = process.platform,
hostArch = process.arch,
) {
const platform = env.DSH_DESKTOP_TARGET_PLATFORM ?? env.npm_config_platform ?? hostPlatform
const arch = env.DSH_DESKTOP_TARGET_ARCH ?? env.npm_config_arch ?? hostArch
const os = platform === 'darwin' ? 'mac' : platform === 'win32' || platform === 'win' ? 'win' : platform
const target = `${os}-${arch}`
if (!SUPPORTED_TARGETS.has(target)) {
throw new Error(`desktop build paths: unsupported target ${target}`)
}
return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target)
}
/**
* 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.
*/
export function desktopTargetBuildPaths(target) {
if (!SUPPORTED_TARGETS.has(target)) {
throw new Error(`desktop build paths: unsupported target ${String(target)}`)
}
const root = join(BUILD_ROOT, 'targets', target)
const packed = join(root, 'packed')
return {
root,
artifacts: join(root, 'artifacts'),
runtime: join(root, 'runtime'),
packageSet: join(root, 'package-set'),
seed: join(root, 'seed'),
seedPnpm: join(root, 'seed-pnpm'),
nodeExtract: join(root, 'node-extract'),
packedDsh: join(packed, 'dsh'),
packedVendor: join(packed, 'vendor'),
packedLandlock: join(packed, 'landlock'),
downloads: join(BUILD_ROOT, 'downloads'),
}
}
/**
* Resolve the paths owned by the target selected in a packaging environment.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists.
* @param {string} hostArch - Build-host architecture used when no target override exists.
* @returns {ReturnType<typeof desktopTargetBuildPaths>} Selected target paths.
*/
export function resolveDesktopTargetBuildPaths(
env = process.env,
hostPlatform = process.platform,
hostArch = process.arch,
) {
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
}
@@ -0,0 +1,61 @@
/** Environment variable that supplies the Electron application identifier. */
export const DESKTOP_APP_ID_ENV: 'DSH_DESKTOP_APP_ID'
/** Environment variable that supplies electron-builder's macOS certificate qualifier. */
export const MACOS_SIGNING_IDENTITY_ENV: 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY'
/** Environment variable that supplies the expected Apple Developer Team ID. */
export const MACOS_TEAM_ID_ENV: 'DSH_DESKTOP_MACOS_TEAM_ID'
/** Public identity expected on a macOS release. */
export interface MacOSSigningEnvironment {
readonly signingIdentity: string
readonly teamId: string
}
/** Apple ID credentials accepted by notarytool. */
export interface MacOSAppleIdNotarizationEnvironment {
readonly appleId: string
readonly appleIdPassword: string
readonly teamId: string
}
/** App Store Connect API credentials accepted by notarytool. */
export interface MacOSApiKeyNotarizationEnvironment {
readonly appleApiKey: string
readonly appleApiKeyId: string
readonly appleApiIssuer: string
}
/** Keychain profile accepted by notarytool. */
export interface MacOSKeychainNotarizationEnvironment {
readonly keychainProfile: string
readonly keychain?: string
}
/** One complete credential strategy accepted by notarytool. */
export type MacOSNotarizationEnvironment =
| MacOSAppleIdNotarizationEnvironment
| MacOSApiKeyNotarizationEnvironment
| MacOSKeychainNotarizationEnvironment
/**
* Resolve and validate the application identifier shared by every platform target.
* @param env - Packaging environment.
* @returns Reverse-DNS application identifier.
*/
export function resolveDesktopAppId(env: NodeJS.ProcessEnv): string
/**
* Resolve and validate the public identity expected on a macOS release.
* @param env - Packaging environment.
* @returns Expected certificate qualifier and Team ID.
*/
export function resolveMacOSSigningEnvironment(env: NodeJS.ProcessEnv): MacOSSigningEnvironment
/**
* Resolve one complete credential set accepted by Apple's notary service.
* @param env - Packaging environment.
* @returns Notary credentials without the submitted artifact path.
*/
export function resolveMacOSNotarizationEnvironment(env: NodeJS.ProcessEnv): MacOSNotarizationEnvironment
@@ -0,0 +1,98 @@
/** Resolve public release identifiers supplied by the packaging environment. */
/** Environment variable that supplies the Electron application identifier. */
export const DESKTOP_APP_ID_ENV = 'DSH_DESKTOP_APP_ID'
/** Environment variable that supplies electron-builder's macOS certificate qualifier. */
export const MACOS_SIGNING_IDENTITY_ENV = 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY'
/** Environment variable that supplies the expected Apple Developer Team ID. */
export const MACOS_TEAM_ID_ENV = 'DSH_DESKTOP_MACOS_TEAM_ID'
const APPLE_API_KEY_ENV = 'APPLE_API_KEY'
const APPLE_API_KEY_ID_ENV = 'APPLE_API_KEY_ID'
const APPLE_API_ISSUER_ENV = 'APPLE_API_ISSUER'
const APPLE_ID_ENV = 'APPLE_ID'
const APPLE_APP_SPECIFIC_PASSWORD_ENV = 'APPLE_APP_SPECIFIC_PASSWORD'
const APPLE_TEAM_ID_ENV = 'APPLE_TEAM_ID'
const APPLE_KEYCHAIN_ENV = 'APPLE_KEYCHAIN'
const APPLE_KEYCHAIN_PROFILE_ENV = 'APPLE_KEYCHAIN_PROFILE'
/**
* Read one required non-empty environment variable.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @param {string} name - Required variable name.
* @returns {string} Trimmed variable value.
*/
function requireEnvironmentValue(env, name) {
const value = env[name]?.trim()
if (value === undefined || value === '') {
throw new Error(`desktop release environment: ${name} must be set to a non-empty value`)
}
return value
}
/**
* Resolve and validate the application identifier shared by every platform target.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @returns {string} Reverse-DNS application identifier.
*/
export function resolveDesktopAppId(env) {
const appId = requireEnvironmentValue(env, DESKTOP_APP_ID_ENV)
if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/u.test(appId)) {
throw new Error(`desktop release environment: ${DESKTOP_APP_ID_ENV} must be a reverse-DNS identifier`)
}
return appId
}
/**
* Resolve and validate the public identity expected on a macOS release.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @returns {{ signingIdentity: string, teamId: string }} Expected certificate qualifier and Team ID.
*/
export function resolveMacOSSigningEnvironment(env) {
const signingIdentity = requireEnvironmentValue(env, MACOS_SIGNING_IDENTITY_ENV)
if (signingIdentity.startsWith('Developer ID Application:')) {
throw new Error(`desktop release environment: ${MACOS_SIGNING_IDENTITY_ENV} must omit the "Developer ID Application:" prefix`)
}
const teamId = requireEnvironmentValue(env, MACOS_TEAM_ID_ENV)
if (!/^[A-Z0-9]{10}$/u.test(teamId)) {
throw new Error(`desktop release environment: ${MACOS_TEAM_ID_ENV} must contain 10 uppercase letters or digits`)
}
return { signingIdentity, teamId }
}
/**
* Resolve one complete credential set accepted by Apple's notary service.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @returns {{ appleId: string, appleIdPassword: string, teamId: string } | { appleApiKey: string, appleApiKeyId: string, appleApiIssuer: string } | { keychainProfile: string, keychain?: string }} Notary credentials without the submitted artifact path.
*/
export function resolveMacOSNotarizationEnvironment(env) {
const appleIdValues = [env[APPLE_ID_ENV], env[APPLE_APP_SPECIFIC_PASSWORD_ENV], env[APPLE_TEAM_ID_ENV]]
if (appleIdValues.some(value => value !== undefined)) {
return {
appleId: requireEnvironmentValue(env, APPLE_ID_ENV),
appleIdPassword: requireEnvironmentValue(env, APPLE_APP_SPECIFIC_PASSWORD_ENV),
teamId: requireEnvironmentValue(env, APPLE_TEAM_ID_ENV),
}
}
const apiKeyValues = [env[APPLE_API_KEY_ENV], env[APPLE_API_KEY_ID_ENV], env[APPLE_API_ISSUER_ENV]]
if (apiKeyValues.some(value => value !== undefined)) {
return {
appleApiKey: requireEnvironmentValue(env, APPLE_API_KEY_ENV),
appleApiKeyId: requireEnvironmentValue(env, APPLE_API_KEY_ID_ENV),
appleApiIssuer: requireEnvironmentValue(env, APPLE_API_ISSUER_ENV),
}
}
const keychainProfile = env[APPLE_KEYCHAIN_PROFILE_ENV]?.trim()
if (keychainProfile !== undefined && keychainProfile !== '') {
const keychain = env[APPLE_KEYCHAIN_ENV]?.trim()
return keychain === undefined || keychain === ''
? { keychainProfile }
: { keychainProfile, keychain }
}
throw new Error('desktop release environment: macOS packaging requires APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER; APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID; or APPLE_KEYCHAIN_PROFILE')
}
+257
View File
@@ -0,0 +1,257 @@
/** Validate packaged Desktop update artifacts before any network upload begins. */
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { load } from 'js-yaml'
import type { DesktopPackageTargetName } from './package-target.ts'
import {
desktopBuildRecordFilename,
desktopUpdateMetadataFilename,
resolveDesktopUploadConfig,
} from './desktop-auto-update-environment.mjs'
import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
const TARGETS = {
'mac-arm64': { platform: 'darwin', arch: 'arm64', os: 'mac' },
'mac-x64': { platform: 'darwin', arch: 'x64', os: 'mac' },
'win-x64': { platform: 'win32', arch: 'x64', os: 'win' },
} as const satisfies Record<DesktopPackageTargetName, {
readonly platform: NodeJS.Platform
readonly arch: string
readonly os: string
}>
/** One local file and its final object metadata. */
export interface DesktopUploadArtifact {
readonly path: string
readonly filename: string
readonly key: string
readonly contentType: string
readonly cacheControl: string
readonly channelMetadata: boolean
}
/** A fully validated upload operation with channel metadata ordered last. */
export interface DesktopUploadPlan {
readonly environment: 'test' | 'production'
readonly target: DesktopPackageTargetName
readonly version: string
readonly publicUrl: string
readonly bucket: string
readonly secretIdEnvName: string
readonly secretKeyEnvName: string
readonly artifacts: readonly DesktopUploadArtifact[]
}
/** Filesystem and environment inputs used to validate one upload. */
export interface DesktopUploadPlanOptions {
readonly environment?: NodeJS.ProcessEnv
readonly repositoryRoot?: string
readonly appRoot?: string
readonly artifactsRoot?: string
}
interface UpdateFileInfo {
readonly filename: string
readonly size: number
readonly sha512: string
}
function object(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`desktop upload: ${label} must be an object`)
}
return value as Record<string, unknown>
}
function stringField(value: unknown, label: string): string {
if (typeof value !== 'string' || value === '') {
throw new Error(`desktop upload: ${label} must be a non-empty string`)
}
return value
}
function numberField(value: unknown, label: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
throw new Error(`desktop upload: ${label} must be a positive integer`)
}
return value
}
async function jsonFile(path: string, label: string): Promise<Record<string, unknown>> {
let parsed: unknown
try {
parsed = JSON.parse(await readFile(path, 'utf8'))
}
catch (error) {
throw new Error(`desktop upload: cannot read ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`)
}
return object(parsed, label)
}
async function manifestVersion(path: string, label: string): Promise<string> {
return stringField((await jsonFile(path, label)).version, `${label}.version`)
}
function updateFileInfo(value: unknown, label: string, expectedFilename: string): UpdateFileInfo {
const info = object(value, label)
const filename = stringField(info.url ?? info.path, `${label}.url`)
if (filename !== basename(filename) || filename !== expectedFilename) {
throw new Error(`desktop upload: ${label} must reference ${expectedFilename}, received ${filename}`)
}
return {
filename,
size: numberField(info.size, `${label}.size`),
sha512: stringField(info.sha512, `${label}.sha512`),
}
}
async function sha512(path: string): Promise<string> {
const hash = createHash('sha512')
for await (const chunk of createReadStream(path)) hash.update(chunk)
return hash.digest('base64')
}
async function verifyChecksummedArtifact(
artifactsRoot: string,
info: UpdateFileInfo,
): Promise<string> {
const path = join(artifactsRoot, info.filename)
const details = await stat(path).catch(() => undefined)
if (details === undefined || !details.isFile()) {
throw new Error(`desktop upload: missing artifact ${path}`)
}
if (details.size !== info.size) {
throw new Error(`desktop upload: ${info.filename} size ${details.size} does not match update metadata ${info.size}`)
}
const actual = await sha512(path)
if (actual !== info.sha512) {
throw new Error(`desktop upload: ${info.filename} SHA-512 does not match update metadata`)
}
return path
}
async function requireArtifact(artifactsRoot: string, filename: string): Promise<string> {
const path = join(artifactsRoot, filename)
const details = await stat(path).catch(() => undefined)
if (details === undefined || !details.isFile() || details.size === 0) {
throw new Error(`desktop upload: missing or empty artifact ${path}`)
}
return path
}
function uploadArtifact(
path: string,
keyPrefix: string,
contentType: string,
channelMetadata = false,
): DesktopUploadArtifact {
const filename = basename(path)
return {
path,
filename,
key: `${keyPrefix}/${filename}`,
contentType,
cacheControl: channelMetadata
? 'no-cache'
: 'public, max-age=31536000, immutable',
channelMetadata,
}
}
/**
* Validate the completed package record, dsh version, update metadata, hashes, and target files.
* @param targetName - Fixed platform and architecture selected by the upload command.
* @param options - Optional filesystem roots and environment for tests or release automation.
* @returns An upload plan whose mutable channel metadata is the final entry.
*/
export async function createDesktopUploadPlan(
targetName: DesktopPackageTargetName,
options: DesktopUploadPlanOptions = {},
): Promise<DesktopUploadPlan> {
const target = TARGETS[targetName]
if (target === undefined) {
throw new Error(`desktop upload: unsupported target ${String(targetName)}`)
}
const environment = options.environment ?? process.env
const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT
const appRoot = options.appRoot ?? APP_ROOT
const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts
const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package')
const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package')
if (dshVersion !== desktopVersion) {
throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`)
}
const update = resolveDesktopUploadConfig(environment, target.platform, target.arch)
const buildRecord = await jsonFile(
join(artifactsRoot, desktopBuildRecordFilename(targetName)),
`${targetName} package completion record`,
)
if (buildRecord.schemaVersion !== 1
|| buildRecord.target !== targetName
|| buildRecord.version !== dshVersion
|| buildRecord.environment !== update.environment
|| buildRecord.publicUrl !== update.publicUrl) {
throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`)
}
const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform)
const metadataPath = join(artifactsRoot, metadataFilename)
let metadataValue: unknown
try {
metadataValue = load(await readFile(metadataPath, 'utf8'))
}
catch (error) {
throw new Error(`desktop upload: cannot read update metadata at ${metadataPath}: ${error instanceof Error ? error.message : String(error)}`)
}
const metadata = object(metadataValue, metadataFilename)
const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`)
if (metadataVersion !== dshVersion) {
throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`)
}
if (!Array.isArray(metadata.files) || metadata.files.length !== 1) {
throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`)
}
const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}`
const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe'
const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`)
const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo)
const artifacts: DesktopUploadArtifact[] = []
if (target.platform === 'darwin') {
const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`)
const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`)
artifacts.push(
uploadArtifact(dmgPath, update.keyPrefix, 'application/x-apple-diskimage'),
uploadArtifact(updaterPath, update.keyPrefix, 'application/zip'),
uploadArtifact(blockmapPath, update.keyPrefix, 'application/octet-stream'),
)
}
else {
const blockMapSize = object(metadata.files[0], `${metadataFilename}.files[0]`).blockMapSize
numberField(blockMapSize, `${metadataFilename}.files[0].blockMapSize`)
artifacts.push(uploadArtifact(
updaterPath,
update.keyPrefix,
'application/vnd.microsoft.portable-executable',
))
}
artifacts.push(uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true))
return {
environment: update.environment,
target: targetName,
version: dshVersion,
publicUrl: update.publicUrl,
bucket: update.bucket,
secretIdEnvName: update.secretIdEnvName,
secretKeyEnvName: update.secretKeyEnvName,
artifacts,
}
}
+118
View File
@@ -0,0 +1,118 @@
/** Build and launch the unpackaged Electron shell against the current workspace. */
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import type { DesktopRelease } from '../src/release.ts'
import { prepareDevelopmentProject } from './development-project.ts'
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
const DEVELOPMENT_ROOT = join(BUILD_ROOT, 'development')
interface PackageManifest {
readonly version?: string
}
function packageVersion(path: string, subject: string): string {
const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
if (typeof manifest.version !== 'string') throw new Error(`desktop development: ${subject} has no version`)
return manifest.version
}
function debugPort(name: string, fallback: number): number {
const value = process.env[name]
if (value === undefined || value === '') return fallback
const port = Number(value)
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
throw new Error(`desktop development: ${name} must be an integer from 1 through 65535`)
}
return port
}
async function run(command: string, args: readonly string[], cwd: string, environment = process.env): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, { cwd, env: environment, stdio: 'inherit' })
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) resolvePromise()
else reject(new Error(`desktop development: ${args.join(' ')} exited with ${String(code ?? signal)}`))
})
})
}
async function runPackageScript(script: string, cwd: string): Promise<void> {
const packageManager = process.env.npm_execpath
if (packageManager === undefined || packageManager === '') {
throw new Error('desktop development: invoke this launcher through pnpm run dev:desktop or start:desktop')
}
await run(process.execPath, [packageManager, 'run', script], cwd)
}
async function launchElectron(projectDir: string): Promise<void> {
const require = createRequire(import.meta.url)
const electron: unknown = require('electron')
if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable')
const mainPort = debugPort('DSH_DESKTOP_MAIN_INSPECT_PORT', 9229)
const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222)
const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230)
const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home'))
const userData = join(DEVELOPMENT_ROOT, 'electron-user-data')
const environment: NodeJS.ProcessEnv = {
...process.env,
DSH_HOME: home,
DSH_DESKTOP_DEV_PROJECT_DIR: projectDir,
DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort),
DSH_DESKTOP_NODE_BINARY: process.execPath,
DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1',
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
}
console.log(`desktop development: DSH_HOME=${home}`)
console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`)
await run(electron, [
`--inspect=127.0.0.1:${String(mainPort)}`,
`--remote-debugging-port=${String(rendererPort)}`,
`--user-data-dir=${userData}`,
APP_ROOT,
], APP_ROOT, environment)
}
async function main(): Promise<void> {
const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } })
if (!values['skip-build']) {
await runPackageScript('build', REPOSITORY_ROOT)
await runPackageScript('build', APP_ROOT)
}
for (const path of [
join(APP_ROOT, 'lib', 'main.js'),
join(REPOSITORY_ROOT, 'apps', 'desktop-host', 'lib', 'index.js'),
]) {
if (!existsSync(path)) throw new Error(`desktop development: missing built artifact ${path}`)
}
const version = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
const pnpmVersion = packageVersion(join(APP_ROOT, 'node_modules', 'pnpm', 'package.json'), 'pnpm package')
const release: DesktopRelease = {
schemaVersion: 1,
version,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: process.versions.node,
pnpmVersion,
}
const projectDir = prepareDevelopmentProject({
projectDir: join(DEVELOPMENT_ROOT, 'project'),
cliDir: join(REPOSITORY_ROOT, 'apps', 'cli'),
hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'),
dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'),
release,
})
await launchElectron(projectDir)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+120
View File
@@ -0,0 +1,120 @@
/** Prepare the disposable npm-project view used by an unpackaged Electron shell. */
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
unlinkSync,
} from 'node:fs'
import { dirname, join } from 'node:path'
import { createDevelopmentProjectMetadata } from '../src/project-manager.ts'
import type { DesktopRelease } from '../src/release.ts'
interface PackageManifest {
readonly name?: string
readonly version?: string
}
/** Inputs whose locations differ between the launcher and isolated tests. */
export interface DevelopmentProjectOptions {
/** Directory replaced with the generated development project. */
readonly projectDir: string
/** Current workspace's `apps/cli` package directory. */
readonly cliDir: string
/** Current workspace's private Desktop Host application directory. */
readonly hostDir: string
/** pnpm's workspace-wide virtual-hoist directory. */
readonly dependencyDir: string
/** Release identity written into the disposable project metadata. */
readonly release: DesktopRelease
}
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
}
function removeOwnedPath(path: string): void {
let stat: ReturnType<typeof lstatSync>
try {
stat = lstatSync(path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
throw error
}
if (stat.isSymbolicLink()) {
unlinkSync(path)
return
}
if (stat.isDirectory()) {
rmSync(path, { recursive: true })
return
}
unlinkSync(path)
}
function linkDirectory(source: string, destination: string): void {
mkdirSync(dirname(destination), { recursive: true })
symlinkSync(realpathSync(source), destination, process.platform === 'win32' ? 'junction' : 'dir')
}
function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): void {
for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
if (entry.name === '.bin') continue
const source = join(sourceRoot, entry.name)
if (entry.name.startsWith('@') && (entry.isDirectory() || entry.isSymbolicLink())) {
mkdirSync(join(destinationRoot, entry.name), { recursive: true })
for (const scoped of readdirSync(source, { withFileTypes: true })) {
if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue
linkDirectory(join(source, scoped.name), join(destinationRoot, entry.name, scoped.name))
}
continue
}
if (entry.isDirectory() || entry.isSymbolicLink()) linkDirectory(source, join(destinationRoot, entry.name))
}
}
/**
* Replace one disposable project with links to the current built workspace.
* @param options - Project destination, CLI package, and release identity.
* @returns the absolute project directory supplied by the caller.
*/
export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string {
const cliManifest = readManifest(join(options.cliDir, 'package.json'))
if (cliManifest.name !== '@deepseek-ai/dsh' || cliManifest.version !== options.release.version) {
throw new Error(
`desktop development: apps/cli must be @deepseek-ai/dsh@${options.release.version}, found `
+ `${String(cliManifest.name)}@${String(cliManifest.version)}`,
)
}
if (!existsSync(options.dependencyDir)) {
throw new Error('desktop development: workspace dependency links are missing; run pnpm install')
}
const hostManifest = readManifest(join(options.hostDir, 'package.json'))
if (hostManifest.name !== '@deepseek-ai/dsh-desktop-host' || hostManifest.version !== options.release.version) {
throw new Error(
`desktop development: apps/desktop-host must be @deepseek-ai/dsh-desktop-host@${options.release.version}, found `
+ `${String(hostManifest.name)}@${String(hostManifest.version)}`,
)
}
if (!existsSync(join(options.hostDir, 'lib', 'index.js'))) {
throw new Error('desktop development: apps/desktop-host/lib/index.js is missing; run pnpm run build')
}
removeOwnedPath(options.projectDir)
createDevelopmentProjectMetadata(options.projectDir, options.release)
const destinationModules = join(options.projectDir, 'node_modules')
mkdirSync(destinationModules, { recursive: true })
mirrorDependencyLinks(options.dependencyDir, destinationModules)
const dshLink = join(destinationModules, '@deepseek-ai', 'dsh')
removeOwnedPath(dshLink)
linkDirectory(options.cliDir, dshLink)
const hostLink = join(destinationModules, '@deepseek-ai', 'dsh-desktop-host')
removeOwnedPath(hostLink)
linkDirectory(options.hostDir, hostLink)
return options.projectDir
}
+413
View File
@@ -0,0 +1,413 @@
/** 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
}
@@ -0,0 +1,23 @@
import type { NotarizeOptions } from '@electron/notarize'
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
/** Completed electron-builder artifact needed for disk-image notarization. */
export interface DesktopBuildArtifact {
readonly file: string
}
/**
* Submit one generated DMG to Apple, staple its ticket, and verify Gatekeeper acceptance.
* @param artifact - Completed electron-builder artifact.
* @param env - Packaging environment.
* @param expected - Public release identity.
* @param submit - Notary submission implementation.
* @param verify - Disk-image qualification implementation.
*/
export function notarizeMacOSDiskImageArtifact(
artifact: DesktopBuildArtifact,
env: NodeJS.ProcessEnv,
expected: MacOSSigningEnvironment,
submit?: (options: NotarizeOptions) => Promise<void>,
verify?: (path: string, expected: MacOSSigningEnvironment) => void,
): Promise<void>
@@ -0,0 +1,30 @@
/** Notarize and qualify macOS disk images after electron-builder creates them. */
import { notarize } from '@electron/notarize'
import { rmSync } from 'node:fs'
import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
import { verifyMacOSDiskImage } from './verify-macos-signature.mjs'
/**
* Submit one generated DMG to Apple, staple its ticket, and verify Gatekeeper acceptance.
* @param {{ file: string }} artifact - Completed electron-builder artifact.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @param {(options: object) => Promise<void>} submit - Notary submission implementation.
* @param {(path: string, expected: object) => void} verify - Disk-image qualification implementation.
* @returns {Promise<void>}
*/
export async function notarizeMacOSDiskImageArtifact(
artifact,
env,
expected,
submit = notarize,
verify = verifyMacOSDiskImage,
) {
if (!artifact.file.endsWith('.dmg')) return
rmSync(`${artifact.file}.blockmap`, { force: true })
const credentials = resolveMacOSNotarizationEnvironment(env)
await submit({ appPath: artifact.file, ...credentials })
verify(artifact.file, expected)
process.stdout.write(`desktop macOS notarization: verified disk image ${artifact.file}\n`)
}
+285
View File
@@ -0,0 +1,285 @@
/** Build one release target with matching Electron, Node.js, and seed architecture. */
import { spawn } from 'node:child_process'
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { join, resolve } from 'node:path'
import {
desktopBuildRecordFilename,
resolveDesktopAutoUpdateConfig,
} from './desktop-auto-update-environment.mjs'
import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_'
const WINDOWS_SIGNING_ENV_NAMES = [
'DSH_DESKTOP_WINDOWS_CER_FILE',
'DSH_DESKTOP_WINDOWS_KEY_CONTAINER',
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
] as const
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
'DOWNLOAD_TEST_COS_SECRET_ID',
'DOWNLOAD_TEST_COS_SECRET_KEY',
'DOWNLOAD_PROD_COS_SECRET_ID',
'DOWNLOAD_PROD_COS_SECRET_KEY',
])
/** Fixed platform and architecture identifiers exposed by package scripts. */
export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64'
/** One supported release target and its electron-builder selectors. */
export interface DesktopPackageTarget {
readonly name: DesktopPackageTargetName
readonly platform: 'darwin' | 'win32'
readonly arch: 'arm64' | 'x64'
readonly builderPlatform: '--mac' | '--win'
readonly builderArch: '--arm64' | '--x64'
}
const TARGETS: Record<DesktopPackageTargetName, DesktopPackageTarget> = {
'mac-arm64': {
name: 'mac-arm64',
platform: 'darwin',
arch: 'arm64',
builderPlatform: '--mac',
builderArch: '--arm64',
},
'mac-x64': {
name: 'mac-x64',
platform: 'darwin',
arch: 'x64',
builderPlatform: '--mac',
builderArch: '--x64',
},
'win-x64': {
name: 'win-x64',
platform: 'win32',
arch: 'x64',
builderPlatform: '--win',
builderArch: '--x64',
},
}
/**
* Remove Windows signing configuration from package preparation subprocesses.
* @param environment - Packaging command environment.
* @returns A copy without Windows signing fields.
*/
export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX)))
}
/**
* Remove upload-only COS credentials from every packaging subprocess.
* @param environment - Packaging command environment.
* @returns A copy without Desktop upload credentials.
*/
export function withoutDesktopUploadCredentials(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES.has(name)))
}
function isTargetName(value: string): value is DesktopPackageTargetName {
return Object.hasOwn(TARGETS, value)
}
function packageVersion(path: string, label: string): string {
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
if (typeof manifest.version !== 'string' || manifest.version === '') {
throw new Error(`desktop package: ${label} has no version`)
}
return manifest.version
}
function writeReleaseRecord(
target: DesktopPackageTarget,
environment: NodeJS.ProcessEnv,
artifactsRoot: string,
): void {
const desktopVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
const dshVersion = packageVersion(join(REPOSITORY_ROOT, 'package.json'), 'dsh package')
if (desktopVersion !== dshVersion) {
throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`)
}
const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name))
const temporaryPath = `${recordPath}.tmp`
writeFileSync(temporaryPath, `${JSON.stringify({
schemaVersion: 1,
target: target.name,
version: dshVersion,
environment: update.environment,
publicUrl: update.publicUrl,
}, null, 2)}\n`)
renameSync(temporaryPath, recordPath)
}
/**
* Resolve a named release target and reject hosts that cannot execute its packaged runtime.
* @param name - One of the fixed Desktop release target names.
* @param hostPlatform - Build-host Node.js platform.
* @param hostArch - Build-host Node.js architecture.
* @returns The target selectors shared by runtime preparation and electron-builder.
*/
export function resolveDesktopPackageTarget(
name: string,
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
): DesktopPackageTarget {
if (!isTargetName(name)) {
throw new Error(`desktop package: unsupported target ${JSON.stringify(name)}; expected ${Object.keys(TARGETS).join(', ')}`)
}
const target = TARGETS[name]
if (target.platform === 'win32' && (hostPlatform !== 'win32' || hostArch !== 'x64')) {
throw new Error('desktop package: win-x64 requires a Windows x64 build host')
}
if (target.platform === 'darwin' && hostPlatform !== 'darwin') {
throw new Error(`desktop package: ${name} requires a macOS build host`)
}
if (name === 'mac-arm64' && hostArch !== 'arm64') {
throw new Error('desktop package: mac-arm64 requires an Apple Silicon build host')
}
if (name === 'mac-x64' && hostArch !== 'arm64' && hostArch !== 'x64') {
throw new Error('desktop package: mac-x64 requires an Intel Mac or Apple Silicon with Rosetta')
}
return target
}
interface DesktopPackageInvocation {
readonly target: DesktopPackageTarget
readonly directory: boolean
readonly prepareOnly: boolean
}
function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
const name = `${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}-${arch}`
if (!isTargetName(name)) throw new Error(`desktop package: unsupported build host ${platform}-${arch}`)
return name
}
/**
* Parse the fixed-target packaging command line.
* @param argv - Arguments after the script entry point.
* @param hostPlatform - Build-host Node.js platform.
* @param hostArch - Build-host Node.js architecture.
* @returns The validated target and whether to emit an unpacked directory.
*/
export function parseDesktopPackageInvocation(
argv: readonly string[],
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
): DesktopPackageInvocation {
const { values, positionals } = parseArgs({
args: [...argv],
allowPositionals: true,
options: {
dir: { type: 'boolean', default: false },
'prepare-only': { type: 'boolean', default: false },
},
})
if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch)
return {
target: resolveDesktopPackageTarget(name, hostPlatform, hostArch),
directory: values.dir,
prepareOnly: values['prepare-only'],
}
}
/**
* Build the electron-builder command arguments for one validated target.
* @param target - Supported release target.
* @param directory - Whether to stop at an unpacked application directory.
* @returns Arguments that keep publishing under the separate validated upload command.
*/
export function desktopElectronBuilderArguments(
target: DesktopPackageTarget,
directory: boolean,
): readonly string[] {
return [
'exec',
'electron-builder',
'--config',
'electron-builder.config.mjs',
target.builderPlatform,
target.builderArch,
'--publish',
'never',
...(directory ? ['--dir'] : []),
]
}
function runPnpm(
args: readonly string[],
env: NodeJS.ProcessEnv = process.env,
cwd: string = APP_ROOT,
): Promise<void> {
const pnpmEntry = process.env.npm_execpath
if (pnpmEntry === undefined || pnpmEntry === '') {
throw new Error('desktop package: invoke this script through a pnpm package command')
}
return new Promise((resolvePromise, reject) => {
const child = spawn(process.execPath, [pnpmEntry, ...args], {
cwd,
env,
stdio: 'inherit',
})
child.once('error', reject)
child.once('close', (code, signal) => {
if (code === 0) resolvePromise()
else reject(new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`))
})
})
}
async function main(): Promise<void> {
const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
const { target } = invocation
const buildPaths = desktopTargetBuildPaths(target.name)
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
if (!invocation.prepareOnly) {
rmSync(releaseRecordPath, { force: true })
rmSync(`${releaseRecordPath}.tmp`, { force: true })
}
const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(process.env))
const targetEnv: NodeJS.ProcessEnv = {
...buildEnv,
DSH_DESKTOP_TARGET_PLATFORM: target.platform,
DSH_DESKTOP_TARGET_ARCH: target.arch,
}
const electronBuilderEnv = { ...targetEnv }
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
}
await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
await runPnpm([
'--dir',
'apps/desktop-host',
'pack',
'--pack-destination',
buildPaths.packedDsh,
], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
mkdirSync(buildPaths.packedLandlock, { recursive: true })
await runPnpm(['--dir', 'native/landlock-run', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
await runPnpm([
'--dir',
'native/landlock-run/packages/entry',
'pack',
'--pack-destination',
buildPaths.packedLandlock,
], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'prepare:runtime'], targetEnv)
await runPnpm(['run', 'prepare:packages'], targetEnv)
await runPnpm(['run', 'prepare:seed'], targetEnv)
if (invocation.prepareOnly) return
await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
+172
View File
@@ -0,0 +1,172 @@
/** Select and copy the local npm tarball closures that supply Desktop dsh and its private Host. */
import { createHash } from 'node:crypto'
import {
constants,
copyFileSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import {
DESKTOP_HOST_PACKAGE,
DESKTOP_HOST_RUNTIME_FILES,
DESKTOP_PACKAGES_DIR,
DESKTOP_PACKAGE_SET_FILE,
parseDesktopCorePackageSet,
type DesktopCorePackageRecord,
} from '../src/core-package-set.ts'
import { capture } from '../../../scripts/release/process.ts'
import { tarballFiles } from '../../../scripts/release/tarball.ts'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const DSH_PACKAGE = '@deepseek-ai/dsh'
const ROOT_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
const REQUIRED_DEPENDENCY_SECTIONS = ['dependencies', 'peerDependencies'] as const
const OPTIONAL_DEPENDENCY_SECTION = 'optionalDependencies'
/** Packed package information needed to form the local Desktop closure. */
export interface PackedDesktopPackage {
readonly tarball: string
readonly manifest: Readonly<Record<string, unknown>>
}
function dependencyNames(manifest: Readonly<Record<string, unknown>>, section: string): string[] {
const value = manifest[section]
if (value === undefined) return []
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`desktop package set: ${String(manifest.name)} has invalid ${section}`)
}
return Object.keys(value).sort()
}
/**
* Select the complete available first-party dependency closures rooted at dsh and its private Host.
* @param available - Packed packages indexed by package name.
* @returns Selected packages sorted by name.
*/
export function selectDesktopPackageClosure(
available: ReadonlyMap<string, PackedDesktopPackage>,
): PackedDesktopPackage[] {
const selected = new Map<string, PackedDesktopPackage>()
const visit = (name: string): void => {
if (selected.has(name)) return
const packed = available.get(name)
if (packed === undefined) throw new Error(`desktop package set: packed inputs omit required package ${name}`)
selected.set(name, packed)
for (const section of REQUIRED_DEPENDENCY_SECTIONS) {
for (const dependency of dependencyNames(packed.manifest, section)) {
if (available.has(dependency)) visit(dependency)
else if (dependency.startsWith('@deepseek-ai/')) {
throw new Error(`desktop package set: ${name} requires unpacked internal package ${dependency}`)
}
}
}
for (const dependency of dependencyNames(packed.manifest, OPTIONAL_DEPENDENCY_SECTION)) {
if (available.has(dependency)) visit(dependency)
}
}
for (const name of ROOT_PACKAGES) {
if (!available.has(name)) throw new Error(`desktop package set: packed inputs omit ${name}`)
visit(name)
}
return [...selected.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, packed]) => packed)
}
function packedManifest(tarball: string): Record<string, unknown> {
const value: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json']))
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`desktop package set: ${tarball} has no package manifest`)
}
return value as Record<string, unknown>
}
function packedPackages(inputs: readonly string[]): Map<string, PackedDesktopPackage> {
const available = new Map<string, PackedDesktopPackage>()
for (const input of inputs) {
const tarballs = readdirSync(input).filter(file => file.endsWith('.tgz')).sort()
if (tarballs.length === 0) throw new Error(`desktop package set: ${input} contains no tarballs`)
for (const file of tarballs) {
const tarball = join(input, file)
const manifest = packedManifest(tarball)
const name = manifest.name
if (typeof name !== 'string' || name === '') throw new Error(`desktop package set: ${tarball} has no package name`)
if (available.has(name)) throw new Error(`desktop package set: duplicate packed package ${name}`)
available.set(name, { tarball, manifest })
}
}
return available
}
/**
* Require every private Host file used before the Desktop profile can pass its health check.
* @param files - Tarball paths rooted at `package/`.
* @returns Nothing.
*/
export function assertDesktopHostPackageFiles(files: readonly string[]): void {
const available = new Set(files)
const missing = DESKTOP_HOST_RUNTIME_FILES
.map(file => `package/${file}`)
.filter(file => !available.has(file))
if (missing.length > 0) {
throw new Error(`desktop package set: ${DESKTOP_HOST_PACKAGE} tarball omits required file(s): ${missing.join(', ')}`)
}
}
/** Prepare a package set from release tarball directories. */
export function prepareDesktopPackageSet(inputs: readonly string[], output: string): void {
const selected = selectDesktopPackageClosure(packedPackages(inputs))
const host = selected.find(packed => packed.manifest.name === DESKTOP_HOST_PACKAGE)
if (host === undefined) throw new Error(`desktop package set: selected closure omits ${DESKTOP_HOST_PACKAGE}`)
assertDesktopHostPackageFiles(tarballFiles(host.tarball))
rmSync(output, { recursive: true, force: true })
const packageDir = join(output, DESKTOP_PACKAGES_DIR)
mkdirSync(packageDir, { recursive: true })
const records: DesktopCorePackageRecord[] = selected.map((packed) => {
const name = packed.manifest.name
const version = packed.manifest.version
if (typeof name !== 'string' || typeof version !== 'string') {
throw new Error(`desktop package set: ${packed.tarball} has no package identity`)
}
const file = basename(packed.tarball)
const destination = join(packageDir, file)
copyFileSync(packed.tarball, destination, constants.COPYFILE_EXCL)
const body = readFileSync(destination)
return {
name,
version,
file,
bytes: statSync(destination).size,
integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`,
}
})
const packageSet = parseDesktopCorePackageSet({ schemaVersion: 1, packages: records })
writeFileSync(join(output, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify(packageSet, undefined, 2)}\n`, { mode: 0o600 })
}
function main(): void {
const buildPaths = resolveDesktopTargetBuildPaths()
const defaultInputs = [
buildPaths.packedDsh,
buildPaths.packedVendor,
buildPaths.packedLandlock,
]
const { values } = parseArgs({
options: { from: { type: 'string', multiple: true }, out: { type: 'string' } },
allowPositionals: false,
})
const inputs = (values.from ?? defaultInputs).map(path => resolve(REPOSITORY_ROOT, path))
const output = values.out === undefined ? buildPaths.packageSet : resolve(REPOSITORY_ROOT, values.out)
prepareDesktopPackageSet(inputs, output)
console.log(`desktop package set: prepared ${output}`)
}
if (import.meta.main) main()
+107
View File
@@ -0,0 +1,107 @@
/** Download and verify the upstream Node.js runtime and copy the pinned pnpm CLI. */
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { chmod, readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import extractZip from 'extract-zip'
import { extract } from 'tar'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const NODE_VERSION = '24.17.0'
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
const RUNTIME_ROOT = BUILD_PATHS.runtime
const DOWNLOAD_ROOT = BUILD_PATHS.downloads
type RuntimePlatform = 'darwin' | 'linux' | 'win'
type RuntimeArch = 'arm64' | 'x64'
function target(): { platform: RuntimePlatform; arch: RuntimeArch } {
const rawPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.env.npm_config_platform ?? process.platform
const rawArch = process.env.DSH_DESKTOP_TARGET_ARCH ?? process.env.npm_config_arch ?? process.arch
const platform = rawPlatform === 'win32' ? 'win' : rawPlatform
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win') {
throw new Error(`desktop runtime: unsupported platform ${rawPlatform}`)
}
if (rawArch !== 'arm64' && rawArch !== 'x64') throw new Error(`desktop runtime: unsupported architecture ${rawArch}`)
return { platform, arch: rawArch }
}
async function download(url: string, path: string): Promise<void> {
const response = await fetch(url)
if (!response.ok) throw new Error(`desktop runtime: ${url} returned HTTP ${String(response.status)}`)
writeFileSync(path, new Uint8Array(await response.arrayBuffer()), { mode: 0o600 })
}
async function prepareNode(platform: RuntimePlatform, arch: RuntimeArch): Promise<void> {
const extension = platform === 'win' ? 'zip' : 'tar.gz'
const folder = `node-v${NODE_VERSION}-${platform}-${arch}`
const archiveName = `${folder}.${extension}`
const releaseRoot = `https://nodejs.org/download/release/v${NODE_VERSION}`
const archive = join(DOWNLOAD_ROOT, archiveName)
const sums = join(DOWNLOAD_ROOT, `node-v${NODE_VERSION}-SHASUMS256.txt`)
if (!existsSync(archive)) await download(`${releaseRoot}/${archiveName}`, archive)
if (!existsSync(sums)) await download(`${releaseRoot}/SHASUMS256.txt`, sums)
const line = (await readFile(sums, 'utf8')).split(/\r?\n/u)
.find(candidate => candidate.endsWith(` ${archiveName}`))
if (line === undefined) throw new Error(`desktop runtime: ${archiveName} is absent from Node.js SHASUMS256.txt`)
const expected = line.split(/\s+/u)[0]
const actual = createHash('sha256').update(await readFile(archive)).digest('hex')
if (actual !== expected) throw new Error(`desktop runtime: checksum mismatch for ${archiveName}`)
const extraction = BUILD_PATHS.nodeExtract
rmSync(extraction, { recursive: true, force: true })
mkdirSync(extraction, { recursive: true })
if (platform === 'win') await extractZip(archive, { dir: extraction })
else await extract({ cwd: extraction, file: archive })
const source = join(extraction, folder, platform === 'win' ? 'node.exe' : 'bin/node')
const destinationRoot = join(RUNTIME_ROOT, 'node')
const destination = join(destinationRoot, platform === 'win' ? 'node.exe' : 'node')
rmSync(destinationRoot, { recursive: true, force: true })
mkdirSync(destinationRoot, { recursive: true })
// A fresh write prevents macOS from retaining invalid code-signature vnode state from a tar-extracted Mach-O clone.
await pipeline(createReadStream(source), createWriteStream(destination, { flags: 'wx' }))
if (platform !== 'win') await chmod(destination, 0o755)
const hostPlatform = process.platform === 'win32' ? 'win' : process.platform
const hostCanExecute = platform === hostPlatform
&& (arch === process.arch || (platform === 'darwin' && arch === 'x64' && process.arch === 'arm64'))
if (hostCanExecute) {
const result = spawnSync(destination, ['--version'], { encoding: 'utf8' })
if (result.error !== undefined || result.status !== 0 || result.stdout.trim() !== `v${NODE_VERSION}`) {
const detail = result.error?.message ?? result.signal ?? result.stderr.trim()
const outcome = detail === '' ? `exit ${String(result.status)}` : detail
throw new Error(`desktop runtime: prepared Node.js ${NODE_VERSION} failed executable verification: ${outcome}`)
}
}
rmSync(extraction, { recursive: true, force: true })
}
function preparePnpm(): string {
const require = createRequire(import.meta.url)
const manifestPath = require.resolve('pnpm')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: unknown }
if (typeof manifest.version !== 'string') throw new Error('desktop runtime: pnpm manifest has no version')
const packageDir = dirname(manifestPath)
const destination = join(RUNTIME_ROOT, 'pnpm')
rmSync(destination, { recursive: true, force: true })
cpSync(packageDir, destination, { recursive: true })
return manifest.version
}
async function main(): Promise<void> {
const { platform, arch } = target()
mkdirSync(DOWNLOAD_ROOT, { recursive: true })
mkdirSync(RUNTIME_ROOT, { recursive: true })
await prepareNode(platform, arch)
const pnpmVersion = preparePnpm()
writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({
schemaVersion: 1,
node: NODE_VERSION,
pnpm: pnpmVersion,
}, undefined, 2)}\n`)
}
await main()
+200
View File
@@ -0,0 +1,200 @@
/** 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()
+83
View File
@@ -0,0 +1,83 @@
/** Upload one validated Desktop release to its Tencent COS update directory. */
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type { DesktopPackageTargetName } from './package-target.ts'
import {
createDesktopUploadPlan,
type DesktopUploadArtifact,
} from './desktop-upload-plan.ts'
const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
function targetName(value: string): DesktopPackageTargetName {
if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) {
throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`)
}
return value as DesktopPackageTargetName
}
function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string {
const value = environment[name]?.trim()
if (value === undefined || value === '') {
throw new Error(`desktop upload: ${name} must be set to a non-empty value`)
}
return value
}
async function putArtifact(
client: S3Client,
bucket: string,
artifact: DesktopUploadArtifact,
): Promise<void> {
const details = await stat(artifact.path)
const body = createReadStream(artifact.path)
try {
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: artifact.key,
Body: body,
ContentLength: details.size,
ContentType: artifact.contentType,
CacheControl: artifact.cacheControl,
}))
}
finally {
body.destroy()
}
process.stdout.write(`desktop upload: uploaded ${artifact.key}\n`)
}
async function main(): Promise<void> {
const { positionals } = parseArgs({ args: process.argv.slice(2), allowPositionals: true })
const target = positionals[0]
if (target === undefined || positionals.length !== 1) {
throw new Error('desktop upload: expected exactly one target')
}
const plan = await createDesktopUploadPlan(targetName(target))
const client = new S3Client({
region: 'Auto',
endpoint: 'https://cos.ap-beijing.myqcloud.com',
credentials: {
accessKeyId: requiredEnvironmentValue(process.env, plan.secretIdEnvName),
secretAccessKey: requiredEnvironmentValue(process.env, plan.secretKeyEnvName),
},
})
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
try {
for (const artifact of plan.artifacts) await putArtifact(client, plan.bucket, artifact)
}
finally {
client.destroy()
}
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
process.exitCode = 1
})
}
@@ -0,0 +1,73 @@
import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs'
/**
* Reject signature metadata that does not name the company release authority and team.
* @param details - Output from `codesign --display --verbose=4`.
* @param expected - Public release identity.
*/
export function assertMacOSSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
/**
* Require the signature properties Apple validates for executable seed content.
* @param details - Output from `codesign --display --verbose=4`.
* @param expected - Public release identity.
*/
export function assertMacOSSeedSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
/**
* Sign one Mach-O file embedded in the seed store.
* @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(
path: string,
identifier: string,
expected: MacOSSigningEnvironment,
): Promise<void>
/**
* Verify one Mach-O file embedded in the seed store.
* @param path - Mach-O file to inspect.
* @param expected - Public release identity.
*/
export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void
/**
* Verify the full application signature and its release owner.
* @param appPath - Path to the packaged `.app` directory.
* @param expected - Public release identity.
*/
export function verifyMacOSSignature(appPath: string, expected: MacOSSigningEnvironment): void
/**
* Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image.
* @param diskImagePath - Path to the packaged `.dmg` file.
* @param expected - Public release identity.
*/
export function verifyMacOSDiskImage(
diskImagePath: string,
expected: MacOSSigningEnvironment,
): void
/** Electron-builder fields required to locate a signed macOS application. */
export interface MacOSAfterSignContext {
readonly electronPlatformName: string
readonly appOutDir: string
readonly packager: {
readonly appInfo: {
readonly productFilename: string
}
}
}
/**
* Verify the macOS application produced by electron-builder's signing phase.
* @param context - electron-builder hook context.
* @param expected - Public release identity.
*/
export function verifyMacOSSignatureAfterSign(
context: MacOSAfterSignContext,
expected: MacOSSigningEnvironment,
): void
@@ -0,0 +1,186 @@
/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */
import { spawn, spawnSync } from 'node:child_process'
import { resolve } from 'node:path'
import { resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
/**
* Reject signature metadata that does not name the company release authority and team.
* @param {string} details - Output from `codesign --display --verbose=4`.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {void}
*/
export function assertMacOSSignatureDetails(details, expected) {
const fields = new Set(details.split(/\r?\n/u).map(line => line.trim()))
const expectedAuthority = `Authority=Developer ID Application: ${expected.signingIdentity}`
const expectedTeam = `TeamIdentifier=${expected.teamId}`
const missing = [expectedAuthority, expectedTeam].filter(field => !fields.has(field))
if (missing.length > 0) {
throw new Error(`desktop macOS signing: signature does not match the release identity; missing ${missing.join(', ')}`)
}
}
/**
* Require the signature properties Apple validates for executable seed 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) {
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')
}
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')
}
}
/**
* Execute one Apple release tool and return its diagnostic streams.
* @param {string} command - Absolute executable path.
* @param {readonly string[]} args - Tool arguments.
* @param {string} label - Stable diagnostic name.
* @returns {string} Combined stdout and stderr.
*/
function runAppleCommand(command, args, label) {
const result = spawnSync(command, args, { encoding: 'utf8' })
if (result.error !== undefined) {
throw new Error(`desktop macOS signing: could not execute ${label}: ${result.error.message}`)
}
if (result.signal !== null) {
throw new Error(`desktop macOS signing: ${label} was terminated by ${result.signal}`)
}
if (result.status !== 0) {
const diagnostic = `${result.stdout}${result.stderr}`.trim()
throw new Error(`desktop macOS signing: ${label} exited with ${String(result.status)}${diagnostic === '' ? '' : `: ${diagnostic}`}`)
}
return `${result.stdout}${result.stderr}`
}
/**
* Execute one Apple release tool without blocking other independent seed signers.
* @param {string} command - Absolute executable path.
* @param {readonly string[]} args - Tool arguments.
* @param {string} label - Stable diagnostic name.
* @returns {Promise<string>} Combined stdout and stderr after process exit.
*/
function runAppleCommandAsync(command, args, label) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
let spawnError
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', chunk => { stdout += chunk })
child.stderr.on('data', chunk => { stderr += chunk })
child.once('error', error => { spawnError = error })
child.once('close', (code, signal) => {
if (spawnError !== undefined) {
reject(new Error(`desktop macOS signing: could not execute ${label}: ${spawnError.message}`))
return
}
if (signal !== null) {
reject(new Error(`desktop macOS signing: ${label} was terminated by ${signal}`))
return
}
if (code !== 0) {
const diagnostic = `${stdout}${stderr}`.trim()
reject(new Error(`desktop macOS signing: ${label} exited with ${String(code)}${diagnostic === '' ? '' : `: ${diagnostic}`}`))
return
}
resolvePromise(`${stdout}${stderr}`)
})
})
}
/**
* Execute Apple's code-signing tool and return its diagnostic streams.
* @param {readonly string[]} args - Arguments passed to `/usr/bin/codesign`.
* @returns {string} Combined stdout and stderr.
*/
function runCodeSign(args) {
return runAppleCommand('/usr/bin/codesign', args, 'codesign')
}
/**
* Sign one Mach-O file embedded in the seed store.
* @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) {
await runAppleCommandAsync('/usr/bin/codesign', [
'--force',
'--sign', expected.signingIdentity,
'--identifier', identifier,
'--timestamp',
'--options', 'runtime',
path,
], 'codesign')
}
/**
* Verify one Mach-O file embedded in the seed store.
* @param {string} path - Mach-O file to inspect.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {void}
*/
export function verifyMacOSSeedCode(path, expected) {
runCodeSign(['--verify', '--strict', '--verbose=2', path])
const details = runCodeSign(['--display', '--verbose=4', path])
assertMacOSSeedSignatureDetails(details, expected)
}
/**
* Verify the full application signature and its release owner.
* @param {string} appPath - Path to the packaged `.app` directory.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {void}
*/
export function verifyMacOSSignature(appPath, expected) {
runCodeSign(['--verify', '--deep', '--strict', '--verbose=2', appPath])
const details = runCodeSign(['--display', '--verbose=4', appPath])
assertMacOSSignatureDetails(details, expected)
}
/**
* Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image.
* @param {string} diskImagePath - Path to the packaged `.dmg` file.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {void}
*/
export function verifyMacOSDiskImage(diskImagePath, expected) {
runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath])
const details = runCodeSign(['--display', '--verbose=4', diskImagePath])
assertMacOSSignatureDetails(details, expected)
runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', diskImagePath], 'stapler validate')
runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'install', '--verbose=4', diskImagePath], 'spctl')
}
/**
* Verify the macOS application produced by electron-builder's signing phase.
* @param {{ electronPlatformName: string, appOutDir: string, packager: { appInfo: { productFilename: string } } }} context - electron-builder hook context.
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
* @returns {void}
*/
export function verifyMacOSSignatureAfterSign(context, expected) {
if (context.electronPlatformName !== 'darwin') return
const appPath = resolve(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
verifyMacOSSignature(appPath, expected)
process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
const cliArgs = process.argv[2] === '--' ? process.argv.slice(3) : process.argv.slice(2)
const appPath = cliArgs[0]
if (appPath === undefined || cliArgs.length !== 1) {
throw new Error('usage: node scripts/verify-macos-signature.mjs <path-to-app>')
}
const expected = resolveMacOSSigningEnvironment(process.env)
verifyMacOSSignature(resolve(appPath), expected)
process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`)
}
+17
View File
@@ -0,0 +1,17 @@
@echo off
setlocal DisableDelayedExpansion
set "signTool=%DSH_DESKTOP_WINDOWS_SIGNTOOL%"
set "certificateFile=%DSH_DESKTOP_WINDOWS_CER_FILE%"
set "tokenPin=%DSH_DESKTOP_WINDOWS_TOKEN_PIN%"
set "keyContainer=%DSH_DESKTOP_WINDOWS_KEY_CONTAINER%"
set "targetFile=%DSH_DESKTOP_WINDOWS_SIGN_TARGET%"
set "appendSignature="
if "%DSH_DESKTOP_WINDOWS_SIGN_APPEND%"=="1" set "appendSignature=/as"
set "DSH_DESKTOP_WINDOWS_SIGNTOOL="
set "DSH_DESKTOP_WINDOWS_CER_FILE="
set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="
set "DSH_DESKTOP_WINDOWS_KEY_CONTAINER="
set "DSH_DESKTOP_WINDOWS_SIGN_TARGET="
set "DSH_DESKTOP_WINDOWS_SIGN_APPEND="
set "signTool=" & set "certificateFile=" & set "tokenPin=" & set "keyContainer=" & set "targetFile=" & set "appendSignature=" & "%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"
exit /b %errorlevel%
+91
View File
@@ -0,0 +1,91 @@
/**
* Build the minimal CMD environment for one Electron artifact.
*
* @param environment Parent environment.
* @param input Validated signing identity and task.
* @returns Scrubbed environment plus the fields consumed and cleared by the signing CMD.
*/
export function buildWindowsSigningEnvironment(environment: NodeJS.ProcessEnv, input: {
certificateFile: string
signTool: string
path: string
isNest: boolean
tokenPin: string
keyContainer: string
}): NodeJS.ProcessEnv
/**
* Create the electron-builder hook for a hardware-backed Windows code-signing certificate.
*
* @param options Release signing configuration.
* @returns The signing hook.
*/
export function createWindowsTokenSigner(options: {
certificateFile?: string | undefined
signTool?: string | undefined
tokenPin?: string | undefined
keyContainer?: string | undefined
commandInterpreter?: string | undefined
}): (
configuration: {
path: string
hash: string
isNest: boolean
},
) => Promise<void>
/**
* Remove inherited credentials before starting a signing-related subprocess.
*
* @param environment Parent environment.
* @returns Environment without credential-shaped names.
*/
export function scrubWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv
/**
* Replace a SignTool failure with a diagnostic that cannot retain its command line.
*
* @param error SignTool process failure.
* @param path Artifact that failed signing.
* @param secrets Values that must not appear in the diagnostic.
* @returns Sanitized signing failure without the original error as its cause.
*/
export function createRedactedWindowsSigningError(
error: unknown,
path: string,
secrets: readonly string[],
): Error
/**
* Clear a certificate-table entry that points beyond the end of a generated executable.
*
* @param path Executable to inspect.
* @returns Whether an invalid certificate-table entry was cleared.
*/
export function repairDanglingAuthenticodeDirectory(path: string): Promise<boolean>
/**
* Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it.
*
* @param options Signing hook and injectable host values.
* @returns Nothing.
*/
export function installWindowsNsisBootstrapSigner(options: {
sign: (configuration: {
path: string
hash: string
isNest: boolean
}) => Promise<void>
wineVmManager?: {
prototype: {
exec: (
file: string,
args: string[],
options?: { env?: NodeJS.ProcessEnv },
isLogOutIfDebug?: boolean,
) => unknown
}
}
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
}): void
+266
View File
@@ -0,0 +1,266 @@
import { execFile } from 'node:child_process'
import { X509Certificate } from 'node:crypto'
import { readFileSync, realpathSync, statSync } from 'node:fs'
import { open } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import wineVmModule from 'app-builder-lib/out/vm/WineVm.js'
const execFileAsync = promisify(execFile)
const { WineVmManager } = wineVmModule
const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3'
const NSIS_RUN_AS_INVOKER = 'RunAsInvoker'
const NSIS_BOOTSTRAP_PATCH = Symbol.for('@deepseek-ai/dsh-desktop/nsis-bootstrap-signing')
const WINDOWS_SIGN_SCRIPT = 'windows-sign.cmd'
const WINDOWS_SIGN_SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url))
const PE_HEADER_READ_SIZE = 4096
const PE32_MAGIC = 0x10B
const PE32_PLUS_MAGIC = 0x20B
const SENSITIVE_ENVIRONMENT_NAME = /(?:KEY|SECRET|TOKEN|PASSWORD)/iu
const WINDOWS_SIGNING_ENVIRONMENT_PREFIX = 'DSH_DESKTOP_WINDOWS_'
/**
* Remove inherited credentials before starting a signing-related subprocess.
*
* @param {NodeJS.ProcessEnv} environment Parent environment.
* @returns {NodeJS.ProcessEnv} Environment without credential-shaped names.
*/
export function scrubWindowsSigningEnvironment(environment) {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !SENSITIVE_ENVIRONMENT_NAME.test(name)
&& !name.startsWith(WINDOWS_SIGNING_ENVIRONMENT_PREFIX)))
}
function resolveTokenIdentity(input) {
const keyContainer = input.keyContainer?.trim()
if (!keyContainer) {
throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER must contain the SafeNet private-key container name')
}
if (/["\r\n]/u.test(keyContainer)) {
throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER cannot contain quotes or line breaks')
}
const tokenPin = input.tokenPin
if (tokenPin === undefined || tokenPin.length === 0) {
throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN must contain the SafeNet Token Password')
}
if (/[\]"\r\n]/u.test(tokenPin)) {
throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN cannot contain "]", quotes, or line breaks because the SafeNet key-container syntax uses them as delimiters')
}
return { keyContainer, tokenPin }
}
function resolveCertificateFile(value) {
const candidate = value?.trim()
if (!candidate) {
throw new Error('DSH_DESKTOP_WINDOWS_CER_FILE must identify the public X.509 leaf certificate file')
}
let path
let certificate
try {
path = realpathSync(candidate)
certificate = new X509Certificate(readFileSync(path))
}
catch {
throw new Error(`Windows code-signing certificate file is missing or invalid: ${candidate}`)
}
if (certificate.ca || !certificate.keyUsage?.includes(CODE_SIGNING_EKU)) {
throw new Error(`Windows code-signing certificate file must contain a non-CA Code Signing certificate: ${path}`)
}
return path
}
function resolveSignTool(value) {
const candidate = value?.trim()
if (!candidate) {
throw new Error('DSH_DESKTOP_WINDOWS_SIGNTOOL must identify the SafeNet-compatible SignTool executable')
}
let path
try {
path = realpathSync(candidate)
if (!statSync(path).isFile() || !path.toLowerCase().endsWith('.exe')) throw new Error('not an executable file')
}
catch {
throw new Error(`DSH_DESKTOP_WINDOWS_SIGNTOOL is missing or is not an executable file: ${candidate}`)
}
return path
}
function redactedSigningOutput(value, secrets) {
let output = Buffer.isBuffer(value) ? value.toString('utf8') : typeof value === 'string' ? value : ''
for (const secret of secrets) {
if (secret !== '') output = output.replaceAll(secret, '<redacted>')
}
return output
}
/**
* Replace a SignTool failure with a diagnostic that cannot retain its command line.
*
* @param {unknown} error SignTool process failure.
* @param {string} path Artifact that failed signing.
* @param {readonly string[]} secrets Values that must not appear in the diagnostic.
* @returns {Error} Sanitized signing failure without the original error as its cause.
*/
export function createRedactedWindowsSigningError(error, path, secrets) {
const record = error !== null && typeof error === 'object' ? error : undefined
const code = record !== undefined && 'code' in record
&& (typeof record.code === 'number' || typeof record.code === 'string')
? ` (exit ${String(record.code)})`
: ''
const stderr = record !== undefined && 'stderr' in record
? redactedSigningOutput(record.stderr, secrets).trim()
: ''
return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`)
}
/**
* Build the minimal CMD environment for one Electron artifact.
*
* @param {NodeJS.ProcessEnv} environment Parent environment.
* @param {{ certificateFile: string, signTool: string, path: string, isNest: boolean, tokenPin: string, keyContainer: string }} input Validated signing identity and task.
* @returns {NodeJS.ProcessEnv} Scrubbed environment plus fields consumed and cleared by the signing CMD.
*/
export function buildWindowsSigningEnvironment(environment, input) {
return {
...scrubWindowsSigningEnvironment(environment),
DSH_DESKTOP_WINDOWS_SIGNTOOL: input.signTool,
DSH_DESKTOP_WINDOWS_CER_FILE: input.certificateFile,
DSH_DESKTOP_WINDOWS_TOKEN_PIN: input.tokenPin,
DSH_DESKTOP_WINDOWS_KEY_CONTAINER: input.keyContainer,
DSH_DESKTOP_WINDOWS_SIGN_TARGET: input.path,
DSH_DESKTOP_WINDOWS_SIGN_APPEND: input.isNest ? '1' : '',
}
}
/**
* Create the electron-builder hook for a SafeNet-backed Windows code-signing certificate.
*
* @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration.
* @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>} The signing hook.
*/
export function createWindowsTokenSigner(options) {
const certificateFile = resolveCertificateFile(options.certificateFile)
const signTool = resolveSignTool(options.signTool)
const { keyContainer, tokenPin } = resolveTokenIdentity(options)
const commandInterpreter = options.commandInterpreter
?? process.env.ComSpec
?? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
return async (configuration) => {
if (configuration.hash !== 'sha256') {
throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`)
}
await repairDanglingAuthenticodeDirectory(configuration.path)
const secrets = [tokenPin]
let result
try {
result = await execFileAsync(commandInterpreter, [
'/d',
'/v:off',
'/c',
WINDOWS_SIGN_SCRIPT,
], {
cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY,
env: buildWindowsSigningEnvironment(process.env, {
certificateFile,
signTool,
path: configuration.path,
isNest: configuration.isNest,
tokenPin,
keyContainer,
}),
windowsHide: false,
})
}
catch (error) {
throw createRedactedWindowsSigningError(error, configuration.path, secrets)
}
const stdout = redactedSigningOutput(result.stdout, secrets)
const stderr = redactedSigningOutput(result.stderr, secrets)
if (stdout !== '') process.stdout.write(stdout)
if (stderr !== '') process.stderr.write(stderr)
}
}
/**
* Clear a certificate-table entry that points beyond the end of a generated executable.
*
* @param {string} path Executable to inspect.
* @returns {Promise<boolean>} Whether an invalid certificate-table entry was cleared.
*/
export async function repairDanglingAuthenticodeDirectory(path) {
const file = await open(path, 'r+')
try {
const { size } = await file.stat()
const header = Buffer.alloc(Math.min(PE_HEADER_READ_SIZE, size))
await file.read(header, 0, header.length, 0)
const directoryOffset = findDanglingAuthenticodeDirectory(header, size)
if (directoryOffset === undefined) return false
await file.write(Buffer.alloc(8), 0, 8, directoryOffset)
return true
}
finally {
await file.close()
}
}
/**
* Locate an Authenticode certificate-table entry whose declared bytes are outside the file.
*
* @param {Buffer} header Initial executable bytes.
* @param {number} fileSize Complete file size.
* @returns {number | undefined} File offset of the invalid data-directory entry.
*/
function findDanglingAuthenticodeDirectory(header, fileSize) {
if (header.length < 64 || header.toString('ascii', 0, 2) !== 'MZ') return undefined
const peOffset = header.readUInt32LE(60)
const optionalHeaderOffset = peOffset + 24
if (optionalHeaderOffset + 2 > header.length
|| header.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') return undefined
const magic = header.readUInt16LE(optionalHeaderOffset)
const dataDirectoryOffset = magic === PE32_MAGIC
? optionalHeaderOffset + 96
: magic === PE32_PLUS_MAGIC
? optionalHeaderOffset + 112
: undefined
if (dataDirectoryOffset === undefined) return undefined
const certificateDirectoryOffset = dataDirectoryOffset + (4 * 8)
if (certificateDirectoryOffset + 8 > header.length) return undefined
const certificateOffset = header.readUInt32LE(certificateDirectoryOffset)
const certificateSize = header.readUInt32LE(certificateDirectoryOffset + 4)
if (certificateOffset === 0 && certificateSize === 0) return undefined
return certificateOffset > 0
&& certificateSize > 0
&& certificateOffset + certificateSize <= fileSize
? undefined
: certificateDirectoryOffset
}
/**
* Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it.
*
* @param {{ sign: (configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>, wineVmManager?: typeof WineVmManager, platform?: NodeJS.Platform, environment?: NodeJS.ProcessEnv }} options Signing hook and injectable host values.
* @returns {void}
*/
export function installWindowsNsisBootstrapSigner(options) {
if ((options.platform ?? process.platform) !== 'win32') return
const prototype = (options.wineVmManager ?? WineVmManager).prototype
if (prototype[NSIS_BOOTSTRAP_PATCH] === true) return
const originalExec = prototype.exec
prototype.exec = async function (file, args, execOptions, isLogOutIfDebug) {
const isNsisBootstrap = file.toLowerCase().endsWith('.exe')
&& execOptions?.env?.__COMPAT_LAYER === NSIS_RUN_AS_INVOKER
if (!isNsisBootstrap) {
return originalExec.call(this, file, args, execOptions, isLogOutIfDebug)
}
await options.sign({ path: file, hash: 'sha256', isNest: false })
return originalExec.call(this, file, args, {
...execOptions,
env: scrubWindowsSigningEnvironment({
...(options.environment ?? process.env),
...execOptions.env,
}),
}, isLogOutIfDebug)
}
Object.defineProperty(prototype, NSIS_BOOTSTRAP_PATCH, { value: true })
}
+183
View File
@@ -0,0 +1,183 @@
/** Signed local npm package set that supplies the Desktop-owned dsh runtime and private Host. */
import { createHash } from 'node:crypto'
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
/** Descriptor copied beside every Desktop profile's local core tarballs. */
export const DESKTOP_PACKAGE_SET_FILE = 'desktop-packages.json'
/** Profile-relative directory containing immutable core npm tarballs. */
export const DESKTOP_PACKAGES_DIR = 'desktop-packages'
/** Private package installed beside dsh to boot the Desktop Host process. */
export const DESKTOP_HOST_PACKAGE = '@deepseek-ai/dsh-desktop-host'
/** Package-relative Desktop Host files required before a profile can boot. */
export const DESKTOP_HOST_RUNTIME_FILES = [
'lib/index.js',
'config/desktop.cordis.patch.yml',
] as const
/** One immutable npm tarball in the Desktop core package set. */
export interface DesktopCorePackageRecord {
readonly name: string
readonly version: string
readonly file: string
readonly bytes: number
readonly integrity: string
}
/** Complete union of the first-party package closures rooted at dsh and its private Desktop Host. */
export interface DesktopCorePackageSet {
readonly schemaVersion: 1
readonly packages: readonly DesktopCorePackageRecord[]
}
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u
const FILE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.tgz$/u
const INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]+={0,2}$/u
const DSH_PACKAGE = '@deepseek-ai/dsh'
const RELEASE_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Validate package-set data read from a release artifact or active profile.
* @param value - Parsed descriptor JSON.
* @param expectedReleaseVersion - Required dsh and Desktop Host version when validating one release.
* @returns The normalized package set in deterministic name order.
*/
export function parseDesktopCorePackageSet(
value: unknown,
expectedReleaseVersion?: string,
): DesktopCorePackageSet {
if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.packages)) {
throw new Error('desktop package set: invalid descriptor')
}
const packages = value.packages.map((entry): DesktopCorePackageRecord => {
if (!isRecord(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME_PATTERN.test(entry.name)
|| typeof entry.version !== 'string' || !VERSION_PATTERN.test(entry.version)
|| typeof entry.file !== 'string' || !FILE_PATTERN.test(entry.file)
|| typeof entry.bytes !== 'number' || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0
|| typeof entry.integrity !== 'string' || !INTEGRITY_PATTERN.test(entry.integrity)) {
throw new Error('desktop package set: invalid package record')
}
return {
name: entry.name,
version: entry.version,
file: entry.file,
bytes: entry.bytes,
integrity: entry.integrity,
}
})
const names = new Set(packages.map(entry => entry.name))
const files = new Set(packages.map(entry => entry.file))
if (names.size !== packages.length || files.size !== packages.length) {
throw new Error('desktop package set: duplicate package name or filename')
}
const sorted = [...packages].sort((left, right) => left.name.localeCompare(right.name))
if (JSON.stringify(sorted) !== JSON.stringify(packages)) {
throw new Error('desktop package set: packages must be sorted by name')
}
for (const name of RELEASE_PACKAGES) {
const entry = packages.find(candidate => candidate.name === name)
if (entry === undefined) throw new Error(`desktop package set: missing ${name}`)
if (expectedReleaseVersion !== undefined && entry.version !== expectedReleaseVersion) {
throw new Error(`desktop package set: ${name}@${entry.version} does not match Desktop ${expectedReleaseVersion}`)
}
}
return { schemaVersion: 1, packages }
}
/** Read and structurally validate one profile's core package descriptor. */
export function readDesktopCorePackageSet(projectDir: string, expectedReleaseVersion?: string): DesktopCorePackageSet {
const path = join(projectDir, DESKTOP_PACKAGE_SET_FILE)
let value: unknown
try {
value = JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error(`desktop package set: failed to read ${path}: ${String(error)}`)
}
return parseDesktopCorePackageSet(value, expectedReleaseVersion)
}
/** Return the project-relative `file:` spec for one local core tarball. */
export function desktopCorePackageSpec(record: DesktopCorePackageRecord): string {
return `file:./${DESKTOP_PACKAGES_DIR}/${record.file}`
}
/** Return the exact pnpm override map that keeps every core package off registries. */
export function desktopCorePackageOverrides(packageSet: DesktopCorePackageSet): Record<string, string> {
return Object.fromEntries(packageSet.packages.map(record => [record.name, desktopCorePackageSpec(record)]))
}
/** Return the local direct dependency spec for the dsh package. */
export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string {
const record = packageSet.packages.find(entry => entry.name === DSH_PACKAGE)
if (record === undefined) throw new Error(`desktop package set: missing ${DSH_PACKAGE}`)
return desktopCorePackageSpec(record)
}
/**
* Verify every local tarball and reject extra package files before pnpm executes them.
* @param projectDir - Seed or profile directory containing the package set.
* @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron.
* @returns The verified package set.
*/
export function verifyDesktopCorePackageSet(
projectDir: string,
expectedReleaseVersion: string,
): DesktopCorePackageSet {
const packageSet = readDesktopCorePackageSet(projectDir, expectedReleaseVersion)
const packageDir = join(projectDir, DESKTOP_PACKAGES_DIR)
const expectedFiles = packageSet.packages.map(entry => entry.file).sort()
let actualFiles: string[]
try {
actualFiles = readdirSync(packageDir).sort()
} catch (error) {
throw new Error(`desktop package set: failed to read ${packageDir}: ${String(error)}`)
}
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error('desktop package set: package directory does not match its descriptor')
}
for (const record of packageSet.packages) {
const path = join(packageDir, record.file)
if (!existsSync(path) || !lstatSync(path).isFile()) {
throw new Error(`desktop package set: ${record.file} is not a regular file`)
}
const body = readFileSync(path)
const integrity = `sha512-${createHash('sha512').update(body).digest('base64')}`
if (body.byteLength !== record.bytes || integrity !== record.integrity) {
throw new Error(`desktop package set: integrity check failed for ${record.file}`)
}
}
return packageSet
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
}
/**
* Reject a lockfile that resolved any packaged core name through a registry version.
* @param lockfile - Generated pnpm lockfile text.
* @param packageSet - Verified local core package set.
*/
export function verifyDesktopCoreLockfile(
lockfile: string,
packageSet: DesktopCorePackageSet,
): void {
for (const record of packageSet.packages) {
const registryResolution = new RegExp(
`^ ['"]?${escapeRegExp(record.name)}@${escapeRegExp(record.version)}(?:\\([^\\r\\n]*\\))?['"]?:`,
'mu',
)
if (registryResolution.test(lockfile)) {
throw new Error(`desktop package set: lockfile resolved ${record.name}@${record.version} outside the local package set`)
}
}
}
+413
View File
@@ -0,0 +1,413 @@
/** Upstream-Node child lifecycle and streaming custom-protocol carrier. */
import { spawn, type ChildProcess } from 'node:child_process'
import { once } from 'node:events'
import { join } from 'node:path'
import { Readable, Writable } from 'node:stream'
import {
DESKTOP_HOST_PROTOCOL_VERSION,
DESKTOP_PIPE_CHUNK_BYTES,
DESKTOP_REQUEST_PIPE_FD,
DESKTOP_RESPONSE_PIPE_FD,
DesktopHostResponseDecoder,
encodeDesktopRequestCancel,
encodeDesktopRequestData,
encodeDesktopRequestEnd,
encodeDesktopRequestStart,
type DesktopHostCommand,
type DesktopHostEvent,
type DesktopHostResponseFrame,
} from './host-protocol.ts'
interface PendingResponse {
readonly resolve: (response: Response) => void
readonly reject: (error: Error) => void
responseStarted: boolean
uploadOpen: boolean
controller?: ReadableStreamDefaultController<Uint8Array>
requestReader?: ReadableStreamDefaultReader<Uint8Array>
removeAbort?: () => void
}
function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
if (typeof message !== 'object' || message === null || !('type' in message)) return false
const candidate = message as Record<string, unknown>
switch (candidate.type) {
case 'ready':
return candidate.protocolVersion === DESKTOP_HOST_PROTOCOL_VERSION && typeof candidate.dshVersion === 'string'
case 'fatal':
return typeof candidate.message === 'string'
default:
return false
}
}
function errorOf(reason: unknown, fallback: string): Error {
return reason instanceof Error ? reason : new Error(fallback)
}
async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<false>((resolve) => {
timer = setTimeout(() => { resolve(false) }, milliseconds)
timer.unref()
})
try {
return await Promise.race([exit.then(() => true), timeout])
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
/** Ready facts reported by one installed dsh child. */
export interface DesktopHostReady {
readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
readonly dshVersion: string
}
/** One dsh backend running under the bundled upstream Node.js executable. */
export class DesktopHostProcess {
private child: ChildProcess | undefined
private requestPipe: Writable | undefined
private responsePipe: Readable | undefined
private readonly responseDecoder = new DesktopHostResponseDecoder()
private requestWriteTail: Promise<void> = Promise.resolve()
private nextStreamId = 1
private readonly pending = new Map<number, PendingResponse>()
private readonly blockedResponses = new Set<number>()
private readyResolve!: (ready: DesktopHostReady) => void
private readyReject!: (error: Error) => void
private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
this.readyResolve = resolve
this.readyReject = reject
})
private exitPromise: Promise<void> | undefined
private stderr = ''
/**
* @param node - absolute bundled upstream Node.js executable.
* @param projectDir - active or staged desktop npm project.
* @param inspectPort - optional loopback inspector port for workspace development.
*/
constructor(
private readonly node: string,
private readonly projectDir: string,
private readonly inspectPort?: number,
) {}
/** 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 child = spawn(this.node, [
...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
entry,
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)
))),
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'],
})
const requestPipe = child.stdio[DESKTOP_REQUEST_PIPE_FD]
const responsePipe = child.stdio[DESKTOP_RESPONSE_PIPE_FD]
if (!(requestPipe instanceof Writable) || !(responsePipe instanceof Readable)) {
child.kill('SIGTERM')
throw new Error('dsh desktop host did not expose the required byte pipes and IPC channel')
}
this.child = child
this.requestPipe = requestPipe
this.responsePipe = responsePipe
child.stderr?.setEncoding('utf8')
child.stderr?.on('data', (chunk: string) => { this.stderr += chunk })
child.stdout?.pipe(process.stdout)
responsePipe.on('data', (chunk: Buffer) => { this.acceptResponseBytes(chunk) })
responsePipe.once('end', () => {
try {
this.responseDecoder.finish()
this.fail(new Error('dsh desktop host response pipe ended'))
} catch (error) {
this.fail(errorOf(error, 'dsh desktop host response pipe failed'))
}
})
requestPipe.once('error', (error) => { this.fail(error) })
responsePipe.once('error', (error) => { this.fail(error) })
child.on('message', (message: unknown) => {
if (!isDesktopHostEvent(message)) {
this.fail(new Error('dsh desktop host sent an invalid IPC event'))
child.kill('SIGTERM')
return
}
this.handleMessage(message)
})
child.once('error', (error) => { this.fail(error) })
this.exitPromise = new Promise<void>((resolve) => {
child.once('exit', (code) => {
const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
else this.fail(new Error(`dsh desktop host stopped${suffix}`))
resolve()
})
})
return this.readyPromise
}
/** Forward one `dsh-app://app` request to the child without buffering its body. */
async fetch(request: Request): Promise<Response> {
await this.start()
const child = this.child
if (child === undefined || !child.connected || this.requestPipe === undefined) {
throw new Error('dsh desktop host is unavailable')
}
if (this.nextStreamId > 0xffff_ffff) throw new Error('dsh desktop host exhausted its request stream ids')
const streamId = this.nextStreamId++
const method = request.method.toUpperCase()
const hasBody = method !== 'GET' && method !== 'HEAD' && request.body !== null
return new Promise<Response>((resolve, reject) => {
const pending: PendingResponse = {
resolve,
reject,
responseStarted: false,
uploadOpen: hasBody,
}
const abort = (): void => {
if (!this.pending.has(streamId)) return
const error = errorOf(request.signal.reason, 'request aborted')
pending.uploadOpen = false
void pending.requestReader?.cancel(error).catch(() => undefined)
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => {
this.fail(errorOf(pipeError, 'dsh desktop request pipe failed'))
})
if (pending.controller === undefined) pending.reject(error)
else pending.controller.error(error)
this.finishPending(streamId, false)
}
if (request.signal.aborted) {
reject(errorOf(request.signal.reason, 'request aborted'))
return
}
request.signal.addEventListener('abort', abort, { once: true })
pending.removeAbort = () => { request.signal.removeEventListener('abort', abort) }
this.pending.set(streamId, pending)
this.pumpRequest(streamId, request, hasBody).catch((error: unknown) => {
this.failPending(streamId, errorOf(error, 'dsh desktop request upload failed'))
})
})
}
/** Request graceful teardown, then wait for child exit. */
async stop(): Promise<void> {
const child = this.child
if (child === undefined) return
this.blockedResponses.clear()
this.responsePipe?.resume()
if (child.connected) this.send({ type: 'shutdown' })
// Closing the parent-owned write end releases the Host's pending Windows pipe read.
this.requestPipe?.destroy()
const exited = this.exitPromise ?? Promise.resolve()
if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
if (!await exitsWithin(exited, 5_000)) {
child.kill('SIGKILL')
if (!await exitsWithin(exited, 5_000)) {
throw new Error('dsh desktop host did not exit after SIGKILL')
}
}
this.child = undefined
this.requestPipe = undefined
this.responsePipe = undefined
}
private async pumpRequest(streamId: number, request: Request, hasBody: boolean): Promise<void> {
await this.enqueueRequestFrame(encodeDesktopRequestStart(streamId, {
url: request.url,
method: request.method.toUpperCase(),
headers: [...request.headers.entries()],
hasBody,
}))
if (!hasBody) return
const body = request.body
if (body === null) throw new Error('dsh desktop request body disappeared before upload')
const reader = body.getReader()
const pending = this.pending.get(streamId)
if (pending === undefined) {
await reader.cancel()
return
}
pending.requestReader = reader
try {
for (;;) {
const next = await reader.read()
if (next.done) break
for (let offset = 0; offset < next.value.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) {
if (!this.pending.has(streamId)) return
await this.enqueueRequestFrame(encodeDesktopRequestData(
streamId,
next.value.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES),
))
}
}
const live = this.pending.get(streamId)
if (live !== undefined) {
await this.enqueueRequestFrame(encodeDesktopRequestEnd(streamId))
live.uploadOpen = false
}
} finally {
reader.releaseLock()
const live = this.pending.get(streamId)
if (live?.requestReader === reader) delete live.requestReader
}
}
private enqueueRequestFrame(frame: Buffer): Promise<void> {
const write = this.requestWriteTail.then(async () => {
const pipe = this.requestPipe
if (pipe === undefined || pipe.destroyed) throw new Error('dsh desktop host request pipe is unavailable')
if (!pipe.write(frame)) await once(pipe, 'drain')
})
this.requestWriteTail = write.catch(() => undefined)
return write
}
private send(message: DesktopHostCommand): void {
const child = this.child
if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable')
child.send(message)
}
private acceptResponseBytes(chunk: Buffer): void {
try {
for (const frame of this.responseDecoder.push(chunk)) this.handleResponseFrame(frame)
} catch (error) {
this.fail(errorOf(error, 'dsh desktop host response pipe failed'))
this.child?.kill('SIGTERM')
}
}
private handleResponseFrame(frame: DesktopHostResponseFrame): void {
const pending = this.pending.get(frame.streamId)
if (pending === undefined) {
if (frame.streamId >= this.nextStreamId) {
throw new Error(`dsh desktop host responded for unknown stream ${String(frame.streamId)}`)
}
return
}
switch (frame.type) {
case 'start': {
if (pending.responseStarted) throw new Error(`dsh desktop host started stream ${String(frame.streamId)} twice`)
pending.responseStarted = true
let body: ReadableStream<Uint8Array> | null = null
if (frame.hasBody) {
body = new ReadableStream<Uint8Array>({
start: (controller) => { pending.controller = controller },
pull: () => {
this.blockedResponses.delete(frame.streamId)
this.resumeResponsePipe()
},
cancel: (reason) => { this.cancelResponse(frame.streamId, reason) },
})
}
pending.resolve(new Response(body, {
status: frame.status,
headers: new Headers(frame.headers.map(([name, value]) => [name, value] as [string, string])),
}))
return
}
case 'data': {
const controller = pending.controller
if (!pending.responseStarted || controller === undefined) {
throw new Error(`dsh desktop host sent body data before a body start for stream ${String(frame.streamId)}`)
}
controller.enqueue(frame.data)
if ((controller.desiredSize ?? 0) <= 0) {
this.blockedResponses.add(frame.streamId)
this.responsePipe?.pause()
}
return
}
case 'end':
if (!pending.responseStarted) {
throw new Error(`dsh desktop host ended stream ${String(frame.streamId)} before its response start`)
}
pending.controller?.close()
this.finishPending(frame.streamId, true)
return
case 'error':
this.failPending(frame.streamId, new Error(frame.message))
return
default:
frame satisfies never
}
}
private cancelResponse(streamId: number, reason: unknown): void {
const pending = this.pending.get(streamId)
if (pending === undefined) return
pending.uploadOpen = false
void pending.requestReader?.cancel(reason).catch(() => undefined)
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
this.fail(errorOf(error, 'dsh desktop request pipe failed'))
})
this.finishPending(streamId, false)
}
private failPending(streamId: number, error: Error): void {
const pending = this.pending.get(streamId)
if (pending === undefined) return
pending.uploadOpen = false
void pending.requestReader?.cancel(error).catch(() => undefined)
if (pending.controller === undefined) pending.reject(error)
else pending.controller.error(error)
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => {
this.fail(errorOf(pipeError, 'dsh desktop request pipe failed'))
})
this.finishPending(streamId, false)
}
private finishPending(streamId: number, cancelOpenUpload: boolean): void {
const pending = this.pending.get(streamId)
if (pending === undefined) return
if (cancelOpenUpload && pending.uploadOpen) {
pending.uploadOpen = false
void pending.requestReader?.cancel().catch(() => undefined)
this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => {
this.fail(errorOf(error, 'dsh desktop request pipe failed'))
})
}
pending.removeAbort?.()
this.pending.delete(streamId)
this.blockedResponses.delete(streamId)
this.resumeResponsePipe()
}
private resumeResponsePipe(): void {
if (this.blockedResponses.size === 0) this.responsePipe?.resume()
}
private handleMessage(message: DesktopHostEvent): void {
switch (message.type) {
case 'ready':
this.readyResolve(message)
return
case 'fatal':
this.fail(new Error(message.message))
return
default:
message satisfies never
}
}
private fail(error: Error): void {
this.readyReject(error)
for (const pending of this.pending.values()) {
void pending.requestReader?.cancel(error).catch(() => undefined)
if (pending.controller === undefined) pending.reject(error)
else pending.controller.error(error)
pending.removeAbort?.()
}
this.pending.clear()
this.blockedResponses.clear()
this.responsePipe?.resume()
}
}
+215
View File
@@ -0,0 +1,215 @@
/** Versioned control messages and framed byte transport for the Desktop Host child. */
/** Protocol version implemented by the Electron shell and installed dsh Host. */
export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const
/** Child descriptor Electron writes request frames to. */
export const DESKTOP_REQUEST_PIPE_FD = 3
/** Child descriptor Electron reads response frames from. */
export const DESKTOP_RESPONSE_PIPE_FD = 4
/** Child descriptor reserved for Node's lifecycle IPC channel. */
export const DESKTOP_CONTROL_IPC_FD = 5
/** Maximum raw body bytes carried by one data frame. */
export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024
const FRAME_MAGIC = 0x44534833
const FRAME_HEADER_BYTES = 13
const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024
const REQUEST_FRAME_START = 1
const REQUEST_FRAME_DATA = 2
const REQUEST_FRAME_END = 3
const REQUEST_FRAME_CANCEL = 4
type RequestFrameType = typeof REQUEST_FRAME_START | typeof REQUEST_FRAME_DATA
| typeof REQUEST_FRAME_END | typeof REQUEST_FRAME_CANCEL
const RESPONSE_FRAME_START = 1
const RESPONSE_FRAME_DATA = 2
const RESPONSE_FRAME_END = 3
const RESPONSE_FRAME_ERROR = 4
/** Metadata that precedes one optional request body on the request pipe. */
export interface DesktopHostRequestStart {
readonly url: string
readonly method: string
readonly headers: readonly [string, string][]
readonly hasBody: boolean
}
/** Commands retained on Node IPC because they do not carry Fetch payload bytes. */
export type DesktopHostCommand = {
readonly type: 'shutdown'
}
/** Lifecycle events retained on Node IPC. */
export type DesktopHostEvent = {
readonly type: 'ready'
readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
readonly dshVersion: string
} | {
readonly type: 'fatal'
readonly message: string
}
/** One decoded response-pipe frame. */
export type DesktopHostResponseFrame = {
readonly type: 'start'
readonly streamId: number
readonly status: number
readonly headers: readonly [string, string][]
readonly hasBody: boolean
} | {
readonly type: 'data'
readonly streamId: number
readonly data: Buffer
} | {
readonly type: 'end'
readonly streamId: number
} | {
readonly type: 'error'
readonly streamId: number
readonly message: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isHeaders(value: unknown): value is readonly [string, string][] {
return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2
&& typeof header[0] === 'string' && typeof header[1] === 'string')
}
function assertStreamId(streamId: number): void {
if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) {
throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`)
}
}
function encodeFrame(type: RequestFrameType, streamId: number, payload: Buffer): Buffer {
assertStreamId(streamId)
const limit = type === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
if (payload.byteLength > limit) {
throw new Error(`dsh desktop: request pipe frame exceeds the ${String(limit)}-byte limit`)
}
const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength)
frame.writeUInt32BE(FRAME_MAGIC, 0)
frame.writeUInt8(type, 4)
frame.writeUInt32BE(streamId, 5)
frame.writeUInt32BE(payload.byteLength, 9)
payload.copy(frame, FRAME_HEADER_BYTES)
return frame
}
function encodeJsonFrame(type: RequestFrameType, streamId: number, value: unknown): Buffer {
return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8'))
}
/** Encode the metadata opening one request stream. */
export function encodeDesktopRequestStart(streamId: number, request: DesktopHostRequestStart): Buffer {
return encodeJsonFrame(REQUEST_FRAME_START, streamId, request)
}
/** Encode one bounded raw request-body chunk. */
export function encodeDesktopRequestData(streamId: number, data: Uint8Array): Buffer {
return encodeFrame(REQUEST_FRAME_DATA, streamId, Buffer.from(data))
}
/** Encode normal request-body completion. */
export function encodeDesktopRequestEnd(streamId: number): Buffer {
return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0))
}
/** Encode cancellation of one request and its response. */
export function encodeDesktopRequestCancel(streamId: number): Buffer {
return encodeFrame(REQUEST_FRAME_CANCEL, streamId, Buffer.alloc(0))
}
/** Incrementally decode validated response frames from the Host byte pipe. */
export class DesktopHostResponseDecoder {
private buffer: Buffer = Buffer.alloc(0)
/**
* Append bytes and return every complete response frame.
* @param chunk - next bytes read from the Host response pipe.
* @returns complete frames in pipe order.
*/
push(chunk: Buffer): DesktopHostResponseFrame[] {
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk])
const frames: DesktopHostResponseFrame[] = []
for (;;) {
const frame = this.next()
if (frame === undefined) return frames
frames.push(frame)
}
}
/** Reject EOF that splits a frame. */
finish(): void {
if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Host response pipe ended inside a frame')
}
private next(): DesktopHostResponseFrame | undefined {
if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined
if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Host response frame marker')
const rawType = this.buffer.readUInt8(4)
const streamId = this.buffer.readUInt32BE(5)
const payloadLength = this.buffer.readUInt32BE(9)
assertStreamId(streamId)
const limit = rawType === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES
if (payloadLength > limit) {
throw new Error(`dsh desktop: Host response frame exceeds the ${String(limit)}-byte limit`)
}
const frameLength = FRAME_HEADER_BYTES + payloadLength
if (this.buffer.byteLength < frameLength) return undefined
const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength)
this.buffer = this.buffer.subarray(frameLength)
switch (rawType) {
case RESPONSE_FRAME_START:
return this.parseStart(streamId, payload)
case RESPONSE_FRAME_DATA:
return { type: 'data', streamId, data: payload }
case RESPONSE_FRAME_END:
if (payloadLength !== 0) throw new Error('dsh desktop: Host response end frame carried a payload')
return { type: 'end', streamId }
case RESPONSE_FRAME_ERROR:
return this.parseError(streamId, payload)
default:
throw new Error(`dsh desktop: unknown Host response frame type ${String(rawType)}`)
}
}
private parseStart(streamId: number, payload: Buffer): DesktopHostResponseFrame {
const value = this.parseJson(payload, 'start')
if (!isRecord(value) || !Number.isInteger(value.status) || (value.status as number) < 100
|| (value.status as number) > 599 || !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') {
throw new Error('dsh desktop: invalid Host response start payload')
}
return {
type: 'start',
streamId,
status: value.status as number,
headers: value.headers,
hasBody: value.hasBody,
}
}
private parseError(streamId: number, payload: Buffer): DesktopHostResponseFrame {
const value = this.parseJson(payload, 'error')
if (!isRecord(value) || typeof value.message !== 'string') {
throw new Error('dsh desktop: invalid Host response error payload')
}
return { type: 'error', streamId, message: value.message }
}
private parseJson(payload: Buffer, subject: string): unknown {
try {
return JSON.parse(payload.toString('utf8')) as unknown
} catch (error) {
throw new Error(`dsh desktop: Host response ${subject} payload is not JSON: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/** Typed preload operations exposed only by the Electron shell. */
import type { DesktopPluginRecord } from './project-manager.ts'
import type { DesktopLocale } from './locale.ts'
/** IPC channel names kept private to the desktop application bundle. */
export const DESKTOP_IPC = {
localeGet: 'dsh-desktop:locale-get',
pluginsList: 'dsh-desktop:plugins-list',
pluginsAdd: 'dsh-desktop:plugins-add',
pluginsRemove: 'dsh-desktop:plugins-remove',
pluginsUpdate: 'dsh-desktop:plugins-update',
updatesCheck: 'dsh-desktop:updates-check',
updatesInstall: 'dsh-desktop:updates-install',
updatesState: 'dsh-desktop:updates-state',
} as const
/** Desktop release update state rendered by desktop-owned UI. */
export interface DesktopUpdateState {
readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error'
readonly version?: string
readonly message?: string
}
/** Narrow bridge exposed through context isolation. */
export interface DshDesktopApi {
readonly protocolVersion: 1
locale(): Promise<DesktopLocale>
readonly plugins: {
list(): Promise<readonly DesktopPluginRecord[]>
add(spec: string): Promise<void>
remove(name: string): Promise<void>
update(name: string, version: string): Promise<void>
}
readonly updates: {
check(): Promise<DesktopUpdateState>
install(): Promise<void>
subscribe(listener: (state: DesktopUpdateState) => void): () => void
}
}
+97
View File
@@ -0,0 +1,97 @@
/** Typed English and Chinese copy owned by the Electron shell. */
export const en = {
application: 'Application',
startupFailed: 'DeepSeek Harness could not start',
pluginsMenu: 'Desktop Plugins…',
pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)',
checkUpdatesMenu: 'Check for Updates…',
updateCheckFailedTitle: 'Update Check Failed',
unknownError: 'Unknown error',
updateCheckTitle: 'Check for Updates',
updateCurrent: 'You already have the latest version.',
updateTitle: 'DeepSeek Harness Update',
updateAvailable: 'An update is available',
updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.',
installAndRestart: 'Install and Restart',
later: 'Later',
updateFailedTitle: 'Update Failed',
pluginManagerTitle: 'Desktop Plugins',
pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins',
pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.',
refresh: 'Refresh',
npmPackage: 'npm package',
install: 'Install',
installed: 'Installed',
noPlugins: 'No Desktop plugins are installed.',
remove: 'Remove',
update: 'Update',
targetVersion: 'Enter the target version for {name}',
removing: 'Removing {name}…',
updating: 'Updating {name}…',
installing: 'Installing {spec}…',
operationComplete: 'Done. The Desktop backend has restarted.',
refreshing: 'Refreshing…',
refreshed: 'Plugin list refreshed.',
loadingPlugins: 'Reading Desktop plugins…',
} as const
/** Every Desktop locale supplies the complete English key set. */
export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
application: '应用',
startupFailed: 'DeepSeek Harness 无法启动',
pluginsMenu: '桌面插件…',
pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)',
checkUpdatesMenu: '检查更新…',
updateCheckFailedTitle: '更新检查失败',
unknownError: '未知错误',
updateCheckTitle: '检查更新',
updateCurrent: '当前已是最新版本。',
updateTitle: 'DeepSeek Harness 更新',
updateAvailable: '发现可用更新',
updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。',
installAndRestart: '安装并重启',
later: '稍后',
updateFailedTitle: '更新失败',
pluginManagerTitle: '桌面插件',
pluginWindowTitle: 'DeepSeek Harness — 桌面插件',
pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。',
refresh: '刷新',
npmPackage: 'npm 包',
install: '安装',
installed: '已安装',
noPlugins: '还没有安装桌面插件。',
remove: '移除',
update: '更新',
targetVersion: '输入 {name} 的目标版本',
removing: '正在移除 {name}…',
updating: '正在更新 {name}…',
installing: '正在安装 {spec}…',
operationComplete: '操作完成,桌面后端已重新启动。',
refreshing: '正在刷新…',
refreshed: '插件列表已刷新。',
loadingPlugins: '正在读取桌面插件…',
} as const satisfies DesktopMessages
/** Locale payload exposed to the Desktop-owned renderer. */
export interface DesktopLocale {
readonly id: 'en' | 'zh-CN'
readonly messages: DesktopMessages
}
/** Resolve Electron's locale to one shipped Desktop dictionary. */
export function resolveDesktopLocale(locale: string): DesktopLocale {
return locale.toLowerCase().startsWith('zh')
? { id: 'zh-CN', messages: zh }
: { id: 'en', messages: en }
}
/** Replace named placeholders in one locale-owned message. */
export function formatDesktopMessage(
message: string,
values: Readonly<Record<string, string>>,
): string {
return message.replaceAll(/\{([^{}]+)\}/gu, (placeholder, key: string) => values[key] ?? placeholder)
}
+397
View File
@@ -0,0 +1,397 @@
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
import { readFile, writeFile } from 'node:fs/promises'
import { extname, join, normalize, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
protocol,
type IpcMainInvokeEvent,
} from 'electron'
import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
import { DesktopHostProcess } from './host-process.ts'
import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
import { claimDesktopSingleInstance } from './single-instance.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
const SCHEME = 'dsh-app'
let focusPrimaryWindow = (): void => {}
function errorOf(reason: unknown, fallback: string): Error {
return reason instanceof Error ? reason : new Error(fallback)
}
protocol.registerSchemesAsPrivileged([{
scheme: SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: false,
stream: true,
codeCache: true,
},
}])
const MIME: Readonly<Record<string, string>> = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.svg': 'image/svg+xml',
}
interface RuntimeResources {
readonly node: string
readonly pnpm: string
readonly seed: string
}
function runtimeResources(): RuntimeResources {
const development = !app.isPackaged
const node = (development ? process.env.DSH_DESKTOP_NODE_BINARY : undefined)
?? 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 }
}
function developmentProject(): string | undefined {
const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR
if (configured === undefined || configured === '') return undefined
if (app.isPackaged) throw new Error('dsh desktop: development project override is unavailable in packaged applications')
return resolve(configured)
}
function developmentHostInspectPort(enabled: boolean): number | undefined {
const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
if (!enabled || configured === undefined || configured === '') return undefined
const port = Number(configured)
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
throw new Error('dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 1 through 65535')
}
return port
}
function createWindow(preload: string): BrowserWindow {
const window = new BrowserWindow({
width: 1280,
height: 840,
minWidth: 880,
minHeight: 600,
show: false,
webPreferences: {
preload,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
},
})
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', (event, url) => {
if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault()
})
return window
}
function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void {
const senderFrame = event.senderFrame
if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame')
const url = new URL(senderFrame.url)
if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) {
throw new Error('dsh desktop: rejected IPC from an unowned renderer')
}
}
async function serveShellAsset(request: Request): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
const root = resolve(app.getAppPath(), 'renderer')
const url = new URL(request.url)
let pathname: string
try {
pathname = decodeURIComponent(url.pathname)
} catch {
return new Response(null, { status: 400 })
}
const target = resolve(normalize(join(root, pathname)))
if (target !== root && !target.startsWith(root + sep)) return new Response(null, { status: 403 })
try {
const body = request.method === 'HEAD' ? null : await readFile(target)
return new Response(body, { headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' } })
} catch {
return new Response(null, { status: 404 })
}
}
async function main(): Promise<void> {
const resources = runtimeResources()
const paths = resolveDesktopPaths()
const development = developmentProject()
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 mainWindow: BrowserWindow | undefined
let pluginWindow: BrowserWindow | undefined
let shellInstallerOwnsQuit = false
let updateState: DesktopUpdateState = { phase: 'idle' }
const locale = resolveDesktopLocale(app.getLocale())
const messages = locale.messages
const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
updateState = state
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(DESKTOP_IPC.updatesState, state)
}
return state
}
const startHost = async (projectDir = activeProject): Promise<DesktopHostProcess> => {
const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort)
await next.start()
return next
}
const hooks: DesktopProjectHooks = {
healthCheck: async (projectDir) => {
const active = host
host = undefined
await active?.stop()
let healthFailure: unknown
let probe: DesktopHostProcess | undefined
try {
probe = await startHost(projectDir)
await probe.stop()
} catch (error) {
healthFailure = error
await probe?.stop().catch(() => undefined)
}
let restartFailure: unknown
if (active !== undefined) {
try {
host = await startHost()
} catch (error) {
restartFailure = error
}
}
if (healthFailure !== undefined && restartFailure !== undefined) {
throw new AggregateError([
errorOf(healthFailure, 'desktop project: staged health check failed'),
errorOf(restartFailure, 'desktop project: active backend restart failed'),
], 'desktop project: staged health check and active backend restart failed')
}
if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed')
if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed')
},
beforeActivate: async () => {
const active = host
host = undefined
await active?.stop()
},
afterActivate: async () => {
host = await startHost()
},
}
if (development === undefined) {
await manager.applyRelease(resources.seed, app.getVersion(), {
...hooks,
beforeActivate: async () => {},
afterActivate: async () => {},
})
}
host = await startHost()
const updates = new DesktopUpdateCoordinator(
publishUpdate,
async () => {
shellInstallerOwnsQuit = true
const active = host
host = undefined
await active?.stop()
},
)
protocol.handle(SCHEME, (request) => {
const url = new URL(request.url)
if (url.hostname === 'shell') return serveShellAsset(request)
if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 }))
const active = host
if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 }))
return active.fetch(request)
})
const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
assertDesktopSender(event, ['shell'])
if (development !== undefined) {
throw new Error('dsh desktop: plugin package changes require a packaged application')
}
await manager.mutate(mutation, hooks)
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload()
}
ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
assertDesktopSender(event, ['shell'])
return locale
})
ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => {
assertDesktopSender(event, ['shell'])
if (development !== undefined) return []
return manager.listPlugins()
})
ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => {
if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string')
return mutate(event, { type: 'plugin-add', spec })
})
ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => {
if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string')
return mutate(event, { type: 'plugin-remove', name })
})
ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => {
if (typeof name !== 'string' || typeof version !== 'string') {
throw new Error('dsh desktop: plugin name and version must be strings')
}
return mutate(event, { type: 'plugin-update', name, version })
})
ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
assertDesktopSender(event, ['shell'])
return updates.check()
})
ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => {
assertDesktopSender(event, ['shell'])
await updates.install()
})
const checkAndPrompt = async (manual: boolean): Promise<void> => {
const state = await updates.check()
if (state.phase === 'error') {
if (manual) {
await dialog.showMessageBox({
type: 'error',
title: messages.updateCheckFailedTitle,
message: state.message ?? messages.unknownError,
})
}
return
}
if (state.phase !== 'available') {
if (manual) {
await dialog.showMessageBox({
type: 'info',
title: messages.updateCheckTitle,
message: state.message ?? messages.updateCurrent,
})
}
return
}
const result = await dialog.showMessageBox({
type: 'info',
title: messages.updateTitle,
message: messages.updateAvailable,
detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
buttons: [messages.installAndRestart, messages.later],
defaultId: 0,
cancelId: 1,
})
if (result.response !== 0) return
const installed = await updates.install()
if (installed.phase === 'error') {
await dialog.showMessageBox({
type: 'error',
title: messages.updateFailedTitle,
message: installed.message ?? messages.unknownError,
})
}
}
const openPluginWindow = (): void => {
if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) {
pluginWindow.focus()
return
}
pluginWindow = createWindow(managementPreload)
pluginWindow.setSize(900, 620)
pluginWindow.setTitle(messages.pluginWindowTitle)
pluginWindow.once('ready-to-show', () => { pluginWindow?.show() })
pluginWindow.once('closed', () => { pluginWindow = undefined })
void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
}
Menu.setApplicationMenu(Menu.buildFromTemplate([{
label: process.platform === 'darwin' ? app.name : messages.application,
submenu: [
{
label: development === undefined ? messages.pluginsMenu : messages.pluginsMenuPackagedOnly,
accelerator: 'CmdOrCtrl+,',
enabled: development === undefined,
click: openPluginWindow,
},
{ label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
{ type: 'separator' },
{ role: 'quit' },
],
}]))
const createMainWindow = (): BrowserWindow => {
const window = createWindow(appPreload)
mainWindow = window
window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
return window
}
focusPrimaryWindow = () => {
const window = mainWindow
if (window === undefined || window.isDestroyed()) {
const replacement = createMainWindow()
void replacement.loadURL(`${SCHEME}://app/index.html`)
return
}
if (window.isMinimized()) window.restore()
window.show()
window.focus()
}
mainWindow = createMainWindow()
await mainWindow.loadURL(`${SCHEME}://app/index.html`)
if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
mainWindow.webContents.openDevTools({ mode: 'detach' })
}
publishUpdate(updateState)
setTimeout(() => { void checkAndPrompt(false) }, 10_000)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('before-quit', (event) => {
if (shellInstallerOwnsQuit) return
if (host === undefined) return
event.preventDefault()
const active = host
host = undefined
void active.stop().finally(() => { app.quit() })
})
}
const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() })
if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
console.error(error)
const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE
if (diagnosticFile !== undefined) {
await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
}
dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message)
app.exit(1)
})
+48
View File
@@ -0,0 +1,48 @@
/** Filesystem ownership for the Electron-managed desktop installation. */
import { join } from 'node:path'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
/** Stable desktop installation paths under the shared Harness home. */
export interface DesktopPaths {
readonly root: string
readonly profile: string
readonly staging: string
readonly rollback: string
readonly pending: string
readonly lock: string
readonly pnpm: {
readonly root: string
readonly store: string
readonly cache: string
readonly state: string
readonly config: string
readonly home: string
}
}
/**
* Resolve every Electron-owned path without changing the shared data roots.
* @param dshHome - Harness home shared with npm-installed dsh.
* @returns immutable desktop path set.
*/
export function resolveDesktopPaths(dshHome: string = resolveDshHome()): DesktopPaths {
const root = join(dshHome, 'desktop')
const pnpm = join(root, 'pnpm')
return {
root,
profile: join(dshHome, 'profiles', 'desktop'),
staging: join(root, 'staging'),
rollback: join(root, 'rollback', 'profile'),
pending: join(root, 'pending.json'),
lock: join(root, 'lock'),
pnpm: {
root: pnpm,
store: join(pnpm, 'store'),
cache: join(pnpm, 'cache'),
state: join(pnpm, 'state'),
config: join(pnpm, 'config'),
home: join(pnpm, 'home'),
},
}
}
+5
View File
@@ -0,0 +1,5 @@
/** Minimal marker that selects the desktop custom-protocol API carrier. */
import { contextBridge } from 'electron'
contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 })
+26
View File
@@ -0,0 +1,26 @@
/** Context-isolated renderer bridge for desktop package and update operations. */
import { contextBridge, ipcRenderer } from 'electron'
import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts'
const api: DshDesktopApi = {
protocolVersion: 1,
locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise<ReturnType<DshDesktopApi['locale']> extends Promise<infer T> ? T : never>,
plugins: {
list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise<ReturnType<DshDesktopApi['plugins']['list']> extends Promise<infer T> ? T : never>,
add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise<void>,
remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise<void>,
update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise<void>,
},
updates: {
check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise<DesktopUpdateState>,
install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise<void>,
subscribe(listener) {
const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) }
ipcRenderer.on(DESKTOP_IPC.updatesState, handle)
return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) }
},
},
}
contextBridge.exposeInMainWorld('dshDesktop', api)
+725
View File
@@ -0,0 +1,725 @@
/** Transactional owner of the reserved desktop profile and its private pnpm state. */
import { spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import {
constants,
copyFileSync,
cpSync,
existsSync,
fsyncSync,
ftruncateSync,
lstatSync,
mkdirSync,
openSync,
closeSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync,
writeSync,
} 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
/** Desktop plugin record derived from the installed profile. */
export interface DesktopPluginRecord {
readonly name: string
readonly version: string
}
/** Installed desktop project manifest slice. */
interface DesktopProjectManifest {
readonly name: string
readonly private: true
readonly version: string
readonly dependencies: Record<string, string>
readonly dsh: {
readonly profile: {
readonly bundles: string[]
}
}
}
/** Journaled activation step used for crash recovery. */
interface DesktopPendingTransaction {
readonly schemaVersion: 1
readonly id: string
readonly stagingProfile: string
readonly step: 'prepared' | 'active-moved' | 'staging-activated'
}
/** Exact executables the desktop shell bundles. */
export interface DesktopRuntimeExecutables {
readonly node: string
readonly pnpm: string
}
/** Hooks that bind project replacement to backend lifecycle and health. */
export interface DesktopProjectHooks {
/** Prove the staged dependency graph while the active backend is stopped. */
healthCheck(projectDir: string): Promise<void>
/** Stop the active backend and await process exit before directory moves. */
beforeActivate(): Promise<void>
/** Start the selected active project after commit or rollback. */
afterActivate(): Promise<void>
}
/** Supported dependency mutation. */
export type DesktopProjectMutation =
| { readonly type: 'plugin-add'; readonly spec: string }
| { readonly type: 'plugin-remove'; readonly name: string }
| { readonly type: 'plugin-update'; readonly name: string; readonly version: string }
interface DesktopSeedIntegrityRecord {
readonly path: string
readonly bytes: number
readonly sha256: string
}
const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
const DSH_PACKAGE = '@deepseek-ai/dsh'
const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local'
const DESKTOP_PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] as const
const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\nstrictDepBuilds: true\n'
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u
const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024
const DESKTOP_REGISTRY = 'https://registry.npmjs.org/'
function errorOf(reason: unknown, fallback: string): Error {
return reason instanceof Error ? reason : new Error(fallback)
}
function writeJson(path: string, value: unknown): void {
writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 })
}
function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, 'utf8'))
}
function workspaceFile(overrides: Readonly<Record<string, string>> = {}): string {
const entries = Object.entries(overrides).sort(([left], [right]) => left.localeCompare(right))
const overrideSection = entries.length === 0
? ''
: `overrides:\n${entries.map(([name, spec]) => ` ${JSON.stringify(name)}: ${JSON.stringify(spec)}`).join('\n')}\n`
const coreBuildSpec = overrides[CORE_BUILD_PACKAGE]
const coreBuildKey = coreBuildSpec === undefined
? CORE_BUILD_PACKAGE
: `${CORE_BUILD_PACKAGE}@${coreBuildSpec.replace('file:./', 'file:')}`
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
}
function isDescendant(root: string, target: string): boolean {
const child = relative(root, target)
return child !== '' && child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)
}
function assertPackageName(name: string): void {
if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`)
}
function assertVersion(version: string): void {
if (!VERSION_PATTERN.test(version)) throw new Error(`desktop project: invalid exact version ${JSON.stringify(version)}`)
}
/**
* Validate one registry package spec and return its requested package name when explicit.
* @param spec - npm registry name with an optional version or tag.
* @returns package name, or undefined when the spec's final name is registry-resolved.
*/
export function packageNameFromSpec(spec: string): string | undefined {
if (spec === '' || spec.startsWith('-') || /[\s\\]/u.test(spec) || spec.includes('://') || spec.startsWith('file:')) {
throw new Error(`desktop project: unsupported npm package spec ${JSON.stringify(spec)}`)
}
if (spec.startsWith('@')) {
const slash = spec.indexOf('/')
if (slash === -1) throw new Error(`desktop project: invalid scoped package spec ${JSON.stringify(spec)}`)
const versionAt = spec.indexOf('@', slash)
const name = versionAt === -1 ? spec : spec.slice(0, versionAt)
assertPackageName(name)
if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1))
return name
}
const versionAt = spec.indexOf('@')
const name = versionAt === -1 ? spec : spec.slice(0, versionAt)
assertPackageName(name)
if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1))
return name
}
function removeOwnedDirectory(path: string): void {
if (!existsSync(path)) return
const stat = lstatSync(path)
if (stat.isSymbolicLink()) {
unlinkSync(path)
return
}
if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`)
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)
|| !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}`)
}
return manifest
}
function profilePluginNames(projectDir: string): readonly string[] {
const bundles = projectManifest(projectDir).dsh.profile.bundles
if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) {
throw new Error('desktop project: profile must begin with the built-in desktop bundle list')
}
const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length)
if (new Set(bundles).size !== bundles.length) {
throw new Error('desktop project: profile bundle list contains a duplicate package')
}
for (const plugin of plugins) assertPackageName(plugin)
return plugins
}
function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] {
return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name))
}
function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void {
const manifest = projectManifest(projectDir)
writeJson(join(projectDir, 'package.json'), {
...manifest,
dsh: {
...manifest.dsh,
profile: {
...manifest.dsh.profile,
bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)],
},
},
} satisfies DesktopProjectManifest)
}
function inspectPlugin(projectDir: string, requestedName: string): DesktopPluginRecord {
const manifestPath = join(projectDir, 'node_modules', ...requestedName.split('/'), 'package.json')
if (!existsSync(manifestPath)) {
throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has no manifest`)
}
const manifest = readJson(manifestPath)
if (!isRecord(manifest) || manifest.name !== requestedName || typeof manifest.version !== 'string') {
throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has inconsistent name or version`)
}
const dsh = manifest.dsh
const bundle = isRecord(dsh) ? dsh.bundle : undefined
const patch = isRecord(bundle) ? bundle.patch : undefined
if (typeof patch !== 'string' || patch === '') {
throw new Error(`desktop project: ${requestedName}@${manifest.version} does not declare dsh.bundle.patch`)
}
const packageDir = dirname(manifestPath)
const patchPath = resolve(packageDir, patch)
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 }
}
/** Transactional desktop npm project manager. */
export class DesktopProjectManager {
private lockDescriptor: number | undefined
/**
* @param paths - Electron-owned package state and reserved desktop profile paths.
* @param runtime - absolute bundled Node.js and pnpm entry paths.
*/
constructor(
readonly paths: DesktopPaths,
readonly runtime: DesktopRuntimeExecutables,
) {}
/** Recover an interrupted directory replacement before reading the active project. */
recover(): void {
if (!existsSync(this.paths.pending)) return
const value = readJson(this.paths.pending)
if (!isRecord(value) || value.schemaVersion !== 1
|| typeof value.id !== 'string' || typeof value.stagingProfile !== 'string'
|| !isDescendant(this.paths.staging, value.stagingProfile)
|| (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) {
throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`)
}
const pending: DesktopPendingTransaction = {
schemaVersion: 1,
id: value.id,
stagingProfile: value.stagingProfile,
step: value.step,
}
if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) {
mkdirSync(dirname(this.paths.profile), { recursive: true })
renameSync(this.paths.rollback, this.paths.profile)
}
removeOwnedDirectory(pending.stagingProfile)
unlinkSync(this.paths.pending)
}
/** Read the active desktop plugin inventory. */
listPlugins(): readonly DesktopPluginRecord[] {
if (!existsSync(this.paths.profile)) return []
return pluginRecords(this.paths.profile)
}
/** Read the exact dsh version installed in the active desktop project. */
dshVersion(): string {
if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
return this.installedPackageVersion(DSH_PACKAGE)
}
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. */
releaseVersion(): string {
if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
return releaseFile(this.paths.profile).version
}
/** Install or reconcile the active project to the Electron package's exact release. */
async applyRelease(seedDir: string, 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}`)
}
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)
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'])
}
await hooks.healthCheck(stagingProfile)
await this.activate(stagingProfile, hooks)
return true
} catch (error) {
removeOwnedDirectory(stagingProfile)
throw error
}
})
}
/** Apply one exact dependency mutation through a staging project. */
async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise<void> {
await this.withLock(async () => {
this.recover()
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)
await this.applyMutation(stagingProfile, mutation)
await hooks.healthCheck(stagingProfile)
await this.activate(stagingProfile, hooks)
} catch (error) {
removeOwnedDirectory(stagingProfile)
throw error
}
})
}
private newStagingProfile(): string {
const path = join(this.paths.staging, randomUUID(), 'profile')
mkdirSync(path, { recursive: true, mode: 0o700 })
return path
}
private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise<void> {
switch (mutation.type) {
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)
const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name)
writeProfilePlugins(
projectDir,
[...current, installed].sort((left, right) => left.name.localeCompare(right.name)),
)
return
}
case 'plugin-remove': {
assertPackageName(mutation.name)
if (!profilePluginNames(projectDir).includes(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])
writeProfilePlugins(projectDir, remaining)
return
}
case 'plugin-update':
assertPackageName(mutation.name)
assertVersion(mutation.version)
if (!profilePluginNames(projectDir).includes(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'])
{
const installed = inspectPlugin(projectDir, mutation.name)
writeProfilePlugins(
projectDir,
pluginRecords(projectDir).map(plugin => plugin.name === installed.name ? installed : 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,
step: 'prepared',
}
writeJson(this.paths.pending, pending)
await hooks.beforeActivate()
let activeMoved = false
try {
removeOwnedDirectory(this.paths.rollback)
mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 })
writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction)
if (existsSync(this.paths.profile)) {
renameSync(this.paths.profile, this.paths.rollback)
activeMoved = true
}
mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 })
writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction)
renameSync(stagingProfile, this.paths.profile)
await hooks.afterActivate()
unlinkSync(this.paths.pending)
} catch (error) {
if (existsSync(this.paths.profile)) 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)
throw error
}
}
private async runPnpm(projectDir: string, args: readonly string[]): Promise<void> {
const [command, ...commandArgs] = args
if (command === undefined) throw new Error('desktop project: pnpm command is required')
for (const path of [this.paths.root, this.paths.pnpm.store, this.paths.pnpm.cache,
this.paths.pnpm.state, this.paths.pnpm.config, this.paths.pnpm.home]) {
mkdirSync(path, { recursive: true, mode: 0o700 })
}
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)
)))
await new Promise<void>((settle, reject) => {
const child = spawn(this.runtime.node, [
this.runtime.pnpm,
`--config.registry=${DESKTOP_REGISTRY}`,
`--config.store-dir=${this.paths.pnpm.store}`,
'--config.enable-global-virtual-store=false',
`--config.userconfig=${npmrc}`,
command,
...commandArgs,
], {
cwd: projectDir,
env: {
...inherited,
COREPACK_HOME: this.paths.pnpm.home,
NPM_CONFIG_REGISTRY: DESKTOP_REGISTRY,
NPM_CONFIG_STORE_DIR: this.paths.pnpm.store,
NPM_CONFIG_USERCONFIG: npmrc,
PATH: `${dirname(this.runtime.node)}${delimiter}${process.env.PATH ?? ''}`,
PNPM_HOME: this.paths.pnpm.home,
XDG_CACHE_HOME: this.paths.pnpm.cache,
XDG_CONFIG_HOME: this.paths.pnpm.config,
XDG_STATE_HOME: this.paths.pnpm.state,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
const childPid = child.pid
if (childPid === undefined) {
child.kill('SIGKILL')
reject(new Error('desktop project: pnpm did not report a process id'))
return
}
try {
this.writeLockOwner(childPid)
} catch (error) {
child.kill('SIGKILL')
reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm'))
return
}
let diagnostics = ''
let completed = false
const appendDiagnostics = (chunk: string): void => {
diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES)
}
child.stdout.setEncoding('utf8')
child.stdout.on('data', appendDiagnostics)
child.stderr.setEncoding('utf8')
child.stderr.on('data', appendDiagnostics)
const complete = (settleChild: () => void): void => {
if (completed) return
completed = true
try {
this.writeLockOwner(process.pid)
} catch (error) {
reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron'))
return
}
settleChild()
}
child.once('error', (error) => { complete(() => { reject(error) }) })
child.once('close', (code, signal) => {
complete(() => {
if (code === 0) {
settle()
return
}
reject(new Error(
`desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`,
))
})
})
})
}
private writeLockOwner(pid: number): void {
const descriptor = this.lockDescriptor
if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock')
const content = Buffer.from(`${String(pid)}\n`)
ftruncateSync(descriptor, 0)
writeSync(descriptor, content, 0, content.byteLength, 0)
fsyncSync(descriptor)
}
private async withLock<T>(operation: () => Promise<T>): Promise<T> {
mkdirSync(this.paths.root, { recursive: true, mode: 0o700 })
let descriptor: number
try {
descriptor = openSync(this.paths.lock, 'wx', 0o600)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
const lock = lstatSync(this.paths.lock)
if (lock.isSymbolicLink() || !lock.isFile()) {
throw new Error('desktop project: package transaction lock is not a regular file')
}
const owner = Number.parseInt(readFileSync(this.paths.lock, 'utf8').trim(), 10)
let active = !Number.isSafeInteger(owner) || owner <= 0
if (!active) {
try {
process.kill(owner, 0)
active = true
} catch (signalError) {
active = (signalError as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
if (active) throw new Error('desktop project: another package transaction is active')
unlinkSync(this.paths.lock)
descriptor = openSync(this.paths.lock, 'wx', 0o600)
} else {
throw error
}
}
try {
this.lockDescriptor = descriptor
this.writeLockOwner(process.pid)
return await operation()
} finally {
this.lockDescriptor = undefined
closeSync(descriptor)
unlinkSync(this.paths.lock)
}
}
}
/** 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)
const manifest: DesktopProjectManifest = {
name: PROJECT_NAME,
private: true,
version: '0.0.0',
dependencies: desktopCorePackageOverrides(packageSet),
dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
}
writeJson(join(seedDir, 'package.json'), manifest)
writeFileSync(
join(seedDir, 'pnpm-workspace.yaml'),
workspaceFile(desktopCorePackageOverrides(packageSet)),
{ mode: 0o600 },
)
writeJson(join(seedDir, 'desktop-release.json'), release)
}
/**
* Create metadata for the unpackaged development project that links the current workspace.
* @param projectDir - Disposable development profile directory.
* @param release - Release identity shared by the linked CLI package and Electron shell.
*/
export function createDevelopmentProjectMetadata(projectDir: string, release: DesktopRelease): void {
mkdirSync(projectDir, { recursive: true, mode: 0o700 })
const manifest = {
name: PROJECT_NAME,
private: true,
version: '0.0.0',
dependencies: {
[DSH_PACKAGE]: release.version,
[DESKTOP_HOST_PACKAGE]: release.version,
},
dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
}
writeJson(join(projectDir, 'package.json'), manifest)
writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
writeJson(join(projectDir, 'desktop-release.json'), release)
}
+35
View File
@@ -0,0 +1,35 @@
/** Immutable version identity shared by one Electron shell and its dsh seed. */
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. */
export interface DesktopRelease {
readonly schemaVersion: 1
/** Exact version used by both Electron and `@deepseek-ai/dsh`. */
readonly version: string
readonly hostProtocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION
readonly nodeVersion: string
readonly pnpmVersion: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
/** Validate release data read from an installed or packaged filesystem resource. */
export function parseDesktopRelease(value: unknown): DesktopRelease {
if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.version !== 'string'
|| valid(value.version) === null || value.hostProtocolVersion !== DESKTOP_HOST_PROTOCOL_VERSION
|| typeof value.nodeVersion !== 'string' || valid(value.nodeVersion) === null
|| typeof value.pnpmVersion !== 'string' || valid(value.pnpmVersion) === null) {
throw new Error('dsh desktop: invalid desktop release metadata')
}
return {
schemaVersion: 1,
version: value.version,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: value.nodeVersion,
pnpmVersion: value.pnpmVersion,
}
}
+271
View File
@@ -0,0 +1,271 @@
/** 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,
})
}
}
+26
View File
@@ -0,0 +1,26 @@
/** Electron single-instance ownership before any Desktop profile lifecycle begins. */
/** Minimal Electron application operations needed for instance ownership. */
export interface DesktopSingleInstanceApplication {
requestSingleInstanceLock(): boolean
quit(): void
on(event: 'second-instance', listener: () => void): unknown
}
/**
* Claim the process-lifetime Desktop lock and route later launches to the owner.
* @param application - Electron application singleton.
* @param focusOwner - focus or recreate the primary window after a later launch.
* @returns true only in the process that may access the Desktop profile.
*/
export function claimDesktopSingleInstance(
application: DesktopSingleInstanceApplication,
focusOwner: () => void,
): boolean {
if (!application.requestSingleInstanceLock()) {
application.quit()
return false
}
application.on('second-instance', focusOwner)
return true
}
+95
View File
@@ -0,0 +1,95 @@
/** One Electron release stream for the version-bound shell and dsh seed. */
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import electronUpdater, { type AppUpdater } from 'electron-updater'
import type { DesktopUpdateState } from './ipc.ts'
const { autoUpdater } = electronUpdater
/** Checks, downloads, and installs one complete Desktop release. */
export class DesktopUpdateCoordinator {
private availableVersion: string | undefined
private checkOperation: Promise<DesktopUpdateState> | undefined
private installOperation: Promise<DesktopUpdateState> | undefined
/**
* @param publish - state sink for every desktop window.
* @param beforeRestart - stop application-owned processes before replacement.
* @param updater - Electron artifact updater; replaceable for tests.
* @param enabled - whether this packaged process carries updater configuration.
*/
constructor(
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
private readonly beforeRestart: () => Promise<void> = async () => {},
private readonly updater: AppUpdater = autoUpdater,
private readonly enabled: () => boolean = () => (
app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml'))
),
) {
this.updater.autoDownload = false
this.updater.autoInstallOnAppQuit = false
}
/** Check the configured Desktop release stream and retain an available version. */
async check(): Promise<DesktopUpdateState> {
if (this.installOperation !== undefined) return this.installOperation
if (this.checkOperation !== undefined) return this.checkOperation
this.checkOperation = this.doCheck().finally(() => { this.checkOperation = undefined })
return this.checkOperation
}
/** Wait for an in-flight check, then download and install its retained release. */
async install(): Promise<DesktopUpdateState> {
if (this.installOperation !== undefined) return this.installOperation
this.installOperation = (async () => {
await this.checkOperation
return this.doInstall()
})().finally(() => { this.installOperation = undefined })
return this.installOperation
}
private async doCheck(): Promise<DesktopUpdateState> {
this.publish({ phase: 'checking' })
try {
if (!this.enabled()) {
this.availableVersion = undefined
return this.publish({ phase: 'idle' })
}
const result = await this.updater.checkForUpdates()
const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined
this.availableVersion = version
return version === undefined
? this.publish({ phase: 'idle' })
: this.publish({ phase: 'available', version })
} catch (error) {
this.availableVersion = undefined
return this.publish({
phase: 'error',
message: error instanceof Error ? error.message : String(error),
})
}
}
private async doInstall(): Promise<DesktopUpdateState> {
const version = this.availableVersion
if (version === undefined) {
throw new Error('desktop update: no verified update is available')
}
this.publish({ phase: 'installing', version })
try {
await this.updater.downloadUpdate()
this.availableVersion = undefined
const ready = this.publish({ phase: 'ready', version })
await this.beforeRestart()
this.updater.quitAndInstall(false, true)
return ready
} catch (error) {
return this.publish({
phase: 'error',
version,
message: error instanceof Error ? error.message : String(error),
})
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import { createHash } from 'node:crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
DESKTOP_PACKAGES_DIR,
DESKTOP_PACKAGE_SET_FILE,
desktopCorePackageOverrides,
desktopDshPackageSpec,
parseDesktopCorePackageSet,
verifyDesktopCoreLockfile,
verifyDesktopCorePackageSet,
type DesktopCorePackageRecord,
} from '../src/core-package-set.ts'
const roots: string[] = []
function record(name: string, file: string, body: Buffer, version = '1.2.3'): DesktopCorePackageRecord {
return {
name,
version,
file,
bytes: body.byteLength,
integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`,
}
}
function packageSetProject(): {
root: string
dsh: DesktopCorePackageRecord
base: DesktopCorePackageRecord
host: DesktopCorePackageRecord
} {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-package-set-'))
roots.push(root)
const packageDir = join(root, DESKTOP_PACKAGES_DIR)
mkdirSync(packageDir)
const dshBody = Buffer.from('dsh')
const baseBody = Buffer.from('base')
const hostBody = Buffer.from('host')
const dsh = record('@deepseek-ai/dsh', 'dsh.tgz', dshBody)
const base = record('@deepseek-ai/dsh-base', 'dsh-base.tgz', baseBody)
const host = record('@deepseek-ai/dsh-desktop-host', 'dsh-desktop-host.tgz', hostBody)
writeFileSync(join(packageDir, dsh.file), dshBody)
writeFileSync(join(packageDir, base.file), baseBody)
writeFileSync(join(packageDir, host.file), hostBody)
writeFileSync(join(root, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({
schemaVersion: 1,
packages: [dsh, base, host],
})}\n`)
return { root, dsh, base, host }
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('desktop core package set', () => {
it('pins the direct dsh dependency and every internal package to local tarballs', () => {
const { root } = packageSetProject()
const packageSet = verifyDesktopCorePackageSet(root, '1.2.3')
expect(desktopDshPackageSpec(packageSet)).toBe('file:./desktop-packages/dsh.tgz')
expect(desktopCorePackageOverrides(packageSet)).toEqual({
'@deepseek-ai/dsh': 'file:./desktop-packages/dsh.tgz',
'@deepseek-ai/dsh-base': 'file:./desktop-packages/dsh-base.tgz',
'@deepseek-ai/dsh-desktop-host': 'file:./desktop-packages/dsh-desktop-host.tgz',
})
})
it('rejects version drift, descriptor disorder, corruption, and extra files', () => {
const { root, dsh, base, host } = packageSetProject()
expect(() => verifyDesktopCorePackageSet(root, '2.0.0')).toThrow(/does not match Desktop/u)
expect(() => parseDesktopCorePackageSet({
schemaVersion: 1,
packages: [dsh, base, { ...host, version: '2.0.0' }],
}, '1.2.3')).toThrow(/dsh-desktop-host@2\.0\.0 does not match Desktop 1\.2\.3/u)
expect(() => parseDesktopCorePackageSet({ schemaVersion: 1, packages: [base, dsh, host] }))
.toThrow(/sorted by name/u)
writeFileSync(join(root, DESKTOP_PACKAGES_DIR, dsh.file), 'changed')
expect(() => verifyDesktopCorePackageSet(root, '1.2.3')).toThrow(/integrity check failed/u)
writeFileSync(join(root, DESKTOP_PACKAGES_DIR, 'extra.tgz'), '')
expect(() => verifyDesktopCorePackageSet(root, '1.2.3')).toThrow(/does not match its descriptor/u)
})
it('rejects registry resolutions for names supplied by the local package set', () => {
const dsh = record('@deepseek-ai/dsh', 'dsh.tgz', Buffer.from('dsh'))
const host = record('@deepseek-ai/dsh-desktop-host', 'host.tgz', Buffer.from('host'))
const packageSet = parseDesktopCorePackageSet({ schemaVersion: 1, packages: [dsh, host] })
expect(() => {
verifyDesktopCoreLockfile(
"packages:\n '@deepseek-ai/dsh@file:desktop-packages/dsh.tgz':\n resolution: {}\n",
packageSet,
)
}).not.toThrow()
expect(() => {
verifyDesktopCoreLockfile(
"packages:\n '@deepseek-ai/[email protected]':\n resolution: {integrity: sha512-registry}\n",
packageSet,
)
}).toThrow(/outside the local package set/u)
})
})
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
desktopBuildRecordFilename,
desktopUpdateMetadataFilename,
resolveDesktopAutoUpdateConfig,
resolveDesktopAutoUpdateEnvironment,
resolveDesktopAutoUpdateTarget,
resolveDesktopUploadConfig,
} from '../scripts/desktop-auto-update-environment.mjs'
describe('desktop auto-update environment', () => {
it('defaults packages and uploads to the test deployment', () => {
expect(resolveDesktopAutoUpdateEnvironment({})).toBe('test')
expect(resolveDesktopAutoUpdateConfig({
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/',
}, 'darwin', 'arm64')).toEqual({
environment: 'test',
target: 'mac-arm64',
origin: 'https://desktop-updates.example.com',
publicUrl: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/',
keyPrefix: '_/harness/desktop/stable/mac-arm64',
})
expect(resolveDesktopUploadConfig({
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/',
DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket',
}, 'darwin', 'arm64')).toMatchObject({
bucket: 'test-download-bucket',
secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID',
secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY',
})
})
it('selects the production URL for packages and bucket for uploads', () => {
expect(resolveDesktopAutoUpdateConfig({
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
}, 'win32', 'x64')).toMatchObject({
environment: 'production',
target: 'win-x64',
publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/',
})
expect(resolveDesktopUploadConfig({
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket',
}, 'win32', 'x64')).toMatchObject({
bucket: 'production-download-bucket',
secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID',
secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY',
})
})
it('requires the selected deployment origin for packages and bucket only for uploads', () => {
expect(() => resolveDesktopAutoUpdateConfig({}, 'darwin', 'arm64'))
.toThrow(/DOWNLOAD_TEST_ORIGIN/u)
expect(resolveDesktopAutoUpdateConfig({
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com',
}, 'darwin', 'arm64').publicUrl).toContain('/mac-arm64/')
expect(() => resolveDesktopUploadConfig({
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com',
}, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_COS_BUCKET/u)
expect(() => resolveDesktopUploadConfig({
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
}, 'win32', 'x64')).toThrow(/DOWNLOAD_PROD_COS_BUCKET/u)
})
it('rejects a test download URL that is not an HTTPS origin', () => {
expect(() => resolveDesktopAutoUpdateConfig({
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/releases',
}, 'darwin', 'arm64')).toThrow(/HTTPS origin without a path/u)
expect(() => resolveDesktopAutoUpdateConfig({
DOWNLOAD_TEST_ORIGIN: 'http://desktop-updates.example.com',
}, 'darwin', 'arm64')).toThrow(/HTTPS origin/u)
})
it('rejects unknown deployments and targets', () => {
expect(() => resolveDesktopAutoUpdateEnvironment({
DSH_DESKTOP_AUTO_UPDATE_ENV: 'staging',
})).toThrow(/test.*production/u)
expect(() => resolveDesktopAutoUpdateTarget('linux', 'x64')).toThrow(/unsupported target/u)
expect(() => desktopBuildRecordFilename('linux-x64' as 'mac-arm64')).toThrow(/unsupported target/u)
})
it('matches electron-builder channel metadata names to the Desktop version', () => {
expect(desktopUpdateMetadataFilename('1.2.3', 'darwin')).toBe('latest-mac.yml')
expect(desktopUpdateMetadataFilename('1.2.3-alpha.4', 'darwin')).toBe('alpha-mac.yml')
expect(desktopUpdateMetadataFilename('1.2.3-beta.2', 'win32')).toBe('beta.yml')
expect(() => desktopUpdateMetadataFilename('not-semver', 'darwin')).toThrow(/invalid Desktop version/u)
expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u)
})
})
@@ -0,0 +1,50 @@
import { join, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
desktopTargetBuildPaths,
resolveDesktopBuildTarget,
} from '../scripts/desktop-build-paths.mjs'
describe('desktop build paths', () => {
it('isolates every mutable build directory by complete target', () => {
const arm64 = desktopTargetBuildPaths('mac-arm64')
const x64 = desktopTargetBuildPaths('mac-x64')
const windows = desktopTargetBuildPaths('win-x64')
const mutableKeys = [
'root',
'artifacts',
'runtime',
'packageSet',
'seed',
'seedPnpm',
'nodeExtract',
'packedDsh',
'packedVendor',
'packedLandlock',
] as const
for (const key of mutableKeys) {
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(windows.runtime).toContain(join('targets', 'win-x64', 'runtime'))
})
it('shares only the immutable upstream download cache', () => {
const arm64 = desktopTargetBuildPaths('mac-arm64')
const x64 = desktopTargetBuildPaths('mac-x64')
expect(arm64.downloads).toBe(x64.downloads)
expect(arm64.downloads).not.toContain(`${sep}targets${sep}`)
})
it('resolves environment overrides and rejects unsupported targets', () => {
expect(resolveDesktopBuildTarget({
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
DSH_DESKTOP_TARGET_ARCH: 'x64',
}, 'darwin', 'arm64')).toBe('mac-x64')
expect(resolveDesktopBuildTarget({}, 'win32', 'x64')).toBe('win-x64')
expect(() => resolveDesktopBuildTarget({}, 'linux', 'x64')).toThrow(/unsupported target/u)
expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u)
})
})
@@ -0,0 +1,195 @@
import { createHash } from 'node:crypto'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs'
import type { DesktopPackageTargetName } from '../scripts/package-target.ts'
const temporaryDirectories: string[] = []
const TEST_ORIGIN = 'https://desktop-updates.example.com'
const TEST_BUCKET = 'test-download-bucket'
const PRODUCTION_BUCKET = 'production-download-bucket'
interface Fixture {
readonly repositoryRoot: string
readonly appRoot: string
readonly artifactsRoot: string
readonly environment: NodeJS.ProcessEnv
}
function digest(contents: string): string {
return createHash('sha512').update(contents).digest('base64')
}
async function fixture(
target: DesktopPackageTargetName,
version = '1.2.3',
environment: 'test' | 'production' = 'test',
): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-'))
temporaryDirectories.push(root)
const repositoryRoot = join(root, 'repository')
const appRoot = join(repositoryRoot, 'apps', 'desktop')
const artifactsRoot = join(appRoot, '.desktop-build', 'artifacts')
await mkdir(artifactsRoot, { recursive: true })
await writeFile(join(repositoryRoot, 'package.json'), `${JSON.stringify({ version })}\n`)
await writeFile(join(appRoot, 'package.json'), `${JSON.stringify({ version })}\n`)
const [os, arch] = target.split('-') as ['mac' | 'win', 'arm64' | 'x64']
const base = `deepseek-harness-${version}-${os}-${arch}`
const origin = environment === 'test'
? TEST_ORIGIN
: 'https://download.deepseek.com'
await writeFile(join(artifactsRoot, `${target}-release.json`), `${JSON.stringify({
schemaVersion: 1,
target,
version,
environment,
publicUrl: `${origin}/_/harness/desktop/stable/${target}/`,
})}\n`)
if (os === 'mac') {
const zip = 'signed macOS ZIP fixture'
await writeFile(join(artifactsRoot, `${base}.zip`), zip)
await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap')
await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture')
await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({
version,
files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }],
})}\n`)
}
else {
const executable = 'signed NSIS executable fixture'
await writeFile(join(artifactsRoot, `${base}.exe`), executable)
await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({
version,
files: [{
url: `${base}.exe`,
size: Buffer.byteLength(executable),
sha512: digest(executable),
blockMapSize: 128,
}],
})}\n`)
}
return {
repositoryRoot,
appRoot,
artifactsRoot,
environment: environment === 'test'
? {
DSH_DESKTOP_AUTO_UPDATE_ENV: 'test',
DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN,
DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET,
}
: {
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
DOWNLOAD_PROD_COS_BUCKET: PRODUCTION_BUCKET,
},
}
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map(async path => rm(path, {
recursive: true,
force: true,
})))
})
describe('desktop upload plan', () => {
it('validates macOS artifacts and puts channel metadata last', async () => {
const paths = await fixture('mac-arm64')
const plan = await createDesktopUploadPlan('mac-arm64', paths)
expect(plan).toMatchObject({
environment: 'test',
version: '1.2.3',
publicUrl: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/',
bucket: TEST_BUCKET,
})
expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([
'deepseek-harness-1.2.3-mac-arm64.dmg',
'deepseek-harness-1.2.3-mac-arm64.zip',
'deepseek-harness-1.2.3-mac-arm64.zip.blockmap',
'latest-mac.yml',
])
expect(plan.artifacts.at(-1)).toMatchObject({
channelMetadata: true,
cacheControl: 'no-cache',
})
})
it('uploads the prerelease channel metadata emitted by electron-builder', async () => {
const paths = await fixture('mac-arm64', '1.2.3-alpha.4')
const plan = await createDesktopUploadPlan('mac-arm64', paths)
expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([
'deepseek-harness-1.2.3-alpha.4-mac-arm64.dmg',
'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip',
'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip.blockmap',
'alpha-mac.yml',
])
})
it('validates the Windows installer with its embedded blockmap and production destination', async () => {
const paths = await fixture('win-x64', '2.0.0', 'production')
const plan = await createDesktopUploadPlan('win-x64', paths)
expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([
'deepseek-harness-2.0.0-win-x64.exe',
'latest.yml',
])
expect(plan).toMatchObject({
publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/',
bucket: PRODUCTION_BUCKET,
})
})
it('rejects Windows metadata without an embedded blockmap size', async () => {
const paths = await fixture('win-x64')
const executable = 'signed NSIS executable fixture'
await writeFile(join(paths.artifactsRoot, 'latest.yml'), `${JSON.stringify({
version: '1.2.3',
files: [{
url: 'deepseek-harness-1.2.3-win-x64.exe',
size: Buffer.byteLength(executable),
sha512: digest(executable),
}],
})}\n`)
await expect(createDesktopUploadPlan('win-x64', paths)).rejects.toThrow(/blockMapSize/u)
})
it('rejects a completed build from another dsh version or deployment', async () => {
const paths = await fixture('mac-x64')
await writeFile(join(paths.repositoryRoot, 'package.json'), '{"version":"1.2.4"}\n')
await writeFile(join(paths.appRoot, 'package.json'), '{"version":"1.2.4"}\n')
await expect(createDesktopUploadPlan('mac-x64', paths)).rejects.toThrow(/completion record.*1\.2\.4/u)
const productionPaths = await fixture('mac-x64', '1.2.3', 'production')
await expect(createDesktopUploadPlan('mac-x64', {
...productionPaths,
environment: {
DSH_DESKTOP_AUTO_UPDATE_ENV: 'test',
DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN,
DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET,
},
})).rejects.toThrow(/completion record.*test/u)
})
it('rejects stale architecture metadata and modified updater bytes', async () => {
const paths = await fixture('mac-arm64')
const metadataPath = join(paths.artifactsRoot, 'latest-mac.yml')
const zipPath = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-mac-arm64.zip')
await writeFile(zipPath, 'modified')
await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/size.*metadata/u)
const x64 = 'wrong architecture'
await writeFile(metadataPath, `${JSON.stringify({
version: '1.2.3',
files: [{
url: 'deepseek-harness-1.2.3-mac-x64.zip',
size: Buffer.byteLength(x64),
sha512: digest(x64),
}],
})}\n`)
await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/mac-arm64\.zip/u)
})
})
@@ -0,0 +1,89 @@
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { prepareDevelopmentProject } from '../scripts/development-project.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import type { DesktopRelease } from '../src/release.ts'
const roots: string[] = []
function temporaryRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-development-test-'))
roots.push(root)
return root
}
function release(version = '1.2.3'): DesktopRelease {
return {
schemaVersion: 1,
version,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
}
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('desktop development project', () => {
it('projects the built dsh and Desktop Host applications with their dependency graph', () => {
const root = temporaryRoot()
const cli = join(root, 'apps', 'cli')
const host = join(root, 'apps', 'desktop-host')
const dependencies = join(root, 'workspace-dependencies')
mkdirSync(join(cli, 'lib'), { recursive: true })
mkdirSync(join(host, 'lib'), { recursive: true })
mkdirSync(join(dependencies, '@scope'), { recursive: true })
mkdirSync(join(dependencies, '@deepseek-ai', 'dsh'), { recursive: true })
writeFileSync(join(cli, 'package.json'), '{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n')
writeFileSync(join(host, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","version":"1.2.3"}\n')
writeFileSync(join(host, 'lib', 'index.js'), '')
writeFileSync(join(dependencies, '@deepseek-ai', 'dsh', 'package.json'), '{}\n')
mkdirSync(join(dependencies, 'plain-dependency'))
writeFileSync(join(dependencies, 'plain-dependency', 'package.json'), '{}\n')
mkdirSync(join(dependencies, '@scope', 'dependency'))
writeFileSync(join(dependencies, '@scope', 'dependency', 'package.json'), '{}\n')
const project = prepareDevelopmentProject({
projectDir: join(root, 'development'),
cliDir: cli,
hostDir: host,
dependencyDir: dependencies,
release: release(),
})
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(realpathSync(cli))
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))).toBe(realpathSync(host))
expect(realpathSync(join(project, 'node_modules', 'plain-dependency')))
.toBe(realpathSync(join(dependencies, 'plain-dependency')))
expect(realpathSync(join(project, 'node_modules', '@scope', 'dependency')))
.toBe(realpathSync(join(dependencies, '@scope', 'dependency')))
const manifest = JSON.parse(readFileSync(join(project, 'package.json'), 'utf8')) as {
dependencies: Record<string, string>
}
expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3')
expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3')
})
it('rejects a CLI package from another release', () => {
const root = temporaryRoot()
const cli = join(root, 'apps', 'cli')
const host = join(root, 'apps', 'desktop-host')
const dependencies = join(root, 'workspace-dependencies')
mkdirSync(join(cli, 'lib'), { recursive: true })
mkdirSync(join(host, 'lib'), { recursive: true })
mkdirSync(dependencies, { recursive: true })
writeFileSync(join(cli, 'package.json'), '{"name":"@deepseek-ai/dsh","version":"2.0.0"}\n')
writeFileSync(join(host, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","version":"1.2.3"}\n')
writeFileSync(join(host, 'lib', 'index.js'), '')
expect(() => prepareDevelopmentProject({
projectDir: join(root, 'development'),
cliDir: cli,
hostDir: host,
dependencyDir: dependencies,
release: release(),
})).toThrow(/must be @deepseek-ai\/dsh@1\.2\.3/u)
})
})
+216
View File
@@ -0,0 +1,216 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { DesktopHostProcess } from '../src/host-process.ts'
const roots: string[] = []
const HOST_WIRE = `
import { closeSync, createReadStream, createWriteStream } from 'node:fs'
const requestPipe = createReadStream('', { fd: 3, autoClose: false })
const responsePipe = createWriteStream('', { fd: 4, autoClose: false })
const MAGIC = 0x44534833
const HEADER = 13
function responseFrame(type, streamId, payload = Buffer.alloc(0)) {
const frame = Buffer.allocUnsafe(HEADER + payload.length)
frame.writeUInt32BE(MAGIC, 0)
frame.writeUInt8(type, 4)
frame.writeUInt32BE(streamId, 5)
frame.writeUInt32BE(payload.length, 9)
payload.copy(frame, HEADER)
return frame
}
function responseStart(streamId, options = {}) {
const value = { status: options.status ?? 200, headers: options.headers ?? [], hasBody: options.hasBody ?? true }
responsePipe.write(responseFrame(1, streamId, Buffer.from(JSON.stringify(value))))
}
function responseData(streamId, data) {
responsePipe.write(responseFrame(2, streamId, Buffer.from(data)))
}
function responseEnd(streamId) { responsePipe.write(responseFrame(3, streamId)) }
function responseError(streamId, message) {
responsePipe.write(responseFrame(4, streamId, Buffer.from(JSON.stringify({ message }))))
}
let requestBuffer = Buffer.alloc(0)
requestPipe.on('data', chunk => {
requestBuffer = requestBuffer.length === 0 ? chunk : Buffer.concat([requestBuffer, chunk])
while (requestBuffer.length >= HEADER) {
if (requestBuffer.readUInt32BE(0) !== MAGIC) throw new Error('invalid request marker')
const type = requestBuffer.readUInt8(4)
const streamId = requestBuffer.readUInt32BE(5)
const length = requestBuffer.readUInt32BE(9)
if (requestBuffer.length < HEADER + length) return
const payload = requestBuffer.subarray(HEADER, HEADER + length)
requestBuffer = requestBuffer.subarray(HEADER + length)
onRequestFrame({ type, streamId, payload })
}
})
process.on('message', message => {
if (message.type === 'shutdown') {
requestPipe.destroy()
closeSync(3)
responsePipe.end(() => {
responsePipe.destroy()
closeSync(4)
process.disconnect()
process.exitCode = 0
})
}
})
`
function projectWithHost(source: string): string {
const project = mkdtempSync(join(tmpdir(), 'dsh-desktop-host-test-'))
roots.push(project)
const packageRoot = join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host')
mkdirSync(join(packageRoot, 'lib'), { recursive: true })
writeFileSync(join(packageRoot, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","type":"module"}\n')
writeFileSync(join(packageRoot, 'lib', 'index.js'), `${HOST_WIRE}\n${source}`)
return project
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('desktop host process', () => {
it('carries raw request and response bytes and shuts the child down cleanly', async () => {
const project = projectWithHost(`
const bodies = new Map()
process.send({ type: 'ready', protocolVersion: 3, dshVersion: process.env.NODE_OPTIONS ?? 'clean' })
function onRequestFrame(frame) {
if (frame.type === 1) {
const request = JSON.parse(frame.payload)
bodies.set(frame.streamId, Buffer.alloc(0))
if (!request.hasBody) answer(frame.streamId)
} else if (frame.type === 2) {
bodies.set(frame.streamId, Buffer.concat([bodies.get(frame.streamId), frame.payload]))
} else if (frame.type === 3) {
answer(frame.streamId)
}
}
function answer(streamId) {
responseStart(streamId, { headers: [['content-type', 'text/plain']] })
responseData(streamId, Buffer.concat([Buffer.from('desktop:'), bodies.get(streamId)]))
responseEnd(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)
try {
await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' })
const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' }))
expect(response.status).toBe(200)
await expect(response.text()).resolves.toBe('desktop:request')
await expect(host.stop()).resolves.toBeUndefined()
} finally {
if (previous === undefined) delete process.env.NODE_OPTIONS
else process.env.NODE_OPTIONS = previous
await host.stop().catch(() => undefined)
}
})
it('streams a large binary response in bounded raw frames', async () => {
const size = 2 * 1024 * 1024
const project = projectWithHost(`
process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'large-response' })
function onRequestFrame(frame) {
if (frame.type !== 1) return
responseStart(frame.streamId)
const bytes = Buffer.alloc(${String(64 * 1024)}, 97)
for (let offset = 0; offset < ${String(size)}; offset += bytes.length) responseData(frame.streamId, bytes)
responseEnd(frame.streamId)
}
`)
const host = new DesktopHostProcess(process.execPath, project)
try {
const response = await host.fetch(new Request('dsh-app://app/large'))
const body = new Uint8Array(await response.arrayBuffer())
expect(body).toHaveLength(size)
expect(body[0]).toBe(97)
expect(body.at(-1)).toBe(97)
} finally {
await host.stop().catch(() => undefined)
}
})
it('stops an unfinished upload when the Host completes its response early', async () => {
const project = projectWithHost(`
process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'early-response' })
function onRequestFrame(frame) {
if (frame.type !== 2) return
responseStart(frame.streamId)
responseData(frame.streamId, 'accepted')
responseEnd(frame.streamId)
}
`)
let canceled = false
const body = new ReadableStream<Uint8Array>({
start(controller) { controller.enqueue(Buffer.from('first')) },
cancel() { canceled = true },
})
const host = new DesktopHostProcess(process.execPath, project)
try {
const request = new Request('dsh-app://app/early', {
method: 'POST',
body,
duplex: 'half',
} as RequestInit & { duplex: 'half' })
const response = await host.fetch(request)
await expect(response.text()).resolves.toBe('accepted')
await expect.poll(() => canceled).toBe(true)
} finally {
await host.stop().catch(() => undefined)
}
})
it('ignores a response end that arrives after the renderer cancels its stream', async () => {
const project = projectWithHost(`
process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'cancel-race' })
const urls = new Map()
function onRequestFrame(frame) {
if (frame.type === 1) {
const request = JSON.parse(frame.payload)
urls.set(frame.streamId, request.url)
responseStart(frame.streamId)
if (request.url.endsWith('/after')) {
responseData(frame.streamId, 'alive')
responseEnd(frame.streamId)
}
} else if (frame.type === 4 && urls.get(frame.streamId).endsWith('/cancel')) {
responseEnd(frame.streamId)
}
}
`)
const host = new DesktopHostProcess(process.execPath, project)
try {
const canceled = await host.fetch(new Request('dsh-app://app/cancel'))
await canceled.body?.cancel()
await new Promise(resolve => setTimeout(resolve, 25))
const after = await host.fetch(new Request('dsh-app://app/after'))
await expect(after.text()).resolves.toBe('alive')
} finally {
await host.stop().catch(() => undefined)
}
})
it('rejects invalid response framing and a clean exit before readiness', async () => {
const invalid = new DesktopHostProcess(process.execPath, projectWithHost(`
process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' })
function onRequestFrame(frame) {
if (frame.type === 1) responsePipe.write(Buffer.alloc(13))
}
`))
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)
const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(`
function onRequestFrame() {}
process.exit(0)
`))
await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
DesktopHostRequestDecoder,
encodeDesktopResponseData,
encodeDesktopResponseEnd,
encodeDesktopResponseError,
encodeDesktopResponseStart,
} from '../../desktop-host/src/wire.ts'
import {
DesktopHostResponseDecoder,
encodeDesktopRequestCancel,
encodeDesktopRequestData,
encodeDesktopRequestEnd,
encodeDesktopRequestStart,
} from '../src/host-protocol.ts'
function decodeInPieces<T>(bytes: Buffer, push: (chunk: Buffer) => readonly T[]): T[] {
const values: T[] = []
for (let offset = 0; offset < bytes.byteLength; offset += 7) {
values.push(...push(bytes.subarray(offset, offset + 7)))
}
return values
}
describe('desktop Host pipe protocol', () => {
it('keeps Electron request frames compatible with the installed Host decoder', () => {
const decoder = new DesktopHostRequestDecoder()
const bytes = Buffer.concat([
encodeDesktopRequestStart(7, {
url: 'dsh-app://app/api/session',
method: 'POST',
headers: [['content-type', 'application/json']],
hasBody: true,
}),
encodeDesktopRequestData(7, Buffer.from('{"ok":true}')),
encodeDesktopRequestEnd(7),
encodeDesktopRequestCancel(7),
])
expect(decodeInPieces(bytes, chunk => decoder.push(chunk))).toEqual([
{
type: 'start',
streamId: 7,
url: 'dsh-app://app/api/session',
method: 'POST',
headers: [['content-type', 'application/json']],
hasBody: true,
},
{ type: 'data', streamId: 7, data: Buffer.from('{"ok":true}') },
{ type: 'end', streamId: 7 },
{ type: 'cancel', streamId: 7 },
])
expect(() => { decoder.finish() }).not.toThrow()
})
it('keeps Host response frames compatible with the Electron decoder', () => {
const decoder = new DesktopHostResponseDecoder()
const bytes = Buffer.concat([
encodeDesktopResponseStart(9, {
status: 201,
headers: [['content-type', 'application/octet-stream']],
hasBody: true,
}),
encodeDesktopResponseData(9, Buffer.from([0, 1, 2, 255])),
encodeDesktopResponseEnd(9),
encodeDesktopResponseError(10, 'failed'),
])
expect(decodeInPieces(bytes, chunk => decoder.push(chunk))).toEqual([
{
type: 'start',
streamId: 9,
status: 201,
headers: [['content-type', 'application/octet-stream']],
hasBody: true,
},
{ type: 'data', streamId: 9, data: Buffer.from([0, 1, 2, 255]) },
{ type: 'end', streamId: 9 },
{ type: 'error', streamId: 10, message: 'failed' },
])
expect(() => { decoder.finish() }).not.toThrow()
})
it('rejects a corrupt marker and truncated EOF on both directions', () => {
const request = new DesktopHostRequestDecoder()
const response = new DesktopHostResponseDecoder()
expect(() => request.push(Buffer.alloc(13))).toThrow(/request frame marker/u)
expect(() => response.push(Buffer.alloc(13))).toThrow(/response frame marker/u)
const partialRequest = new DesktopHostRequestDecoder()
partialRequest.push(encodeDesktopRequestEnd(1).subarray(0, 5))
expect(() => { partialRequest.finish() }).toThrow(/ended inside a frame/u)
const partialResponse = new DesktopHostResponseDecoder()
partialResponse.push(encodeDesktopResponseEnd(1).subarray(0, 5))
expect(() => { partialResponse.finish() }).toThrow(/ended inside a frame/u)
})
})
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts'
describe('desktop locale dictionaries', () => {
it('ships the same key set in English and Chinese', () => {
expect(Object.keys(zh)).toEqual(Object.keys(en))
expect(resolveDesktopLocale('zh-Hans-CN')).toEqual({ id: 'zh-CN', messages: zh })
expect(resolveDesktopLocale('en-US')).toEqual({ id: 'en', messages: en })
expect(resolveDesktopLocale('fr-FR')).toEqual({ id: 'en', messages: en })
})
it('formats named values without consuming unknown placeholders', () => {
expect(formatDesktopMessage('{name}@{version} {missing}', { name: 'plugin', version: '1.2.3' }))
.toBe('[email protected] {missing}')
})
it('keeps visible plugin-manager HTML copy in the locale dictionaries', () => {
const html = readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8')
const staticText = [...html.matchAll(/>([^<]*\p{L}[^<]*)</gu)].map(match => match[1]?.trim())
expect(staticText).toEqual([])
})
})
+245
View File
@@ -0,0 +1,245 @@
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)
})
})
+171
View File
@@ -0,0 +1,171 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import type { NotarizeOptions } from '@electron/notarize'
import {
resolveDesktopAppId,
resolveMacOSNotarizationEnvironment,
resolveMacOSSigningEnvironment,
} from '../scripts/desktop-release-environment.mjs'
import { notarizeMacOSDiskImageArtifact } from '../scripts/notarize-macos-disk-images.mjs'
import {
assertMacOSSeedSignatureDetails,
assertMacOSSignatureDetails,
} from '../scripts/verify-macos-signature.mjs'
const RELEASE_ENVIRONMENT = {
DSH_DESKTOP_APP_ID: 'com.example.desktop',
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
DSH_DESKTOP_TARGET_ARCH: 'arm64',
DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234',
APPLE_API_KEY: '/private/credentials/AuthKey_TEST123456.p8',
APPLE_API_KEY_ID: 'TEST123456',
APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555',
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com',
}
function portablePath(value: string): string {
return value.replaceAll('\\', '/')
}
describe('desktop macOS release signature', () => {
beforeAll(() => {
for (const [name, value] of Object.entries(RELEASE_ENVIRONMENT)) vi.stubEnv(name, value)
})
afterAll(() => {
vi.unstubAllEnvs()
})
it('loads release identifiers from the environment and requires code signing', async () => {
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[0]?.to).toBe('runtime')
expect(config.extraResources[1]?.to).toBe('seed')
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(config).toMatchObject({
appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
mac: {
identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY,
forceCodeSigning: true,
notarize: true,
},
dmg: {
sign: true,
writeUpdateInfo: false,
},
publish: [{
provider: 'generic',
url: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/',
}],
})
expect(typeof config.artifactBuildCompleted).toBe('function')
})
it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
expect(() => createElectronBuilderConfig({
DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID,
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
}, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
})
it('accepts the configured authority and team', () => {
const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT)
expect(() => {
assertMacOSSignatureDetails([
`Authority=Developer ID Application: ${expected.signingIdentity}`,
`TeamIdentifier=${expected.teamId}`,
].join('\n'), expected)
}).not.toThrow()
})
it('requires a secure timestamp and hardened runtime for seed code', () => {
const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT)
const details = [
`Authority=Developer ID Application: ${expected.signingIdentity}`,
`TeamIdentifier=${expected.teamId}`,
'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(() => {
assertMacOSSeedSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected)
}).toThrow(/secure timestamp/u)
expect(() => {
assertMacOSSeedSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected)
}).toThrow(/hardened runtime/u)
})
it('rejects another developer identity', () => {
const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT)
expect(() => {
assertMacOSSignatureDetails([
'Authority=Developer ID Application: Other Company (OTHERID123)',
'TeamIdentifier=OTHERID123',
].join('\n'), expected)
}).toThrow(/release identity/u)
})
it('rejects an unexpected team even when the authority is present', () => {
const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT)
expect(() => {
assertMacOSSignatureDetails([
`Authority=Developer ID Application: ${expected.signingIdentity}`,
'TeamIdentifier=OTHERID123',
].join('\n'), expected)
}).toThrow(`TeamIdentifier=${expected.teamId}`)
})
it('rejects missing and malformed release identifiers', () => {
expect(() => resolveDesktopAppId({})).toThrow(/DSH_DESKTOP_APP_ID/u)
expect(() => resolveDesktopAppId({ DSH_DESKTOP_APP_ID: 'not-a-bundle-id' })).toThrow(/reverse-DNS/u)
expect(() => resolveMacOSSigningEnvironment({})).toThrow(/DSH_DESKTOP_MACOS_SIGNING_IDENTITY/u)
expect(() => resolveMacOSSigningEnvironment({
DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Developer ID Application: Example Company (TEAMID1234)',
DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234',
})).toThrow(/must omit/u)
expect(() => resolveMacOSSigningEnvironment({
DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
DSH_DESKTOP_MACOS_TEAM_ID: 'short',
})).toThrow(/10 uppercase/u)
})
it('requires one complete notarization credential strategy', () => {
expect(resolveMacOSNotarizationEnvironment(RELEASE_ENVIRONMENT)).toEqual({
appleApiKey: RELEASE_ENVIRONMENT.APPLE_API_KEY,
appleApiKeyId: RELEASE_ENVIRONMENT.APPLE_API_KEY_ID,
appleApiIssuer: RELEASE_ENVIRONMENT.APPLE_API_ISSUER,
})
expect(resolveMacOSNotarizationEnvironment({
APPLE_KEYCHAIN_PROFILE: 'dsh-notary',
})).toEqual({ keychainProfile: 'dsh-notary' })
expect(() => resolveMacOSNotarizationEnvironment({})).toThrow(/macOS packaging requires/u)
expect(() => resolveMacOSNotarizationEnvironment({ APPLE_API_KEY: '/tmp/key.p8' })).toThrow(/APPLE_API_KEY_ID/u)
})
it('notarizes and qualifies a DMG before electron-builder publishes it', async () => {
const submitted: string[] = []
const submit = vi.fn(async (options: NotarizeOptions) => { submitted.push(options.appPath) })
const verified: string[] = []
const verify = vi.fn((path: string) => { verified.push(path) })
await notarizeMacOSDiskImageArtifact(
{ file: '/tmp/release.dmg' },
RELEASE_ENVIRONMENT,
resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT),
submit,
verify,
)
await notarizeMacOSDiskImageArtifact(
{ file: '/tmp/release.zip' },
RELEASE_ENVIRONMENT,
resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT),
submit,
verify,
)
expect(submitted).toEqual(['/tmp/release.dmg'])
expect(verified).toEqual(['/tmp/release.dmg'])
})
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
desktopElectronBuilderArguments,
parseDesktopPackageInvocation,
resolveDesktopPackageTarget,
withoutDesktopUploadCredentials,
withoutWindowsSigningEnvironment,
} from '../scripts/package-target.ts'
describe('desktop package target', () => {
it('selects matching runtime and electron-builder architectures', () => {
expect(resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')).toMatchObject({
platform: 'darwin', arch: 'arm64', builderPlatform: '--mac', builderArch: '--arm64',
})
expect(resolveDesktopPackageTarget('mac-x64', 'darwin', 'x64')).toMatchObject({
platform: 'darwin', arch: 'x64', builderPlatform: '--mac', builderArch: '--x64',
})
expect(resolveDesktopPackageTarget('win-x64', 'win32', 'x64')).toMatchObject({
platform: 'win32', arch: 'x64', builderPlatform: '--win', builderArch: '--x64',
})
})
it('allows an Apple Silicon host to build the Intel target through Rosetta', () => {
expect(resolveDesktopPackageTarget('mac-x64', 'darwin', 'arm64').arch).toBe('x64')
})
it('rejects unsupported targets and hosts before building', () => {
expect(() => resolveDesktopPackageTarget('linux-x64', 'linux', 'x64')).toThrow(/unsupported target/u)
expect(() => resolveDesktopPackageTarget('win-x64', 'darwin', 'arm64')).toThrow(/Windows x64/u)
expect(() => resolveDesktopPackageTarget('mac-arm64', 'darwin', 'x64')).toThrow(/Apple Silicon/u)
expect(() => resolveDesktopPackageTarget('mac-arm64', 'linux', 'arm64')).toThrow(/macOS/u)
expect(() => resolveDesktopPackageTarget('mac-x64', 'darwin', 'ppc64')).toThrow(/Rosetta/u)
})
it('parses installer and unpacked-directory invocations', () => {
expect(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64').directory).toBe(false)
expect(parseDesktopPackageInvocation(['mac-arm64', '--dir'], 'darwin', 'arm64').directory).toBe(true)
expect(parseDesktopPackageInvocation([], 'darwin', 'arm64').target.name).toBe('mac-arm64')
expect(parseDesktopPackageInvocation(['--prepare-only'], 'darwin', 'arm64').prepareOnly).toBe(true)
expect(() => parseDesktopPackageInvocation(['mac-arm64', 'mac-x64'], 'darwin', 'arm64'))
.toThrow(/at most one target/u)
})
it('keeps electron-builder publishing disabled for the separate validated upload', () => {
const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')
expect(desktopElectronBuilderArguments(target, false)).toEqual([
'exec',
'electron-builder',
'--config',
'electron-builder.config.mjs',
'--mac',
'--arm64',
'--publish',
'never',
])
expect(desktopElectronBuilderArguments(target, true)).toContain('--dir')
})
it('keeps Windows signing fields out of build and seed preparation subprocesses', () => {
expect(withoutWindowsSigningEnvironment({
DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer',
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'container',
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe',
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
})).toEqual({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' })
})
it('keeps COS credentials out of every packaging subprocess', () => {
expect(withoutDesktopUploadCredentials({
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com',
DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket',
DOWNLOAD_TEST_COS_SECRET_ID: 'test-id',
DOWNLOAD_TEST_COS_SECRET_KEY: 'test-key',
DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket',
DOWNLOAD_PROD_COS_SECRET_ID: 'production-id',
DOWNLOAD_PROD_COS_SECRET_KEY: 'production-key',
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
})).toEqual({
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com',
DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket',
DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket',
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
})
})
})
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
assertDesktopHostPackageFiles,
selectDesktopPackageClosure,
type PackedDesktopPackage,
} from '../scripts/prepare-package-set.ts'
function packed(name: string, manifest: Record<string, unknown> = {}): PackedDesktopPackage {
return { tarball: `${name}.tgz`, manifest: { name, version: '1.0.0', ...manifest } }
}
describe('desktop package-set selection', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('does not select a packaging target when imported as a library', async () => {
vi.stubEnv('DSH_DESKTOP_TARGET_PLATFORM', 'linux')
vi.stubEnv('DSH_DESKTOP_TARGET_ARCH', 'x64')
vi.resetModules()
await expect(import('../scripts/prepare-package-set.ts')).resolves.toHaveProperty('prepareDesktopPackageSet')
})
it('includes only the available internal production closure', () => {
const available = new Map<string, PackedDesktopPackage>([
['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', {
dependencies: { '@deepseek-ai/dsh-base': '^1.0.0', external: '^2.0.0' },
optionalDependencies: { '@deepseek-ai/platform-package': '1.0.0', '@deepseek-ai/missing-platform': '1.0.0' },
})],
['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', {
dependencies: { '@deepseek-ai/dsh': '^1.0.0' },
})],
['@deepseek-ai/dsh-base', packed('@deepseek-ai/dsh-base', {
peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' },
})],
['@deepseek-ai/cordis', packed('@deepseek-ai/cordis')],
['@deepseek-ai/platform-package', packed('@deepseek-ai/platform-package')],
['@deepseek-ai/unused', packed('@deepseek-ai/unused')],
])
expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([
'@deepseek-ai/cordis',
'@deepseek-ai/dsh',
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-desktop-host',
'@deepseek-ai/platform-package',
])
})
it('rejects a required internal package absent from the packed release inputs', () => {
const available = new Map<string, PackedDesktopPackage>([
['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', {
dependencies: { '@deepseek-ai/dsh-base': '^1.0.0' },
})],
['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', {
dependencies: { '@deepseek-ai/dsh': '^1.0.0' },
})],
])
expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked internal package/u)
expect(() => selectDesktopPackageClosure(new Map([
['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')],
]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u)
})
it('requires the Desktop Host entry and its packaged overlay', () => {
const files = [
'package/lib/index.js',
'package/config/desktop.cordis.patch.yml',
]
expect(() => {
assertDesktopHostPackageFiles(files)
}).not.toThrow()
expect(() => {
assertDesktopHostPackageFiles(files.slice(0, 1))
}).toThrow(/desktop\.cordis\.patch\.yml/u)
expect(() => {
assertDesktopHostPackageFiles(files.slice(1))
}).toThrow(/lib\/index\.js/u)
})
})
+392
View File
@@ -0,0 +1,392 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, relative, sep } 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'
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'
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'), '')
}
}
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,
}
}
function release(version = '1.0.0'): DesktopRelease {
return {
schemaVersion: 1,
version,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
}
}
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', () => {
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
}
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)
expect(manager.listPlugins()).toEqual([])
expect(manager.dshVersion()).toBe('1.0.0')
expect(starts).toBe(2)
})
it('restores rollback when the active move completed before its journal update', async () => {
const root = temporaryRoot()
const seed = join(root, 'seed')
createTestSeedMetadata(seed, release())
writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
archiveStore(seed)
writeIntegrity(seed)
const paths = resolveDesktopPaths(join(root, '.dsh'))
const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) })
await manager.applyRelease(seed, '1.0.0', hooks())
await manager.mutate({ type: 'plugin-add', spec: '@scope/[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(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)
})
})
+167
View File
@@ -0,0 +1,167 @@
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)
})
})
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest'
import { claimDesktopSingleInstance, type DesktopSingleInstanceApplication } from '../src/single-instance.ts'
describe('desktop single-instance ownership', () => {
it('quits a second process without registering lifecycle work', () => {
const quit = vi.fn()
const on = vi.fn()
const application = {
requestSingleInstanceLock: () => false,
quit,
on,
} satisfies DesktopSingleInstanceApplication
expect(claimDesktopSingleInstance(application, vi.fn())).toBe(false)
expect(quit).toHaveBeenCalledOnce()
expect(on).not.toHaveBeenCalled()
})
it('routes a later launch to the primary process', () => {
let secondInstance: (() => void) | undefined
const focus = vi.fn()
const application = {
requestSingleInstanceLock: () => true,
quit: vi.fn(),
on: vi.fn((_event: 'second-instance', listener: () => void) => { secondInstance = listener }),
} satisfies DesktopSingleInstanceApplication
expect(claimDesktopSingleInstance(application, focus)).toBe(true)
secondInstance?.()
expect(focus).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppUpdater } from 'electron-updater'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { parseDesktopRelease } from '../src/release.ts'
import type { DesktopUpdateState } from '../src/ipc.ts'
vi.mock('electron', () => ({ app: { isPackaged: false } }))
vi.mock('electron-updater', () => ({
default: { autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true } },
}))
const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts')
describe('desktop release metadata', () => {
it('accepts one exact release identity for Electron and dsh', () => {
expect(parseDesktopRelease({
schemaVersion: 1,
version: '1.2.3',
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
})).toEqual({
schemaVersion: 1,
version: '1.2.3',
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
})
})
it('rejects invalid versions and unsupported host protocols', () => {
const base = {
schemaVersion: 1,
version: '1.2.3',
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
nodeVersion: '24.17.0',
pnpmVersion: '11.7.0',
}
expect(() => parseDesktopRelease({ ...base, version: 'latest' })).toThrow(/invalid desktop release metadata/u)
expect(() => parseDesktopRelease({ ...base, hostProtocolVersion: 999 })).toThrow(/invalid desktop release metadata/u)
})
})
describe('desktop update coordinator', () => {
it('installs one Electron release and restarts after download', async () => {
const states: DesktopUpdateState[] = []
const downloadUpdate = vi.fn(async () => [])
const quitAndInstall = vi.fn()
const beforeRestart = vi.fn(async () => {})
const updater = {
autoDownload: true,
autoInstallOnAppQuit: true,
checkForUpdates: vi.fn(async () => ({
isUpdateAvailable: true,
updateInfo: { version: '1.1.0' },
})),
downloadUpdate,
quitAndInstall,
} as unknown as AppUpdater
const coordinator = new DesktopUpdateCoordinator(
(state) => {
states.push(state)
return state
},
beforeRestart,
updater,
() => true,
)
await expect(coordinator.check()).resolves.toEqual({ phase: 'available', version: '1.1.0' })
await expect(coordinator.install()).resolves.toEqual({ phase: 'ready', version: '1.1.0' })
expect(downloadUpdate).toHaveBeenCalledOnce()
expect(beforeRestart).toHaveBeenCalledOnce()
expect(quitAndInstall).toHaveBeenCalledWith(false, true)
expect(states.map(state => state.phase)).toEqual(['checking', 'available', 'installing', 'ready'])
})
it('queues install behind an in-flight check instead of returning the check result', async () => {
const checked = Promise.withResolvers<{
isUpdateAvailable: true
updateInfo: { version: string }
}>()
const downloadUpdate = vi.fn(async () => [])
const updater = {
autoDownload: true,
autoInstallOnAppQuit: true,
checkForUpdates: vi.fn(() => checked.promise),
downloadUpdate,
quitAndInstall: vi.fn(),
} as unknown as AppUpdater
const coordinator = new DesktopUpdateCoordinator(state => state, async () => {}, updater, () => true)
const checking = coordinator.check()
const installing = coordinator.install()
expect(downloadUpdate).not.toHaveBeenCalled()
checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } })
await expect(checking).resolves.toEqual({ phase: 'available', version: '1.2.0' })
await expect(installing).resolves.toEqual({ phase: 'ready', version: '1.2.0' })
expect(downloadUpdate).toHaveBeenCalledOnce()
})
})
+226
View File
@@ -0,0 +1,226 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
buildWindowsSigningEnvironment,
createRedactedWindowsSigningError,
createWindowsTokenSigner,
installWindowsNsisBootstrapSigner,
repairDanglingAuthenticodeDirectory,
scrubWindowsSigningEnvironment,
} from '../scripts/windows-sign.mjs'
vi.mock('node:crypto', () => ({
X509Certificate: class {
readonly ca = false
readonly keyUsage = ['1.3.6.1.5.5.7.3.3']
constructor(contents: Buffer) {
if (contents.toString('utf8') !== 'code-signing-certificate-fixture') {
throw new Error('invalid test certificate')
}
}
},
}))
const CERTIFICATE_FILE = 'C:\\release\\server.cer'
const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd')
describe('Windows token signing', () => {
it('passes only the validated BAT fields to the signing command interpreter', () => {
expect(buildWindowsSigningEnvironment({
SystemRoot: 'C:\\Windows',
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'inherited-token-secret',
DEEPSEEK_API_KEY: 'api-secret',
BUILD_PASSWORD: 'build-secret',
}, {
certificateFile: CERTIFICATE_FILE,
signTool: 'C:\\tools\\signtool.exe',
path: 'C:\\release\\DeepSeek Harness.exe',
isNest: false,
tokenPin: 'token-secret!',
keyContainer: 'te-container',
})).toEqual({
SystemRoot: 'C:\\Windows',
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe',
DSH_DESKTOP_WINDOWS_CER_FILE: CERTIFICATE_FILE,
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret!',
DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'te-container',
DSH_DESKTOP_WINDOWS_SIGN_TARGET: 'C:\\release\\DeepSeek Harness.exe',
DSH_DESKTOP_WINDOWS_SIGN_APPEND: '',
})
})
it('requests an appended signature only for an electron-builder nested task', () => {
expect(buildWindowsSigningEnvironment({}, {
certificateFile: CERTIFICATE_FILE,
signTool: 'C:\\tools\\signtool.exe',
path: 'C:\\release\\setup.exe',
isNest: true,
tokenPin: 'token-secret!',
keyContainer: 'te-container',
}).DSH_DESKTOP_WINDOWS_SIGN_APPEND).toBe('1')
})
it('keeps the verified SafeNet command in an ASCII CRLF CMD file', async () => {
const contents = await readFile(SIGN_SCRIPT)
const text = contents.toString('ascii')
expect(contents.every(byte => byte <= 0x7F)).toBe(true)
expect(text).toContain('\r\n')
expect(text.replaceAll('\r\n', '')).not.toContain('\n')
expect(text).toContain('setlocal DisableDelayedExpansion\r\n')
expect(text).toContain('set "DSH_DESKTOP_WINDOWS_CER_FILE="\r\n')
expect(text).toContain('set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="\r\n')
expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"\r\n')
})
it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-tool-'))
const certificateFile = join(directory, 'server.cer')
const signTool = join(directory, 'signtool.exe')
await writeFile(certificateFile, 'code-signing-certificate-fixture')
await writeFile(signTool, 'fixture')
expect(() => createWindowsTokenSigner({
certificateFile: undefined,
signTool,
tokenPin: 'token-secret!',
keyContainer: 'te-container',
})).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u)
expect(() => createWindowsTokenSigner({
certificateFile,
signTool: undefined,
tokenPin: 'token-secret!',
keyContainer: 'te-container',
})).toThrow(/DSH_DESKTOP_WINDOWS_SIGNTOOL/u)
const signer = createWindowsTokenSigner({
certificateFile,
signTool,
tokenPin: 'token-secret!',
keyContainer: 'te-container',
})
try {
expect(() => createWindowsTokenSigner({
certificateFile,
signTool,
tokenPin: 'token-secret!',
})).toThrow(/DSH_DESKTOP_WINDOWS_KEY_CONTAINER/u)
expect(() => createWindowsTokenSigner({
certificateFile,
signTool,
tokenPin: '',
keyContainer: 'te-container',
})).toThrow(/DSH_DESKTOP_WINDOWS_TOKEN_PIN/u)
expect(() => createWindowsTokenSigner({
certificateFile,
signTool,
tokenPin: 'token]secret',
keyContainer: 'te-container',
})).toThrow(/cannot contain/u)
await expect(signer({
path: 'C:\\release\\setup.exe',
hash: 'sha1',
isNest: false,
})).rejects.toThrow(/requires SHA-256/u)
}
finally {
await rm(directory, { recursive: true })
}
})
it('removes inherited credentials and redacts SignTool process failures', () => {
expect(scrubWindowsSigningEnvironment({
SystemRoot: 'C:\\Windows',
DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer',
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe',
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
DEEPSEEK_API_KEY: 'api-secret',
BUILD_PASSWORD: 'build-secret',
})).toEqual({ SystemRoot: 'C:\\Windows' })
const processError = Object.assign(new Error('failed'), {
code: 1,
cmd: 'signtool /kc [{{token-secret}}]=te-container',
stderr: 'provider rejected token-secret',
})
const failure = createRedactedWindowsSigningError(
processError,
'C:\\release\\setup.exe',
['token-secret'],
)
expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected <redacted>')
expect(failure.message).not.toContain('token-secret')
expect(failure).not.toHaveProperty('cause')
expect(failure).not.toHaveProperty('cmd')
})
it('signs the temporary NSIS executable before enterprise policy evaluates it', async () => {
const events: string[] = []
let receivedEnvironment: NodeJS.ProcessEnv | undefined
class FakeWineVmManager {
async exec(
file: string,
_args: string[],
options?: { env?: NodeJS.ProcessEnv },
): Promise<string> {
events.push(`exec:${file}`)
receivedEnvironment = options?.env
return 'executed'
}
}
installWindowsNsisBootstrapSigner({
sign: async (configuration) => {
events.push(`sign:${configuration.path}:${configuration.hash}:${String(configuration.isNest)}`)
},
wineVmManager: FakeWineVmManager,
platform: 'win32',
environment: {
SystemRoot: 'C:\\Windows',
DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret',
},
})
const result = await new FakeWineVmManager().exec('C:\\release\\setup.exe', [], {
env: {
__COMPAT_LAYER: 'RunAsInvoker',
BUILD_PASSWORD: 'build-secret',
},
})
expect(result).toBe('executed')
expect(events).toEqual([
'sign:C:\\release\\setup.exe:sha256:false',
'exec:C:\\release\\setup.exe',
])
expect(receivedEnvironment).toEqual({
SystemRoot: 'C:\\Windows',
__COMPAT_LAYER: 'RunAsInvoker',
})
})
it('clears a certificate table inherited beyond the generated uninstaller', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-'))
const path = join(directory, 'uninstaller.exe')
const executable = Buffer.alloc(512)
const peOffset = 216
const optionalHeaderOffset = peOffset + 24
const certificateDirectoryOffset = optionalHeaderOffset + 96 + (4 * 8)
executable.write('MZ', 0, 'ascii')
executable.writeUInt32LE(peOffset, 60)
executable.write('PE\0\0', peOffset, 'ascii')
executable.writeUInt16LE(0x10B, optionalHeaderOffset)
executable.writeUInt32LE(600, certificateDirectoryOffset)
executable.writeUInt32LE(100, certificateDirectoryOffset + 4)
await writeFile(path, executable)
try {
await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(true)
const repaired = await readFile(path)
expect(repaired.readUInt32LE(certificateDirectoryOffset)).toBe(0)
expect(repaired.readUInt32LE(certificateDirectoryOffset + 4)).toBe(0)
await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(false)
}
finally {
await rm(directory, { recursive: true })
}
})
})
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../packages/util/home-paths" }
]
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'tsdown'
export default defineConfig([
{
entry: ['lib/types/main.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
deps: { neverBundle: ['electron'] },
},
{
// Sandboxed Electron preloads run as CommonJS even though the application package is ESM.
entry: {
preload: 'lib/types/preload.js',
'preload-app': 'lib/types/preload-app.js',
},
outDir: 'lib',
format: ['cjs'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
deps: { neverBundle: ['electron'] },
},
])
@@ -10,7 +10,6 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts'
@@ -82,7 +81,6 @@ async function measure(root: string, scenario: string): Promise<ContinuationRepo
const adapter = new SyntheticAdapter(toolHeavy ? WORKLOAD.toolsPerLiveTurn : 0)
let toolCalls = 0
try {
await ctx.plugin(SessionProjectionRegistry)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
await ctx.plugin(AgentLoop, { agents: [] })
@@ -159,10 +159,12 @@ class SessionBenchmarkHost {
static async create(root: string, scenario: SessionOpenBenchmarkScenario): Promise<SessionBenchmarkHost> {
const ctx = new Context()
await ctx.plugin(SessionProjectionRegistry)
const agentScenario = scenario === 'agent-resume'
if (agentScenario) await mountAgentLoopTestDependencies(ctx)
else await ctx.plugin(SessionStore)
else {
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionStore)
}
await installProjectionSet(ctx, agentScenario)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
let history: SessionHistoryController | undefined
+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: fcb9c1e59b60dc059ab66b64ac26acd3c9157c96
architecture.zh.md: d7a0a3833ebf9397837967065249d7fe1650d707
architecture.md: a77dfd06c61cbb12d7008133a7e8d515a3449a02
architecture.zh.md: 83fa958609500cc85af9c0d1f7e339104f6c18c7
+6
View File
@@ -46,6 +46,12 @@ Vendored CLIs, build-only and test-only executables, direct in-process plugin mo
The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime-<platform>-<arch>`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser.
## 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.
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`.
## Core packages
Here are some core packages that contribute to the Cordis tree.
+6
View File
@@ -46,6 +46,12 @@ Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂
Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime-<platform>-<arch>`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。
## 桌面应用
[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 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包从保留 profile 加载已安装的 dsh 后端与匹配的客户端图。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。
## 核心包
以下是向 Cordis 树贡献内容的部分核心包。
+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/config-catalog.md
config-catalog.md: 90ec60c30c5ef2ef5fcf75e18ab3d2b51f9cac60
config-catalog.zh.md: e5c3134561393afe3267808ee04a4cf46399a0f9
config-catalog.md: 32644b4782cc424835f9e303fff5f30ce95cd14f
config-catalog.zh.md: 24dc4a3cb7acc1b793d25e5ac3cafc1b5e86e14a
+2 -2
View File
@@ -315,7 +315,7 @@ Source: [`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-s
## `@deepseek-ai/dsh-client-connection`
Requires: `webServer` · `credentials`
Requires: `credentials`
```ts config-catalog
/** Browser authentication, request limits, and connection recovery configuration. */
@@ -3397,7 +3397,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
- `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts))
- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — requires `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
- `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts))
- `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts))
+2 -2
View File
@@ -317,7 +317,7 @@ export type Config = LocalConfig
## `@deepseek-ai/dsh-client-connection`
需要: `webServer` · `credentials`
需要:`credentials`
```ts config-catalog
/** Browser authentication, request limits, and connection recovery configuration. */
@@ -3399,7 +3399,7 @@ export interface Config {
- `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
- `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts))
- `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — 需要 `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
- `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts))
- `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts))
+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/event-producer-consumer.md
event-producer-consumer.md: c352fe57795ee3c0c8f8a4adef0b06ff109dec76
event-producer-consumer.zh.md: 5a091d09ca9ba8ea9ee3274096b1cca181768cfe
event-producer-consumer.md: edf5ea33526afb46e941314852dea653105fb748
event-producer-consumer.zh.md: f7d96a94eef611817e9a9c0c2dcc93d62ea3a178
+13 -13
View File
@@ -9,19 +9,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+17 -17
View File
@@ -11,19 +11,19 @@
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
@@ -56,10 +56,10 @@
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
+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/module-graph.md
module-graph.md: 0951780b0bf53b35f75ef13c50a3875138d756cc
module-graph.zh.md: 2a073370bd4de913f3a1293fcbebb9b9e25954f4
module-graph.md: 2c31c50117cc6caabccdcb8fdf6afc9a160fe4dd
module-graph.zh.md: d41df4241163ca9b17e47c62051a891e45e131e0
+10 -7
View File
@@ -457,6 +457,7 @@ flowchart TD
pkg_agent --> pkg_session_projection
pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_typert_protocol
pkg_agent --> pkg_util_values
pkg_fs --> pkg_brand
pkg_fs --> pkg_invariants
pkg_fs --> pkg_llm
@@ -868,11 +869,6 @@ flowchart TD
pkg_tool_terminal --> pkg_system_prompt
pkg_tool_terminal --> pkg_terminal
pkg_tool_terminal --> pkg_tools
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_llm_replay --> pkg_compaction
pkg_llm_replay --> pkg_deepseek_llm_api_extensions
pkg_llm_replay --> pkg_llm
@@ -946,6 +942,13 @@ flowchart TD
pkg_tool_pwsh --> pkg_system_prompt
pkg_tool_pwsh --> pkg_tools
pkg_tool_pwsh --> pkg_user_approval
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_agent_loop
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_session_projection
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_presets
@@ -1290,7 +1293,7 @@ flowchart TD
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1377,7 +1380,6 @@ flowchart TD
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
@@ -1390,6 +1392,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more