diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 072caec450..d2f8d60d2d 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md -2026-07-08-tool-output-spill-files.md: fa6697aad63c97b0ef580fe1ca46e50541e3d682 -2026-07-08-tool-output-spill-files.zh.md: a995eafc7878b342a2164c5116da1f43ada791a5 +2026-07-08-tool-output-spill-files.md: e32aec72f423dff33a29d9012c0f71c9cfbd24fb +2026-07-08-tool-output-spill-files.zh.md: 2941fc516723e72b5d5670e674ce612f3cf581cc diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index fa6697aad6..e32aec72f4 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -66,36 +66,7 @@ interface SpillRef { ### Spill policy -`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob: - -```ts ignore-check -interface Config { - /** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */ - maxInlineBytes?: number -} -``` - -When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results: - -1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first. -2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched. -3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged. -4. If it is larger, call `ctx.spillStore.saveText()` with the full final text. -5. Replace the model-facing result with a retained head/tail preview plus the spill reference. - -The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it. - -The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource: - -```text - - -(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.) -``` - -If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result. - -The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it. +`dsh-spill-policy` retains ordered text/image ends under `maxInlineTokens` after post-execute policy accepts the result. `saveText()` stores the complete result with readable image paths; image bytes remain in attachment storage. The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) owns image projection order, atomic image omission, and route pricing. An omitted budget installs no listeners, recovery failures keep the original result, and model-facing `read` results skip retention to avoid a read/spill loop. ## Showcase: web_fetch @@ -129,7 +100,7 @@ With `dsh-spill-policy` configured, a large formatted fetch result is automatica - id: spill-policy name: '@deepseek-ai/dsh-spill-policy' config: - maxInlineBytes: 50000 + maxInlineTokens: 12500 ``` This separation is important. `web-fetch-http` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise. @@ -186,11 +157,11 @@ The local-backend value proposition depends on the existing `read`/`grep` tools **Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario. -The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work. +The policy can become too large if it starts owning tool-specific semantics. It handles accepted text/image sequences without interpreting tool-specific text. Tool-owned early spill remains future work. ## Alternatives considered -**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape. +**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineTokens` deployment knob is enough to prove the shape. **Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint. diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index a995eafc78..2941fc5167 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -66,36 +66,7 @@ interface SpillRef { ### spill 策略 -`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项: - -```ts ignore-check -interface Config { - /** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */ - maxInlineBytes?: number -} -``` - -省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略: - -1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。 -2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。 -3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。 -4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。 -5. 把模型可见结果替换为保留的首尾预览和 spill 引用。 - -预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二个部署证明有此需求后,未来配置才会公开预览大小。 - -替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源: - -```text - - -(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.) -``` - -如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。spill 失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。 - -策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。 +`dsh-spill-policy` 在执行后策略接受结果之后,按 `maxInlineTokens` 对文字和图片共同计量并保留首尾。完整结果仍通过 `saveText()` 保存,图片本体留在附件存储,结果文件记录可读取路径。图片投影顺序、整图省略和模型计量由[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)负责。省略配置时不安装监听器,存储失败时保留原结果;`read` 的模型可见结果跳过自动省略,避免重复读取产生循环。 ## 示例:web_fetch @@ -129,7 +100,7 @@ ctx.tools.register(defineTool({ - id: spill-policy name: '@deepseek-ai/dsh-spill-policy' config: - maxInlineBytes: 50000 + maxInlineTokens: 12500 ``` 这项分离很重要。`web-fetch-http` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,spill 文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。 @@ -186,11 +157,11 @@ ctx.tools.register(defineTool({ **快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` spill 提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` spill 需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。 -如果策略开始负责工具专用语义,就会膨胀得过大。它的范围保持狭窄:只处理纯文本最终结果。由工具负责的提前 spill 仍留作未来工作。 +如果策略开始负责工具专用语义,就会膨胀得过大。它处理已接受的图文序列,不解释工具专用的文字语义。由工具负责的提前 spill 仍留作未来工作。 ## 考虑过的替代方案 -**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。 +**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineTokens` 部署配置项即可验证该形态。 **把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前 spill 合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 730cfb413c..608fe717d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: a8d016a70792ad4ac6a71083677ce5d82a30af0a -2026-07-23-client-plugin-loading-model.zh.md: 1b94909c9611f01e5a6d7fcb50ec3f91ee88fab1 +2026-07-23-client-plugin-loading-model.md: b8c23108a7a1cf3a04ded373d8705bae626c70c2 +2026-07-23-client-plugin-loading-model.zh.md: efaea843120e5b023bbc8f59663ea47468944c71 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index a8d016a707..b8c23108a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -46,7 +46,7 @@ A `require.async("./client..js")` call requests the exact revisioned `/plu The shared tsdown preset emits a map for every plugin entry and chunk and rewrites first-party source paths into the browser-resolvable repository form `/packages///src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute combo map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??/client.js,/client.js&rev=` points to `/plugins/??/client.js.map,/client.js.map&rev=`. A chunk script likewise points to its own map URL. Source-map files are read and combined only when those map URLs receive `GET`; `HEAD` materializes neither script nor map bodies. The Vite shell also emits source maps, letting shell code and externally loaded plugins map stacks and performance profiles back to TypeScript/TSX. -The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. The shared preset stamps the entry after every package output is written. After the watcher observes that completed-build stamp, `rebuilt(id)` hashes the entry bytes together with its timestamp and publishes the resulting revision; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. +The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial publication and `rebuilt(id)` use the same hash of entry mtime, ctime, and size; artifact contents never enter the hash. Ctime distinguishes rewrites that preserve mtime and size. A Host restart over unchanged artifacts therefore retains every row revision, so the reconnecting SSE snapshot does not trigger browser plugin replacement. The shared preset stamps the entry after every package output is written; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id. ### The loading flow, end to end @@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. -3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. +3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. @@ -80,7 +80,7 @@ The Host SSE adapter forwards existing graph-change notifications and sends the Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row. -How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance forces a new revision even with identical metadata. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime or size delta, or a dirty row, `rebuilt(id)` derives the revision from the entry bytes and completed-build timestamp. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol. +How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids redundant reads when installing the watcher and avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance retries publication even when mtime and size match the baseline. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime, ctime, or size delta, or a dirty row, `rebuilt(id)` derives the revision from those three metadata values. It reads the new bundle snapshot only if the revision changes. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol. On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation: @@ -120,4 +120,4 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi | Full ctx-ification now (React and libraries via services, no module table) | Static UI libraries still expose synchronous values, so removing the table would leave those imports without a shared identity | | Eager instantiation with a frozen table | Runs bundle side effects at script arrival; lazy registration keeps execution at Cordis import while recursive `require` materializes registered requests | | Fetch response text, then inject an inline ` + + +
+ + diff --git a/apps/desktop/resources/icon-windows.png b/apps/desktop/resources/icon-windows.png index 02bc5931d3..cc8a09e311 100644 Binary files a/apps/desktop/resources/icon-windows.png and b/apps/desktop/resources/icon-windows.png differ diff --git a/apps/desktop/resources/icon-windows.svg b/apps/desktop/resources/icon-windows.svg index 4ab25b9e70..eb07a50158 100644 --- a/apps/desktop/resources/icon-windows.svg +++ b/apps/desktop/resources/icon-windows.svg @@ -1,8 +1,8 @@ - - - + + + diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index b26c633d9c..9a8e408ed1 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -36,6 +36,17 @@ export function resolveDesktopBuildTarget( */ export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths +/** + * Return the platform and architecture of the payload one release target prepares. + * Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine. + * @param target - Supported Desktop target name. + * @returns Platform and architecture of the prepared payload. + */ +export function desktopTargetPlatform(target: DesktopAutoUpdateTarget): { + readonly platform: 'darwin' | 'win32' + readonly arch: 'arm64' | 'x64' +} + /** * Resolve the paths owned by the target selected in a packaging environment. * @param env - Packaging environment. @@ -48,3 +59,16 @@ export function resolveDesktopTargetBuildPaths( hostPlatform?: NodeJS.Platform, hostArch?: string, ): DesktopTargetBuildPaths + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * @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 Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 841da2c4ba..4f33e804c3 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -29,15 +29,19 @@ export function resolveDesktopBuildTarget( return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target) } +function assertSupportedTarget(target) { + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`desktop build paths: unsupported target ${String(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, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: 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)}`) - } + assertSupportedTarget(target) const root = join(BUILD_ROOT, 'targets', target) const packed = join(root, 'packed') return { @@ -56,6 +60,20 @@ export function desktopTargetBuildPaths(target) { } } +/** + * Return the platform and architecture of the payload one release target prepares. + * Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. + * @returns {{ platform: 'darwin' | 'win32', arch: 'arm64' | 'x64' }} Platform and architecture of the prepared payload. + */ +export function desktopTargetPlatform(target) { + assertSupportedTarget(target) + return { + platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'), + arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'), + } +} + /** * Resolve the paths owned by the target selected in a packaging environment. * @param {NodeJS.ProcessEnv} env - Packaging environment. @@ -70,3 +88,20 @@ export function resolveDesktopTargetBuildPaths( ) { return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) } + +/** + * Resolve the primary-runtime directory an unpackaged development launch uses. + * The build target fixes Windows to x64, so the shell cannot derive this directory from + * the architecture of the process that launched it. + * @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 {string} Primary-runtime directory prepared for the selected target. + */ +export function developmentRuntimeDirectory( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime') +} diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts index 32ed5a37b2..6bb7a1b045 100644 --- a/apps/desktop/scripts/desktop-build-version-discovery.ts +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -30,8 +30,8 @@ const LISTING_DEADLINE_MS = 8_000 /** Objects one listing page may return. */ const LISTING_PAGE_SIZE = 1000 -/** Artifact name electron-builder writes for one build, on either platform. */ -const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u +/** Artifact name electron-builder writes for one build, on either platform; unsigned Windows builds add a suffix. */ +const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned)?\.(?:exe|dmg|zip)$/u /** Inputs that decide which versions are already taken. */ export interface DesktopBuildVersionSuggestionOptions { diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 71dd9a6655..3addf3ff65 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -4,7 +4,7 @@ import { accessSync, constants, readFileSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseEnv } from 'node:util' -import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs' +import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' @@ -13,7 +13,7 @@ import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu @@ -78,6 +78,7 @@ function requireReadableFile(environment, name) { */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { resolveDesktopAppId(environment) + resolveNpmRegistry(environment) resolveDesktopPolicyEnvironment(environment) if (target.platform === 'darwin') resolveMacOSPackageSettings(environment) else resolveWindowsPackageSettings(environment) diff --git a/apps/desktop/scripts/desktop-release-environment.d.mts b/apps/desktop/scripts/desktop-release-environment.d.mts index ad48933f65..26190ab2b8 100644 --- a/apps/desktop/scripts/desktop-release-environment.d.mts +++ b/apps/desktop/scripts/desktop-release-environment.d.mts @@ -7,6 +7,9 @@ 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' +/** Environment variable that selects the npm registry used for the bundled runtime install. */ +export const NPM_REGISTRY_ENV: 'DSH_DESKTOP_NPM_REGISTRY' + /** Public identity expected on a macOS release. */ export interface MacOSSigningEnvironment { readonly signingIdentity: string @@ -46,6 +49,13 @@ export type MacOSNotarizationEnvironment = */ export function resolveDesktopAppId(env: NodeJS.ProcessEnv): string +/** + * Resolve the npm registry used to materialize the bundled runtime and its external dependencies. + * @param env - Packaging environment. + * @returns Registry origin; the public registry unless a local mirror is configured. + */ +export function resolveNpmRegistry(env: NodeJS.ProcessEnv): string + /** * Resolve and validate the public identity expected on a macOS release. * @param env - Packaging environment. diff --git a/apps/desktop/scripts/desktop-release-environment.mjs b/apps/desktop/scripts/desktop-release-environment.mjs index 46746428d9..635a57a3c9 100644 --- a/apps/desktop/scripts/desktop-release-environment.mjs +++ b/apps/desktop/scripts/desktop-release-environment.mjs @@ -9,6 +9,11 @@ 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' +/** Environment variable that selects the npm registry used for the bundled runtime install. */ +export const NPM_REGISTRY_ENV = 'DSH_DESKTOP_NPM_REGISTRY' + +const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/' + 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' @@ -32,6 +37,24 @@ function requireEnvironmentValue(env, name) { return value } +/** + * Resolve the npm registry used to materialize the bundled runtime and its external dependencies. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {string} Registry origin; the public registry unless a local mirror is configured. + */ +export function resolveNpmRegistry(env) { + const configured = env[NPM_REGISTRY_ENV]?.trim() ?? '' + if (configured === '') return DEFAULT_NPM_REGISTRY + let url + try { url = new URL(configured) } + catch { throw new Error(`desktop release environment: ${NPM_REGISTRY_ENV} must be an HTTPS origin`) } + if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '' + || (url.pathname !== '/' && url.pathname !== '')) { + throw new Error(`desktop release environment: ${NPM_REGISTRY_ENV} must be an HTTPS origin without credentials, path, query, or fragment`) + } + return url.origin +} + /** * Resolve and validate the application identifier shared by every platform target. * @param {NodeJS.ProcessEnv} env - Packaging environment. diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 05e1bdbf0e..fdd724e579 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -7,7 +7,9 @@ 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 { developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { prepareDevelopmentProject } from './development-project.ts' +import { prepareDevelopmentApp } from './development-app.ts' import { preparePrimaryRuntime } from './prepare-primary-runtime.ts' const APP_ROOT = resolve(import.meta.dirname, '..') @@ -66,12 +68,19 @@ async function launchElectron(): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR ?? developmentRuntimeDirectory(), DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), 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)}`) + if (process.platform === 'darwin') { + const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData, + mainPort, rendererPort, hostPort, openDevtools: environment.DSH_DESKTOP_OPEN_DEVTOOLS! }) + await run(executable, [], APP_ROOT, environment) + return + } await run(electron, [ `--inspect=127.0.0.1:${String(mainPort)}`, `--remote-debugging-port=${String(rendererPort)}`, @@ -108,6 +117,7 @@ async function main(): Promise { hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'), dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'), release, + target: resolveDesktopBuildTarget(), }) await preparePrimaryRuntime() await launchElectron() diff --git a/apps/desktop/scripts/development-app.ts b/apps/desktop/scripts/development-app.ts new file mode 100644 index 0000000000..ff09b8b783 --- /dev/null +++ b/apps/desktop/scripts/development-app.ts @@ -0,0 +1,84 @@ +/** macOS development bundle that loads the current workspace through Electron. */ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +/** Workspace and runtime settings captured for Launch Services cold starts. */ +export interface DevelopmentAppOptions { + readonly electron: string + readonly appRoot: string + readonly directory: string + readonly home: string + readonly userData: string + readonly mainPort: number + readonly rendererPort: number + readonly hostPort: number + readonly openDevtools: string +} + +function quote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } + +/** + * Prepare and register a disposable .app without changing the installed Electron package. + * @param options - Current workspace, Electron binary, and private development locations. + * @returns executable used by the supported desktop development launcher. + */ +export function prepareDevelopmentApp(options: DevelopmentAppOptions): string { + const source = dirname(dirname(dirname(options.electron))) + const bundle = join(options.directory, 'Harness Dev.app') + const executable = join(bundle, 'Contents', 'MacOS', 'HarnessDev') + const stamp = join(bundle, 'Contents', 'Resources', 'dsh-development.json') + const launcher = developmentLauncher(options, bundle) + const identity = JSON.stringify({ ...options, launcher, plist: readFileSync(join(source, 'Contents', 'Info.plist'), 'utf8') }) + if (!existsSync(stamp) || readFileSync(stamp, 'utf8') !== identity) { + rmSync(bundle, { recursive: true, force: true }) + execFileSync('/usr/bin/ditto', [source, bundle]) + const plist = join(bundle, 'Contents', 'Info.plist') + const values = { + CFBundleIdentifier: `com.deepseek.harness.dev.${createHash('sha256').update(options.appRoot).digest('hex').slice(0, 12)}`, + CFBundleName: 'Harness Dev', + CFBundleDisplayName: 'Harness Dev', + CFBundleExecutable: 'HarnessDev', + CFBundleURLTypes: [{ CFBundleURLName: 'DeepSeek Harness', CFBundleURLSchemes: ['dsh'], CFBundleTypeRole: 'Viewer' }], + } + for (const [key, value] of Object.entries(values)) { + execFileSync('/usr/bin/plutil', ['-replace', key, '-json', JSON.stringify(value), plist]) + } + writeFileSync(executable, launcher, { mode: 0o755 }) + writeFileSync(stamp, identity, { mode: 0o600 }) + execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', bundle], { stdio: 'pipe' }) + } + execFileSync('/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister', ['-f', bundle]) + return executable +} + +/** + * Create the development executable used by both the CLI and Launch Services. + * @param options - Persisted development locations and debugging settings. + * @param bundle - Generated application bundle. + * @returns shell program with literal arguments and environment values. + */ +export function developmentLauncher(options: DevelopmentAppOptions, bundle: string): string { + const environment = { + DSH_HOME: options.home, + DSH_DESKTOP_DEV_APP: '1', + DSH_DESKTOP_HOST_INSPECT_PORT: String(options.hostPort), + DSH_DESKTOP_OPEN_DEVTOOLS: options.openDevtools, + ELECTRON_ENABLE_LOGGING: '1', + } + const args = [ + join(bundle, 'Contents', 'MacOS', 'Electron'), + `--inspect=127.0.0.1:${String(options.mainPort)}`, + `--remote-debugging-port=${String(options.rendererPort)}`, + `--user-data-dir=${options.userData}`, + options.appRoot, + ] + return [ + '#!/bin/sh', + ...Object.entries(environment).map(([key, value]) => `export ${key}=${quote(value)}`), + `cd ${quote(options.appRoot)}`, + `exec ${args.map(quote).join(' ')} "$@"`, + '', + ].join('\n') +} diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts index eddd683161..d544ce6d36 100644 --- a/apps/desktop/scripts/development-project.ts +++ b/apps/desktop/scripts/development-project.ts @@ -16,10 +16,13 @@ import { dirname, join } from 'node:path' import { createDevelopmentProjectMetadata } from '../src/project-manager.ts' import type { DesktopRelease } from '../src/release.ts' import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' +import { desktopTargetPlatform } from './desktop-build-paths.mjs' interface PackageManifest { readonly name?: string readonly version?: string + readonly dependencies?: Readonly> } /** Inputs whose locations differ between the launcher and isolated tests. */ @@ -34,6 +37,8 @@ export interface DevelopmentProjectOptions { readonly dependencyDir: string /** Release identity written into the disposable project metadata. */ readonly release: DesktopRelease + /** Build target whose prepared payload the disposable project runs against. */ + readonly target: DesktopAutoUpdateTarget } function readManifest(path: string): PackageManifest { @@ -86,9 +91,35 @@ function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): str return names } +/** Configured workspace plugins must resolve from the profile even when pnpm does not hoist them. */ +function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot: string): string[] { + const names: string[] = [] + const visited = new Set() + const visit = (directory: string): void => { + const source = realpathSync(directory) + if (visited.has(source)) return + visited.add(source) + const manifest = readManifest(join(source, 'package.json')) + for (const [name, specifier] of Object.entries(manifest.dependencies ?? {})) { + if (!specifier.startsWith('workspace:')) continue + const dependency = join(source, 'node_modules', ...name.split('/')) + if (!existsSync(dependency)) { + throw new Error(`desktop development: ${name} is missing from ${source}; run pnpm install`) + } + const destination = join(destinationRoot, ...name.split('/')) + removeOwnedPath(destination) + linkDirectory(dependency, destination) + names.push(name) + visit(dependency) + } + } + for (const directory of roots) visit(directory) + return names +} + /** * Replace one disposable project with links to the current built workspace. - * @param options - Project destination, CLI package, and release identity. + * @param options - Project destination, CLI package, release identity, and build target. * @returns the absolute project directory supplied by the caller. */ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string { @@ -117,7 +148,10 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s createDevelopmentProjectMetadata(options.projectDir, options.release) const destinationModules = join(options.projectDir, 'node_modules') mkdirSync(destinationModules, { recursive: true }) - const names = mirrorDependencyLinks(options.dependencyDir, destinationModules) + const names = [ + ...mirrorDependencyLinks(options.dependencyDir, destinationModules), + ...mirrorWorkspaceDependencies([options.cliDir, options.hostDir], destinationModules), + ] const dshLink = join(destinationModules, '@deepseek-ai', 'dsh') removeOwnedPath(dshLink) linkDirectory(options.cliDir, dshLink) @@ -129,7 +163,7 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s return typeof manifest.version === 'string' ? [{ name, version: manifest.version, path: `node_modules/${name}` }] : [] }) const runtime: DesktopRuntimeDescriptor = { schemaVersion: 1, release: options.release, - platform: process.platform, arch: process.arch, sharedPackages, files: [] } + ...desktopTargetPlatform(options.target), sharedPackages, files: [] } writeFileSync(join(options.projectDir, DESKTOP_RUNTIME_FILE), `${JSON.stringify(runtime, undefined, 2)}\n`) return options.projectDir } diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 4ecb4cc759..e0a707dc32 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -1,7 +1,8 @@ +import { officePackageDirectories } from '../../../scripts/libreoffice-packages.mjs' import { X509Certificate } from 'node:crypto' import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { execFile } from 'node:child_process' import { promisify } from 'node:util' @@ -98,6 +99,7 @@ export function createElectronBuilderConfig( const packaged = resolveDesktopBuildCommit(env) return { appId, + protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }], extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy, @@ -105,7 +107,8 @@ export function createElectronBuilderConfig( ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, }, productName: 'DeepSeek Harness', - artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', + // Unsigned builds carry their own suffix so a shared file can never pass for a release artifact. + artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : ''}.\${ext}`, directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, asar: true, electronDist: buildPaths.electron, @@ -125,9 +128,12 @@ export function createElectronBuilderConfig( }, files: [ 'lib/main.js', + 'lib/welcome/**/*', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', + 'lib/preload-platform-account.cjs', 'lib/preload-update-dialog.cjs', + 'lib/preload-welcome.cjs', 'renderer/**/*', 'package.json', { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] }, @@ -158,6 +164,10 @@ export function createElectronBuilderConfig( writeUpdateInfo: false, }, beforePack: async context => { + const office = await officePackageDirectories(buildPaths.dsh, { platform: resolvedPlatform, arch: resolvedArch }) + const patterns = office.map(directory => `**/${relative(buildPaths.dsh, directory).split(sep).join('/')}/**/*`) + const existing = context.packager.config.asarUnpack ?? [] + context.packager.config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing), ...patterns] if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh) if (windowsSigner !== undefined) { primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime') diff --git a/apps/desktop/scripts/installer-directories.nsh b/apps/desktop/scripts/installer-directories.nsh index 881bdc8b20..49d4a2fb82 100644 --- a/apps/desktop/scripts/installer-directories.nsh +++ b/apps/desktop/scripts/installer-directories.nsh @@ -17,7 +17,11 @@ Var dshNewMoved ${If} $R0 != 0 DetailPrint $R1 Call dshRollbackDirectories - MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK + !ifmacrodef customInstallerExtractFailed + !insertmacro customInstallerExtractFailed "${FILE}" + !else + MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK + !endif SetErrorLevel 2 Quit ${EndIf} diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 8d0378ebc0..5980287859 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -1,4 +1,5 @@ !include "LogicLib.nsh" +!include "FileFunc.nsh" !define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer" !define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui" @@ -120,16 +121,43 @@ ManifestDPIAware true System::Call /NOUNLOAD '$PLUGINSDIR\window-frame.dll::InstallerExtract(p $HWNDPARENT, w "$PLUGINSDIR\dsh-7za.exe", w "${Archive}", w "$INSTDIR", w "$PLUGINSDIR\extract.log") i.s ?c' System::Store "L" Pop $R0 + ; The failure report owns 7-Zip's UTF-8 output; the details view only needs the result code. StrCpy $R1 "$R0" - ${If} $R0 != 0 - Push $0 - FileOpen $0 "$PLUGINSDIR\extract.log" r - ${IfNot} ${Errors} - FileRead $0 $R1 - FileClose $0 - ${EndIf} - Pop $0 +!macroend + +; The report outlives $PLUGINSDIR so a user can send it; silent installs keep only the file. The updater cache +; directory is never an installation target and leaves with the application on uninstall. +; The directory smoke fixture predefines DSH_INSTALLER_LOG_DIR to keep reports inside its scratch tree. +!ifndef DSH_INSTALLER_LOG_DIR + !define DSH_INSTALLER_LOG_DIR "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}\installer-logs" +!endif + +!macro customInstallerExtractFailed Archive + Push $0 + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + Push $6 + ; GetTime yields zero-padded day, month, year, weekday, hour, minute, second. + ${GetTime} "" "L" $0 $1 $2 $3 $4 $5 $6 + StrCpy $0 "${DSH_INSTALLER_LOG_DIR}\extract-failure-$2$1$0-$4$5$6.log" + StrCpy $1 1 + ${If} ${Silent} + StrCpy $1 0 ${EndIf} + System::Call '$PLUGINSDIR\window-frame.dll::InstallerReportExtractFailure(p $HWNDPARENT, i R0, w "${Archive}", w "$dshNewDirectory", w "$PLUGINSDIR\extract.log", w r0, i r1, w "$(^SetupCaption)", w "$(INSTALLER_EXTRACT_FAILED)", w "$(INSTALLER_EXTRACT_HINT)", w "$(INSTALLER_EXTRACT_COPY)", w "$(INSTALLER_EXTRACT_EXPAND)", w "$(INSTALLER_EXTRACT_COLLAPSE)", w "$(INSTALLER_EXTRACT_SAVED)", w "$(INSTALLER_EXTRACT_UNSAVED)", w "$(INSTALLER_EXTRACT_COPIED)") i.r2 ?c' + ${If} $2 == 1 + DetailPrint $0 + ${EndIf} + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 !macroend !macro customCheckAppRunning diff --git a/apps/desktop/scripts/macos-signing-keychain.d.mts b/apps/desktop/scripts/macos-signing-keychain.d.mts index 452878aad1..f95a370c98 100644 --- a/apps/desktop/scripts/macos-signing-keychain.d.mts +++ b/apps/desktop/scripts/macos-signing-keychain.d.mts @@ -3,14 +3,15 @@ /** * Import and authorize the required p12 before work; delete the owned keychain after work settles. * Children receive only its path, never the p12 password. Existing login keychains are not unlocked. + * The owned keychain is prepended to the user search list for the run and the previous list is restored. * Abrupt process termination requires the CI runner to clean its temporary directory. * @param environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD. * @param action All signing work, settled before cleanup. - * @param run Apple command executor. + * @param run Apple command executor returning command standard output. * @returns Resolves after work and cleanup; rejects on setup, work, or cleanup failure. */ export function withMacOSSigningKeychain( environment: NodeJS.ProcessEnv, action: (environment: NodeJS.ProcessEnv) => Promise, - run?: (command: string, args: string[]) => void, + run?: (command: string, args: string[]) => string, ): Promise diff --git a/apps/desktop/scripts/macos-signing-keychain.mjs b/apps/desktop/scripts/macos-signing-keychain.mjs index e71d469395..425eb6a96c 100644 --- a/apps/desktop/scripts/macos-signing-keychain.mjs +++ b/apps/desktop/scripts/macos-signing-keychain.mjs @@ -9,23 +9,40 @@ import { join } from 'node:path' * Execute a credential-bearing Apple command without exposing arguments or tool output on failure. * @param {string} command Absolute executable path. * @param {string[]} args Command arguments, potentially containing secrets. - * @returns {void} + * @returns {string} Command standard output, needed when the caller reads a security query. */ function execute(command, args) { - try { execFileSync(command, args, { stdio: 'pipe', timeout: 120_000 }) } + try { + return execFileSync(command, args, { stdio: 'pipe', timeout: 120_000, encoding: 'utf8' }) + } catch (error) { // execFile errors contain the command line, including private-key passwords. throw new Error(`desktop macOS signing: ${command} ${args[0]} failed; check certificate, password, and signing access`) } } +/** + * Read the user's keychain search list so this invocation can extend and later restore it. + * @param {(command: string, args: string[]) => string} run Apple command executor. + * @returns {string[]} Keychain paths on the user search list. + */ +function readUserKeychains(run) { + const output = run('/usr/bin/security', ['list-keychains', '-d', 'user']) + if (typeof output !== 'string') { + throw new Error('desktop macOS signing: the user keychain search list could not be read') + } + return output.split('\n').map(line => line.trim().replace(/^"|"$/gu, '')).filter(path => path !== '') +} + /** * Import and authorize the required p12 before work; delete the owned keychain after work settles. * Children receive only its path, never the p12 password. Existing login keychains are not unlocked. + * codesign resolves an identity only from a keychain on the user search list, so the owned keychain + * is prepended during the run and the previous list is restored before deletion. * Abrupt process termination requires the CI runner to clean its temporary directory. * @param {NodeJS.ProcessEnv} environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD. * @param {(environment: NodeJS.ProcessEnv) => Promise} action All signing work, settled before cleanup. - * @param {(command: string, args: string[]) => void} run Apple command executor. + * @param {(command: string, args: string[]) => string} run Apple command executor. * @returns {Promise} Resolves after work and cleanup; rejects on setup, work, or cleanup failure. */ export async function withMacOSSigningKeychain(environment, action, run = execute) { @@ -38,6 +55,7 @@ export async function withMacOSSigningKeychain(environment, action, run = execut /** @param {string[]} args Security command arguments. */ const security = args => run('/usr/bin/security', args) let created = false + let previousKeychains try { security(['create-keychain', '-p', password, keychain]) created = true @@ -45,6 +63,10 @@ export async function withMacOSSigningKeychain(environment, action, run = execut security(['set-keychain-settings', keychain]) security(['import', certificate, '-k', keychain, '-P', exportPassword, '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild']) security(['set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k', password, keychain]) + // codesign ignores --keychain unless that file is also on the search list + // (https://github.com/electron-userland/electron-builder/issues/1457). + previousKeychains = readUserKeychains(run) + security(['list-keychains', '-d', 'user', '-s', keychain, ...previousKeychains]) const probe = join(directory, 'probe') run('/bin/cp', ['/usr/bin/true', probe]) run('/usr/bin/codesign', ['--force', '--sign', `Developer ID Application: ${environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY}`, '--keychain', keychain, '--timestamp', '--options', 'runtime', probe]) @@ -54,7 +76,10 @@ export async function withMacOSSigningKeychain(environment, action, run = execut delete childEnvironment.CSC_KEY_PASSWORD await action(childEnvironment) } finally { - try { if (created) security(['delete-keychain', keychain]) } + try { + if (previousKeychains !== undefined) security(['list-keychains', '-d', 'user', '-s', ...previousKeychains]) + if (created) security(['delete-keychain', keychain]) + } finally { rmSync(directory, { recursive: true, force: true }) } } } diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 23099a5b46..377c300efb 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -24,13 +24,14 @@ import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.t import { resolveDesktopAppId, resolveMacOSSigningEnvironment, + resolveNpmRegistry, } from './desktop-release-environment.mjs' import { signMacOSRuntime, } from './macos-runtime.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' -import { selectOfficeEngine } from '../../../scripts/libreoffice-engine.ts' +import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() @@ -69,6 +70,7 @@ function runPnpm(args: readonly string[]): Promise { return new Promise((resolvePromise, reject) => { const [command, ...commandArgs] = args if (command === undefined) throw new Error('desktop runtime: pnpm command is required') + const registry = resolveNpmRegistry(process.env) const config = join(PNPM_BUILD_STATE, 'config') const userConfig = join(config, 'npmrc') mkdirSync(config, { recursive: true }) @@ -76,7 +78,7 @@ function runPnpm(args: readonly string[]): Promise { const child = spawn(NODE, [ '--expose-internals', PNPM, - '--config.registry=https://registry.npmjs.org/', + `--config.registry=${registry}`, `--config.store-dir=${STORE_ROOT}`, '--config.enable-global-virtual-store=false', `--config.userconfig=${userConfig}`, @@ -88,7 +90,7 @@ function runPnpm(args: readonly string[]): Promise { ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))), - NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', + NPM_CONFIG_REGISTRY: registry, NPM_CONFIG_STORE_DIR: STORE_ROOT, NPM_CONFIG_USERCONFIG: userConfig, ...desktopNodeEnvironment(NODE, join(RUNTIME_ROOT, 'bin'), {}), diff --git a/apps/desktop/scripts/prepare-windows-installer.ps1 b/apps/desktop/scripts/prepare-windows-installer.ps1 index 0209e74d78..98e02cf9a6 100644 --- a/apps/desktop/scripts/prepare-windows-installer.ps1 +++ b/apps/desktop/scripts/prepare-windows-installer.ps1 @@ -50,6 +50,16 @@ if ($TestProgress -or $CompileProgressOnly) { & $presentationExecutable if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' } } + $reportSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-extract-report.cpp')) + $reportExecutable = Join-Path $output 'extract-report-test.exe' + $reportScript = Join-Path $output 'compile-extract-report-test.cmd' + [IO.File]::WriteAllLines($reportScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link "{3}" user32.lib shell32.lib' -f $reportSource, (Join-Path $output 'extract-report-test.obj'), $reportExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default) + & $env:ComSpec /d /c $reportScript + if ($LASTEXITCODE -ne 0) { throw 'Extraction report test compilation failed.' } + if ($TestProgress) { + & $reportExecutable + if ($LASTEXITCODE -ne 0) { throw 'Extraction report regression failed.' } + } } Add-Type -AssemblyName System.Drawing foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninstaller-sidebar')) { diff --git a/apps/desktop/scripts/smoke-installer-directories.ps1 b/apps/desktop/scripts/smoke-installer-directories.ps1 index 2fa8eabfb5..092750b2c1 100644 --- a/apps/desktop/scripts/smoke-installer-directories.ps1 +++ b/apps/desktop/scripts/smoke-installer-directories.ps1 @@ -8,6 +8,7 @@ param( $ErrorActionPreference = 'Stop' $Makensis = [System.IO.Path]::GetFullPath($Makensis) $SevenZip = [System.IO.Path]::GetFullPath($SevenZip) +if ($FrameLibrary) { $FrameLibrary = [System.IO.Path]::GetFullPath($FrameLibrary) } $scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-directory-smoke-').FullName $fixture = Join-Path $PSScriptRoot '../tests/fixtures/installer-directory-smoke.nsi' @@ -42,8 +43,9 @@ try { } $probe = Join-Path $caseRoot 'probe.exe' $caseArchive = if ($mode -eq 'broken') { $brokenArchive } else { $archive } + $reportDir = Join-Path $caseRoot 'installer-logs' $compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip") - if ($FrameLibrary) { $compileArgs += "/DSOURCE_DLL=$FrameLibrary" } + if ($FrameLibrary) { $compileArgs += @("/DSOURCE_DLL=$FrameLibrary", "/DREPORT_DIR=$reportDir") } if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' } if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' } Invoke-Checked $Makensis ($compileArgs + $fixture) @@ -76,9 +78,24 @@ try { if ((Test-Path -LiteralPath (Join-Path $target 'obsolete.txt')) -ne $expectObsolete) { throw "Directory smoke $mode did not preserve the expected old-only file state" } - if (@(Get-ChildItem -LiteralPath $caseRoot -Directory | Where-Object Name -ne 'Application').Count -ne 0) { + if (@(Get-ChildItem -LiteralPath $caseRoot -Directory | Where-Object Name -notin @('Application', 'installer-logs')).Count -ne 0) { throw "Directory smoke $mode left transaction directories behind" } + if ($FrameLibrary) { + # Only an extraction failure writes a report; it must quote 7-Zip's verdict so support can read the cause. + $reports = @(if (Test-Path -LiteralPath $reportDir) { Get-ChildItem -LiteralPath $reportDir -File }) + if ($mode -eq 'broken') { + if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^extract-failure-20\d{6}-\d{6}\.log$') { + throw "Directory smoke $mode did not save exactly one extraction report" + } + $report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8 + foreach ($expected in @('Result: 7-Zip exit code 2 (fatal error)', "Archive: $caseArchive", 'Cannot open the file as [7z] archive')) { + if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" } + } + } elseif ($reports.Count -ne 0) { + throw "Directory smoke $mode saved an unexpected extraction report" + } + } Write-Output "Directory replacement smoke passed: $mode" } } finally { diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index 19b26fac1a..f15f10a928 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -49,11 +49,30 @@ export async function smokeDesktopRuntime( })) writeFileSync(join(plugin, 'index.js'), ` import { Context } from '@deepseek-ai/cordis' -import { inspect } from 'node:util' +import { inspect, promisify } from 'node:util' +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' export function apply(ctx) { if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke', handler(_request, response) { response.end('plugin route ready') } })) + ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli', + async handler(_request, response) { + try { + const skill = await ctx.skills.get('office-docx') + const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1] + if (json === undefined) throw new Error('Office skill did not supply CLI paths') + const { libreofficeKit: { node, cli } } = JSON.parse(json) + const options = { cwd: ${JSON.stringify(home)}, env: { ...process.env, PATH: '' }, timeout: 120_000 } + const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options) + const output = ${JSON.stringify(join(home, 'cli.pdf'))} + await promisify(execFile)(node, [cli, 'convert', '--input', ${JSON.stringify(join(home, 'input.docx'))}, '--output', output], options) + response.end(JSON.stringify({ capabilities: JSON.parse(capabilities.stdout), pdf: (await readFile(output)).toString('base64') })) + } catch (error) { + response.statusCode = 500 + response.end(inspect(error, { depth: 5 })) + } + } })) for (const input of ${JSON.stringify(inputs)}) { ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office/' + input.extension, async handler(_request, response) { @@ -71,7 +90,7 @@ export function apply(ctx) { } } `) - writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf]\n') + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills]\n') const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { dependencies: Record dsh: { profile: { bundles: string[] } } @@ -103,7 +122,15 @@ export function apply(ctx) { throw new Error(`desktop runtime: invalid ${extension} PDF output`) } } - console.log('desktop runtime: DOCX, XLSX, PPTX to PDF passed') + const cliResponse = await fetch(new URL('/desktop-smoke-office-cli', ready.url), { + headers: { cookie }, signal: AbortSignal.timeout(120_000), + }) + if (!cliResponse.ok) throw new Error(`desktop runtime: skill CLI failed: ${await cliResponse.text()}`) + const cliResult = await cliResponse.json() as { capabilities: { runtime: { cliPath: string } }; pdf: string } + if (!cliResult.capabilities.runtime.cliPath.endsWith('cli.js') || Buffer.from(cliResult.pdf, 'base64').subarray(0, 5).toString() !== '%PDF-') { + throw new Error('desktop runtime: skill CLI did not return capabilities and a PDF') + } + console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed') } finally { clearTimeout(timer) await host.stop() diff --git a/apps/desktop/scripts/smoke-windows.ps1 b/apps/desktop/scripts/smoke-windows.ps1 index 5f3c0372e8..8ae29ad614 100644 --- a/apps/desktop/scripts/smoke-windows.ps1 +++ b/apps/desktop/scripts/smoke-windows.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory)][string]$Makensis, [Parameter(Mandatory)][string]$SevenZip, - [Parameter(Mandatory)][string]$PluginDir + [Parameter(Mandatory)][string]$PluginDir, + [string]$FrameLibrary ) $ErrorActionPreference = 'Stop' $desktopRoot = Split-Path $PSScriptRoot -Parent @@ -15,7 +16,10 @@ function Invoke-Checked([string]$Executable, [string[]]$Arguments) { } try { - & (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') -Makensis $Makensis -SevenZip $SevenZip + # The prepared window-frame.dll adds the native extraction path and its failure report to the directory checks. + $directoryArguments = @{ Makensis = $Makensis; SevenZip = $SevenZip } + if ($FrameLibrary) { $directoryArguments.FrameLibrary = $FrameLibrary } + & (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') @directoryArguments $payload = Join-Path $scratch 'payload' New-Item -ItemType Directory -Path $payload | Out-Null [System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime') diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index 669d572b9d..41f40caa09 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { createDevelopmentProjectMetadata, createPluginProfile } from '../src/project-manager.ts' import { removeOwnedDirectory } from '../src/owned-directory.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' const repo = resolve(import.meta.dirname, '../../..') const interactive = process.argv.includes('--interactive') @@ -27,10 +28,11 @@ try { nodeVersion: execFileSync(electron, ['-p', 'process.versions.node'], { encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true }).trim(), pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION } + const target = resolveDesktopBuildTarget() createDevelopmentProjectMetadata(project, release) createPluginProfile(profile) await writeFile(join(project, 'desktop-runtime.json'), JSON.stringify({ - schemaVersion: 1, release, platform: process.platform, arch: process.arch, files: [], + schemaVersion: 1, release, ...desktopTargetPlatform(target), files: [], sharedPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host'] .map(name => ({ name, version: manifest.version, path: `node_modules/${name}` })), })) @@ -43,7 +45,6 @@ try { for (const owner of [application, project]) { await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') } - const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}` const targetRoot = join(application, '.desktop-build/targets', target) await mkdir(targetRoot, { recursive: true }) await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'), @@ -61,7 +62,7 @@ try { ...(interactive ? ['--interactive'] : [])], { cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root, TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(), - DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, + DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_OPEN_DEVTOOLS: '0' }, // Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks. stdio: 'inherit', windowsHide: false, }) diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts new file mode 100644 index 0000000000..0db791f631 --- /dev/null +++ b/apps/desktop/src/account-backend.ts @@ -0,0 +1,112 @@ +/** Native account commands and Gateway state stream; no renderer receives credentials. */ +import { randomUUID } from 'node:crypto' +import WebSocket from 'ws' +import { parseRemoteStreamServerMessage, REMOTE_STREAM_MUX_PATH } from '@deepseek-ai/dsh-api-gateway/stream-protocol' +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' + +/** Authenticated unary caller shared with native onboarding. */ +export type AccountInvoke = (request: { namespace: string; method: string; args: Record }) => Promise + +/** Decode the UI-safe state received across HTTP or WebSocket. @param value - wire value. @returns account projection. */ +export function accountView(value: unknown): AccountView { + if (typeof value !== 'object' || value === null || !('status' in value) + || !['signed-out', 'credential-stored'].includes(String(value.status)) || !('attempt' in value)) { + throw new Error('desktop account: invalid state') + } + if (!('links' in value) || typeof value.links !== 'object' || value.links === null + || !('usageUrl' in value.links) || typeof value.links.usageUrl !== 'string' + || !('topUpUrl' in value.links) || typeof value.links.topUpUrl !== 'string') { + throw new Error('desktop account: invalid platform links') + } + validateBrowserDestination(value.links.usageUrl) + validateBrowserDestination(value.links.topUpUrl) + const attempt = value.attempt + if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string' + || !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase)) + || ('authorizeUrl' in attempt && typeof attempt.authorizeUrl !== 'string') + || ('expiresAt' in attempt && (typeof attempt.expiresAt !== 'number' || !Number.isFinite(attempt.expiresAt))) + || ('errorCode' in attempt && !['network', 'protocol', 'expired', 'storage'].includes(String(attempt.errorCode))))) { + throw new Error('desktop account: invalid attempt') + } + if (attempt !== null && 'authorizeUrl' in attempt) { + validateBrowserDestination(String(attempt.authorizeUrl)) + } + const parsed = value as AccountView + return { + status: parsed.status, + links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl }, + attempt: parsed.attempt === null ? null : { + id: parsed.attempt.id, phase: parsed.attempt.phase, + ...parsed.attempt.authorizeUrl === undefined ? {} : { authorizeUrl: parsed.attempt.authorizeUrl }, + ...parsed.attempt.expiresAt === undefined ? {} : { expiresAt: parsed.attempt.expiresAt }, + ...parsed.attempt.errorCode === undefined ? {} : { errorCode: parsed.attempt.errorCode }, + }, + } +} + +/** Only HTTP loopback or HTTPS destinations can leave the native app. */ +function validateBrowserDestination(value: string): void { + const url = new URL(value) + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + if (url.username || url.password || !(url.protocol === 'https:' || (loopback && url.protocol === 'http:'))) { + throw new Error('desktop account: invalid browser destination') + } +} + +/** Native account operations and explicitly owned stream lifetime. */ +export interface DesktopAccountBackend { + /** @returns current account snapshot. */ + state(): Promise + /** @param locale - active desktop UI language. @returns new or already-running login attempt. */ + start(locale: string): Promise + /** @param id - attempt to cancel. @returns cancellation or completed commit state. */ + cancel(id: SignInAttemptId): Promise + /** @returns state after local sign-out. */ + signOut(): Promise + /** @param listener - state recipient. @param failed - stream failure recipient. @returns stream disposer. */ + watch(listener: (state: AccountView) => void, failed: () => void): () => void +} + +/** + * Connect native account operations to the standard authenticated Web backend. + * @param origin - Host Web origin. + * @param invoke - validated unary RPC caller. + * @param cookies - Electron session cookie reader. + * @returns account operations; watch callers own their subscriptions. + */ +export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise): DesktopAccountBackend { + const call = async (method: string, args: Record = {}): Promise => + accountView(await invoke({ namespace: 'account', method, args })) + return { + state: () => call('getState'), start: locale => call('startSignIn', { locale, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }), + cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: () => call('signOut'), + watch(listener, failed) { + let closed = false + let socket: WebSocket | undefined + let retry: ReturnType | undefined + const connect = (): void => { + const streamId = randomUUID() + void cookies().then((cookie) => { + if (closed) return + const url = new URL(REMOTE_STREAM_MUX_PATH, origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) + socket.on('open', () => socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))) + socket.on('message', (data) => { + try { + const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) + const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') + if (frame.type === 'item') listener(accountView(frame.value)) + else socket?.close() + } catch { socket?.close() } + }) + socket.on('error', () => { socket?.close() }) + socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) + }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } }) + } + connect() + return () => { closed = true; clearTimeout(retry); socket?.close() } + }, + } +} diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts index f78a081c51..3e85b868ea 100644 --- a/apps/desktop/src/backend-controller.ts +++ b/apps/desktop/src/backend-controller.ts @@ -3,7 +3,16 @@ import { desktopErrorState } from './startup-error.ts' /** Backend availability presented by the desktop window. */ -export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string } +export type DesktopBackendState = + | { readonly phase: 'starting' } + | { readonly phase: 'ready' } + /** `message` is the rendered text; `failure` is the original error so a crash report keeps its stack, properties, and cause. */ + | { readonly phase: 'error'; readonly message: string; readonly failure: unknown } + +/** The error state for one failure: rendered message plus the failure itself. */ +function errorState(failure: unknown): DesktopBackendState { + return { ...desktopErrorState(failure), failure } +} /** Child lifecycle owned by the desktop backend controller. */ export interface DesktopBackendHost { @@ -80,7 +89,7 @@ export class DesktopBackendController { try { await this.cleanup(attempt) } catch (cleanupError) { if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed') } - if (!cancelled) this.update(desktopErrorState(failure)) + if (!cancelled) this.update(errorState(failure)) throw failure } }).finally(() => { if (this.pending === pending) this.pending = undefined }) @@ -132,9 +141,9 @@ export class DesktopBackendController { if (this.current.phase !== 'ready') return attempt.cancelled = true const cleanup = this.cleanup(attempt) - this.update(desktopErrorState(error)) + this.update(errorState(error)) void cleanup.catch((cleanupError: unknown) => { - if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop'))) + if (this.attempt === attempt) this.update(errorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop'))) }) } diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx new file mode 100644 index 0000000000..179bc4ef09 --- /dev/null +++ b/apps/desktop/src/client/WelcomePage.tsx @@ -0,0 +1,208 @@ +/** Desktop welcome presentation; account and credential operations stay in the preload. */ +import { useEffect, useRef, useState } from 'react' +import type { FormEvent } from 'react' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives/src/StateDot.tsx' +import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' +import type { WelcomeApi } from '../welcome-api.ts' + +type Page = 'entry' | 'key' | 'account' + +/** + * Render the standalone welcome flow using shell-owned operations and localized copy. + * @param props.api - isolated preload API; no account credentials reach the renderer. + * @returns welcome pages with fixed bottom actions. + */ +export function Welcome({ api }: { api: WelcomeApi }) { + const { messages: m } = api + const [page, setPage] = useState('entry') + const pageRef = useRef('entry') + const [attempt, setAttempt] = useState(null) + const attemptRef = useRef(null) + const [starting, setStarting] = useState(false) + const [cancelling, setCancelling] = useState(false) + const [copyFeedback, setCopyFeedback] = useState<{ status: 'idle' | 'busy' | 'copied' | 'failed' }>({ status: 'idle' }) + const copyState = copyFeedback.status + const [draft, setDraft] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const busyRef = useRef(false) + const mounted = useRef(true) + const revision = useRef(0) + const input = useRef(null) + const keyButton = useRef(null) + const focusEntry = useRef(false) + + function navigate(next: Page) { + pageRef.current = next + setPage(next) + } + function showAccount(state: AccountView) { + if (pageRef.current === 'key' || (state.attempt === null && pageRef.current === 'entry')) return + attemptRef.current = state.attempt + setAttempt(state.attempt) + setStarting(false) + setCopyFeedback({ status: 'idle' }) + navigate(state.attempt?.phase === 'cancelled' ? 'entry' : 'account') + } + + useEffect(() => { + mounted.current = true + document.documentElement.lang = api.id + document.title = m.welcomeTitle + const stop = api.onAccountState((state) => { + revision.current++ + showAccount(state) + }) + return () => { mounted.current = false; stop() } + }, [api, m.welcomeTitle]) + + useEffect(() => { + if (page === 'key') input.current?.focus() + else if (page === 'entry' && focusEntry.current) { + focusEntry.current = false + keyButton.current?.focus() + } + }, [page]) + + useEffect(() => { + if (copyState !== 'copied' && copyState !== 'failed') return + const timer = setTimeout(() => { setCopyFeedback({ status: 'idle' }) }, 2000) + return () => { clearTimeout(timer) } + }, [copyFeedback]) + + async function saveKey(event: FormEvent) { + event.preventDefault() + if (busyRef.current) return + const value = draft.trim() + if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value) + || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) { + setError(value === '' ? m.welcomeKeyBlank : m.welcomeKeyInvalid) + input.current?.focus() + return + } + busyRef.current = true + setBusy(true) + setError('') + try { + const result = await api.saveApiKey(value) + if (!mounted.current) return + if (result.ok) setDraft('') + else setError(m.welcomeKeyFailed) + } catch { + if (mounted.current) setError(m.welcomeKeyFailed) + } finally { + busyRef.current = false + if (mounted.current) setBusy(false) + } + } + async function skip() { + if (busyRef.current) return + busyRef.current = true + setBusy(true) + try { + await api.skip() + if (mounted.current) setDraft('') + } catch { + if (mounted.current) setError(m.welcomeContinueFailed) + } finally { + busyRef.current = false + if (mounted.current) setBusy(false) + } + } + async function start() { + navigate('account') + setStarting(true) + setAttempt(null) + attemptRef.current = null + const current = ++revision.current + try { + const state = await api.startSignIn() + if (mounted.current && revision.current === current) showAccount(state) + } catch { + if (mounted.current && revision.current === current) setStarting(false) + } + } + async function cancel() { + if (cancelling || attemptRef.current === null) return + setCancelling(true) + const current = ++revision.current + try { + const state = await api.cancelSignIn(attemptRef.current.id) + if (mounted.current && revision.current === current) showAccount(state) + } catch { + // The current attempt remains visible so cancellation can be retried. + } finally { + if (mounted.current) setCancelling(false) + } + } + async function copyLink() { + const current = attemptRef.current + if (current?.phase !== 'waiting-browser' || (copyState === 'busy' || copyState === 'copied')) return + setCopyFeedback({ status: 'busy' }) + try { + await api.copySignInLink(current.id) + if (mounted.current && attemptRef.current === current) setCopyFeedback({ status: 'copied' }) + } catch { + if (mounted.current && attemptRef.current === current) setCopyFeedback({ status: 'failed' }) + } + } + + const phase = starting ? 'initializing' : attempt?.phase ?? 'failed' + const waiting = phase === 'waiting-browser' + const failed = phase === 'expired' || phase === 'failed' + const title = phase === 'initializing' ? m.welcomeAuthStarting + : waiting ? m.welcomeAuthWaiting : phase === 'expired' ? m.welcomeAuthExpired + : phase === 'failed' ? m.welcomeAuthFailed : m.welcomeAuthExchanging + const heading = page === 'entry' ? 'welcome-heading' : page === 'key' ? 'key-title' : 'auth-status' + + return <> +