diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index af337114a0..f6289cf96f 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md -README.md: 8bf1adf875b54eb4580b4d189dfeed07c9f671c0 -README.zh.md: ff9bdb358d584d4f4ad0aea295c31d8d8fff9376 +README.md: 6872de0a1390fa5a1707b9f464c9e0edeb2da4be +README.zh.md: ced81f77fa904ad0b5cb5219383ef651d87392b8 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 8bf1adf875..6872de0a13 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -16,7 +16,7 @@ Every Agent Note has two axes, both encoded in its **path** — `{lifecycle}/{cl The date in the filename is when the topic was **first proposed** (per git history). Cross-references between Agent Notes use relative markdown links (`[topic](../../implemented/architecture/2026-…-….md)`) — never bare prose or numbers — so they are mechanically checkable and survive moves between folders. -The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Low-future-value implemented records move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below. +The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Implemented records with historical decision value but little future guidance move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below. ## Classification @@ -35,17 +35,17 @@ The `architecture` / `process` line: **architecture** is about the source we shi ## Archiving and deletion -Archive an implemented Agent Note when the shipped decision is complete and its rationale is unlikely to guide future work. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota. +Delete an implemented Agent Note that only describes small UI adjustments or purely mechanical changes; delete its English, Chinese, and sidecar files together and repair or remove inbound links. A local bug fix, performance change, new capability, or substantive behavior decision does not qualify merely because its implementation is small. Archive other implemented notes only when the shipped decision is complete, its rationale is unlikely to guide future work, and the record still has historical decision value. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota. -The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. +The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body. Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale. ## When to write one -Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a contract shared across files or packages, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). +Add or update an Agent Note in the same PR only for lasting decision rationale that code, tests, and existing documentation do not explain. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no change to behavior, contracts, structure, process, or rationale is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Mechanical or local edits, including local UI presentation and interaction changes, are exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index ff9bdb358d..ced81f77fa 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -16,7 +16,7 @@ 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 -活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 +活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。具有历史决策价值但未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 @@ -37,9 +37,9 @@ ## 归档与删除 -当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 +仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 直接删除;一并删除英文、中文和伴随记录文件,并修复或移除入站链接。局部 bug 修复、性能变更、新能力或实质行为决策,不会仅因实现规模小就符合删除条件。其他已实施记录只有在交付决策完整落地、其依据不太可能再指导未来工作,但仍具有历史决策价值时才归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 -归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 +归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。 封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。 @@ -47,9 +47,9 @@ ## 何时需要写一份 -每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包约定、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者维护者可能合理重新审视的其他决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 +只有代码、测试和现有文档未能解释的决策理由具有长期价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、约定、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 +更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。机械性或局部编辑均可豁免,包括局部 UI 展示和交互调整。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml similarity index 69% rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml index 8d8ad118e3..b8b5ab8c63 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.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-09-09-desktop-in-place-profile.md -2026-09-09-desktop-in-place-profile.md: 6f0e9598903d922e4b765d785be6dd3aa79ce93c -2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd +2026-09-09-desktop-in-place-profile.md: 57dabc0d03c999b90dc18baa9d833aa90320863a +2026-09-09-desktop-in-place-profile.zh.md: 2b57c49ce45b1937552596cf4f59e0251b2d5774 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md index 6f0e959890..57dabc0d03 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md +++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.md @@ -1,6 +1,7 @@ # Agent Note: Modify the Desktop profile in place Status: implemented +Archived: 2026-09-17 English | [中文](2026-09-09-desktop-in-place-profile.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md rename to .agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md index bec79b3b9d..2b57c49ce4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md +++ b/.agents/notes/archived/architecture/2026-09-09-desktop-in-place-profile.zh.md @@ -1,6 +1,7 @@ # Agent Note: 直接修改 Desktop profile Status: implemented +Archived: 2026-09-17 [English](2026-09-09-desktop-in-place-profile.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml rename to .agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml index 209fc7e492..0afd4836d9 100644 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml @@ -1,6 +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/feature/2026-09-09-turn-duration-hour-unit.md -2026-09-09-turn-duration-hour-unit.md: f0f5cb478dafa7938614bdeeec3a6de9f58aed8d -2026-09-09-turn-duration-hour-unit.zh.md: c9c94eef2a88a0f58d5f5a05332b4b5034ef977a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-local-office-preview.md +2026-09-10-local-office-preview.md: ea32e37f0b2dcba866b78947c785dcee8e475b29 +2026-09-10-local-office-preview.zh.md: a1ac8337a1cf22f5842485c0790c6554635dc125 diff --git a/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md new file mode 100644 index 0000000000..ea32e37f0b --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.md @@ -0,0 +1,52 @@ +# Agent Note: Local Office preview through WASM PDF bytes + +Status: implemented +Archived: 2026-09-11 + +English | [中文](2026-09-10-local-office-preview.zh.md) + +## Problem + +Office Open XML files are ZIP archives, so text fallback cannot provide a useful preview. Document conversion must stay local without taking focus or adding document content to a model conversation. Engine success alone cannot prove that the input has the claimed format or that a PDF was produced. + +## Decision + +A [document-render Service Definition](../../../../packages/document/document-render/README.md), a LibreOffice WASM provider, and Client/API consumers form the conversion capability. [Boot composition](../../../../packages/document/document-render-auto/README.md) mounts the provider and Remote controller only when `wasm.artifactDirectory` is configured. Invalid explicit configuration fails initialization. The Office Client registration always exists so an unconfigured Host receives guidance. Availability follows the generated Remote namespace without executable discovery or a duplicate Client flag. + +The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) uses pinned official LibreOffice source, Emscripten, and a LibreOfficeKit adapter. Each Node Worker owns one engine instance, its pthreads, and its memory filesystem. Terminating the Worker cancels synchronous engine calls; every conversion waits for its Worker to stop before returning. OOXML inspection verifies archive membership, content type, and resource bounds before startup. + +A VCL callback requests a font family before substitution or missing Unicode characters during glyph fallback. Host code indexes installed font metadata and copies selected files into engine memory, including complete font collections. Exact installed families precede configured alternatives; original-first fontconfig aliases preserve that ordering inside LibreOffice. Optional initial families use the same resolver. The engine has no Host filesystem mount, and rendering neither installs nor downloads fonts. The [build recipe](../../../../native/libreoffice-wasm/README.md) records source and toolchain revisions, patches, and asset hashes. Engine bundles remain immutable during provider use; a different build uses a new directory. + +The [independent engine release](../process/2026-09-11-independent-libreoffice-package.md) owns precompiled npm packaging and keeps engine compilation outside ordinary DSH builds. + +The provider returns caller-owned PDF bytes and independent `succeeded`, `timedOut`, and `cancelled` facts. PDF bytes are copied out of engine memory before teardown. The API consumer authorizes the source through [Workspace Files](2026-09-09-workspace-file-read-authority.md), requires successful uninterrupted conversion, and encodes the PDF directly. Source path and freshness version survive the PDF transport. Workspace Files limits source reads; the provider's `maxOutputBytes` alone limits generated PDFs. No temporary PDF or file lease is needed. + +The Office Client watches the selected Conversation's existing Deliverables projection and warms recent Office files without opening tabs. Its bounded memory cache checks authorized source metadata on every read and shares pending conversions between background and foreground callers. Only the last departing reader cancels a shared conversion; failures are not cached and connection reset discards cached bytes. [Document Preview](2026-09-08-document-preview-operations.md) owns format selection, loading, cancellation, and the PDF.js Worker. + +PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. + +Missing-family observations accompany the PDF through the worker and Remote response. The Office plugin renders a keyed notice above the shared PDF scrollport, so dismissing it removes its layout height. The font resolver intersects explicit source and theme references with actual document requests, excluding engine defaults and font-table inventories; installed aliases and glyph fallback do not by themselves mean a family is absent. + +## Alternatives considered + +**Keep an installed native LibreOffice provider.** A second provider adds executable discovery, version queries, subprocess supervision, scratch files, and platform-specific confinement. This design uses WASM for all supported Office previews. A native provider needs a demonstrated rendering or deployment requirement that WASM cannot satisfy. + +**Return a temporary PDF path.** The Worker already supplies independent PDF bytes. Writing them to disk, authorizing another file read, and maintaining leases adds file ownership without a current consumer. Source authorization and output limits apply directly to the memory result. + +**Automate installed Microsoft Office.** Word for Mac requires GUI automation rather than headless conversion, introducing focus changes and per-file operating-system authorization. Native Office automation remains outside this preview capability. + +**Use an online converter or download the engine automatically.** Remote conversion uploads document content. Automatic installation adds runtime distribution and update responsibilities; explicit artifact configuration leaves deployment with the operator. + +**Convert PDF pages to PNG.** PDF.js already owns PDF presentation. Rasterizing adds another image pipeline and loses the existing PDF controls and selectable text. + +**Add a model-facing rendering tool or durable preview events.** Client preview does not supply model input. A model-facing tool requires logged facts and both SDK projections, so it is a separate consumer. + +**Scan every font-table entry for warnings.** Font tables include unused styles and templates. Reporting them would make the notice unrelated to rendered content; collection instead starts after engine initialization and ends after PDF save. + +## Consequences + +The single-provider design assumes WASM covers the required Office rendering behavior; it is not evidence of complete fidelity equivalence with native LibreOffice. Fidelity depends on the engine build and installed fonts. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) cover DOCX, XLSX, PPTX, selectable Chinese text, and embedded system fonts; ABI fixtures alone cannot establish rendering fidelity or assembled browser presentation. + +WASM instances have substantial memory and startup costs, so the provider bounds concurrency. The [font reuse and image resolution decision](2026-09-11-wasm-preview-font-and-image-budgets.md) owns shared Host metadata, Worker request memoization, and raster export limits; only selected fonts enter engine memory. Large collections require bounded memory growth, and CFF export requires checked stack space. A fatal abort prevents further calls into C++. A device without a font covering a character cannot render it correctly without additional fonts. Owning the WASM build adds compiler compatibility, patch maintenance, and redistribution obligations. + +Prewarming consumes conversion resources to reduce foreground waits; entry and byte limits bound retained Client results. Source and PDF bytes remain outside Session storage and persistent caches. The [provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover independent output facts, input/output rejection, queued cancellation, and active Worker termination. Existing preview registry, file read authority, and Session ownership decisions remain active. diff --git a/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md new file mode 100644 index 0000000000..a1ac8337a1 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 通过 WASM PDF 字节进行本地 Office 预览 + +Status: implemented +Archived: 2026-09-11 + +[English](2026-09-10-local-office-preview.md) | 中文 + +## 问题 + +Office Open XML 文件是 ZIP 归档,因此文本回退无法提供有用的预览。文档转换必须留在本地,不抢占焦点,也不将文档内容放入模型对话。仅凭引擎成功状态无法证明输入具有声明的格式或生成了 PDF。 + +## 决定 + +[document-render Service Definition](../../../../packages/document/document-render/README.zh.md)、LibreOffice WASM 提供方及 Client/API 消费方构成转换能力。[启动组合](../../../../packages/document/document-render-auto/README.zh.md)仅在配置 `wasm.artifactDirectory` 时挂载提供方和 Remote 控制器。无效的显式配置会使初始化失败。Office Client 注册始终存在,为未配置的 Host 提供指引。可用性由生成的 Remote 命名空间决定,不发现可执行文件,也不维护重复的 Client 标记。 + +[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)使用固定的官方 LibreOffice 源码、Emscripten 和 LibreOfficeKit 适配器。每个 Node Worker 拥有一个引擎实例、其 pthread 和内存文件系统。终止 Worker 可取消同步引擎调用;每次转换均在返回前等待 Worker 停止。OOXML 检查在启动前验证归档成员、内容类型和资源限制。 + +VCL 回调在替换前请求字体族,或在字形回退时请求缺失的 Unicode 字符。Host 代码索引已安装字体元数据,将选中的文件复制到引擎内存,包括完整字体集合。精确匹配的已安装字体优先于配置的替代项;原字体优先的 fontconfig 别名在 LibreOffice 内保留此顺序。可选的初始字体族使用同一解析器。引擎不挂载 Host 文件系统,渲染不安装或下载字体。[构建配方](../../../../native/libreoffice-wasm/README.zh.md)记录源码与工具链版本、补丁及产物哈希。提供方使用期间,引擎包保持不可变;不同构建使用新目录。 + +[独立引擎发布](../process/2026-09-11-independent-libreoffice-package.zh.md)负责预编译 npm 打包,并将引擎编译与普通 DSH 构建隔离。 + +提供方返回调用方拥有的 PDF 字节,以及独立的 `succeeded`、`timedOut` 和 `cancelled` 事实。PDF 字节在清理前从引擎内存复制出来。API 消费方通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件,要求转换成功且未被中断,并直接编码 PDF。源路径和新鲜度版本保留在 PDF 传输中。Workspace Files 限制源文件读取;生成 PDF 仅受提供方的 `maxOutputBytes` 限制。无需临时 PDF 或文件租约。 + +Office Client 观察所选 Conversation 现有的 Deliverables 投影,在不打开 tab 的情况下预转换最近的 Office 文件。有上限的内存缓存每次读取都检查已授权的源文件元数据,并在后台和前台调用方之间共享进行中的转换。仅最后一个退出的读取方取消共享转换;失败不缓存,连接重置丢弃缓存字节。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责格式选择、加载、取消和 PDF.js Worker。 + +PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 + +缺失字体族观察值随 PDF 经过 Worker 和 Remote 响应。Office 插件在共享 PDF 滚动区域上方呈现 keyed 提示,关闭时移除其布局高度。字体解析器取源文档及主题显式引用与实际文档请求的交集,排除引擎默认字体和字体表清单;已安装别名和字形回退本身不代表字体族缺失。 + +## 考虑过的替代方案 + +**保留已安装的原生 LibreOffice 提供方。** 第二个提供方增加可执行文件发现、版本查询、子进程监督、临时文件和平台特定隔离。本设计将 WASM 用于全部受支持的 Office 预览。引入原生提供方需要证明存在 WASM 无法满足的渲染或部署需求。 + +**返回临时 PDF 路径。** Worker 已提供独立的 PDF 字节。将其写入磁盘、授权另一处文件读取并维护租约,会增加文件所有权,而没有当前消费方需要这些操作。源文件授权和输出限制直接应用于内存结果。 + +**自动操作已安装的 Microsoft Office。** Word for Mac 需要 GUI 自动化而非 headless 转换,会引入焦点变化和逐文件操作系统授权。原生 Office 自动化不属于本预览能力。 + +**使用在线转换器或自动下载引擎。** 远程转换会上传文档内容。自动安装增加运行时分发和更新责任;显式产物配置将部署留给运维者。 + +**将 PDF 页面转换为 PNG。** PDF.js 已负责 PDF 展示。栅格化增加另一条图像流水线,并失去现有 PDF 控件和可选择文本。 + +**添加面向模型的渲染工具或持久化预览事件。** Client 预览不提供模型输入。面向模型的工具需要记录事实并更新两种 SDK 投影,因此属于独立消费方。 + +**扫描全部字体表条目生成提示。** 字体表包含未使用的样式和模板。报告这些条目会使提示与实际渲染内容无关,因此收集从引擎初始化完成后开始,在 PDF 保存后结束。 + +## 影响 + +单一提供方设计假设 WASM 覆盖所需的 Office 渲染行为;这不是与原生 LibreOffice 完全等价的保真度证据。保真度取决于引擎构建和已安装字体。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)覆盖 DOCX、XLSX、PPTX、可选择中文文本和嵌入系统字体;仅有 ABI fixture 无法证明渲染保真度或完整浏览器展示。 + +WASM 实例有较高的内存与启动成本,因此提供方限制并发。[字体复用与图像分辨率决策](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)负责共享 Host 元数据、Worker 请求记忆化和栅格导出限制;仅选中字体进入引擎内存。大型集合需要有界内存增长,CFF 导出需要受检查的栈空间。致命 abort 后不再调用 C++。设备没有覆盖某字符的字体时,无法在不添加字体的前提下正确渲染该字符。自行构建 WASM 会增加编译器兼容、补丁维护和再分发义务。 + +预转换消耗转换资源以减少前台等待;条目数和字节限制约束 Client 保留的结果。源文件和 PDF 字节不进入 Session 存储或持久缓存。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖独立输出事实、输入/输出拒绝、排队取消和活跃 Worker 终止。现有预览注册表、文件读权限和 Session 所有权决策保持有效。 diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml new file mode 100644 index 0000000000..5e3c3d687d --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md +2026-09-11-wasm-preview-font-and-image-budgets.md: ab33b0bb6e28e5749ac38f36c08bc7539a368515 +2026-09-11-wasm-preview-font-and-image-budgets.zh.md: e6ff458e8b68ce08ff5d81ce6767a08a82974a74 diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md new file mode 100644 index 0000000000..ab33b0bb6e --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md @@ -0,0 +1,36 @@ +# Agent Note: Font reuse and image resolution in WASM previews + +Status: implemented +Archived: 2026-09-11 + +English | [中文](2026-09-11-wasm-preview-font-and-image-budgets.zh.md) + +## Problem + +Office conversion repeats font metadata parsing across documents and font matching within one document. Image-heavy documents also spend substantial PDF export time resampling images above preview resolution. These costs need separate controls because font reuse does not reduce image decoding or resampling. + +## Decision + +The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) builds a font metadata snapshot once during initialization. Each conversion Worker receives a structured clone. The Host retains names, face attributes, paths, sizes, and modification times; glyph coverage, request caches, and imported bytes belong to the Worker. Reloading the provider refreshes the snapshot. Workers validate indexed files when reading them, and an already imported font remains usable within that conversion. + +Each Worker memoizes complete requests: family, style, weight, italic, width, pitch, language, and ordered code points. The cache stores MEMFS paths and missing-family observations. Hits replay those observations because an initialization request can recur after document font collection starts. Worker termination releases both the cache and engine memory. + +Runtime PDF filter options reduce raster images to a configurable `maxImageResolution`, defaulting to 192 DPI. The [shared PDF canvas](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts) renders at 96 CSS DPI times the device pixel ratio; the default covers ratio 2. Text and vector graphics remain scalable. Explicit bookmark export preserves LibreOfficeKit's default when JSON filter options replace its implicit filter data. + +The [local Office preview decision](2026-09-10-local-office-preview.md) continues to own conversion lifetime, authorization, missing-font presentation, and PDF transport. + +## Alternatives considered + +**Index fonts inside every conversion Worker.** This keeps discovery off the Host event loop and sees newly installed fonts immediately, but repeats full-file metadata parsing across previews. A provider snapshot removes that repeated work at the cost of synchronous initialization and explicit reload after font changes. + +**Cache only the family name.** Style, language, pitch, and missing characters can select different files. A complete request key retains those distinctions and still captures repeated layout requests. + +**Keep 300 DPI for every preview.** Higher raster resolution retains detail for high pixel ratios and magnification but increases image export work. The configurable 192-DPI default follows the common display target without rasterizing text or rebuilding the engine. + +## Consequences + +Cold provider startup includes indexing and remains separate from conversion deadlines. Shared metadata retains no original font buffers, while each Worker still reads selected fonts and lazily decodes coverage. Installing or replacing fonts requires provider reload; a file changed since indexing can fail a later conversion. + +Reducing image resolution trades raster detail at high magnification for less export work. It is not an engine memory cap: LibreOffice can still decode full-resolution source images, load large font collections, or exhaust its build-time WASM memory maximum. No persistent font cache, filesystem watcher, or cross-document engine instance is introduced. + +[Font tests](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts) cover complete request keys, repeated missing-family observations, snapshot isolation, and failed imports. [Provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover snapshot reuse and reload. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) inspect exported image dimensions with default and overridden DPI settings alongside selectable text and page count; ABI forwarding alone cannot establish filter behavior. diff --git a/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md new file mode 100644 index 0000000000..e6ff458e8b --- /dev/null +++ b/.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md @@ -0,0 +1,36 @@ +# Agent Note: WASM 预览的字体复用与图像分辨率 + +Status: implemented +Archived: 2026-09-11 + +[English](2026-09-11-wasm-preview-font-and-image-budgets.md) | 中文 + +## 问题 + +Office 转换在不同文档间重复解析字体元数据,并在同一文档内重复匹配字体。图片较多的文档还会在 PDF 导出时花费大量时间,重采样高于预览显示分辨率的图像。这些成本需要分别控制,因为字体复用不会减少图像解码或重采样。 + +## 决策 + +[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)在初始化时建立一次字体元数据快照。每个转换 Worker 接收结构化克隆。Host 保留名称、字体面属性、路径、大小和修改时间;字形覆盖、请求缓存及导入字节属于 Worker。重新加载提供方会刷新快照。Worker 读取已索引文件时校验文件,已经导入的字体在该次转换内仍可使用。 + +每个 Worker 记忆化完整请求:字体族、样式、字重、斜体、字宽、字距类型、语言及有序码点。缓存保存 MEMFS 路径和缺失字体族观察值。命中时重放这些观察值,因为初始化请求可能在文档字体收集开始后再次出现。Worker 终止时释放缓存及引擎内存。 + +运行时 PDF 过滤器选项将栅格图像降低到可配置的 `maxImageResolution`,默认 192 DPI。[共享 PDF 画布](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts)按 96 CSS DPI 乘以设备像素比渲染;默认值覆盖像素比 2。文本和矢量图形仍可缩放。JSON 过滤器选项替换隐含过滤器数据时,显式导出书签保留 LibreOfficeKit 的默认行为。 + +[本地 Office 预览决策](2026-09-10-local-office-preview.zh.md)继续负责转换生命周期、授权、缺失字体展示和 PDF 传输。 + +## 考虑过的替代方案 + +**在每个转换 Worker 内索引字体。** 这会将发现工作移出 Host 事件循环,并立即识别新安装的字体,但会在不同预览间重复读取完整字体文件并解析元数据。提供方快照消除重复工作,代价是同步初始化,以及字体变化后显式重新加载。 + +**只缓存字体族名称。** 样式、语言、字距类型和缺失字符可能选择不同文件。完整请求键保留这些区别,同时仍能命中重复排版请求。 + +**所有预览保持 300 DPI。** 更高栅格分辨率保留高像素比及放大时的细节,但增加图像导出工作。可配置的 192 DPI 默认值符合常见显示目标,无需栅格化文本或重新构建引擎。 + +## 影响 + +提供方冷启动包含索引成本,不计入转换时限。共享元数据不保留原始字体缓冲区,各 Worker 仍会读取选中字体并按需解码字形覆盖。安装或替换字体后需要重新加载提供方;索引后发生变化的文件可能使后续转换失败。 + +降低图像分辨率以高倍缩放下的栅格细节换取更少的导出工作。它不是引擎内存上限:LibreOffice 仍可能解码全分辨率原图、加载大型字体集合,或耗尽构建时设定的 WASM 内存上限。此实现不引入持久化字体缓存、文件系统监听器或跨文档引擎实例。 + +[字体测试](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts)覆盖完整请求键、重复缺失字体族观察值、快照隔离和失败导入。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖快照复用和重新加载。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)检查默认及覆盖 DPI 设置时的导出图像尺寸、可选择文本和页数;仅验证 ABI 参数转发无法证明过滤器行为。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml index b8c09374e3..4fac2496cb 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml @@ -1,6 +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/feature/2026-08-03-web-sticky-collapsible-headers.md -2026-08-03-web-sticky-collapsible-headers.md: 6e8b4b94c9c6aa40bc7190e6705b84caa8189c52 -2026-08-03-web-sticky-collapsible-headers.zh.md: d4e1f69539db6870ae60d5c4b05cd52c7d129360 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md +2026-09-09-content-sized-diagram-previews.md: fec86111a68ec0fbc12ed6514a60c6a243924963 +2026-09-09-content-sized-diagram-previews.zh.md: 8880f12853f9e7d2d566fca3a482fd5e17514526 diff --git a/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.md b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.md new file mode 100644 index 0000000000..fec86111a6 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.md @@ -0,0 +1,26 @@ +# Agent Note: Content-sized diagram previews + +Status: implemented +Archived: 2026-09-10 + +English | [中文](2026-09-09-content-sized-diagram-previews.zh.md) + +## Problem + +Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser. + +## Decision + +Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer. + +This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document. + +## Alternatives considered + +**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport. + +**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content. + +## Consequences + +Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements. diff --git a/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md new file mode 100644 index 0000000000..8880f12853 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md @@ -0,0 +1,26 @@ +# Agent Note: 按内容定高的图表预览 + +Status: implemented +Archived: 2026-09-10 + +[English](2026-09-09-content-sized-diagram-previews.md) | 中文 + +## Problem + +较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。 + +## Decision + +Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。 + +这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。 + +## Alternatives considered + +**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。 + +**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。 + +## Consequences + +较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 0d758eec58..926441caeb 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -364,6 +364,15 @@ "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30", "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371", "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1", + "architecture/2026-09-09-desktop-in-place-profile.i18n.yaml": "sha256:08543070a32503373d38ca98b7ce96ee60c972302534030c5ba380f19e04fa00", + "architecture/2026-09-09-desktop-in-place-profile.md": "sha256:82bdcad48207c5aecbc9695717aaaa4c8cca90c82ca58884bfa653b60f192290", + "architecture/2026-09-09-desktop-in-place-profile.zh.md": "sha256:f105636c9160c64b12bd1822c8ec81ee744ba27c0b0ab6eeed870561ccd81ce7", + "architecture/2026-09-10-local-office-preview.i18n.yaml": "sha256:a43a2370c7434293ef98d1901a29b7b4b03f400a108c119f3ec5f2f7ca8ca3f8", + "architecture/2026-09-10-local-office-preview.md": "sha256:754cfb4dde9f1ee70e495095f12fc9e3c43af4a959250bb3a59ae44fd32fd763", + "architecture/2026-09-10-local-office-preview.zh.md": "sha256:d8fb9a65f437b585f2dfa2b4aa091634eeabf571e98e881cf7344b7574704e53", + "architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e", + "architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed", + "architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10", "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7", @@ -739,6 +748,9 @@ "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5", "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9", "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe", + "bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c", + "bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80", + "bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", @@ -1609,6 +1621,9 @@ "process/2026-09-03-workspace-version-coherence-gate.i18n.yaml": "sha256:ac442dee172d396395b8516d6d38c4fcedbc855d735c86c378aec5c1098ecd8d", "process/2026-09-03-workspace-version-coherence-gate.md": "sha256:37b63a78506a8741eb04826dc1eea7e3a64c19fd4494c2d7754054a5602d308b", "process/2026-09-03-workspace-version-coherence-gate.zh.md": "sha256:cfa2c8fe6cf1d4665121a987861b70a91b5b41eb99e7ed192c85760a916fddf6", + "process/2026-09-11-independent-libreoffice-package.i18n.yaml": "sha256:e8d2fe75e05ac53438513a7c51dab6510bcecf644019391ee6650e73961c0f8d", + "process/2026-09-11-independent-libreoffice-package.md": "sha256:1292b409b7b4cbc4420868de5e3b7417b3c855889023ccfd9a61d824bf3019d8", + "process/2026-09-11-independent-libreoffice-package.zh.md": "sha256:384783da1614c7e2ea84ea513013af6b20e9d4545a03cf7fa068415078126932", "simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml": "sha256:43fe5daadc1491f94a3e34595182cad1591f76b64e1f13b48811b80fd7e53b94", "simplification/2026-06-19-drop-mutable-session-summary.md": "sha256:01647a5a14aa4e195328d4d39c6d80eb0723739e5a543115314716b280a93560", "simplification/2026-06-19-drop-mutable-session-summary.zh.md": "sha256:22389e0c29158b1f7a5a43ed6073f8795bb87cf51c04cf83be055359cd65c9f5", diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml rename to .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml index c971fb1bfd..7f17afb0a9 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml +++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml @@ -1,6 +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/bug-fix/2026-09-09-composer-placeholder-whitespace.md -2026-09-09-composer-placeholder-whitespace.md: c5d778f8fb1fdfc742636819a9ff27ddeeb0234e -2026-09-09-composer-placeholder-whitespace.zh.md: b5f82bcd86f8567976bba96095d4de9d42bae9b1 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-independent-libreoffice-package.md +2026-09-11-independent-libreoffice-package.md: 05fd8eb86ffb75b564008eaffda5c98669afee27 +2026-09-11-independent-libreoffice-package.zh.md: b2abf61f8a5cf9b247fa2b94697eb90a252d4cff diff --git a/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md new file mode 100644 index 0000000000..05fd8eb86f --- /dev/null +++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md @@ -0,0 +1,32 @@ +# Agent Note: Independent precompiled LibreOffice package + +Status: implemented +Archived: 2026-09-11 + +English | [中文](2026-09-11-independent-libreoffice-package.zh.md) + +## Problem + +Compiling LibreOffice inside ordinary DSH builds would require every contributor and CI job to acquire its toolchain and repeat a large native build. Desktop also needs immutable engine resources that can travel with its offline installation material. + +## Decision + +The [engine package](../../../../native/libreoffice-wasm/package.json) has its own version and an asset-only manifest export. Its source directory stays outside the main pnpm workspace. Installation has no lifecycle hook, and packaging verifies an existing successful build without compiling. The [release workflow](../../../../.github/workflows/libreoffice-wasm-release.yml) compiles only on explicit dispatch; publication requires a matching engine version tag and the protected npm environment. + +Each tarball contains the engine manifest and assets, corresponding source pins, patches, full source diff, and the built distribution's license and notices. Packaging rejects mismatched build receipts, modified assets, unlisted engine files, missing notices, and bundled fonts. A failure never triggers compilation automatically. + +The [local preview decision](../architecture/2026-09-10-local-office-preview.md) continues to own conversion and explicit artifact configuration. This change establishes release machinery; it does not add an unpublished engine dependency to DSH. The existing [Desktop package set](../../../../apps/desktop/scripts/prepare-package-set.ts) remains the intended carrier for a future fixed-version dependency and its offline seed. + +## Alternatives considered + +**Compile during ordinary builds or package installation.** This puts engine toolchain availability and compilation cost on every consumer, even when the engine version has not changed. + +**Extract a general Node conversion library.** Font handling, Worker ownership, and the conversion API can remain with the provider. Moving them is unnecessary to distribute precompiled assets or isolate compilation from DSH CI. + +**Link the engine source as a workspace dependency.** Workspace linking would replace a published precompiled package with a source directory that lacks its engine files. + +## Consequences + +The engine can be compiled and versioned independently of DSH. The package exposes files rather than a conversion API; the provider retains runtime ownership. Ordinary builds remain free of LibreOffice compilation, as checked by the [packaging tests](../../../../scripts/libreoffice-package.spec.ts). + +Initial publication and fixed-version consumer integration remain incomplete. A source tree that differs from its recorded recipe cannot produce a release tarball. Real-engine conversion, installation from the packed package, and Desktop offline seed qualification must accompany the first consumed engine release. diff --git a/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md new file mode 100644 index 0000000000..b2abf61f8a --- /dev/null +++ b/.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 独立的预编译 LibreOffice 包 + +Status: implemented +Archived: 2026-09-11 + +[English](2026-09-11-independent-libreoffice-package.md) | 中文 + +## 问题 + +在普通 DSH 构建中编译 LibreOffice,会要求每个贡献者和 CI job 获取工具链并重复执行大型本机构建。Desktop 也需要能随离线安装材料分发的不可变引擎资源。 + +## 决策 + +[引擎包](../../../../native/libreoffice-wasm/package.json)采用独立版本,只导出产物清单。源码目录不属于主 pnpm workspace。安装没有生命周期 hook,打包只校验已有的成功构建,不执行编译。[发布工作流](../../../../.github/workflows/libreoffice-wasm-release.yml)仅在明确调度时编译;发布要求匹配的引擎版本 tag 和受保护的 npm 环境。 + +每个 tarball 包含引擎清单和产物、对应的源码版本、补丁、完整源码差异,以及构建发行物的许可证和声明。打包拒绝不匹配的构建记录、被修改的产物、未列入清单的引擎文件、缺失的声明和内置字体。失败不会自动触发编译。 + +[本地预览决策](../architecture/2026-09-10-local-office-preview.zh.md)继续负责转换与显式产物配置。此改动建立发布机制,不向 DSH 添加尚未发布的引擎依赖。现有 [Desktop 包集合](../../../../apps/desktop/scripts/prepare-package-set.ts)仍是未来固定版本依赖及其离线 seed 的预期载体。 + +## 考虑过的替代方案 + +**在普通构建或包安装期间编译。** 即使引擎版本没有变化,每个消费方也要承担工具链可用性要求和编译开销。 + +**抽出通用 Node 转换库。** 字体处理、Worker 所有权和转换 API 可以继续由提供方负责。分发预编译产物及将编译与 DSH CI 隔离不需要迁移这些逻辑。 + +**将引擎源码链接为 workspace 依赖。** 工作区链接会把已发布的预编译包替换为缺少引擎文件的源码目录。 + +## 影响 + +引擎可以独立于 DSH 编译和发布版本。包暴露文件而非转换 API;提供方保留运行时所有权。[打包测试](../../../../scripts/libreoffice-package.spec.ts)检查普通构建不编译 LibreOffice。 + +首次发布和固定版本消费方接入尚未完成。与记录的配方不一致的源码树不能产生发布 tarball。首个被消费的引擎发行版必须同时完成真实引擎转换、打包安装,以及 Desktop 离线 seed 验证。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ee3f631f38..d12179dde4 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 665364e6d39a78f7ac497198f18ed9aa160a4cbd -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 8b39df71c615dc59f3ef1bf622a5736a70b085b3 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 665364e6d3..56c9770966 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,17 +42,19 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. +The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in `-office/node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. Missing required packages and missing installed package manifests fail the copy; absent optionals preserve the kit's engine selection. + ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its target-specific Office directory into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, `-office/` directory, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, its Office directory, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. @@ -70,6 +72,8 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. +The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (the declared native target, or WASM when no native target is declared), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. + ## Alternatives considered **Bare Node native SEA.** The injected main script must be a single CJS file, and the blob carries no filesystem and no module resolution, so a dynamic import of a bare specifier has nothing to resolve against; the only option is compiling plugins statically into the main script and registering them by hand — bypassing standard module resolution and hardcoding the plugin set, contrary to "configuration decides everything". The final route is in fact "the official SEA foundation + pkg's VFS/module-hook layer"; what was rejected is the bare use, not SEA itself. @@ -84,6 +88,6 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp ## Consequences -**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. +**Bought**: no system Node.js dependency on supported targets; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. -**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). +**Paid**: large Office engine resources remain external to the SEA archive, and the executable must travel with its required sidecars; source enters the distribution as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 8b39df71c6..04351105a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,17 +42,19 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 +[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于 `-office/node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。必需包缺失或已安装包缺少清单会使复制失败;未安装的可选包保留 kit 自身的引擎选择规则。 + ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与对应目标的 Office 目录一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、`-office/` 目录和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、其 Office 目录及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 @@ -70,6 +72,8 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 +安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(使用已声明的原生目标,未声明原生目标时使用 WASM),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 + ## 曾考虑的替代方案 **裸用 Node 原生 SEA。** 注入的主脚本必须是 CJS 单文件,blob 内没有文件系统与模块解析,因此动态 `import()` 无法解析裸包名;只能把插件静态编译进主脚本并手工注册。这会绕过标准模块解析并硬编码插件集合,与「配置决定一切」相悖。最终路线实际是「官方 SEA 基础 + pkg 的 VFS/模块钩子层」;否决的是裸用方式,而不是 SEA 本身。 @@ -84,6 +88,6 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 ## 后果 -**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 +**买到的**:受支持目标无需依赖系统 Node.js;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 -**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 +**付出的**:大型 Office 引擎资源保留在 SEA 归档之外,可执行文件必须与必需的伴随文件一起迁移;源码原样进入分发物(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 6e4befba1f..a7b0ac458f 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222 +2026-07-25-web-client-session-scope-and-provide-channel.md: 6126d06e48111a551a0dc57247659a3086986c66 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e7bf4aa6952b4fd7b187c91e2ac5d64aac32cabb diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 0f003edc4a..6126d06e48 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -19,6 +19,8 @@ Hard constraints: the host is the single source of truth; every registration goe ## Decision +The reference-owned lifetime and Provider targeting now follow [Client Session references](2026-09-15-client-session-references.md). This note retains the blank-Session and adoption rationale and describes their current realization. + ### The parity model: client and host share one root state axis Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted): @@ -48,13 +50,13 @@ id→ctx handoff is allowed in only three kinds of places (business providers ne - Root coordination services self-addressing: from a projection's sessionId back to the actx via `sessions.scope(id)`. - Root untagged listeners: looking up their own store by the payload's sessionId. -### Scope lifecycle: anchored to the list mirror — birth is entering view, death is prune +### Scope lifecycle: anchored to explicit references -Session instances share the scope's lifecycle; liveness eligibility = host-listed (one criterion, shared by mint and prune): +Session instances share the scope's lifecycle, while the catalog reports discoverability without retaining a generation: -- Birth = a session row entering client view (the list baseline pull / the local `create()` echo / the `host/session-added` frame); a lazy first resolve mints the scope (resolution is a pure function, render-safe). -- One prune tears down three things together: the Session instance, the scope fiber (cascading through every consumer hung on the actx), and the session-keyed slot store. The staged session (= `list.current`) is the exception: removed while still on stage, it keeps a frozen read-only view, torn down only once the stage moves away. -- Reopening = lazily rebuilding the instance + `open()` pulling history (the host session log is the durable truth). +- Birth = the first explicit `sessions.retain(target, options)`; it synchronously returns a reference and mints the Session binding and scope before history is ready. +- Final release withdraws the exact generation before tearing down its Session instance, scope fiber (cascading through every consumer hung on the actx), and session-keyed slot store. Catalog removal does not end a generation while references remain. +- Reopening = a later retain lazily rebuilding the generation and exposing history readiness through `reference.ready` (the Host Session log is the durable truth). - Remaining TODO: approval/question frames never enter history and cannot be recovered across a prune (the manager-level pendingBuffers cover only the never-instantiated window). ### The blank bit: the empty session's visible projection, conversion, and reuse @@ -67,7 +69,7 @@ A session "materialized but with no first prompt" is governed by the summary-der - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank; - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank. -- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally. +- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally. - The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination. ### connectWorkspace: the sole entry point of New Session @@ -77,23 +79,23 @@ A session "materialized but with no first prompt" is governed by the summary-der - The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing. - The create arm: on a miss, `session.create({workspaceId})` returns the new id. - An unknown workspaceId fails loud (never silently creating somewhere else). -- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionRuntime.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush. -- The caller takes the id and does its own `sessions.open`; sending the first prompt is an ordinary `session.prompt` — the session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again. -- The global New Session button defaults to `recentWorkspaceId`: first comparing each Workspace's newest Session `updatedAt`, falling back to the Workspace `createdAt` when it has no Sessions, and keeping host order on ties; only with no Workspace at all does it `sessions.clear()` into the no-session view. Create actions inside a Workspace group still hit that Workspace explicitly. +- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store. The view owner then retains it synchronously, so a draft mover can write text through that binding before history readiness without waiting for a notifier flush. +- The caller takes the id and installs a `mainView` reference; sending the first prompt is an ordinary `session.prompt` — the Session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again. +- The global New Session button defaults to `recentWorkspaceId`: first comparing each Workspace's newest Session `updatedAt`, falling back to the Workspace `createdAt` when it has no Sessions, and keeping Host order on ties; only with no Workspace at all does it clear the main-view reference into the no-Session view. Create actions inside a Workspace group still hit that Workspace explicitly. - At startup the runtime subscribes to the first complete baseline: a successfully restored current session is kept in place; otherwise it automatically calls `connectWorkspace(recentWorkspaceId)` and opens the returned blank session. The policy settles only once; a later user-initiated clear is never overridden by auto-selection again, and a connect failure waits for the next baseline projection to retry. -- Re-picking the Workspace in the blank Hero also goes through `connectWorkspace`; when the target id differs from the current one, the current input machine's non-empty draft moves to the target scope first, then `sessions.open(nextId)`. The old blank entity is not deleted — it merely leaves the list by no longer being current. +- Re-picking the Workspace in the blank Hero also goes through `connectWorkspace`; when the target id differs from the main one, `ui-workspace` retains the target, moves the current input machine's non-empty draft through the preparation callback, and then publishes the new main reference. The old blank entity is not deleted — it merely leaves the list when its `mainView` reference is released. -### Per-session provisioning: the `sessions.provide` standard-kit channel +### Per-session provisioning: the `uiSession.provide` standard-kit channel -The sole provisioning path by which session slot components fetch their own session data. Plugins declare a fixed key map through the static descriptor `sessions.provide({hooks, props, resolve})` (a duplicate key throws at registration); `resolve(binding)` materializes values for a specific session and tears them down with the scope. ui-renderer's `standardKit` single loop binds the hooks compartment into `use` selector hooks (`observableHook`→uSES, anti-tearing) and passes the props compartment through as-is. +The sole provisioning path by which Session slot components fetch their own Session data. Plugins declare a fixed key map through the static descriptor `uiSession.provide({hooks, props, resolve})` (a duplicate key throws at registration); `resolve(binding)` materializes values for a specific binding and tears them down with its scope. ui-renderer's `standardKit` single loop binds the hooks compartment into `use` selector hooks (`observableHook`→uSES, anti-tearing) and passes the props compartment through as-is. Slot scope is the closed set `root | session-maybe | session`: - `root` receives only the global standard kit, with no session identity or provisioning. -- `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. +- `session-maybe` inherits the nearest `SessionProvider` binding with ADOPTION identity: an incarnation born Session-less keeps its React instance when that Provider receives its first binding, then remounts when the Provider switches generation or returns to absence. Component-local per-Session state clears when the Provider switches generation. Across a switch, only persisted Store values survive generation retirement; binding-owned sources survive only when another reference keeps that generation alive. With no binding, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. Provider-roster changes rematerialize the mounted binding without changing its identity, while the per-entry adoption bookkeeping lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no Session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a binding appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 1bae16cfaa..e7bf4aa695 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -19,6 +19,8 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插 ## 决策 +[Client Session 引用](2026-09-15-client-session-references.zh.md)现已定义引用所有的生命周期与 Provider 定位。本 Note 保留 blank Session 与收养语义的理由,并描述它们的当前实现。 + ### 对等模型:client 与 host 同一根状态轴 host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上): @@ -48,13 +50,13 @@ id→ctx 换乘只许三类位置(业务提供方永不换乘): - root 协调服务自寻址:从投影的 sessionId 经 `sessions.scope(id)` 找回 actx。 - root untagged listener:按 payload 的 sessionId 查自有 store。 -### scope 生命周期:挂靠 list mirror,出生即视野、死亡即 prune +### scope 生命周期:挂靠显式引用 -Session 实例与 scope 同生命周期,存活资格 = host listed(一个判据,mint 与 prune 共用): +Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持有 generation: -- 出生 = 会话行进入 client 视野(list 基线拉取 / `create()` 本地回声 / `host/session-added` 帧),lazy 首次 resolve 铸 scope(resolution 纯函数、渲染安全)。 -- prune 一次同拆三样:Session 实例、scope fiber(级联挂在 actx 上的一切消费方)、会话键控 slot store。暂存会话(= `list.current`)例外:被移除仍在台上时保留冻结只读视图,stage 移走才拆。 -- 重开 = lazy 重建实例 + `open()` 拉 history(host 会话日志是持久真相)。 +- 出生 = 第一次显式调用 `sessions.retain(target, options)`;它同步返回 reference,并在历史就绪前铸造 Session binding 与 scope。 +- 最后一份 reference 释放时,Controller 先撤下确切 generation,再拆除其 Session 实例、scope fiber(级联挂在 actx 上的一切消费方)与会话键控 slot store。仍有 reference 时,catalog 移除不会结束 generation。 +- 重开 = 后续 retain 惰性重建 generation,并通过 `reference.ready` 暴露历史就绪结果(Host Session 日志是持久真相)。 - 遗留 TODO:approval/question 帧不进 history,跨 prune 不可恢复(manager 级 pendingBuffers 只覆盖「从未实例化」窗口)。 ### blank 位:空会话的可见投影、转正与复用 @@ -67,7 +69,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank; - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。 -- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。 +- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。 - 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。 ### connectWorkspace:New Session 的唯一入口 @@ -77,23 +79,23 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 - 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。 - 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。 - 未知 workspaceId fail loud(不静默创建到别处)。 -- 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionRuntime.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。 -- 调用方拿 id 自行 `sessions.open`;首条提示词发送就是普通 `session.prompt`——会话本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。 -- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才 `sessions.clear()` 进入无会话视图。Workspace 分组内的创建动作仍显式命中该 Workspace。 +- 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store。视图 owner 随后同步 retain,因此 draft 搬运方可以在历史就绪前通过该 binding 写入文本,无需等待 notifier flush。 +- 调用方拿 id 安装一份 `mainView` reference;首条提示词发送就是普通 `session.prompt`——Session 本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。 +- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才释放主视图 reference,进入无 Session 视图。Workspace 分组内的创建动作仍显式命中该 Workspace。 - 运行时启动时订阅首次完整基线:若已有恢复成功的 current 会话则保持不动,否则自动 `connectWorkspace(recentWorkspaceId)` 并 open 返回的 blank 会话。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。 -- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scope,再 `sessions.open(nextId)`。旧 blank 实体不删除,只因不再 current 而从列表隐藏。 +- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与主视图 id 不同,`ui-workspace` 先 retain 目标,通过 preparation callback 搬运当前 input machine 的非空 draft,再发布新的主 reference。旧 blank 实体不删除,只因其 `mainView` reference 被释放而从列表隐藏。 -### 逐会话供数:`sessions.provide` 标准件通道 +### 逐会话供数:`uiSession.provide` 标准件通道 -会话 slot 组件「自己拿会话数据」的唯一供数路径。插件以静态描述符 `sessions.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定会话下物化值并随 scope 拆;ui-renderer `standardKit` 统一循环把 hooks 格绑成 `use` 选择器钩子(`observableHook`→uSES,防 tearing)、props 格原样透传。 +Session slot 组件「自己拿 Session 数据」的唯一供数路径。插件以静态描述符 `uiSession.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定 binding 下物化值并随其 scope 拆;ui-renderer `standardKit` 统一循环把 hooks 格绑成 `use` 选择器钩子(`observableHook`→uSES,防 tearing)、props 格原样透传。 slot scope 是闭集 `root | session-maybe | session`: - `root` 只拿全局标准件,不接收会话身份或供数。 -- `session-maybe` 以**收养(adoption)身份语义**跟随 current 会话(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个**会话到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格会话 entry 完全一致——切到不同会话重挂,跌回无会话也重挂为崭新的空态化身(之后再次收养)。因此组件本地的逐会话状态**由构造保证**随切换清零;需要活过切换的状态必须住会话绑定的源(machine、store、hooks)。无会话时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅运行时的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无会话时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 +- `session-maybe` 以**收养(adoption)身份语义**继承最近 `SessionProvider` 的 binding:空态出生的化身在该 Provider 第一次收到 binding 时保持 React 实例,此后 Provider 切换 generation 或回到空态时重挂。Provider 切换 generation 时,组件本地的逐 Session 状态会清零。切换过程中,只有持久化 Store 值能活过 generation 退休;只有另一份 reference 保活该 generation 时,binding 自有 source 才能保留。无 binding 时,`sessionId`、`useSession`/`useInput` 的结果与 `inputActions` 均可缺省。Provider roster 变化会重新物化已挂载 binding,但不改变其 identity;逐 entry 的收养记账住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换会话会重建该 entry 及其会话 store。 -`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 Session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。binding 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index aad1a96be8..bb7be001de 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 43f9f0359822f7d7c5b52c09646fd3ffbf80f783 -2026-08-09-client-conversation-node-assembly.zh.md: d7475f5ff8218419743448d455d5d5ffe3cc4091 +2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521 +2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 43f9f03598..bb1d68cc04 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it. -Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)). +Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. Resume headers retain a Chat prompt card even when the system text is unchanged. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor can hide an unchanged `change` header without withdrawing its stable Node key; resume and explicit series-start cards remain visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts)). Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index d7475f5ff8..6291211d58 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。 -Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。 +Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。即使 system 文本未变化,resume header 仍保留 Chat prompt 卡片。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 `change` header 可以隐藏,但不撤回其稳定 Node key;resume 和显式序列起点的卡片仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts))。 Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 0ac7d7109d..b3d68505df 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: 4a78a60c2ad7463c06b670e6ec664579ffd767b9 -2026-08-18-experimental-agent-teams-packages.zh.md: 8056cd8194695e8ce41acb359e51c040d4ed3aec +2026-08-18-experimental-agent-teams-packages.md: a1d29b5e96b185a5539d664e19f2f5cc342a6aba +2026-08-18-experimental-agent-teams-packages.zh.md: f4c4eb481ae6ad6319ddac50eda82fa9b9feebf7 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index 4a78a60c2a..a1d29b5e96 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -12,17 +12,17 @@ Moving the packages into product-role groups would remove their experimental nam ## Decision -`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, and `packages/experimental/client-ui-agent-team` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion. -The dsh pack and publish set and the local baseline publisher include these five Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. +The dsh pack and publish set and the local baseline publisher include these four Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. -The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. +The [single-bundle decision](2026-09-18-agent-teams-single-bundle.md) supersedes the separate Host and Web bundle composition. This note retains publication, dependency isolation, and promotion rationale. The current Team profile remains opt-in and disables the global continuable-child controls whose model-visible names overlap the Team tools; the [optional-bundle decision](../process/2026-09-15-shipped-optional-bundles.md) owns its inclusion in the installation. Profile startup resolves selected bundles before computing the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md). The generation retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. Runtime mode enforces the result in memory; retained link and dual modes materialize the same result as shared and profile-owned projections. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. -Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. +Experimental status changes compatibility and support expectations, not publication for these four packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. ## Alternatives considered @@ -36,6 +36,6 @@ Experimental status changes compatibility and support expectations, not publicat ## Consequences -Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. +Agent Teams publishes as four installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. The release family carries the experimental npm names. Promotion still creates path and npm-name churn as specified by the experimental package rules. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index 8056cd8194..f4c4eb481a 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -12,17 +12,17 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess ## 决策 -`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile` 与 `packages/experimental/client-ui-agent-team` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。 -dsh 打包与发布集合以及本地基线发布器包含这五个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。 +dsh 打包与发布集合以及本地基线发布器包含这四个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 -公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。 +[单组合包决策](2026-09-18-agent-teams-single-bundle.zh.md)取代了独立 Host 与 Web 组合包的组合方式。本记录保留发布、依赖隔离与 promotion 的理由。当前 Team profile 仍须显式启用,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control;[可选组合包决策](../process/2026-09-15-shipped-optional-bundles.zh.md)负责其随安装交付的规则。 profile 启动会先解析所选 bundle,再计算[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md)。generation 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。runtime 模式在内存中强制该结果;保留的 link 与 dual 模式把同一结果物化为共享和 profile 自有投影。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 -对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 +对这四个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 ## 曾考虑的替代方案 @@ -36,6 +36,6 @@ profile 启动会先解析所选 bundle,再计算[不可变 profile resolution ## 后果 -Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 +Agent Teams 会作为 dsh 发布系列中的四个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 发布系列保留实验性 npm 名称。promotion 仍会按照实验性包规则产生路径和 npm 名改动。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml index 7f7cdf3089..d406110a99 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md -2026-08-20-client-session-conversation-ownership.md: 89f27f745c13e070b7f9794d98747bc73bda3b5a -2026-08-20-client-session-conversation-ownership.zh.md: 79be1b898a1fb5a75e5438d55d739d84775fe6d9 +2026-08-20-client-session-conversation-ownership.md: 0d2f19cff1d4625d678a25c87bcc55fc3f2bfad4 +2026-08-20-client-session-conversation-ownership.zh.md: fdb2420efff88e0a5a33dba79bebf0d072280453 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md index 89f27f745c..0d2f19cff1 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -38,7 +38,7 @@ Client Session and Workspace objects belong to `api/session-controller/client` a The React adapters for Session and Workspace belong to `client/ui-session` and `client/ui-workspace`. The Store engine belongs to `client/store`; the Slot registry, scope materialization, and observable-to-hook binding belong to `client/ui-renderer`. -The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines Session history, Remote streams, pagination cursors, and reconnect continuity; this note starts from the Client objects and sources published by Controllers. +The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines history continuity. [Client Session references](2026-09-15-client-session-references.md) owns reference acquisition, exact-generation lifetimes, main-area ownership, and unified UI status; this note owns the layering and source-registration rules. ## Layering principles @@ -55,9 +55,10 @@ Each standard hook belongs to the `ui-*` package closest to its data semantics. | Hook | Owner | Source | | --- | --- | --- | | `useSessions` | `client/ui-session` | Session Controller global list | -| `useSession` | `client/ui-session` | Current Session snapshot | -| `useProjection` | `client/ui-session` | Current Session keyed projection | -| `useSessionPendingInteraction` | `client/ui-session` | Aggregated pending domains | +| `useSession` | `client/ui-session` | Bound Session snapshot | +| `useProjection` | `client/ui-session` | Bound Session keyed projection | +| `useSessionStatus` | `client/ui-session` | Running, effective pending request, and unread completion | +| `useSessionRetainInfo` | `client/ui-session` | Read-only Controller reference-source counts | | `useWorkspaces` | `client/ui-workspace` | Workspace Controller list | | `useConversation` | `client/ui-conversation` | Conversation binding snapshot | | `useChat` | `client/ui-chat` | `chat` target source | @@ -77,10 +78,10 @@ Adding a target does not add a branch to the renderer or Session Controller. The | Package | Owns | Explicitly does not own | | --- | --- | --- | -| `api/session-controller/client` | Session objects, list, selection, commands, projections, queue, event windows, and Agent Contexts | Conversation targets, React, Slots, Workspace | +| `api/session-controller/client` | Session objects, catalog, references, source counts, commands, projections, event windows, and Agent Contexts | Navigation, completion reminders, Conversation targets, React, Slots, Workspace | | `api/workspace-controller/client` | Workspace objects, ordering, archive state, commands, and snapshots | React, Session navigation policy, directory UI | -| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results | -| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data | +| `client/ui-session` | Explicit Session scope, standard sources, `SessionProvider`, and unified UI status | Session transport, reference ownership, Conversation assembly, Approval/Question results | +| `client/ui-workspace` | Workspace hook, browser UI, main-area reference, and cross-Controller navigation policy | Workspace transport, copies of Session data | | `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots | | `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache | | `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots | @@ -117,9 +118,9 @@ Session data reaches the UI through this path: useChat useTrajectory ``` -Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. For cross-domain navigation, `ui-workspace` temporarily reads the Session Controller and issues a selection or command. +Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. `ui-workspace` reads explicit targets for cross-domain navigation and owns the main-area reference without making it a default business Context. -Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.pendingInteractions` then supplies that same object to Session navigation state and Conversation composer selection. +Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.sessionStatus` supplies the same effective object to Workspace indicators and Conversation composer selection. ## Session Controller Client @@ -142,7 +143,7 @@ Whether a field derives from an event, control frame, or local command does not The Session Controller exposes three distinct read faces: -1. The global Session list and current-selection source, used by navigation and `useSessions`. +1. The Session catalog and local ownership sources, used by `useSessions` and read-only reference metadata consumers. 2. A logical binding for each Session containing `sessionId`, a `SessionSnapshot` source, commands, and projection sources. 3. A Conversation-facing `SessionEventSource` used only by the Conversation assembly core. @@ -160,9 +161,9 @@ Initial open, reconnect, gap repair, and updates whose continuity cannot be prov ### Session binding lifecycle -Each Session binding owns a Cordis Context and Fiber. The Session Controller creates and releases the binding. +Each live Session generation owns a Cordis Context and Fiber. The Controller creates its binding on acquisition and retires it on final reference release or root disposal. -Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Releasing a binding cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol. +Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Generation retirement cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol. This cleanup does not require the Session Controller to know the roster of upper-layer consumers. @@ -172,12 +173,12 @@ This cleanup does not require the Session Controller to know the roster of upper `client/ui-session` is the sole Session adapter between the Session Controller and the React/Slot system. It provides `ctx.uiSession` and: -- observes the Session list, current selection, and per-Session bindings; +- observes the Session catalog, local reference metadata, and explicitly supplied bindings; - installs the session and session-maybe scope adapters; - supplies `SessionProvider` rendering semantics; - supplies built-in Session snapshot, projection, and sessionId sources; - accepts Session-scoped source contributions from other domain packages; -- aggregates pending interactions registered by business packages. +- aggregates domain-owned pending interactions with running and completion-reminder facts in `sessionStatus`. It does not own Session transport, event folding, Conversation targets, or concrete business results. @@ -193,12 +194,12 @@ The runtime rejects undeclared, missing, or duplicate standard props. `ui-sessio session and session-maybe use the same adapter with different binding semantics: -- a strict session scope refuses to render without a current binding; +- a strict session scope refuses to render without an explicitly supplied binding; - session-maybe uses a stable absent binding to preserve hook call order; -- changing the current Session rebuilds the strict Session subtree under the `sessionId` key; -- root and session-maybe entries may remain mounted across Session changes. +- changing the exact Context generation remounts a bound subtree, including same-id replacement; +- an unbound session-maybe entry adopts its first binding without remounting; root entries have no Session binding. -Each real materialized binding retains the Controller binding's Context. `ui-session` removes the cache entry and withdraws the current binding through `binding.ctx.effect()`. +Each UI materialization borrows the Controller binding's Context. `ui-session` removes that generation's cache entry and publishes absence through `binding.ctx.effect()`; it does not retain the Session. Changing the contribution roster rematerializes existing bindings and publishes a new source set. Source identity remains stable within one binding lifetime, as required by `useSyncExternalStore` caching. @@ -206,9 +207,9 @@ Changing the contribution roster rematerializes existing bindings and publishes `SessionProvider` is a standard seat derived by `PropsRenderSlots` from a session-scoped child declaration, not a React Context imported directly by business components. -It accepts ordinary `ReactNode` children rather than a `(sessionId) => ReactNode` render function; callers wrap `renderSlot('details', {})` directly. +It accepts ordinary `ReactNode` children and a required `session={reference | undefined}`. The Provider borrows the caller-owned reference without acquiring or releasing it; callers wrap `renderSlot('details', {})` directly. -Session identity comes from the scope binding and standard `sessionId` prop. The Provider handles only the absent branch and subtree isolation by Session identity; components do not obtain Session data through a Provider callback. +Session identity reaches components through the explicit scope binding and standard `sessionId` prop. An absent Provider stays unbound, and neither nested Providers nor root entries fall back to a main-area Session. ### Pending interactions @@ -220,7 +221,7 @@ Concurrent objects with the same key are rejected; replacement requests use a ne `ui-session` selects each Session's effective object using domain precedence. Higher precedence wins; at equal precedence, the later valid object in traversal order wins. -The aggregate is published as `pendingInteractions: ObservableSnapshot>`; `useSessionPendingInteraction` is its React read face. +The pending aggregate is private to `ui-session`; its effective request appears unchanged as `sessionStatus.getSnapshot().get(id)?.pendingInteraction`. `useSessionStatus` is the public UI read face. Session navigation state and composer takeover read the same effective object. They do not maintain separate status maps or takeover rosters. @@ -242,7 +243,7 @@ These combined facts do not enter `WorkspaceSnapshot`: `client/ui-workspace` registers the Workspace list source as the root standard source `workspaces`, from which the renderer provides `useWorkspaces`. -Initial selection, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI navigation policy. That policy may read both `ctx.workspaces` and `ctx.sessions` at decision time, but it issues only Controller commands and selection actions and does not publish a combined snapshot. +Initial restoration, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI policy. `ui-workspace` may read both Controllers, but it keeps the main target and reference in its own navigation owner instead of writing UI selection into a Controller snapshot. Directory pickers, directory browsing, and `openPath` are separate directory capabilities and do not enter the Workspace Controller. @@ -286,7 +287,7 @@ The shell phase is a pure composition of Session lifecycle and Conversation targ ### Input and composer -The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads the current Session's effective object through `useSessionPendingInteraction` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`. +The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads its bound Session's effective request through `useSessionStatus` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`. A selector is a pure function of owner currency. Its non-null result reaches the selected component as `matched`. A stable composer entry and the default composer remain mounted together, while the chain selects one effective presentation. @@ -334,7 +335,7 @@ The Gateway requires only that Remote Event arguments and results are valid JSON ### One pending projection -The Sidebar and composer consume the same `pendingInteractions` snapshot. Navigation displays approval, plan-review, or question state from the effective object's `kind`; each composer entry selects its own panel by object identity. +The Sidebar and composer consume the same effective `sessionStatus` pending request. Navigation displays approval, plan-review, or question state from its `kind`; each composer entry selects its own panel by object identity. The same request identity drives both UI surfaces. A request that replaces another request of the same type uses a new key, so selectors and subscribers observe the identity change. @@ -366,7 +367,7 @@ Stores hold viewing and interaction state such as drafts, View selection, Chat s When one plugin provides both a source and a Slot entry, it registers the source first and the entry second. Reverse Cordis disposal then removes the entry before the source, so a mounted entry never briefly loses a required hook. -Releasing a Session binding cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers. +Final Session-reference release cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers. Every disposer is idempotent and depends on no implicit callback outside the Cordis lifecycle. @@ -386,7 +387,7 @@ UI components do not receive `ctx`. Cross-package collaboration uses Cordis serv Before adding state, choose its sole owner from its consumption semantics: Host communication, commands, and entity lifecycle belong to an API Controller; data assembled from Session events but independent of a target belongs to the Conversation core; projections serving only one View belong to that target package; drafts, selections, and panel state belong to the UI package that owns the interaction. -The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. A cross-domain decision reads multiple sources and immediately issues a command; it does not create a joined snapshot or cache another domain's object. +The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. Cross-domain navigation reads sources at decision time. A UI-owned status source may compose independent running, pending-request, and completion-reminder facts, but must preserve domain ownership and object identity rather than duplicate those domains' state. These are signs of incorrect ownership: a Controller imports React; the renderer branches on business types; a component traverses Session events; a Store holds Session or Workspace entities; changing one target requires changing the Session Controller. @@ -421,7 +422,7 @@ A target must not use another target's snapshot as its data source. Optional col 5. When it can handle the request, create the Pending object, publish it through the publication function, await its result, and remove it in `finally`. 6. Test concurrent keys, precedence, user cancellation, transport abort, plugin disposal, and delegation without a Session. -A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer both read one effective object from `useSessionPendingInteraction`. +A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer read the same effective object from `useSessionStatus`. ### Review checks diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index 79be1b898a..fdb2420eff 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -38,7 +38,7 @@ Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client` Session 与 Workspace 的 React 适配分别归 `client/ui-session` 和 `client/ui-workspace`。Store engine 归 `client/store`,Slot registry、scope materialization 和 observable-to-hook 绑定归 `client/ui-renderer`。 -系统不提供聚合式 `client/runtime` package,也不设置替代它的总控 facade。Session history、Remote stream、分页 cursor 和重连连续性由 [Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md) 定义;本 Note 从 Controller 发布的 Client 对象与 source 开始。 +系统没有聚合式 `client/runtime` 包,也没有替代的中央 facade。[Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md)定义历史连续性。[Client 会话引用](2026-09-15-client-session-references.zh.md)拥有引用获取、精确代际生命周期、主区域所有权和统一 UI 状态;本篇拥有分层与数据源注册规则。 ## 分层原则 @@ -55,9 +55,10 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把 | Hook | Owner | Source | | --- | --- | --- | | `useSessions` | `client/ui-session` | Session Controller 全局列表 | -| `useSession` | `client/ui-session` | 当前 Session snapshot | -| `useProjection` | `client/ui-session` | 当前 Session keyed projection | -| `useSessionPendingInteraction` | `client/ui-session` | pending domain 聚合结果 | +| `useSession` | `client/ui-session` | 已绑定会话快照 | +| `useProjection` | `client/ui-session` | 已绑定会话的键控投影 | +| `useSessionStatus` | `client/ui-session` | 运行状态、有效待处理请求和未读完成提醒 | +| `useSessionRetainInfo` | `client/ui-session` | 控制器的只读引用来源计数 | | `useWorkspaces` | `client/ui-workspace` | Workspace Controller 列表 | | `useConversation` | `client/ui-conversation` | Conversation binding snapshot | | `useChat` | `client/ui-chat` | `chat` target source | @@ -77,10 +78,10 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把 | Package | 拥有内容 | 明确不拥有 | | --- | --- | --- | -| `api/session-controller/client` | Session 对象、列表、选择、命令、projection、queue、事件窗口和 Agent Context | Conversation target、React、Slot、Workspace | +| `api/session-controller/client` | 会话对象、目录、引用、来源计数、命令、投影、事件窗口与 Agent Context | 导航、完成提醒、Conversation target、React、Slot、Workspace | | `api/workspace-controller/client` | Workspace 对象、顺序、归档、命令和 snapshot | React、Session 导航策略、目录 UI | -| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 | -| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 | +| `client/ui-session` | 显式会话作用域、标准数据源、`SessionProvider` 与统一 UI 状态 | 会话传输、引用所有权、Conversation 组装、Approval/Question 结果 | +| `client/ui-workspace` | Workspace 钩子、浏览器 UI、主区域引用与跨控制器导航策略 | Workspace 传输、会话数据副本 | | `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot | | `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache | | `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot | @@ -117,9 +118,9 @@ Session 数据按以下路径进入 UI: useChat useTrajectory ``` -Workspace 数据从 `ctx.remote.workspace` 进入 Workspace Controller,再由 `ui-workspace` 暴露为 `useWorkspaces`;需要跨域导航时,`ui-workspace` 临时读取 Session Controller 并发出选择或命令。 +Workspace 数据由 `ctx.remote.workspace` 进入 Workspace 控制器,再由 `ui-workspace` 通过 `useWorkspaces` 提供。`ui-workspace` 为跨领域导航读取显式目标并持有主区域引用,不把它变为默认业务 Context。 -Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI owner。Owner 发布 Pending 对象,`ui-session.pendingInteractions` 再把同一对象送往 Session 导航状态和 Conversation composer selection。 +Approval 与 Question 通过 `ctx.remote.$on` 从 Host waterfall 到达各自的 UI owner。各 owner 发布 Pending 对象;`ui-session.sessionStatus` 向 Workspace 标识和 Conversation composer 选择提供同一个有效对象。 ## Session Controller Client @@ -142,7 +143,7 @@ Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI ow Session Controller 对外提供三个互不替代的读取面: -1. 全局 Session list 与 current selection source,供导航和 `useSessions` 使用。 +1. 会话目录与本地所有权数据源,由 `useSessions` 和只读引用元数据消费方使用。 2. 每个 Session 的逻辑 binding,包含 `sessionId`、`SessionSnapshot` source、commands 与 projection sources。 3. Conversation-facing `SessionEventSource`,只供 Conversation assemble core 使用。 @@ -160,9 +161,9 @@ Session Controller 对外提供三个互不替代的读取面: ### Session binding 生命周期 -每个 Session binding 持有自己的 Cordis Context 与 Fiber。Session Controller 创建 binding,也负责释放它。 +每个活跃会话 generation 持有 Cordis Context 与 Fiber。控制器在获取引用时创建绑定,并在最后一份引用释放或根销毁时结束该绑定。 -依赖 Session 的对象把清理注册到 `binding.ctx.effect()`。Binding 释放会触发 Conversation binding、UI materialization 和 scoped Slot store 的清理,不存在额外的 `onBindingRelease` 或 `onRelease` 回调协议。 +依赖会话的对象通过 `binding.ctx.effect()` 注册清理。generation 结束会清理 Conversation 绑定、UI 物化结果和作用域 Slot 存储,无需单独的 `onBindingRelease` 或 `onRelease` 回调协议。 这种清理方式不要求 Session Controller 了解上层消费者名册。 @@ -172,12 +173,12 @@ Session Controller 对外提供三个互不替代的读取面: `client/ui-session` 是 Session Controller 与 React/Slot 系统之间唯一的 Session adapter。它提供 `ctx.uiSession`,并负责: -- 观察 Session list、current selection 和 per-Session binding; +- 观察会话目录、本地引用元数据和显式提供的绑定; - 安装 session 与 session-maybe scope adapter; - 提供 `SessionProvider` 的呈现语义; - 内建 session snapshot、projection 和 sessionId source; - 接收其他领域 package 的 Session-scoped source contribution; -- 聚合业务 package 注册的 pending interaction。 +- 在 `sessionStatus` 中组合领域持有的待处理交互、运行事实和完成提醒。 它不拥有 Session transport、event folding、Conversation target 或具体业务结果。 @@ -193,12 +194,12 @@ Session Controller 对外提供三个互不替代的读取面: session 与 session-maybe 使用同一个 adapter,但绑定语义不同: -- strict session scope 在没有 current binding 时拒绝渲染; +- 严格会话作用域在没有显式提供的绑定时拒绝渲染; - session-maybe 使用稳定 absent binding,保持 hook 调用顺序; -- current Session 切换以 `sessionId` 为 key 重建严格 Session subtree; -- root 与 session-maybe entry 可以跨 Session 切换常驻。 +- 精确 Context generation 改变时重新挂载已绑定子树,同一 id 的替代 generation 也如此; +- 未绑定的 session-maybe 条目无需重新挂载即可接纳首个绑定;root 条目没有会话绑定。 -每个真实 materialized binding 保留 Controller binding 的 Context。`ui-session` 通过 `binding.ctx.effect()` 删除缓存项并撤销 current binding。 +每个 UI 物化结果借用控制器绑定的 Context。`ui-session` 通过 `binding.ctx.effect()` 移除该 generation 的缓存项并发布空值,不会 retain 会话。 Contribution roster 变化会重建已 materialize 的 binding 并发布新的 source 集合。同一 binding 生命周期内,source identity 保持稳定,以满足 `useSyncExternalStore` 的缓存要求。 @@ -206,9 +207,9 @@ Contribution roster 变化会重建已 materialize 的 binding 并发布新的 s `SessionProvider` 是 `PropsRenderSlots` 根据 session-scoped child 声明派生的标准席,不是业务 component 直接 import 的 React Context。 -它接收普通 `ReactNode` children,不接收 `(sessionId) => ReactNode` render function;调用方直接用它包裹 `renderSlot('details', {})`。 +它接收普通 `ReactNode` children 和必填的 `session={reference | undefined}`。Provider 借用调用方持有的引用,不获取或释放它;调用方直接包住 `renderSlot('details', {})`。 -Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provider 只负责 absent branch 与按 Session identity 隔离 subtree,组件不得借助 Provider 回调取得 Session 数据。 +会话身份通过显式作用域绑定和标准 `sessionId` prop 到达组件。空 Provider 保持未绑定,嵌套 Provider 和 root 条目都不会回退到主区域会话。 ### Pending interaction @@ -220,7 +221,7 @@ Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provid `ui-session` 使用各 domain 的 precedence 选出每个 Session 当前生效的对象。较高 precedence 胜出,相同 precedence 下后遍历到的有效对象胜出。 -聚合结果发布为 `pendingInteractions: ObservableSnapshot>`,`useSessionPendingInteraction` 是其 React 读取面。 +待处理聚合是 `ui-session` 的私有实现;其有效请求原样出现在 `sessionStatus.getSnapshot().get(id)?.pendingInteraction` 中。`useSessionStatus` 是公开 UI 读取接口。 Session 导航状态和 composer takeover 必须读取同一个 effective object,不得分别维护 status map 或 takeover roster。 @@ -242,7 +243,7 @@ Session 导航状态和 composer takeover 必须读取同一个 effective object `client/ui-workspace` 把 Workspace list source 注册为 root 标准 source `workspaces`,renderer 由此提供 `useWorkspaces`。 -初始选择、blank Session 复用、新建导航、并发创建合并和归档后导航属于 UI navigation policy。该 policy 可以在决定时同时读取 `ctx.workspaces` 与 `ctx.sessions`,但只调用 Controller command 和 selection action,不发布联合 snapshot。 +启动恢复、空白会话复用、新会话导航、并发创建合并与归档后的导航属于 UI 策略。`ui-workspace` 可以读取两个控制器,但把主目标和引用保存在自己的导航 owner 中,不向控制器快照写入 UI 选择。 目录 picker、目录浏览和 `openPath` 属于独立目录能力,不进入 Workspace Controller。 @@ -286,7 +287,7 @@ Shell phase 由 Session lifecycle 与 Conversation target activity 纯合成。S ### Input 与 composer -Composer chain 属于 `ui-conversation`,具体 takeover 属于业务 package。`ConversationRoot` 从 `useSessionPendingInteraction` 读取当前 Session 的 effective object,并作为 `ComposerChainProps.pendingInteraction` 交给 chain selector。 +composer chain 属于 `ui-conversation`,具体接管属于业务包。`ConversationRoot` 通过 `useSessionStatus` 读取已绑定会话的有效请求,并作为 `ComposerChainProps.pendingInteraction` 提供给 chain selector。 Selector 是 owner currency 的纯函数,非 null 结果作为 `matched` 传给获选 component。Stable composer entry 与默认 composer 可以同时常驻,chain 只选择一个有效呈现。 @@ -334,7 +335,7 @@ Gateway 只要求 Remote Event 参数和结果是合法 JSON 传输值,不复 ### 单一 pending 投影 -Sidebar 与 composer 使用相同 `pendingInteractions` snapshot。导航根据 effective object 的 `kind` 显示审批、计划审阅或问题状态,composer entry 根据对象实例选择自己的面板。 +Sidebar 与 composer 消费 `sessionStatus` 中同一个有效待处理请求。导航根据其 `kind` 显示审批、计划审阅或问题状态;每个 composer 条目按对象身份选择自己的面板。 同一请求 identity 同时驱动两处 UI。新请求替换同类型旧请求时使用新 key,因此 selector 与订阅者都观察到身份变化。 @@ -366,7 +367,7 @@ Store 只承载 draft、View selection、Chat selection、inspection request 和 一个 plugin 同时提供 source 与 Slot entry 时,先注册 source,再注册 entry。Cordis 反向 disposal 先移除 entry,再移除 source,仍挂载的 entry 因而不会短暂失去必需 hook。 -Session binding 释放通过 `binding.ctx.effect()` 清理 UI materialization 与 scoped store。Plugin fiber 释放通过 registration disposer 清理 source、listener 和 Slot entry。 +最后一份会话引用释放后,通过 `binding.ctx.effect()` 清理 UI 物化结果和作用域存储。插件 fiber 释放通过注册 disposer 清理数据源、监听器和 Slot 条目。 所有 disposer 都可重复调用,不依赖 Cordis 生命周期以外的隐式回调。 @@ -386,7 +387,7 @@ UI component 不接收 `ctx`。跨 package 协作使用 Cordis service、standar 新增状态前先按消费语义确定唯一 owner:Host 通信、命令和实体生命周期归 API Controller;由 Session events 形成且与 target 无关的数据归 Conversation core;只服务一种 View 的投影归对应 target package;草稿、选择和面板状态归拥有该交互的 UI package。 -同一事实不得同时保存在 Controller snapshot、Conversation snapshot 和 Store。需要跨域决策时读取多个 source 并立即发出 command,不创建联合 snapshot,也不缓存另一领域的对象副本。 +同一个事实不能同时保存在控制器快照、Conversation 快照和存储中。跨领域导航在决策时读取数据源。UI 持有的状态数据源可以组合独立的运行、待处理请求和完成提醒事实,但必须保留领域归属与对象身份,不能复制这些领域的状态。 以下信号表示 owner 选择错误:Controller 开始 import React;renderer 出现业务类型分支;组件遍历 Session events;Store 保存 Session 或 Workspace 实体;一个 target 的变化要求修改 Session Controller。 @@ -421,7 +422,7 @@ Target 不得读取另一个 target 的 snapshot 作为自己的数据源。可 5. 可处理时创建 Pending 对象,使用 publication function 发布,等待结果,并在 `finally` 中移除。 6. 测试并发 key、precedence、用户取消、transport abort、plugin disposal 和无 Session delegation。 -单次请求不得注册 Slot、声明 child Slot、修改 Session snapshot 或另建状态索引。Sidebar 与 composer 都从 `useSessionPendingInteraction` 读取同一个 effective object。 +请求不注册 Slot、不声明子 Slot、不修改会话快照,也不创建独立状态索引。Sidebar 与 composer 从 `useSessionStatus` 读取同一个有效对象。 ### Review 检查点 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index fff8e98bc5..dcadb91f94 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28 -2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9 +2026-08-23-client-derived-tool-presentation.md: 931276fa6d4c0590746b6c59eeae9f998f05629c +2026-08-23-client-derived-tool-presentation.zh.md: b930f98da17a1cb6ecbf605e3a9f37255ba75fc9 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 703960cb24..931276fa6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner ## Decision -The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain. +The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md); all other presentation and ownership constraints remain. The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. @@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | | Forbidden | a new Client presenter service, parallel registry, or wire renderer id | -| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) | +| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md) | | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | ## Terminology @@ -64,7 +64,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall **Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`. -**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row. +**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, lifecycle styling, business glyphs, and ordinary IN/OUT text remain part of the generic tool row. **Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls. @@ -301,7 +301,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n | Input | Preserved result | |---|---| | running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | -| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot | +| successful standard foreground call | terminal output, exit code/signal, and settled state presentation | | `run_in_background:true` | Generic row and raw result | | tool execution error | Generic IN/OUT and error summary | | running persistent `bash`/`pwsh` | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 7f16137955..b930f98da1 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的 ## Decision -下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。 +下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md);其他展示与所有权约束全部保留。 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 @@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | -| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 | +| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md)除外 | | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | ## 术语 @@ -64,7 +64,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa **Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。 -**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。 +**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、生命周期样式、业务图形和普通 IN/OUT 文本仍属于通用工具行。 **对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。 @@ -301,7 +301,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error | 输入 | 保持的结果 | |---|---| | 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | -| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 | +| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 | | `run_in_background:true` | Generic 行与原始结果 | | 工具执行 error | Generic IN/OUT 与错误摘要 | | persistent `bash`/`pwsh` running | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index eba3a6c4a8..9e913ccb88 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141 -2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8 +2026-08-25-electron-desktop-packaging-and-updates.md: c4940942a8d5188faf1846158873c052a43e1fc1 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 011624980f6f7239a33fb2b6c6803646b7d41dd3 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index c532db9c30..c4940942a8 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) -Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). +Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md). The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable. @@ -24,13 +24,13 @@ Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runt 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. +The Desktop Host exposes the shared Web plugin manager for its reserved profile and supplies bundled pnpm through launcher facts. 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 profile recovery or Host startup; later launches focus or recreate the primary window without touching profile state. ## Ownership | Owner | Responsibility | |---|---| -| Electron shell | Window and child lifecycle, local shell pages, reserved desktop profile, plugin GUI, update coordination | +| Electron shell | Window and child lifecycle, reserved desktop profile preparation, native recovery, update coordination | | Electron RunAsNode and pnpm | Execute dsh and install desktop-project dependencies using pnpm’s normal configuration | | Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision | | Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication | @@ -38,7 +38,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p | Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions | | npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state | -The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, 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. +The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload provides boot readiness and failure reporting, native directory selection, theme synchronization, and Windows menu and appearance adapters. It exposes no raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron menus and native dialogs use typed English or Chinese copy with English fallback; Windows follows the main document’s language. The shared Web plugin manager owns its client copy. ## Filesystem layout @@ -59,9 +59,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb ## Installation and resolution -Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md). +The shared Web plugin manager owns profile package operations. Electron retains startup preparation and native recovery; the [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns this separation. -The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing package transaction; after that worker exits, the stale PID can be recovered. +The process-lifetime Electron lock is the authoritative Desktop owner. Profile preparation and native recovery use an exclusive lock recording the Electron PID; a live owner blocks a competing operation, while a stale PID can be recovered. Web package operations use the shared plugin manager’s locking. Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts. @@ -77,11 +77,11 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles. -Electron release artifacts are signed; macOS artifacts are notarized. Package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Electron release artifacts are signed; macOS artifacts are notarized. Package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. -Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification. @@ -97,7 +97,7 @@ Architecture-specific builds report actual component-level compressed and instal | 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. | +| Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, profile preparation, native recovery, update coordination, and electron-builder configuration. | | Installed runtime | Private `@deepseek-ai/dsh-desktop-host` invokes the shared profile runner and reports the authenticated Web URL to Electron. | | Package state | Electron RunAsNode executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. | | Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | @@ -112,7 +112,7 @@ Architecture-specific builds report actual component-level compressed and instal **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. +**Maintain an Electron-only plugin installer.** A separate page, IPC API, and package service duplicate the shared Web manager. The shared service uses the reserved Desktop profile and launcher-supplied pnpm while keeping CLI profile access disabled. **Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other’s dependency graph, Cordis version, plugin version, or native module. Desktop rejects package resolution through the CLI profile fallback. @@ -130,13 +130,13 @@ Architecture-specific builds report actual component-level compressed and instal - A clean offline machine without system Node.js or pnpm starts bundled dsh without installing core dependencies. - The signed application inventories final runtime files; every macOS native file has the release Developer ID, secure timestamp, and hardened runtime, and every Windows artifact has the configured hardware-backed EV signature. -- `.dsh/profiles/desktop/node_modules` resolves shared host links and every GUI-installed desktop plugin. -- Every Desktop package operation uses bundled pnpm with the user’s normal environment and profile configuration. -- 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. +- `.dsh/profiles/desktop/node_modules` stores external plugins managed by the shared Web plugin manager. +- Desktop package operations use launcher-supplied bundled pnpm and the shared manager’s subprocess environment and profile configuration. +- The main application’s Plugins page sends structured package and activation requests to the shared Host service. +- The reserved Desktop profile remains unavailable to the standalone CLI. - 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 UI loads. -- Package or Host failures retain partial profile changes and expose recovery controls; no automatic profile rollback is promised. +- The shared plugin manager owns package-failure handling; native recovery handles fatal Host failures. - 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. - The Web application owns HTTP authentication and serving; the sandboxed renderer cannot access arbitrary filesystem or Electron APIs. @@ -151,8 +151,8 @@ Architecture-specific builds report actual component-level compressed and instal |---|---| | First launch | Check bundled release metadata and create profile links without installing core dependencies | | Desktop profile | One Electron-owned reserved profile for external plugins and shared package links | -| Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | -| Activation | In-place package changes followed by actual Host startup | +| Plugin management | Shared Web Plugins page and Host service with launcher-supplied bundled pnpm | +| Activation | Shared manager applies configuration through HMR when enabled; otherwise changes require restart | | 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 | @@ -160,14 +160,14 @@ Architecture-specific builds report actual component-level compressed and instal Plugin lifecycle scripts execute third-party code. The profile’s `allowBuilds` configuration determines which builds may execute. -Updating the bound dsh can invalidate plugin peer dependencies or native modules. Installed plugin files remain in place; loading failures require explicit repair through the recovery UI. +Updating the bound dsh can invalidate plugin peer dependencies or native modules. Native recovery can disable third-party bundles and restart the application so the shared Plugins page can be used for repair. 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. -Interrupted package operations retain partial changes. Recovery retries the actual Host or permits explicit plugin repair without an automatic package reinstall. +Package-operation failure and cancellation follow the shared plugin manager’s restoration rules. Native recovery does not reinstall packages. Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification. ## Related proposals -The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resources and external plugin dependencies. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) supersedes portless transport and private backend composition. Release identity, signing, process ownership, and Electron-only package authorization remain owned here. +The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resources and external plugin dependencies. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared Web transport and plugin management. Release identity, signing, and process ownership remain owned here. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index fad71d59ea..011624980f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 -profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 +插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。 [Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。 @@ -24,13 +24,13 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] 一个 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。 +Desktop Host 为保留 profile 提供共享 Web 插件管理器,并通过启动器信息提供内置 pnpm。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在 profile 恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。 ## 归属 | Owner | 职责 | |---|---| -| Electron 壳 | 窗口与子进程生命周期、本地壳页面、保留 desktop profile、插件 GUI、更新协调 | +| Electron 壳 | 窗口与子进程生命周期、保留 desktop profile 准备、原生恢复、更新协调 | | Electron RunAsNode 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 | | Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 | | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay | @@ -38,7 +38,7 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] | 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 | | 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 | -渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。 +渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 提供启动就绪与失败报告、原生目录选择、主题同步,以及 Windows 菜单和外观适配。它不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 菜单与原生对话框使用类型化中英文文案,并以英文回退;Windows 跟随主文档语言。共享 Web 插件管理器负责自身客户端文案。 ## 文件系统布局 @@ -59,9 +59,9 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] ## 安装与解析 -Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。 +共享 Web 插件管理器负责 profile 包操作。Electron 保留启动准备和原生恢复;[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这一职责划分。 -进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。 +Electron 进程生命周期锁是 Desktop 的权威所有者。profile 准备和原生恢复使用记录 Electron PID 的排他锁;存活的所有者阻止竞争操作,失效 PID 可被回收。Web 包操作使用共享插件管理器的锁。 核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。 @@ -77,11 +77,11 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 -Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 -Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。 Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。 @@ -97,7 +97,7 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst | 表面 | 实现 | |---|---| -| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 | +| 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、profile 准备、原生恢复、更新协调和 electron-builder 配置。 | | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 调用共享 profile runner,并向 Electron 报告认证 Web URL。 | | 包状态 | Electron RunAsNode 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 | | 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | @@ -112,7 +112,7 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst **把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。 -**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。 +**维护 Electron 专用插件安装器。** 独立页面、IPC API 和包服务会重复共享 Web 管理器的功能。共享服务使用保留 Desktop profile 和启动器提供的 pnpm,同时继续禁止 CLI 访问该 profile。 **让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。 @@ -130,13 +130,13 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst - 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。 - 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。 -- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。 -- 每次 Desktop 包操作都使用内置 pnpm,并遵循用户的正常环境与 profile 配置。 -- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 -- 后端与浏览器应用不能修改桌面包。 +- `.dsh/profiles/desktop/node_modules` 保存由共享 Web 插件管理器管理的外部插件。 +- Desktop 包操作使用启动器提供的内置 pnpm,以及共享管理器的子进程环境与 profile 配置。 +- 主应用的“插件”页面向共享 Host 服务发送结构化包操作与激活请求。 +- 独立 CLI 仍不能访问保留 Desktop profile。 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。 - 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 -- 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。 +- 共享插件管理器负责包操作失败处理;原生恢复负责 Host 致命故障。 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 - Web 应用负责 HTTP 认证与服务;沙箱渲染进程不能访问任意文件系统或 Electron API。 @@ -151,8 +151,8 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst |---|---| | 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 | | 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 | -| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | -| 激活 | 直接修改包后启动实际 Host | +| 插件管理 | 共享 Web“插件”页面与 Host 服务,使用启动器提供的内置 pnpm | +| 激活 | 启用 HMR 时由共享管理器应用配置;否则变更需要重启 | | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh | @@ -160,14 +160,14 @@ Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-inst 插件生命周期脚本会执行第三方代码。profile 的 `allowBuilds` 配置决定哪些构建获准执行。 -更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。已安装插件文件保留原位;加载失败需要通过恢复 UI 显式修复。 +更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。原生恢复可以禁用第三方 bundle 并重启应用,之后可通过共享“插件”页面修复。 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。 -包操作中断会保留部分变更。恢复操作重试实际 Host,或允许显式修复插件,不会自动重新安装包。 +包操作失败与取消遵循共享插件管理器的还原规则。原生恢复不会重新安装包。 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。 ## 相关提案 -[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)取代无端口传输与私有后端组合。发布身份、签名、进程归属及仅限 Electron 的包授权仍由本记录负责。 +[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 传输和插件管理。发布身份、签名与进程归属仍由本记录负责。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 3c5fbdae42..2c2fc1b908 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549 +2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index ff5628c305..0ff8cbf50c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an ### Domain applications -- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. +- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection. - **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header. - **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session. - **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index e433df28fe..f3fca078d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ### 领域应用 -- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。 +- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。 - **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。 - **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。 - **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index bb8c3f29df..d67dd0c16f 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md -2026-08-28-subprocess-native-containment.md: 1b0fd162be77356001bcd9224bb2ee2889e6d6c0 -2026-08-28-subprocess-native-containment.zh.md: f33643f98a6ea6a90c0780432cbe11a35a868562 +2026-08-28-subprocess-native-containment.md: 64fd59e13ea38d7f6e066d5dadec4be830c9c0af +2026-08-28-subprocess-native-containment.zh.md: 59e51c5a5c19f55058eb3881928d4f218a22a59c diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 1b0fd162be..64fd59e13e 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -54,6 +54,8 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification +The host-exit disposal fixture compares the provider's listener identities before and after disposal and verifies that an independently registered exit listener remains installed. Total process listener counts cannot establish provider cleanup because other process-lifetime handlers may register after provider construction. + - Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. - A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index f33643f98a..59e51c5a5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -54,6 +54,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification +宿主退出清理 fixture 对比 provider 在 disposal 前后的监听器身份,并验证独立注册的退出监听器仍然存在。进程监听器总数无法证明 provider 清理完成,因为其他进程生命周期处理器可能在 provider 构造之后注册。 + - provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 - 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml index 89a705592d..55f30d278a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.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-09-05-shared-client-control-primitives.md -2026-09-05-shared-client-control-primitives.md: 14128497cf4152659a62136cbdeb74b92ee3fbf0 -2026-09-05-shared-client-control-primitives.zh.md: 66268b48f81887181c3981bf89ead30eb74250af +2026-09-05-shared-client-control-primitives.md: db8cb9a8bc515918867b27b216fe8edb50c392db +2026-09-05-shared-client-control-primitives.zh.md: 6d4b04a21bf224eaa32b5fbdfb2bf1c3a2cd804d diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md index 14128497cf..db8cb9a8bc 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md @@ -18,15 +18,15 @@ Neither half of the fix was available to an author. No checklist told them to lo `Switch` is the two-state toggle, at the 36×20 track and 16px thumb `ui-settings-plugins` established. `label` is required and has no default, so a render site cannot omit the accessible name; `title` carries a lock reason where a deployment disables the control. -`StateDotState` gains `idle`, a static grey dot in the same halo-and-core construction as `done`, `warning`, and `error`. The plugin inventory's `pending` and `unloading` phases mean no activity is in progress, and the four existing states had no member for that; without `idle` those two phases lose their marker entirely. The addition is safe for the eight packages already consuming `StateDot` because each of them produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. +`StateDotState` includes `idle` for a tracked subject with no activity in progress. The plugin inventory's `pending` and `unloading` phases need that semantic member; without it those two phases lose their marker entirely. The five-state union remains safe for consumers because each package produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. The current solid-dot palette and rotating `ongoing` loader are owned by the later [StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md). -**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. `Pill`'s own header comment previously advertised itself for badges, which contradicted the catalog, and no longer does. +**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. The component comments use the same distinction. The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) as the first step of the new-component checklist, and [docs/web-styling.md](../../../../docs/web-styling.md) points at the catalog so an author arriving from the styling side reaches it too. ## Finding the duplicates -A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly two rules: `Tag` itself and the broken badge below. The unsaved marker keeps only a `flex: none` placement class, which the signature no longer matches. +A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`. Geometry is the reliable probe: search CSS Modules rules carrying both `border-radius: 999px` and `padding: 1px 8px`. That signature belongs to `Tag` and the deliberately local broken badge below; the unsaved marker keeps only its `flex: none` placement class. ## What stays local @@ -34,7 +34,6 @@ A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` mis A mechanical search groups these controls with the promoted three. They stay in their own packages, because the grouping is superficial: - **`ui-trajectory`'s toolbar toggle** carries `role="switch"` but is an 88px labelled control with an inline track, and it currently renders `hidden`. It is not the same widget as `Switch`. -- **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance. - **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge. - **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one. - **`TerminalBlock`'s exit-status pill** stays a static `Pill`. It sits on a 24px command line at the pill's own geometry, and `Tag`'s 11px capsule would not fit that row. The read-only/selectable split is the usual guide, but size decides this one, and the catalog says so. @@ -52,7 +51,7 @@ A mechanical search groups these controls with the promoted three. They stay in **A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which eight ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance. -**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. +**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those phases need a compact inactive or transitional marker; dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. **Putting the reuse rule in the root `AGENTS.md`.** Rejected. The rule governs `packages/client` alone, and the root file sits exactly at its 1950-word ceiling in `scripts/doc-budgets.manifest.json`, so stating it there would have to displace an unrelated repository-wide rule. @@ -60,15 +59,15 @@ A mechanical search groups these controls with the promoted three. They stay in `Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices. -The migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`. +The migrated render sites keep package specs for their role, accessible name, state mapping, and interaction. Web ARIA snapshots cover the assembled presentations — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper because `StateDot` is `aria-hidden`. -That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request. +The component and stylesheet specs pin the capsule geometry, StateDot artwork, animation, tokens, and font weight; Web snapshots pin accessible structure. Manual visual inspection remains responsible for the rendered result across themes. ## Consequences - A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports. - `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument. -- The plugin inventory's tags change from a 5px rectangle to a capsule, and the disabled tag's fill moves from `--dsw-alias-bg-layer-1` to the `neutral` tone's `--dsw-alias-bg-module-platform`, which is a visible grey where it used to be near-transparent. Its phase dots gain a halo and animate through `loading` and `unloading`. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions. -- The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids. +- The plugin inventory uses capsule tags; the disabled tag uses the `neutral` tone's visible `--dsw-alias-bg-module-platform` fill. Its phase markers use the shared `StateDot`, including the rotating loader for active transitions. The unconfigured-secret badge in the plugin settings fields uses the baseline 500 weight. +- The plugin inventory palette contains no literal `#b45309`. The conditional tag uses the real `--dsw-alias-state-warn-primary` token rather than the nonexistent `--dsw-alias-state-warning-primary` alias and its hardcoded fallback. - The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists. -- `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies. +- `Tag` and `Switch` are shared across settings and preset packages; future consumers extend these primitives only when an existing prop cannot express a deliberate difference. diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md index 66268b48f8..6d4b04a21b 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md @@ -18,15 +18,15 @@ Status: implemented `Switch` 是双态开关,采用 `ui-settings-plugins` 确立的 36×20 轨道与 16px 滑块。`label` 必填且没有默认值,渲染点无法省略无障碍名称;`title` 在部署禁用该控件时承载锁定原因。 -`StateDotState` 增加 `idle`,一个静态灰点,与 `done`、`warning`、`error` 采用同样的光晕加实心核结构。插件清单的 `pending` 与 `unloading` 相位表示没有进行中的活动,原有四态没有对应成员;没有 `idle`,这两个相位会彻底失去标记。这次增补对已经消费 `StateDot` 的八个包是安全的:它们各自从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。 +`StateDotState` 包含 `idle`,表示一个被跟踪的对象当前没有进行中的活动。插件清单的 `pending` 与 `unloading` 相位需要这个语义成员;没有 `idle`,这两个相位会彻底失去标记。五态联合对消费方保持安全,因为每个包都从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。当前的纯色点配色与旋转 `ongoing` loading 由后续的 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责。 -**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。`Pill` 自己的头注释此前把自身宣传为可用作徽章,与目录互相矛盾,现已改掉。 +**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。组件注释使用同一套区分。 规则写在 [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) 新组件清单的第一步,[docs/web-styling.md](../../../../docs/web-styling.zh.md) 指向该目录,让从样式一侧进来的作者也能到达。 ## 如何找出重复 -按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配两条规则:`Tag` 本身与下面那个损坏徽章。未保存标记只剩一个 `flex: none` 布局类,已不再匹配该特征。 +按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`。几何特征才是可靠探针:搜索同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。这个特征属于 `Tag` 与下面那个有意保留在原包的损坏徽章;未保存标记只保留自己的 `flex: none` 布局类。 ## 保留在原包的控件 @@ -34,7 +34,6 @@ Status: implemented 机械搜索会把下面这些控件归到被提升的那三类里。它们留在各自的包中,因为那种归并只是表面相似: - **`ui-trajectory` 的工具栏开关**带 `role="switch"`,但它是 88px 宽的带标签控件、轨道内联,而且当前渲染为 `hidden`。它和 `Switch` 不是同一个部件。 -- **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。 - **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。 - **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。 - **`TerminalBlock` 的退出状态胶囊**保持为静态 `Pill`。它落在 24px 的命令行上、用的是 pill 自己的几何,`Tag` 的 11px 胶囊放不进那一行。只读与可选中的区分是通常的判据,但这一处由尺寸决定,目录里也是这么写的。 @@ -52,7 +51,7 @@ Status: implemented **给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中八种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。 -**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 +**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这些阶段需要紧凑的未活动或过渡标记;在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 **把复用规则写进根 `AGENTS.md`。** 否决。这条规则只管 `packages/client`,而根文件正好处在 `scripts/doc-budgets.manifest.json` 给它的 1950 词上限上,写在那里就必须挤掉一条不相干的仓库级规则。 @@ -60,15 +59,15 @@ Status: implemented `Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。 -迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 +迁移后的渲染点保留包级测试,覆盖 role、无障碍名称、状态映射与交互。Web ARIA 快照覆盖装配后的呈现——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 -这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。 +组件与样式表测试固定胶囊几何、StateDot 图稿、动画、token 与字重;Web 快照固定无障碍结构。明暗主题下的最终渲染仍由人工视觉检查负责。 ## Consequences - 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。 - `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。 -- 插件清单的标签从 5px 矩形变为胶囊,其中"已停用"标签的底色从 `--dsw-alias-bg-layer-1` 变为 `neutral` 色调的 `--dsw-alias-bg-module-platform`,由近乎透明变成可见的灰底。相位点获得光晕,并在 `loading` 与 `unloading` 两个相位动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。 -- 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。 +- 插件清单使用胶囊标签;"已停用"标签使用 `neutral` 色调下可见的 `--dsw-alias-bg-module-platform` 填充。相位标记使用共享 `StateDot`,活动转换采用旋转 loading。插件设置字段中的"未配置"徽章使用基准 500 字重。 +- 插件清单调色板不含 `#b45309` 字面量。`conditional` 标签使用真实的 `--dsw-alias-state-warn-primary` token,而不是不存在的 `--dsw-alias-state-warning-primary` 别名及其硬编码兜底色。 - 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。 -- `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。 +- `Tag` 与 `Switch` 由多个设置和预设包共享;只有现有 prop 无法表达明确差异时,后续消费方才扩展这些原语。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index d5073475ba..577fbe18d0 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.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-09-05-sidebar-tab-types-and-navigation.md -2026-09-05-sidebar-tab-types-and-navigation.md: 24332c844d13c41e4f966f39aa7d2fe79fd8553f -2026-09-05-sidebar-tab-types-and-navigation.zh.md: 3aeb38e2ec9c997b9aaf040ae0cb575fa08fa235 +2026-09-05-sidebar-tab-types-and-navigation.md: 599ce36ed4bb1dfc2cce063a461903e1a4aeb40e +2026-09-05-sidebar-tab-types-and-navigation.zh.md: e4e8de5a389fea77aae705d63cc4fb16f88af647 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md index 24332c844d..599ce36ed4 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -36,7 +36,7 @@ interface SidebarRightTabDefinition { `priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes. -`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. [Guide start page and stat pill refinements](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the current description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error. +`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) defines description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error. ### Bodies and titles: keyed Slot seats under the definition's `id` @@ -116,6 +116,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced ## Deferred - A navigation protocol beyond `sidebar://`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes. -- Parameters for the shipped page types, which today declare none. - Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session. - A localized message when an open fails from the conversation; the failure is currently the thrown error's text. diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md index 3aeb38e2ec..e4e8de5a38 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -36,7 +36,7 @@ interface SidebarRightTabDefinition { `priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。 -`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[引导起始页与统计 pill 的细化](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责当前的描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。 +`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)定义描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。 ### 正文与标题:按定义 `id` keyed 的 Slot 坑位 @@ -116,6 +116,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar ## Deferred - `sidebar://` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。 -- 随包页类型的参数,今天未声明任何。 - 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。 - 从会话区打开失败时的本地化提示;目前是抛错文本本身。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index c2dfc82096..62c5aa6ad8 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-09-08-desktop-bundled-runtime-and-external-plugins.md -2026-09-08-desktop-bundled-runtime-and-external-plugins.md: b49ae76bfd8c468e9c971f803cf714755978e5b4 -2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 58dd72124c0fdeb53652a600d0d3c143feb1ff3d +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a5f9266e0e7a8def3f0ff6fc271bddde3db30163 +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 2cc9fd635102f08e6ca4afb4f086a0cf6b8b05f0 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index b49ae76bfd..a5f9266e0e 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) -Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). +Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md). The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable. @@ -40,11 +40,11 @@ First launch creates profile metadata and host links without running pnpm, prese Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation. -Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime. +The shared [plugin manager](../../../../packages/boot/plugin-manager/README.md) owns supported package specifications, bundle validation, activation, and installation failure handling. The bundled pnpm reads normal user and profile settings. Development uses the same Web manager against a separate Desktop profile, with workspace packages supplied by the development runtime. -Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery. +The shared Web plugin manager owns package mutations and activation; Electron retains profile preparation and native recovery. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns these responsibilities. -The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup. The native recovery dialog can disable third-party bundles and back up the profile patch when the Web application cannot start. Installed plugin files remain available for repair. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index 58dd72124c..2cc9fd6351 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 -profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 +插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。 [Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。 @@ -40,11 +40,11 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。 共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。 -依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供。 +共享[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)负责支持的包规格、bundle 验证、激活和安装失败处理。内置 pnpm 读取正常的用户和 profile 设置。开发模式使用相同的 Web 管理器操作独立 Desktop profile,工作区包由开发运行时提供。 -Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复。 +共享 Web 插件管理器负责包变更和激活;Electron 保留 profile 准备和原生恢复。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责这些职责。 -[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host。Web 应用无法启动时,原生恢复对话框可禁用第三方 bundle 并备份 profile patch。已安装插件文件保留以供修复。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 511e949e4c..414fda0806 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.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-09-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: 43cc8d935763512a53379466bb796b5cac469793 -2026-09-08-document-preview-operations.zh.md: 44eccba894b3748a1d8640561a9eb760de481919 +2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2 +2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index 43cc8d9357..be3a835a62 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -16,12 +16,16 @@ Document Preview separates resource observation from content reads. The [resourc Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity. -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives only when at least two exist and remembers a manual choice per tab; plain text is the fallback except for suffixes a registration declares binary or the owner's unviewable list names ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. -Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. +Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. + +PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. ## Alternatives considered +**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and reports only its displayed source version. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot. + **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. **A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index 44eccba894..efb87e16cc 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -16,12 +16,16 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。 -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏仅在候选不少于两个时列出匹配候选,按 tab 记住手动选择;纯文本是兜底,但注册声明为二进制的后缀和 owner 的 unviewable 清单所列后缀除外([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 -Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 + +PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 ## 考虑过的替代方案 +**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。 + **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 **核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml index a643f7935e..35d6b7f310 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.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-09-08-global-main-panels.md -2026-09-08-global-main-panels.md: 6aa7df2dcdd28b94cc0084a0044764efb2e749e2 -2026-09-08-global-main-panels.zh.md: bbb10b2ef5784748a165ad54c907fac2a63c9b17 +2026-09-08-global-main-panels.md: 63530a6b498bd8099cf627d82c888846d3d96767 +2026-09-08-global-main-panels.zh.md: 776787c3de3b79ca7948dcf949ac0393f87e7808 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md index 6aa7df2dcd..63530a6b49 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md @@ -10,13 +10,13 @@ Plugins need application-wide views that do not belong to a Session. A Session-s ## Decision -The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding. +The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to Conversation. `ui-session` derives the root Session binding from `uiWorkspace`'s `mainView` ownership marker; `main.conversation` and its associated right Sidebar inherit that Provider binding. Other main entries receive no implicit Session binding. [Client Session references](2026-09-15-client-session-references.md) owns acquisition and source metadata; this note owns global panel selection. The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registered no panel entry when this landed, so an empty list has no DOM or spacing; the web bundle's plugin manager now registers the first one ([plugin management moves to the Web sidebar](2026-09-09-plugin-management-in-the-web-sidebar.md)). Selection validates the live main entry and rejects a missing key without replacing the current panel. One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame. -`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event. +`uiWorkspace.openSession(target)` acquires the explicit target before replacing its main reference and returning the main area to Conversation. `openWorkspace` and `forkSession` keep the layout's existing `beginNavigation()` signal and service lifetime. `openWorkspace` runs its existing synchronous preparation after acquisition and before replacing the main reference. Supersession prevents a late UI commit, not Session creation. Direct Session opening adds no global navigation cancellation. Panel navigation neither releases the retained main Session nor writes a Session event. DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection. diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md index bbb10b2ef5..776787c3de 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。 +布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation。`ui-session` 根据 `uiWorkspace` 的 `mainView` 所有权标记得出根 Session binding;`main.conversation` 及其关联右 Sidebar 继承该 Provider binding。其他主面板条目不获得隐式会话绑定。[Client 会话引用](2026-09-15-client-session-references.zh.md)拥有引用获取与来源元数据;本篇拥有全局面板选择。 侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。本决定落地时默认组合不注册面板条目,因此空列表没有 DOM 或间距;现在 web bundle 的插件管理器注册了第一个条目([插件管理移到 Web 侧栏](2026-09-09-plugin-management-in-the-web-sidebar.zh.md))。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。 渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。 -`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。 +`uiWorkspace.openSession(target)` 先获取显式目标,再替换主引用并让中央区域返回 Conversation。`openWorkspace` 和 `forkSession` 保留布局既有的 `beginNavigation()` 信号与服务生命周期。`openWorkspace` 在获取完成后、替换主引用前执行既有的同步准备动作。请求被替代会阻止迟到的 UI 提交,不阻止会话创建。直接打开会话不增加全局导航取消。面板导航既不释放所持主会话,也不写入会话事件。 DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml index 1b6639b303..b44324b5dd 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.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-09-09-consumer-owned-startup-strictness.md -2026-09-09-consumer-owned-startup-strictness.md: e009e66ead25ef0a5e6001d33663e32bc04d19d2 -2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4 +2026-09-09-consumer-owned-startup-strictness.md: 2f16078c7051b4038c1d48120b500f0fcd465aef +2026-09-09-consumer-owned-startup-strictness.zh.md: c0fe61e5f708f79f6a5b903f3157ee358c3086bc diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md index e009e66ead..2f16078c70 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -28,11 +28,13 @@ This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transpor ## Consequences -Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures use the same detailed import, activation, or pending-service diagnostic before app-boot disposes the root. +Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures combine every inactive entry into one diagnostic, separating failed plugins from pending services and marking required entries. `StartupError` retains the original failures as its cause after app-boot disposes the root. The CLI prints its message once and exits with code 1, avoiding duplicate wrapper stacks while preserving plugin stacks, nested causes, and aggregate members. The CLI saves original errors, inactive-entry metadata, and startup warning/error records in a unique report directly under `$DSH_HOME/logs/`. This preserves import errors and error properties that the concise terminal output omits. Failed writes fall back to the full report on stderr and keep exit code 1. Unrelated exceptions remain unhandled. + +The compact terminal report keeps the failing plugins visible; a separate file retains raw diagnostics without the default logger buffer's record limit. Raw error values remain intact, so a sharing warning accompanies the report rather than silently redacting fields. An independent exporter lifetime covers asynchronous application disposal. The CLI awaits stderr completion before explicitly exiting, because failed plugins can leave stdin or other handles open. ## Testing -App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. +App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together. diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md index 58c364056f..c0fe61e5f7 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -28,11 +28,13 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles ## 后果 -稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 使用相同的详细 import、activation 或 pending-service 诊断,然后由 app-boot 拆卸 root。 +稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 将所有 inactive entry 合并到一份诊断中,区分失败插件与等待服务的插件,并标记 required entry。App-boot 拆卸 root 后,`StartupError` 仍以 cause 保留原始失败。CLI 仅输出其消息一次,并以退出码 1 结束,避免重复的包装堆栈,同时保留插件堆栈、嵌套原因和聚合错误成员。CLI 将原始错误、未激活条目的元数据及启动警告、错误记录保存到直接位于 `$DSH_HOME/logs/` 下的唯一报告中,保留简洁终端输出省略的导入错误和错误属性。写入失败时,完整报告回退到 stderr,退出码仍为 1。其他异常继续作为未处理异常抛出。 + +简洁的终端报告突出失败插件;单独文件保存原始诊断,不受默认 logger 缓冲区记录数限制。原始错误值保持完整,因此报告附带分享提醒,不会静默脱敏字段。独立的 exporter 生命周期覆盖应用的异步资源释放。CLI 等待 stderr 写入完成后明确退出,因为失败插件可能留下 stdin 或其他打开的句柄。 ## 测试 -App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 +App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 [Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml index 6a440cf660..628396c454 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-09-09-desktop-immediate-window-and-direct-start.md -2026-09-09-desktop-immediate-window-and-direct-start.md: 2d325d5c9f9c3fc8dd83816538d0a1b4b4c5480b -2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 8aebe6bc8c86cc14b78d9dc979eeb902493de8ae +2026-09-09-desktop-immediate-window-and-direct-start.md: e7ccf9b7456213d69795a40d9cd2a8509adfc44e +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: c59cc91f0013293cbaf2c086822085000dd3afd2 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md index 2d325d5c9f..e7ccf9b745 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) -Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). +Plugin management and native recovery follow the [shared Web wrapper decision](2026-09-10-desktop-web-wrapper.md). ## Problem @@ -16,9 +16,9 @@ Electron creates the main window with the packaged Web loading page before profi Fatal presentation follows [native Desktop recovery](2026-09-15-desktop-native-fatal-recovery.md). Window timing, direct Host startup, and shutdown ownership remain governed here. -Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback. +Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. -This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. +This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, and resource ownership rationale. Full runtime file verification remains a packaging operation. ## Alternatives considered @@ -30,4 +30,4 @@ This partially supersedes staged backend probes and waiting to create the main w Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails. -Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests. +Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, native recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle tests. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md index 8aebe6bc8c..c59cc91f00 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 -profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 +插件管理和原生恢复遵循[共享 Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)。 ## 问题 @@ -16,9 +16,9 @@ Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主 致命错误展示遵循[原生 Desktop 恢复](2026-09-15-desktop-native-fatal-recovery.zh.md)。窗口展示时机、直接启动 Host 和关闭所有权仍由本文规定。 -Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供认证 Host URL 与启动注入。壳使用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为归属的应用 origin 认证直接 WebSocket 请求。这一载体适配保留 Web 路由与流语义,同时允许静态 HTML 在 Host 之前显示。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。 +Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪信息提供认证后的 Host URL 和启动注入。桌面壳用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为所属应用源认证直接 WebSocket 请求。此承载适配保留 Web 路由和流语义,同时允许静态 HTML 在 Host 就绪前显示。 -本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。 +本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输与资源归属的理由。完整运行时文件验证仍属于打包操作。 ## 考虑过的替代方案 @@ -30,4 +30,4 @@ Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop- 用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。 -验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。 +验证覆盖 Host 延迟时可见的加载页、新 profile 仅启动一次服务、同一窗口中的失败与重试、原生恢复,以及子进程启动时关闭。安装后 GUI 证据补充生命周期测试。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml index aac049979c..c97659a425 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.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-09-09-profile-resolution-generations.md -2026-09-09-profile-resolution-generations.md: 09ea70e49a4c876eac88e58262644e76c058fca5 -2026-09-09-profile-resolution-generations.zh.md: 0f852e9d31ef4c362f341d6b8fb6d5560225240f +2026-09-09-profile-resolution-generations.md: ad6f05ba88b25ea52da97834cf4638c8feb961ba +2026-09-09-profile-resolution-generations.zh.md: ffa9abc5a6a04bc9553878946fa3fb422328312c diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md index 09ea70e49a..ad6f05ba88 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md @@ -6,23 +6,23 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md) ## Problem -A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. The current launcher bridges the trees by calculating package precedence at startup and materializing that result as shared symlinks, profile-owned links, or packaged-executable proxy packages. The files persist across processes and installations, require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically. +A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically. The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry. ## Decision -Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to link mode and preserves the existing materialized lookup behavior. Internal callers and tests can select runtime mode, which installs the generation into Node's ESM and CommonJS resolvers, or dual mode, which materializes and verifies the same generation. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement. +Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to runtime mode, which installs the generation into Node's ESM and CommonJS resolvers without materializing fallback links. Plain Node callers and tests can explicitly select link mode to materialize the generation or dual mode to materialize and verify it. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement. ### One selection algorithm -The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects link. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path. +The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects runtime. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path. The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before. Profile-local and plugin-private `node_modules` entries stay outside the fallback entries, and Node checks them before the virtual fallback position. The generation records only installed direct profile package names for a no-I/O native fast path. Each fallback entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed to rerun Node's native resolution from the selected package and validate that a successor preserves existing mappings. -The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. The launcher uses it by default. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison. +The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. Named profile launches use it only in explicit link or dual mode. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison. ### Immutable generations @@ -74,11 +74,11 @@ Legacy disk state remains available to link-only launches, old processes, and ro Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route. -The `dsh` launcher selects link mode when an ordinary Node caller omits `resolutionMode`, so existing npm-installed profile startup keeps its filesystem behavior. A pkg executable always selects runtime mode, and the Electron Host installs its runtime generation before any profile row mounts. Tests and low-level embedders can still select runtime or dual explicitly. +The `dsh` launcher selects runtime mode when an ordinary Node caller omits `resolutionMode`. A pkg executable always selects runtime mode, and the Electron Host explicitly selects runtime mode in both development and packaged builds before any profile row mounts. Plain Node tests and low-level embedders can explicitly select link, dual, or runtime. Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver. -Pkg and packaged Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`, reads its dsh tree from ASAR, and maps executable ASAR entries to electron-builder's unpacked tree. Neither carrier creates, updates, or removes legacy resolution links. +Pkg and Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Their runtime resolvers do not create, update, or remove legacy resolution links. ### Performance and verification @@ -115,4 +115,4 @@ Behavior tests compare the runtime generation with the disk materializer over th ## Consequences -Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the ordinary Node launcher default, dual keeps a migration comparison path, and pkg plus Electron carriers force runtime resolution without retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart. +Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Runtime is the ordinary Node launcher default, link and dual remain explicit comparison options, and pkg plus Electron carriers force runtime resolution without the resolver retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart. diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md index 0f852e9d31..ffa9abc5a6 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md @@ -6,23 +6,23 @@ Status: implemented ## Problem -profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。当前启动器在启动时计算包优先级,再将结果物化为共享 symlink、profile 自有链接或打包可执行文件的代理包。文件跨进程和安装版本持续存在,需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。 +profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。 运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。 ## Decision -profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 link 模式,保留现有的物化查找行为。内部调用方和测试可以选择 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以选择 dual 模式,同时物化并校验同一份 generation。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。 +profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS 解析器,不物化 fallback 链接。普通 Node 调用方和测试可以显式选择 link 模式以物化 generation,或选择 dual 模式以物化并校验它。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。 ### 唯一选包算法 -包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。 +包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 runtime。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。 安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。 profile 本地和插件私有 `node_modules` 不进入 fallback entries,由 Node 在虚拟 fallback 位置之前选择。generation 只记录已安装的 profile 直接包名用于 native 快速分流;每个 fallback 记录包名、版本、旧规则选中的查找目录、声明该边的 manifest 锚点和作用域,足以从选定包重新进入 Node 原生解析并验证换代保持既有映射。 -旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。launcher 默认调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。 +旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。具名 profile 启动只在显式 link 或 dual 模式下调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。 ### 不可变 generation @@ -74,11 +74,11 @@ runtime-only 启动流程不创建、更新或退休 symlink 和代理包。reso link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。 -普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 link 模式,因此既有 npm 安装的 profile 启动保留文件系统行为。pkg 可执行文件始终选择 runtime,Electron Host 则在挂载任何 profile 条目前安装 runtime generation。测试与底层嵌入方仍可显式选择 runtime 或 dual。 +普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 runtime 模式。pkg 可执行文件始终选择 runtime,Electron Host 在开发与打包构建中也会在挂载任何 profile 条目前显式选择 runtime。普通 Node 测试与底层嵌入方可以显式选择 link、dual 或 runtime。 runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。 -pkg 与打包 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行,从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。两种载体都不会创建、更新或删除旧解析链接。 +pkg 与 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。它们的运行时解析器不会创建、更新或删除旧解析链接。 ### 性能与验证 @@ -115,4 +115,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve ## Consequences -runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持普通 Node launcher 的默认值,dual 保留迁移比较路径,pkg 与 Electron 载体则强制使用 runtime 且不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。 +runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。runtime 是普通 Node launcher 的默认值,link 与 dual 保留为显式对比选项;pkg 与 Electron 载体强制使用 runtime,解析器不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml index e4d4d80da4..97cdb07d3f 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.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-09-09-workspace-file-read-authority.md -2026-09-09-workspace-file-read-authority.md: 35ac15f5e463910be3b3a0cfe7b80dc69c129ad3 -2026-09-09-workspace-file-read-authority.zh.md: 559a59af0bfa988ff880fad22470397e4e74f296 +2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c +2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md index 35ac15f5e4..d1a5b9d963 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md @@ -14,7 +14,7 @@ Workspace Files serves both file content and workspace navigation. Applying work `list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate. -`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. +`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox. @@ -26,4 +26,4 @@ The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging ## Consequences -Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. A previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates. +Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. With Developer tools enabled, a previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates. diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md index 559a59af0b..f8b0fae432 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md @@ -14,7 +14,7 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应 `list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。 -`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 +`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。 @@ -26,4 +26,4 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应 ## Consequences -持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。 +持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。开启开发者工具时,预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml similarity index 57% rename from .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml index ddf0632164..484c0c2a3b 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml @@ -1,6 +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/feature/2026-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc -2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md +2026-09-10-compact-tool-details.md: 6c1f29c5554c74b01fc2a86c2dee3750a529a6b5 +2026-09-10-compact-tool-details.zh.md: d0822a48f100f9f21ef4706204087fcd1e627da4 diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md new file mode 100644 index 0000000000..6c1f29c555 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md @@ -0,0 +1,29 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +English | [中文](2026-09-10-compact-tool-details.zh.md) + +## Problem + +Goal, todo, and schedule results contain a small set of user-facing facts that take more effort to read as JSON. Dedicated expanded bodies need to fit the existing conversation rows without adding a second presentation registry or live state to historical calls. + +## Decision + +The `ui-tool` package registers goal and schedule toolviews through the existing keyed slot and shares one package-internal fields/list component with the todo view. Successful, recognized results display recorded values; incomplete calls, errors, malformed values, and unsupported result formats retain generic input/output. Schedule domain errors, including uncertain persistence and an unknown deletion target, retain their original result. + +Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection. + +Todo changes compare each root or nested call with its preceding durable `todo/write`, so later writes cannot alter an earlier card. The conversation assembler repairs predecessors when older history loads. Until the predecessor is known, the card shows the submitted list without inferred additions or removals. + +This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative. + +## Alternatives considered + +**A shared presenter framework:** the existing keyed slot already dispatches tools. Reusing only the detail body keeps the abstraction local until another package needs it. + +**A dashboard inside each card:** interactive controls, progress graphics, and live states add visual weight and can misrepresent what a historical call returned. Compact read-only fields and lists preserve the conversation's hierarchy. + +## Consequences + +Registered tools gain readable details without changing Host tools, Session events, or public Client exports. Tool-specific adapters interpret recorded JSON and text; unrecognized formats retain generic output. The authored tool-details Session replay covers their assembled rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md new file mode 100644 index 0000000000..d0822a48f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +[English](2026-09-10-compact-tool-details.md) | 中文 + +## 问题 + +目标、任务清单和定时任务的结果包含少量面向用户的事实,以 JSON 阅读时较费力。专用展开内容需要融入现有对话行,不能为历史调用新增一套呈现注册表或实时状态。 + +## 决策 + +`ui-tool` 包通过现有按键分发的插槽注册目标和定时任务视图,并与任务清单视图共用一个包内字段/列表组件。成功且可识别的结果显示记录值;未完成调用、错误、格式异常和不支持的结果格式保留通用输入/输出。定时任务领域错误(包括持久化不确定和删除目标不存在)保留原始结果。 + +工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。 + +任务清单的变化将每个根调用或嵌套调用与之前的持久化 `todo/write` 比较,因此后续写入不会改变较早的卡片。对话组装器在加载更早的历史时修复前序记录。前序记录尚不明确时,卡片展示提交的列表,不推断新增或删除。 + +这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。 + +## 考虑过的替代方案 + +**共享呈现框架:**现有按键插槽已负责工具分发。仅复用详情内容,可以在其他包出现需求前将抽象限制在包内。 + +**在每张卡片中嵌入仪表盘:**交互控件、进度图形和实时状态会增加视觉负担,也可能歪曲历史调用返回的事实。紧凑的只读字段和列表保留了对话的层级。 + +## 影响 + +已注册的工具获得可读详情,无需改变 Host 工具、Session 事件或公开的 Client 导出。工具专用适配器解读记录中的 JSON 和文本;未识别的格式保留通用输出。手写的 tool-details Session 回放覆盖这些工具组装后的渲染。 diff --git a/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml new file mode 100644 index 0000000000..993f4391a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md +2026-09-10-component-factories-and-local-slots.md: 801e77644d9d2c80c4a9f3cad263420d1deda163 +2026-09-10-component-factories-and-local-slots.zh.md: 3ccd5f01633a1aa4ae830e1ee41243f5d1b65cc5 diff --git a/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md new file mode 100644 index 0000000000..801e77644d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.md @@ -0,0 +1,105 @@ +# Agent Note: Reusable component factories with local slots + +Status: implemented + +English | [中文](2026-09-10-component-factories-and-local-slots.zh.md) + +## Problem + +The browser Slot system starts with a parent-owned extension position. A parent entry declares a child through `children`, and unrelated plugins may then register implementations into that position. The declaration fixes the child Slot's kind, scope, render authority, and lifetime. + +A reusable component assembly has the opposite ownership direction. One package defines the assembly, unrelated parents render independent occurrences, and each parent may choose a different Component for a named internal region. An ordinary Slot cannot represent this relationship because its definition belongs to one parent position in the global Slot tree. + +The defining and consuming packages compile independently. TypeScript cannot infer a selected Component's props from a runtime registration in another package, and a separately maintained flattened props type would duplicate the store, injection, locale, child-render, and scope declarations. + +## Decision + +`ui-slots` and `ui-renderer` provide named Component Factories beside the [ordinary Slot system](2026-07-22-slot-type-chain-implementation.md). `registerFactory()` installs one reusable definition, `renderFactorySlot()` renders an occurrence, and the definition reads caller-selected local Components through `useFactorySlot()`. + +### Opposite registration directions + +Ordinary Slots and Component Factories retain distinct ownership models. + +| Property | Ordinary Slot | Component Factory | +|---|---|---| +| First declaration | Parent declares a child Slot | Definition owner declares the Factory | +| Later operation | Child registers into the parent position | Parent renders an occurrence | +| Static authority | `SlotMap` describes the position | `SlotFactoryMap` describes the complete definition | +| Live definitions | Multiple entries may occupy cells | One definition owns one Factory name | +| Parent input | `renderSlot()` owner and keyed props | `renderFactorySlot()` occurrence props | +| Parent-selected Component | Registry routing selects entries | Caller selects one Component per local slot | +| Descendant extension points | Entry-owned ordinary `children` | Definition-owned ordinary `children` | + +`SlotFactoryMap` declaration-merges the complete static definition: + +```text +interface SlotFactoryDef { + scope: SlotScope + props?: object + children?: ChildrenDecl + store?: StoreDecl + inject?: object + locale?: keyof LocaleNamespaceMap & string + slots?: Record +} +``` + +The map is the only type authority. `registerFactory()` checks the runtime definition and main Component against its map entry. Store declarations normalize through `HandleOf`, so registration accepts one shared handle or one factory that produces that handle, never a nested factory. `FactoryComponentPropsOf` and `FactoryLocalComponentPropsOf` derive the complete Component props from the same entry; definition owners and consumers do not restate a flattened shared type. + +### Definition and occurrence lifecycle + +A Factory name has one live definition in a registry ledger separate from ordinary Slot cells. Registration follows the caller's Cordis effect. Disposal removes the definition, collapses its ordinary child declarations, notifies mounted outlets, and invalidates retained child-render or local-Component authority. + +Each `renderFactorySlot()` call creates an occurrence whose identity follows its React position and `key`. Definition replacement remounts occurrences. Definition Components and their fallback local Components report failures against the definition, while caller-selected local Components report against the caller registration; nested Factory renders preserve the same owner. All failures remain contained to that occurrence without retiring the shared definition. Assembly errors propagate, while ownership and stale-authorization failures report like ordinary component failures. The outer error boundary resets with the Factory scope incarnation, and each local boundary resets with its local slot's scope incarnation. + +Factory scope follows the scope binding at the occurrence's React position. `renderFactorySlot()` accepts no Session id or scope target. A strict `session` Factory requires a current binding and remounts when its identity changes; a `session-maybe` Factory preserves its first empty-to-Session adoption and remounts on later identity changes, matching ordinary Slot behavior. + +A shared store handle keeps the ordinary handle-by-scope behavior, including requiring a Session binding for either non-root scope. An exclusive store factory creates one handle when an occurrence first materializes and rejects that handle if it declares `spec.persist`, because registration must remain lazy and independently live occurrences cannot share one persistence key safely. Render-time records stay weak until an idempotent effect setup retains the committed occurrence; effect cleanup removes the strong mounted reference, while the occurrence-keyed WeakMap preserves identity during React effect replay and permits collection after unmount. + +### Local slots and ordinary children + +The caller may select one Component for each name in the Factory's `slots` declaration. The Factory calls `useFactorySlot(name, fallback)` and receives an identity-stable bound Component that accepts only that local slot's occurrence props. The renderer supplies the Factory's store, injection, locale, child renderers, and the local slot's own standard scope props when the bound Component renders. + +Local slots have no list, keyed, or chain routing and no independent registration lifetime. Multi-contributor extension points remain ordinary child Slots declared by the Factory. Those child declarations are global to the definition, while every occurrence renders their registered entries under its inherited scope. + +Live inspection represents each definition as a `type: 'factory'` node and nests its ordinary child Slots beneath it. Ordinary nodes retain `type: 'slot'` and their existing `kind`; callers select a Factory root with `factory:`. + +Every renderer-created Component receives `renderFactorySlot`, so a Factory occurrence needs no parent-side use declaration. Local selection remains per occurrence and does not introduce a runtime value import from the defining package. + +### First shipped use + +`ui-conversation` registers the optional-Session `conversation.content` Factory around the shared body and Composer. Its strict-Session `views` local position defaults to an adapter that renders the existing `conversation.session` Slot; another occurrence can select a different view Component without mounting the main Conversation Header. + +The Factory does not own the Conversation store. The ordinary `conversation.session` body and `conversation.session.header` retain one shared strict-Session handle, preserving draft and View-selection identity without mounting that handle under both `session` and `session-maybe` scopes. + +### Type and runtime enforcement + +The type chain rejects unknown Factory names, missing or extra occurrence props, child specs that disagree with `SlotMap`, definition fields that disagree with `SlotFactoryMap`, nested store factories, unknown local names, incompatible selected Components, and overlapping ownership among input, registration, injection, and scope props. + +Runtime checks cover dynamically assembled and plain-JavaScript callers: duplicate definitions, child-declaration conflicts, undeclared local names, recursive rendering, prop collisions, stale authority, strict-scope absence, and component failure isolation. Type and runtime tests also pin independent stores per occurrence, shared handles by scope, fallback behavior before registration, definition replacement, local scope projection, and ordinary child rendering. + +## Alternatives considered + +**Reuse an ordinary Slot as a portable definition.** An ordinary Slot belongs to one parent declaration and one position in the global tree. Reusing it elsewhere would borrow the wrong ownership and lifetime. + +**Move the main Conversation Header into the Factory.** Only the main host renders that Header. Keeping it outside the Factory lets embedded occurrences omit it without another local selection and preserves its existing strict-Session Slot lifecycle. + +**Move the shared Conversation store onto the optional-Session Factory.** The Header and Session body share one strict-Session handle. Mounting that handle on both the `session-maybe` Factory and the `session` Header would violate the one-handle-one-scope rule. + +**Register the same assembly under every parent.** Separate registrations would duplicate the definition and its child declarations. Global contributors would need parallel child names or conflicting declarations. + +**Maintain a flattened shared props type.** This repeats facts already present in `children`, `store`, `inject`, `locale`, and scope fields, allowing the declaration and Component props to drift. + +**Pass React nodes or render callbacks as business props.** Those values bypass renderer-supplied scope props, store and injection assembly, stale-authority checks, and local Component type checking. + +**Give local slots ordinary Slot routing.** Ordinary Slots already own multi-contributor routing. A local slot represents one caller choice for one occurrence. + +**Pass Session identity to `renderFactorySlot()`.** The occurrence inherits its render-position scope like an ordinary Slot. A second identity argument would create two authorities that can disagree; independently addressed Session providers are a separate capability. + +## Consequences + +Feature packages can publish one reusable UI assembly without runtime-importing its Component from consumers. Each occurrence gets independently selected local Components and exclusive state while preserving global ordinary child contributions and the existing scope, locale, injection, and store rules. + +The additional registry ledger and occurrence bookkeeping increase renderer complexity. Factory definitions must be globally unique, local slots intentionally support only one selected Component, and the API does not itself create an independently addressed Session scope. + +The Factory type and runtime tests are the executable compatibility record. The Slots subsystem reference and the `ui-slots` and `ui-renderer` package references document the consumer API. diff --git a/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md new file mode 100644 index 0000000000..3ccd5f0163 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-component-factories-and-local-slots.zh.md @@ -0,0 +1,105 @@ +# Agent Note: 带局部 slot 的可复用组件 Factory + +Status: implemented + +[English](2026-09-10-component-factories-and-local-slots.md) | 中文 + +## 问题 + +浏览器 Slot 系统从 parent-owned 扩展位置开始。parent entry 通过 `children` 声明 child,之后互不相关的插件可以向该位置注册实现。声明固定 child Slot 的 kind、scope、渲染权限与生命周期。 + +可复用组件装配采用相反的所有权方向。一个包定义装配,互不相关的 parents 渲染独立 occurrence,并且每个 parent 可以为具名内部区域选择不同 Component。普通 Slot 无法表示这种关系,因为它的 definition 属于全局 Slot 树中的一个 parent 位置。 + +定义包与消费包独立编译。TypeScript 无法从另一个包中的运行时 registration 推导所选 Component 的 props,另行维护的扁平 props 类型则会重复 store、injection、locale、child-render 与 scope 声明。 + +## 决策 + +`ui-slots` 与 `ui-renderer` 在[普通 Slot 体系](2026-07-22-slot-type-chain-implementation.zh.md)之外提供具名 Component Factory。`registerFactory()` 安装一个可复用 definition,`renderFactorySlot()` 渲染一个 occurrence,definition 通过 `useFactorySlot()` 读取调用方选择的局部 Component。 + +### 相反的注册方向 + +普通 Slot 与 Component Factory 保留不同的所有权模型。 + +| 属性 | 普通 Slot | Component Factory | +|---|---|---| +| 首个声明 | Parent 声明 child Slot | Definition owner 声明 Factory | +| 后续操作 | Child 注册进 parent 位置 | Parent 渲染 occurrence | +| 静态权威 | `SlotMap` 描述位置 | `SlotFactoryMap` 描述完整 definition | +| 有效 definitions | 多个 entries 可以占据 cells | 一个 definition 独占一个 Factory 名 | +| Parent 输入 | `renderSlot()` owner 与 keyed props | `renderFactorySlot()` occurrence props | +| Parent 选择的 Component | Registry routing 选择 entries | 调用方为每个局部 slot 选择一个 Component | +| 后代扩展点 | Entry-owned 普通 `children` | Definition-owned 普通 `children` | + +`SlotFactoryMap` 通过声明合并给出完整静态 definition: + +```text +interface SlotFactoryDef { + scope: SlotScope + props?: object + children?: ChildrenDecl + store?: StoreDecl + inject?: object + locale?: keyof LocaleNamespaceMap & string + slots?: Record +} +``` + +该 map 是唯一类型权威。`registerFactory()` 根据对应 map 条目检查运行时 definition 与主 Component。Store 声明通过 `HandleOf` 规范化,因此 registration 只接受一个共享 handle 或一个生成该 handle 的 factory,绝不接受嵌套 factory。`FactoryComponentPropsOf` 和 `FactoryLocalComponentPropsOf` 从同一条目推导完整 Component props;definition owner 与消费方无需重述扁平共享类型。 + +### Definition 与 occurrence 生命周期 + +每个 Factory 名在独立于普通 Slot cells 的 registry ledger 中只有一个有效 definition。registration 遵循调用方的 Cordis effect。dispose 会移除 definition、折叠其普通 child 声明、通知已挂载 outlets,并令保留的 child-render 或局部 Component 权限失效。 + +每次 `renderFactorySlot()` 调用都会创建 occurrence,其 identity 由 React 位置和 `key` 决定。替换 definition 会重新挂载 occurrence。Definition Component 及其 fallback 局部 Component 的失败归属 definition,调用方所选局部 Component 的失败归属调用方 registration;嵌套 Factory 渲染保留同一 owner。所有失败都限制在当前 occurrence 内,不会移除共享 definition。装配错误继续向外传播,所有权与陈旧授权错误则像普通组件失败一样上报。外层错误边界随 Factory scope incarnation 重置,每个局部边界随自身局部 slot 的 scope incarnation 重置。 + +Factory scope 跟随 occurrence 所在 React 位置的 scope binding。`renderFactorySlot()` 不接受 Session id 或 scope target。严格 `session` Factory 要求当前 binding 存在,并在 identity 变化时重新挂载;`session-maybe` Factory 保留首次从空状态采纳 Session 的过程,并在后续 identity 变化时重新挂载,与普通 Slot 行为一致。 + +共享 store handle 保留普通模式的 handle-by-scope 行为,包括两个非 root scope 均要求 Session binding。独占 store factory 在 occurrence 首次物化时创建一个 handle;若该 handle 声明 `spec.persist`,renderer 会拒绝它,因为 registration 必须保持 lazy,且多个同时存活的独立 occurrence 无法安全共享一个 persistence key。渲染期记录在幂等 effect setup 保留已 commit occurrence 前只持有弱引用;effect cleanup 会移除 mounted 强引用,而 occurrence-keyed WeakMap 在 React effect replay 期间保留 identity,并允许实例在卸载后被回收。 + +### 局部 slots 与普通 children + +调用方可以为 Factory `slots` 声明中的每个名称选择一个 Component。Factory 调用 `useFactorySlot(name, fallback)`,取得 identity 稳定的绑定 Component;该 Component 只接受对应局部 slot 的 occurrence props。绑定 Component 渲染时,renderer 提供 Factory 的 store、injection、locale、child renderers 与该局部 slot 自身的标准 scope props。 + +局部 slots 没有 list、keyed 或 chain routing,也没有独立 registration 生命周期。多贡献方扩展点仍使用 Factory 声明的普通 child Slots。这些 child 声明在 definition 范围内全局共享,而每个 occurrence 都在继承的 scope 下渲染其 registered entries。 + +实时检查将每个 definition 表示为 `type: 'factory'` 节点,并把其普通 child Slots 嵌套在该节点下。普通节点保留 `type: 'slot'` 与现有 `kind`;调用方通过 `factory:` 选择 Factory 根节点。 + +每个由 renderer 创建的 Component 都会收到 `renderFactorySlot`,因此 Factory occurrence 不需要 parent-side use declaration。局部选择仍然属于单个 occurrence,并且不会引入对定义包的运行时 value import。 + +### 首个交付用途 + +`ui-conversation` 在共享正文与 Composer 外注册 optional-Session `conversation.content` Factory。其 strict-Session `views` 局部位置默认使用一个渲染现有 `conversation.session` Slot 的 adapter;其他 occurrence 可以选择不同的 View Component,且不会挂载主 Conversation Header。 + +Factory 不拥有 Conversation store。普通 `conversation.session` body 与 `conversation.session.header` 保留同一个 strict-Session handle,在保持草稿与 View 选择 identity 的同时,避免将该 handle 同时挂到 `session` 和 `session-maybe` scope。 + +### 类型与运行时强制规则 + +类型链拒绝未知 Factory 名、缺失或多余的 occurrence props、与 `SlotMap` 不一致的 child spec、与 `SlotFactoryMap` 不一致的 definition 字段、嵌套 store factory、未知局部名称、不兼容的选中 Component,以及 input、registration、injection 与 scope props 之间的所有权重叠。 + +运行时检查覆盖动态装配与纯 JavaScript 调用方:重复 definitions、child 声明冲突、未声明的局部名称、递归渲染、prop 冲突、陈旧权限、严格 scope 缺失与组件失败隔离。类型和运行时测试还固定了各 occurrence 的独占 store、按 scope 共享的 handles、注册前 fallback 行为、definition 替换、局部 scope 投影与普通 child 渲染。 + +## 考虑过的替代方案 + +**把普通 Slot 复用为可移植 definition。** 普通 Slot 属于一个 parent 声明以及全局树中的一个位置。在其他位置复用它会借用错误的所有权与生命周期。 + +**把主 Conversation Header 移进 Factory。** 只有主 host 渲染该 Header。将其留在 Factory 外,使嵌入式 occurrence 无需另一个局部选择即可省略 Header,并保留其现有 strict-Session Slot 生命周期。 + +**把共享 Conversation store 移到 optional-Session Factory。** Header 与 Session body 共享一个 strict-Session handle。将该 handle 同时挂到 `session-maybe` Factory 和 `session` Header 会违反 one-handle-one-scope 规则。 + +**在每个 parent 下分别注册同一装配。** 独立 registrations 会重复 definition 及其 child 声明。全局贡献方需要使用平行 child 名称,或造成声明冲突。 + +**维护扁平共享 props 类型。** 这会重复 `children`、`store`、`inject`、`locale` 与 scope 字段中已有的事实,使声明与 Component props 可以发生漂移。 + +**把 React node 或 render callback 作为业务 props 传递。** 这些值绕过 renderer 提供的 scope props、store 与 injection 装配、陈旧权限检查和局部 Component 类型检查。 + +**让局部 slots 具备普通 Slot routing。** 普通 Slots 已经负责多贡献方 routing。局部 slot 表示一次 occurrence 的一个调用方选择。 + +**向 `renderFactorySlot()` 传递 Session identity。** occurrence 像普通 Slot 一样继承渲染位置的 scope。第二个 identity 参数会产生两个可能不一致的权威;独立定址的 Session provider 属于另一项能力。 + +## 影响 + +功能包可以发布一份可复用 UI 装配,而消费方无需运行时导入其 Component。每个 occurrence 可以获得独立选择的局部 Components 与独占状态,同时保留全局普通 child 贡献以及现有 scope、locale、injection 和 store 规则。 + +新增的 registry ledger 与 occurrence 记录增加了 renderer 复杂度。Factory definitions 必须全局唯一,局部 slots 有意只支持一个选中 Component,并且该 API 本身不会创建可独立定址的 Session scope。 + +Factory 类型与运行时测试是可执行的兼容性记录。Slots 子系统参考以及 `ui-slots` 和 `ui-renderer` 包参考记录消费方 API。 diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml index 1a3ff86ba9..321c9c8c58 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.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-09-10-desktop-web-wrapper.md -2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99 -2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f +2026-09-10-desktop-web-wrapper.md: 05210e2ab85f0afe6d0275e25f31ce8addf11332 +2026-09-10-desktop-web-wrapper.zh.md: 84e72c9261250b103245f974e1a0546a4898e9ca diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md index 445c574f86..05210e2ab8 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md @@ -14,13 +14,13 @@ Separate Desktop composition and request transport require their own configurati The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing. -The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities. +The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template and uses its Plugin Manager in the main application. Electron owns windows, menus, native directory selection, recovery, and release updates. -The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile. +The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The public CLI continues to reject the reserved Desktop profile. Electron profile preparation and native recovery remain available without a working Host. Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web. -App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared. +The shared Web plugin manager owns installation, activation, errors, and restart requirements. Electron has no separate plugin-management renderer, preload, shell asset route, plugin IPC, or package-mutation runner. Packaging explicitly selects the main entry and application preload so stale build outputs cannot restore the removed bridge. Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata. @@ -38,12 +38,14 @@ Native directory selection in the local application uses a narrow preload IPC ca **Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile. +**Keep a separate native plugin-management page.** It can operate while the Web Host is unavailable, but duplicates package operations, renderer IPC, localization, and backend restart handling. Native recovery already supports disabling third-party bundles and backing up the profile patch without the Host. Reintroduction requires a repair operation that this recovery cannot provide. + **Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation. ## Consequences Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; native recovery remains available when startup fails. -User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the native recovery dialog rather than triggering stricter admission checks or automatic rollback. +User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. The shared Web manager owns package-failure handling; Electron retains native recovery for fatal startup failures. The signed core runtime does not attest to user-installed plugin code. Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement. diff --git a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md index a41ce3c2f1..84e72c9261 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md @@ -14,13 +14,13 @@ Status: implemented 私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。 -共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。 +共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载和应用生命周期。Desktop 从共享 Web 模板初始化 profile,并在主应用中使用其插件管理器。Electron 负责窗口、菜单、原生目录选择、恢复和发布更新。 -[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。 +[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。公开 CLI 继续拒绝保留的 Desktop profile。Electron profile 准备和原生恢复在 Host 不可用时仍可执行。 独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。 -App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。 +共享 Web 插件管理器负责安装、激活、错误和重启要求。Electron 不提供独立插件管理渲染器、preload、shell 资源路由、插件 IPC 或包变更执行器。打包明确选择主入口和应用 preload,避免旧构建产物重新带入已删除的桥接。 共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。 @@ -38,12 +38,14 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析 **保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。 +**保留独立原生插件管理页。** 它可以在 Web Host 不可用时运行,但重复维护包操作、渲染器 IPC、本地化和后端重启处理。原生恢复已能在没有 Host 时禁用第三方 bundle 并备份 profile patch。只有出现此恢复方式无法提供的修复操作时,才应重新引入。 + **固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。 ## Consequences Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。 -用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。 +用户选择的运行时选项、包来源和允许的生命周期脚本可能影响 Host 执行、加载第三方代码或造成启动失败。共享 Web 管理器负责包操作失败处理;Electron 为致命启动失败保留原生恢复。签名核心运行时不为用户安装的插件代码背书。 验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。 diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml similarity index 56% rename from .agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 37bf7d8e0c..c27ef04676 100644 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -1,6 +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/process/2026-09-07-issue-policy-module-ownership.md -2026-09-07-issue-policy-module-ownership.md: 696343ddffa3f6a6e411330d038bb4a470ce78d8 -2026-09-07-issue-policy-module-ownership.zh.md: abe539a5baed35cf2f356194eab8e53d61b67314 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9 +2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md new file mode 100644 index 0000000000..6cab947d7c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -0,0 +1,65 @@ +# Agent Note: Node Office conversion with independently packaged engines + +Status: implemented + +English | [中文](2026-09-11-node-office-kit.zh.md) + +## Problem + +Binary Office and OOXML files require document layout before they can be previewed. Conversion must stay on the device without opening an Office application or adding source bytes to a model conversation. Native engines need platform-specific distribution, while browser conversion duplicates font transport, worker ownership, and resource limits across the Host and Client. + +## Decision + +The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares separate provider and controller entries and the shared Document Preview entry with stable IDs. Conversion and authorized transport remain independently configurable; Office UI shares the Document Preview Loader lifetime. + +The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. + +The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. + +A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes. + +The kit owns default serif, sans-serif, and monospace preference groups, including Chinese text families. Missing Chinese glyphs in Western text try the corresponding common text families before searching the remaining catalog, which avoids choosing a handwriting face solely because its file sorts first. The provider's optional `fontFallbacks` replaces these ordered groups without duplicating their defaults. Preferences preserve exact installed fonts and retain other covering fonts as a last resort; they are not a font whitelist. The native adapter writes missing-family choices into its private VCL profile. Installed metric-compatible fonts can resolve before that table, and platform glyph fallback remains available. Native platform selection and whole collection imports require inspecting the fonts actually used in exported PDFs. + +DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine. + +The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. + +Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. + +The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion. + +Desktop installs the kit through its existing target-Node pnpm dependency installation and retains the complete dependency tree. Worker paths and executable permissions remain ordinary package files. The [Desktop build guide](../../../../apps/desktop/README.md) owns target selection and packaging; each signed application requires qualification on its target platform. + +The [Python executable distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md) keeps the kit, target engine, and their dependency closure beside the executable. Its installed-wheel smoke relocates the payload, requires exactly the target backend, and converts DOCX once through that engine. Platform packaging and publication constraints belong to the [platform engine decision](2026-09-15-platform-office-engines.md). + +The notices gate permits only the exact API and engine package names at MPL-2.0 and continues to reject unrelated MPL or changed non-permissive terms. Each recipient must retain access to the kit’s corresponding LibreOffice source pin, patches, build instructions, and license notices; the engine packages retain their third-party notices. [MPL source availability](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) applies when distributing covered executables outside the organization. + +The [browser-only preview](../../../../packages/experimental/webworker-runtime/README.md) replaces the kit entry with an unavailable converter and excludes its engine dependency tree from the VFS image. Keeping the Host provider loadable preserves the ordinary Office error presentation without shipping Node Workers, native helpers, or WASM conversion resources to the browser. + +## Alternatives considered + +**Keep conversion in browser Workers.** This requires shipping the engine to the Client, browser font RPC, and shared-memory response headers. Node already owns authorized disk access and can serve the resulting PDF to every Client. + +**Use the installed soffice CLI or automate Microsoft Office.** Executable discovery and ambient versions weaken reproducibility; Office GUI automation additionally changes focus and requires application permissions. The distributed helper owns a fixed engine without requiring either application installation. + +**Load a native addon in the Host process.** A parser crash or synchronous stall would affect the Host. The separate native helper provides an independently terminable lifetime; its disk exchange also works with the WASM adapter. + +**Fall back after any native error.** Retrying a damaged package or failed document with another engine hides release defects, duplicates work, and makes output depend on failure timing. Platform selection requires the kit’s declared native target engine, or WASM when no native target is declared; it does not retry native failures with WASM. + +**Return a temporary PDF path, or rasterize pages to PNG.** The Client needs PDF bytes for its existing viewer and selectable text. Exposing temporary paths would add authorization and lease ownership; PNG would create another rendering pipeline and remove PDF controls. + +**Index every font for every render, or cache only family names.** Repeated indexing dominates small-document work, while family-only keys lose style and glyph distinctions. Shared metadata and complete per-request keys remove repeated parsing without retaining font buffers or introducing a filesystem watcher. + +**Compile at install time, download engines or fonts at runtime, or use an online converter.** These add toolchain or network requirements and may move private content off device. Prebuilt tarballs keep installation and conversion independent of those operations. + +**Keep an independent Office UI package.** Its reader, cache, and font notice have the same preview lifecycle and PDF presentation consumers. A separate plugin adds a package, boot wiring, and a Loader switch without an independently evolving UI responsibility. Host conversion and Remote authorization retain their separate plugins. + +**Remove the shared Client conversion cache.** Per-tab state cannot share pending conversion or retained PDFs across readers. A bounded cache avoids that repeated work while preserving per-reader cancellation and authorized version checks. + +**Add a model-facing rendering tool or durable preview events.** Preview provides no model input. Such a tool would require logged facts and both SDK projections and remains a separate consumer. + +## Consequences + +Native and WASM fidelity still depends on the build, source formatting, installed fonts, and platform font discovery. A missing glyph cannot be recovered without a covering font. Image resolution limits do not cap image decoding or total process memory. WASM retains bounded memory growth and checked stack space for large collections and CFF fonts; a fatal runtime abort prevents subsequent C++ cleanup calls. Macros and document-link updates are disabled by supported LOKit options and the pinned source patch; this does not constitute an OS sandbox. + +The [provider tests](../../../../packages/document/office-to-pdf/tests/provider.spec.ts), [Loader composition](../../../../packages/bundle/web-app/tests/document-preview.spec.ts), and [browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) own DSH lifecycle, authorization, and presentation evidence. Engine qualification additionally requires real DOC/DOCX/XLS/XLSX/PPT/PPTX conversion, external PDF text/font/page/image inspection, relocation and corrupted-package rejection, and same-input native/WASM performance samples. Mock helpers and microbenchmarks do not establish these outcomes. Each target's real builder and Desktop package need independent qualification; a successful local architecture does not prove the full matrix. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md new file mode 100644 index 0000000000..8ed1a81922 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 独立打包引擎的 Node Office 转换 + +Status: implemented + +[English](2026-09-11-node-office-kit.md) | 中文 + +## 问题 + +二进制 Office 和 OOXML 文件需要经过文档排版才能预览。转换必须留在设备上,不能打开 Office 应用,也不能将源字节加入模型对话。原生引擎需要按平台分发,而浏览器转换会在 Host 和 Client 两侧重复字体传输、worker 生命周期与资源限额管理。 + +## 决策 + +[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明独立的 provider、controller 入口和共享文档预览入口。转换与授权传输仍可独立配置;Office UI 共享文档预览的 Loader 生命周期。 + +[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 + +服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 + +converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 + +kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中文正文字体。西文文本缺少中文字形时,先尝试同类的常用正文字体,再搜索其余字体目录,避免仅因文件排序靠前而选用手写体。provider 的可选 `fontFallbacks` 替换这些有序组,不重复维护默认值。优先规则保留已安装原字体的精确匹配,并以其他覆盖字体作为最后兜底;它们不是字体白名单。原生适配器将缺失字体的选择写入私有 VCL profile。已安装的度量兼容字体可能在查询该表前被选中,平台的字形回退仍然可用。原生平台的选择及完整字体集合的导入要求检查导出 PDF 实际使用的字体。 + +DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。 + +[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 + +普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 + +[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。 + +Desktop 通过现有的目标 Node pnpm 依赖安装流程安装 kit,并保留完整依赖树。Worker 路径和可执行权限仍由普通包文件承载。[Desktop 构建指南](../../../../apps/desktop/README.zh.md) 负责目标选择与打包;每个签名应用仍需在目标平台验收。 + +[Python 可执行分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)将 kit、目标引擎及其依赖闭包保留在可执行文件旁。安装后的 wheel 冒烟测试会迁移载荷,要求仅存在目标后端,并通过该引擎转换一次 DOCX。各平台的打包与发布限制由[平台引擎决策](2026-09-15-platform-office-engines.zh.md)说明。 + +声明检查仅放行精确的 API 与引擎包名及 MPL-2.0 条款,继续拒绝无关 MPL 包或变更后的非宽松条款。每位接收者都必须保有访问 kit 对应 LibreOffice 源码版本、补丁、构建说明与许可证声明的权限;引擎包保留各自的第三方声明。向组织外部分发受覆盖的可执行文件时,须满足 [MPL 源码可用性要求](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)。 + +[纯浏览器预览](../../../../packages/experimental/webworker-runtime/README.zh.md)用返回不可用错误的转换器替代 kit 入口,并从 VFS 镜像排除其引擎依赖树。保留 Host provider 的可加载性,可以沿用正常的 Office 错误展示,而无需向浏览器分发 Node Worker、原生辅助程序或 WASM 转换资源。 + +## 考虑过的替代方案 + +**保留浏览器 Worker 转换。** 这需要向 Client 分发引擎、提供浏览器字体 RPC 和配置共享内存响应头。Node 已经拥有授权磁盘访问能力,可以向所有 Client 提供转换后的 PDF。 + +**使用已安装的 soffice CLI 或自动化 Microsoft Office。** 可执行文件发现和环境中的版本削弱可复现性;Office GUI 自动化还会改变焦点并需要应用权限。随包辅助进程拥有固定引擎,不要求安装这两类应用。 + +**在 Host 进程中加载原生 addon。** 解析器崩溃或同步阻塞会影响 Host。独立的原生辅助进程具有可单独终止的生命周期,其磁盘交换方式也适用于 WASM 适配层。 + +**任何原生错误都触发回退。** 使用另一引擎重试损坏的包或失败文档会隐藏发布缺陷、重复计算,并使输出取决于失败时机。平台选择要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM;原生失败后不会使用 WASM 重试。 + +**返回临时 PDF 路径,或将页面栅格化为 PNG。** Client 需要 PDF 字节来使用现有查看器和可选择文本。暴露临时路径会增加授权与租约管理;PNG 则会建立另一条渲染管线并丢失 PDF 控件。 + +**每次渲染重新索引所有字体,或只按字体族缓存。** 重复索引主导小文档成本,而仅以字体族为键会丢失样式和字形差异。共享元数据与完整请求键减少重复解析,无需保留字体缓冲区或引入文件系统监听器。 + +**安装时编译、运行时下载引擎或字体,或使用在线转换器。** 这些方案增加工具链或网络要求,并可能将私有内容移出设备。预构建 tarball 使安装与转换不依赖这些操作。 + +**保留独立的 Office UI 包。** 其读取器、缓存和字体提示共享预览生命周期与 PDF 展示消费者。独立插件增加包、启动接线和 Loader 开关,却没有独立演进的 UI 职责。Host 转换与 Remote 授权仍保留独立插件。 + +**移除共享的 Client 转换缓存。** tab 内状态无法在读取方之间共享进行中的转换或已保留的 PDF。有界缓存减少这些重复工作,同时保留各读取方的取消和已授权版本检查。 + +**添加面向模型的渲染工具或持久预览事件。** 预览不会提供模型输入。这样的工具需要日志事实及两套 SDK 投影,仍属于独立消费者。 + +## 后果 + +原生与 WASM 的保真度仍取决于构建、源文件格式、已安装字体及平台字体发现。没有覆盖字体就无法恢复缺失字形。图片分辨率限额不限制图片解码或总进程内存。WASM 为大型字体集合和 CFF 字体保留有界内存增长与受检查的栈空间;致命运行时中止会阻止后续 C++ 清理调用。宏与文档链接更新由实际支持的 LOKit 选项和固定源码补丁禁用;这不构成操作系统沙箱。 + +[提供方测试](../../../../packages/document/office-to-pdf/tests/provider.spec.ts)、[Loader 组合](../../../../packages/bundle/web-app/tests/document-preview.spec.ts)和[浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts)负责 DSH 生命周期、授权与展示证据。引擎验收还需要真实 DOC/DOCX/XLS/XLSX/PPT/PPTX 转换、外部 PDF 文本、字体、页数与图片检查、迁移安装和损坏包拒绝,以及同输入的原生/WASM 性能样本。模拟辅助进程和微基准不能证明这些结果。各目标的真实构建机与 Desktop 安装包需要独立验收;一个本地架构成功不能证明整个矩阵。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml new file mode 100644 index 0000000000..c3570fad78 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md +2026-09-14-independent-libreoffice-kit.md: 13efc41e56a4810273fbb6d9314eeda6053dbe92 +2026-09-14-independent-libreoffice-kit.zh.md: 720ca2ba3f57546825ae14b5b5f0b086fbb7f287 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md new file mode 100644 index 0000000000..13efc41e56 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -0,0 +1,29 @@ +# Agent Note: Independent LibreOffice kit ownership + +Status: implemented + +English | [中文](2026-09-14-independent-libreoffice-kit.zh.md) + +## Problem + +LibreOffice compilation, source patches, platform qualification, and large binary releases have a different maintenance cycle from Harness plugins. Keeping them in the application workspace expands routine CI and couples engine repairs to monorepo package rules. + +## Decision + +The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `OfficeToPdf` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. + +Kit releases run independently of Harness releases. The kit repository qualifies and publishes the Node API and engine npm packages at a shared version, starting at `0.0.1`. Harness consumes an exact npm version and commits its dependency resolution in `pnpm-lock.yaml`; Harness releases neither build nor publish kit packages. + +The upstream API declares platform engines as optional dependencies. The [platform engine decision](2026-09-15-platform-office-engines.md) supersedes the original required-WASM fallback policy. Desktop installs the kit as an external npm dependency. Python sidecars keep the Worker, selected engine, and their dependency closure on the real filesystem, outside the executable’s virtual filesystem. Conversion requires neither downloads nor GitHub credentials. + +The kit repository owns engine qualification and corresponding source materials. MPL-2.0 declarations, source availability, and redistribution notices accompany the API and engines; Harness retains these materials when packaging them. The notices check accepts the exact API, WASM, macOS ARM64/x64, and Windows ARM64/x64 package identities only at MPL-2.0; unrelated packages and changed license terms still reject. + +## Alternatives considered + +**Co-locate the public API and Core build in Harness.** This synchronizes source changes but makes application maintenance own long engine builds and special package rules. The Cordis provider is the application integration point; the reusable conversion API belongs with its engine tests. + +**Prepare GitHub Release archives before installation.** This requires separate authentication, hashes, decompression, workspace overrides, and distribution repacking. Published npm packages use the application's ordinary dependency installation and platform selection. + +## Consequences + +Changing the kit requires qualifying a release in its own repository and updating the Harness dependency versions and lockfile. A missing required npm package fails installation; Harness does not compile an engine to recover. Native and WASM conversion smokes validate the installed packages, while Desktop and Python checks cover application packaging. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md new file mode 100644 index 0000000000..720ca2ba3f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 独立 LibreOffice kit 的归属 + +Status: implemented + +[English](2026-09-14-independent-libreoffice-kit.md) | 中文 + +## Problem + +LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布的维护周期不同于 Harness 插件。将它们放在应用工作区会扩大常规 CI,并让引擎修复依赖 monorepo 包规则。 + +## Decision + +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `OfficeToPdf` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 + +kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 + +上游 API 将平台引擎声明为可选依赖。[平台引擎决策](2026-09-15-platform-office-engines.zh.md)取代最初强制携带 WASM 回退引擎的策略。Desktop 将 kit 作为外部 npm 依赖安装。Python sidecar 将 Worker、所选引擎及其依赖闭包保留在真实文件系统中,位于可执行文件的虚拟文件系统之外。转换无需下载或 GitHub 凭据。 + +kit 仓库负责引擎资格验证和对应源码材料。API 和引擎携带 MPL-2.0 声明、可访问源码及再分发声明;Harness 打包时保留这些材料。再分发声明检查仅在许可为 MPL-2.0 时接受 API、WASM、macOS ARM64/x64 和 Windows ARM64/x64 的精确包标识;无关包和改变后的许可条款仍被拒绝。 + +## Alternatives considered + +**将公开 API 和 Core 构建放在 Harness。** 这能同步源码变更,却让应用维护承担耗时的引擎构建和特殊包规则。Cordis provider 是应用集成点;可复用转换 API 应与引擎测试放在一起。 + +**安装前准备 GitHub Release 归档。** 这需要单独的鉴权、哈希、解压、工作区 overrides 和分发重打包。已发布的 npm 包使用应用常规的依赖安装与平台选择流程。 + +## Consequences + +更新 kit 需要在其独立仓库验证并发布新版本,再更新 Harness 依赖版本和锁文件。必需的 npm 包缺失时安装失败;Harness 不通过编译引擎恢复。原生和 WASM 转换冒烟验证已安装的包,Desktop 与 Python 检查覆盖应用打包。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml new file mode 100644 index 0000000000..2f64b37f55 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md +2026-09-15-bounded-office-conversion.md: b15aa5421c575451ddb4a7bf91169bcb2fa38d3c +2026-09-15-bounded-office-conversion.zh.md: f23c38096764cf32207125cc0c3ebbdf1c04d009 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md new file mode 100644 index 0000000000..b15aa5421c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -0,0 +1,43 @@ +# Agent Note: Bounded shared Office conversion + +Status: implemented + +English | [中文](2026-09-15-bounded-office-conversion.zh.md) + +## Problem + +Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion. + +## Decision + +The `office-to-pdf` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. + +The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse. + +A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. + +Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller. + +## Alternatives considered + +**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion. + +**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content. + +**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work. + +**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. + +**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. + +## Consequences + +The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. + +Office preview checks source authorization and versions through Workspace Files, then reads raw input with `fs.readBytes` within its conversion reservation. Office input limits govern that read. The Host conversion path avoids base64 source allocation; PDF responses encode only the converted output. + +Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. + +## Related + +The [background scheduling proposal](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.md) questions the need for speculative priority without a production caller; this note remains the authority for the implemented behavior. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md new file mode 100644 index 0000000000..f23c380967 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 有界的共享 Office 转换 + +Status: implemented + +[English](2026-09-15-bounded-office-conversion.md) | 中文 + +## 问题 + +Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。 + +## 决策 + +`office-to-pdf` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 + +[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 + +有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 + +前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。 + +## 考虑过的替代方案 + +**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。 + +**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。 + +**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。 + +**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 + +**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 + +## 后果 + +缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 + +Office 预览通过 Workspace Files 检查源文件授权与版本,再使用 `fs.readBytes` 在转换预留容量内读取原始输入。该读取受 Office 输入上限约束。Host 转换路径不分配 base64 源数据;PDF 响应只编码转换后的输出。 + +受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 + +## 相关记录 + +[后台调度提案](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md) 质疑没有生产调用方时保留推测性优先级的必要性;本记录仍定义已实现的行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml new file mode 100644 index 0000000000..1bcfbf5d4a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-client-session-references.md +2026-09-15-client-session-references.md: 2d727809b237ca919296dac82a3b201ba2dd8fbf +2026-09-15-client-session-references.zh.md: 34fa6526a57f43fd7636c68c7ed72bb982a129f2 diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md new file mode 100644 index 0000000000..2d727809b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md @@ -0,0 +1,226 @@ +# Agent Note: Client Session references, reference sources, and UI status + +Status: implemented + +English | [中文](2026-09-15-client-session-references.zh.md) + +## Problem + +The Session catalog, live Client objects, views, and asynchronous operations have different lifetimes. Catalog membership does not establish ongoing use. A borrowed binding cannot protect asynchronous work or distinguish successive Client generations with the same Session id. A global current Session makes independently bound components act on another view's Session. + +A reference count identifies ongoing use but not its consumers. Main-area highlighting, Sidebar ownership, and background operations need consumer-source information. Pending interactions and completion reminders also need one UI read interface so Workspace and Conversation do not independently combine the same status. + +Client history access and Host Agent execution have independent lifetimes. History opening can fail; local Context acquisition need not read history. Explicit ownership must preserve navigation, loading, error handling, and recovery behavior without adding unrelated UI policy. + +## Decision + +### Scope and ownership + +Client Session objects, Agent-scoped Client Contexts, references, consumer-source metadata, UI status, and explicit Provider composition use the ownership rules below. Host Session and Agent lifetimes, SlotFactory, activity dashboards, durable Session formats, and both SDKs' Host protocols remain independent. + +| Owner | Responsibility | +| --- | --- | +| Client Session Controller | Catalog, live generations, references, source counts, bindings, history windows, and existing Session control state | +| `ui-session` | Explicit Session Provider integration and the unified UI status source | +| View or operation | Its own reference, target, source identifier, and release point | +| Workspace UI | Main-area target and reference, navigation, persisted target, and creation flow | +| UI composition boundary | Supplies an owner-provided reference to one explicit `SessionProvider` | +| Conversation and Sidebar subtrees | Consume only their Provider's Session, never a main-area reference or global selection | +| Client Gateway | Invocation-lifetime Context ownership while handling a Host event | + +The [Client layering design](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.md) defines one-way data, adapter, renderer, and presentation dependencies. Reference-source bookkeeping does not give the Controller a dependency on UI packages. + +This decision partially supersedes the list-selected scope lifecycle in the [Web Client Session scope and provide-channel decision](2026-07-25-web-client-session-scope-and-provide-channel.md); that note retains the blank-Session and adoption rationale under explicit Provider ownership. + +### Addresses, bindings, and references + +`SessionTarget` is a known `SessionId` or a durable direct-parent `SubagentAddress`. It identifies what to acquire; it owns nothing. The Controller resolves an explicit address without requiring a preloaded parent catalog, while the Host validates its parent, child, and mode when history opens. Child discovery and an already known child address remain distinct from retaining the child. Navigation uses the same target representation rather than adding a separate navigation address. + +`SessionBinding` is the shared Client generation: `sessionId`, Session face, event source, and scoped Context. Multiple references to the same live generation share this binding. A new generation with the same id has a different binding and Context. + +`SessionReference` owns one use of one exact generation. It exposes read-only `sessionId` and `binding`, a `ready` Promise for the shared initial history opening, plus idempotent `release()` and `Symbol.dispose`. `ready` resolves to the exact binding when the corresponding `Session.open()` attempt resolves, including its stateful Remote-failure result. Reading `binding` after release or generation disposal fails. Retaining, releasing, or inspecting a reference does not create durable Sessions or start, stop, or retain a Host Agent. + +`SessionBinding` is a borrowed value and owns no lifetime. Only unreleased `SessionReference` objects contribute to source and total reference counts. Synchronous code may borrow a binding within its owner's reference lifetime; work that outlives that owner must acquire its own reference. + +| API | Result and caller obligation | +| --- | --- | +| `sessions.retain(target, options)` | `SessionReference`; returns immediately, and the caller owns the reference until release | +| `sessions.using(target, options, operation)` | `Promise`; awaits `reference.ready`, awaits `operation(reference)`, releases its reference, and returns the operation's result | +| `sessions.retainInfo(id)` | Stable read-only observable of local reference counts; no acquisition, scope creation, or history I/O | +| `sessions.scope(id)` / `sessions.binding(id)` | Borrow an existing live generation or return `undefined`; neither opens nor extends its lifetime | +| `sessions.sessionOf(ctx)` | Returns the matching live Session face or `undefined`; an ended Context cannot resolve to a same-id replacement | +| `sessions.create(...)` / `sessions.fork(...)` | Existing Host operations returning a Session identity; retaining and displaying it remain explicit | + +`SessionRetainOptions` contains required `source: SessionReferenceSource` and optional `signal: AbortSignal`. Both acquisition methods accept `target: SessionTarget` and these options. Source keys are consumer-defined and declaration-merge extensible; there is no runtime source-registration protocol or default main-view source. A source is a usage label, not another Session address or a permission to act on it. + +### Acquisition, failure, and release + +Acquisition synchronously resolves the target, creates or retains the local Session, Context, fiber, and binding, records one reference and its source contribution, starts the generation's shared initial history opening, and returns the reference. Concurrent acquisitions share that opening but receive independent references and readiness waits. `reference.ready` resolves to the still-live exact binding when the shared `Session.open()` attempt resolves. + +Unknown Session-id addressing fails before a reference is returned; an explicit subagent address is validated by the Host during opening. A thrown opening failure, caller cancellation, reference release, or generation retirement rejects that reference's `ready`; one cancelled waiter does not cancel another owner's shared opening. A Remote failure represented by `openState: 'error'` follows `Session.open()` and resolves readiness, leaving the error renderable through the binding. The caller still owns a returned reference until release, while `sessions.using` releases its reference when readiness or its operation fails. + +`using reference = sessions.retain(target, options)` releases on exit from the enclosing scope; code that requires settlement of the initial history attempt awaits `reference.ready`. `sessions.using` is the callback helper: it waits for that settlement before invoking an operation, waits for its returned value or Promise, and then releases. Returned values must not rely on the helper's released reference remaining usable; a longer-lived consumer acquires its own reference. The helper propagates rejected readiness and operation failures without a fallback result, retry, or error presentation. Model-selection generation checks and error-state updates remain in ModelSelection. + +A fulfilled `ready` Promise means the initial `Session.open()` attempt settled; it does not assert `openState: 'open'` or promise an uninterrupted connection. Stateful opening failures and later stream failures remain observable through existing Session state. Callers retain their existing handling and presentation of those errors. + +Release removes only that reference and its source contribution. Final release withdraws the generation's admission and id mappings before disposing the Session and fiber. A later retain can create a fresh generation immediately; cleanup of the old generation cannot remove the replacement. `release()` initiates local cleanup synchronously, while root disposal awaits outstanding asynchronous teardown. + +The owning Client root invalidates all references during shutdown. It refuses new acquisitions, withdraws live mappings, and joins scoped cleanup and Session stream teardown. References cannot keep a disposed Client root alive. Catalog removal alone does not dispose a referenced generation. + +### Reference sources and Session list records + +The Controller owns source counts alongside each live generation's references. For every source, the count equals that generation's unreleased references bearing the source. A Session can have several sources, and a source can hold several references. Sources have no special lifecycle behavior in the allocator. + +Each available Session list row exposes a read-only `retainedBy` record from source key to positive reference count. An unretained row has an empty record; zero-count keys are absent. Acquisition failure and release update that projection, including removal of the final key. The source counts remain local facts when Host metadata is refreshed; Host responses cannot overwrite them. + +The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` synthesizes a local fallback row for every live generation so Provider and ownership consumers can resolve it; `ids` remains the Host-list membership and ordering. A fallback row is not Host metadata. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host. + +For example, `retainedBy = { mainView: 1, gateway: 2 }` means that the main view and two Host invocations own three references. Releasing the main-view reference removes only `mainView`; the Gateway invocations continue to own the generation. Source names in this example are consumer keys, not a closed Controller enum. + +`current` means main-view occupancy: a row has that marker exactly when its `mainView` source count is positive. It is one use of the general source record, not a separate `sessions.current` value or Session-selection service. Other consumers can derive their own markers from their source keys. The reference system does not impose a globally unique consumer or select one Session from multiple sources. + +The main-area owner manages its own target and reference transitions. The list does not infer selection from how many Providers happen to be mounted. Source records report actual ownership, including temporary overlap during acquisition; UI navigation remains responsible for its target, without assigning selection authority to the allocator. + +Window-level consumers may inspect source markers. Session business operations still use their supplied scope or explicit reference; they cannot find `mainView` in the catalog to recover a missing operation target. A background reference is ownership evidence, not evidence that a user viewed the Session. + +`SessionRetainInfo` contains `referenceCount` and the read-only `retainedBy` record. `sessions.retainInfo(id)` observes that local ownership independently of catalog membership and remains stable across same-id generation replacement. An identity without a live generation has zero references and an empty source record; reading this value does not assert that the identity exists on the Host. + +`ui-session` exposes `useSessionRetainInfo(sessionId, selector)` for an explicit identity and `useSessionRetainInfo(selector)` for the Session bound by the surrounding Provider. An unbound scope supplies absence to the latter form, never the main area's Session. The renderer constructs both forms from the same bare retain-info source. Consumers test the `mainView` source count to recognize the former current-Session role; other source keys remain equally queryable. Reading or subscribing does not retain the Session. + +### Unified UI Session status + +`ui-session` owns a React-free `sessionStatus` source and exposes it through the standard `useSessionStatus` hook. The snapshot is indexed by Session identity and supplies one UI status record per known Session. It combines these independent facts rather than reducing them to one mutually exclusive phase: + +| Field | Value | Meaning and owner | +| --- | --- | --- | +| `running` | `boolean` or `undefined` | Latest known Session running fact; absence of a baseline is not confirmed idle | +| `pendingInteraction` | `SessionPendingInteraction` or `undefined` | Effective domain-owned request, or absence when no request is pending | +| `completionUnread` | `boolean` | UI reminder for an observed stop that has not been acknowledged | + +Source counts remain authoritative in `SessionListState.byId[id].retainedBy`; UI status reads them for acknowledgement policy without owning another reference registry. Titles, Workspace associations, history, queues, and projection data remain with their existing owners. + +Pending domains retain `SessionPendingInteractionMap`, request identities, precedence, publication disposers, and teardown delegation. The unified status includes the same effective request object; it does not copy requests or create a second pending registry. Workspace status indicators and Conversation composer selection read `useSessionStatus` instead of separate `useSessionPendingInteraction` and `useCompletedSessionIds` hooks. + +Completion tracking subscribes to the existing `api-session/status` events so a running-to-idle transition is not lost in batched catalog snapshots. Catalog snapshots establish initial and reconnect baselines. A pending empty catalog is not evidence that Sessions disappeared. The update rules are: + +- An initial idle baseline does not create a completion reminder. +- Observing running clears an earlier reminder and records the running baseline. +- A known running-to-idle transition sets `completionUnread` only when the Session lacks main-view ownership. +- Acquiring main-view ownership clears the reminder; retaining from an unrelated source does not. +- Releasing main-view ownership does not manufacture a reminder for an earlier stop. +- Removing a Session clears its completion reminder and running baseline; pending-request teardown stays with the request's domain. + +The reminder denotes an observed stop, not successful task completion or completion of a particular queued message. Main-view ownership preserves selection-based acknowledgement even while a global panel temporarily hides the Conversation. The design has no `ui-session/view-presence` event, mounted-Provider index, or implicit acknowledgement from Provider mount. + +The Session Controller publishes running and reference-source facts but owns no completion-reminder set, `consumeCompletion` method, or pending-interaction presentation. Reference acquisition does not execute completion-reminder business logic. + +### Explicit Providers and consumer lifetimes + +The single `SessionProvider` can inherit an outer binding or override its subtree with explicit `session={reference | undefined}`. It neither acquires nor releases ownership. The root Provider resolves its main binding from the `mainView` ownership marker without maintaining another current value; sibling and nested Providers affect only their own subtrees. Explicit absence stays absent instead of falling back to the main area. + +The Provider injects the selected binding without keying its whole body. Strict `session` entries remount when the binding Context changes. A blank `session-maybe` entry adopts its first binding without remounting; after adoption, another binding Context or a return to absence starts a new component incarnation. + +`uiWorkspace` owns the main-area reference with source `mainView`, and `ui-session` derives the root Provider from that reference's ownership marker on the Session record. Conversation, right Sidebar, preset, command, input, and model components beneath a Provider consume only its standard bound data. They do not read the main-area reference or a global selection, and they do not reinterpret an ID as a binding from another Provider. + +Each Provider occurrence establishes an independent rendering scope from its supplied `SessionReference`. Two references may identify different Sessions or share one `SessionBinding`; different bindings isolate business and view data, while Providers for the same binding share Session, Conversation, input, and other Session-level data but retain separate component-local state. The main area and Sidebar can mount two Conversations concurrently without either Provider replacement or teardown redirecting the other subtree. + +`ui-session` reuses one stable business observable per active `SessionBinding`. Generation caches for Conversation assembly, input shells, command popups, input-trigger controllers, and model directories use binding identity instead of Session IDs. Caches that must enumerate live values use `WeakMapWithValues`, whose weak key table and strong value set provide identity lookup and value iteration. The container performs no cleanup; subscriptions, controllers, URLs, and other resources still release deterministically through `binding.ctx.effect()`. Provider-occurrence view state ends with that rendering scope, while final generation cleanup belongs to the binding Context. + +Descriptor changes assemble replacement sources before publication without recreating Session generations. A Provider validates its reference while reading it; a released reference, a reference from another Controller, or a reference that no longer matches an active binding cannot establish a scope. + +| Consumer | Acquisition and release | +| --- | --- | +| Main Conversation | `uiWorkspace` acquires the navigation target; `ui-session` establishes the root Provider from the `mainView` marker, and the Conversation subtree consumes only its Provider binding | +| Associated right Sidebar | `RightbarRoot` inherits the root Provider without reading the main-area reference | +| Independent Session view | Its owner retains the target and establishes a Provider from its own reference alongside the main area | +| Host event handler in the Client | Holds a local Context reference through handler and reply settlement | + +Conversation references belong to their view owners, not Chat, Trajectory, or an individual action. Chat, Trajectory, commands, input, uploads, image reads, and model selection borrow the same binding during the Provider lifetime rather than acquiring a reference per action. Switching or closing that Conversation can end its in-flight local work. Sidebar-tab layout and resource ownership remain separate; only a Sidebar view that hosts a Conversation needs its own Session reference. Empty layouts and guide placeholders retain nothing. + +Scoped business objects capture their binding before awaiting work. They do not reinterpret an old Context or directory as a new generation with the same id. ModelSelection borrows its Provider binding while retaining its own selection-generation and error rules. Editor-detach callbacks may overlap scope teardown; optional trigger and popup resolution returns absence for that retired Context rather than resolving another generation. + +### Main-area navigation and presentation + +`uiWorkspace.openSession`, `openWorkspace`, `forkSession`, and `startSession` remain the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership. + +The existing `uiWorkspace` implementation directly owns the main-area reference and target. Its navigation methods update that owner rather than calling a receiver registered by Conversation. `ui-session` derives the root Provider binding from the source marker; the main Conversation and associated right Sidebar only inherit the Provider and neither depend on `uiWorkspace` nor see the main reference. An independent Sidebar Conversation establishes a nested Provider from its own reference and overrides only that subtree's binding. The main reference is not a global standard prop, subtree Hook, or default value for ID-based lookup. + +The main view privately persists its target identity and subagent address under `dsh.sessions.current`, never a reference. Startup restoration, initial Workspace selection, and clearing an archived main target remain UI responsibilities. Archiving or removing catalog metadata does not revoke independent references held by other consumers. + +| UI behavior | Final rule | +| --- | --- | +| Session-list highlight and blank-row treatment | Derive main-area occupancy from `retainedBy.mainView`, not the number of mounted Session Providers | +| New Session Workspace | Explicit Workspace first, then the main Session's Workspace under the existing lookup rule, then the existing recent-Workspace policy | +| Onboarding | Evaluate absence or blankness of the main-area Session, not all historical Sessions | +| Browser document title | Conversation shows Session title plus product title; a global panel shows product title | +| Chat/Trajectory restoration | Restore the view for the explicitly selected main target; independently bound views keep their own state | +| Cordis inventory panel | One list without current/other grouping; no public runner getter for main-area selection | + +Source metadata does not change when DOM focus moves or when a global panel hides a retained view. The [global main-panel design](../../implemented/architecture/2026-09-08-global-main-panels.md) owns panel selection and layout; Session reference ownership does not replace it. + +Conversation retains its `hero`, `settling`, and `active` composition and existing history-loading and `openError` handling. Acquisition adds no outer loading/error phase presentation, extra composer-hiding condition, Retry button, or replacement Sidebar recovery panel. Existing error handlers continue to handle their errors; call sites without error presentation gain none. Promise rejection and correct reference release do not imply an additional UI handler. + +Workspace connection and fork preserve their existing navigation guards and panel-switch invalidation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects. + +### Presets and creation flows + +Preset directories and deployment defaults may be shared. A bound Session's preset is read or changed through its Provider binding; preset controllers are cached by `SessionBinding` rather than managed by one root current-Session follower. The hero preset seat uses the `session-maybe` Provider: it shows the creation-flow choice without a Session and operates on the exact bound blank Session after one arrives. Header labels read the same Provider-bound Session projection. + +A preset chosen before Session creation remains in the main Conversation's `session-maybe` preset surface. After Workspace creation or reuse establishes the main Provider over a blank Session, that surface applies the choice to its Provider-bound Session. When Settings changes the default preset or picker setting, the preset service selects the blank Session whose established Provider binding carries `mainView` ownership and updates that Session. Non-blank main Sessions, independent Sidebar Providers, and other background references remain unchanged; preset subtrees do not read the main reference or use a global current follower to find their target. + +### Host-event Context ownership and Typert + +A validated Host waterfall identity can arrive before catalog discovery. The Client Context resolver must remain synchronous. It acquires a local generation reference with the Gateway's source and returns `TypertOwnedValue` without opening history or refreshing child catalogs. A handler that needs history separately acquires a public reference and awaits its `ready` Promise, or uses `sessions.using`. + +Gateway owns the local reference until both handler use and reply settlement end. Context acquisition failures retain the existing report-and-delegate behavior, while handler failures produce rejected replies. Cancellation reaches the handler through its existing signal and suppresses late replies without releasing a Context still in use. Plugin shutdown joins the active connection generation's outstanding handlers; Connection starts no replacement generation until that source settles. + +`TypertOwnedValue` carries a value and its disposer through the generic Gateway. It has no additional reference count and does not teach Gateway about Session-specific ownership. Independently bundled Client modules share the owned-value marker. Client outgoing `identity(ctx)` remains synchronous; Host Context resolution and Host lifecycle are unchanged. + +## Alternatives considered + +**Catalog membership as ownership.** Discovery data cannot establish ongoing view or operation use, and some Context identities arrive before catalog membership. + +**Implicit main Session plus an explicit alternative.** Two target-resolution rules make reusable components depend on where they render. Explicit Providers supply the target, while generic source records serve window-level observation. + +**Provider subtrees reading `mainSession`.** A component would then have both a Provider target and a window-level target, so an independent Sidebar Conversation could be redirected by a main-area change. The main reference participates only in Provider assembly; subtrees consume their Provider. + +**Session ID as a generation-cache key.** Final release allows a replacement generation with the same ID to appear before old cleanup ends. An ID key can reuse the old object or let old cleanup remove the replacement. Business caches use weak binding identity, and the binding Context still owns resource cleanup. + +**Asynchronous `retain` that resolves only after history opens.** It delays Provider installation and main-view navigation until history arrives, so the existing loading state cannot render immediately. A synchronous reference separates ownership from its explicit `ready` result. + +**History I/O in synchronous Context resolution.** Host-event dispatch needs a scoped lifetime, not necessarily a history window; coupling them delays or blocks handlers before catalog discovery. + +**A dedicated current flag.** One consumer-specific flag cannot describe Sidebar and background ownership. A main-view marker is derivable from the general per-source counts. + +**Completion state in Session Controller, or separate pending and completion hooks.** Completion acknowledgement is UI policy. One UI status source composes independent facts while retaining domain-owned pending objects and Controller-owned running facts. + +**Provider presence as acknowledgement or release.** Mounting is neither an owner's lifetime nor selection-based acknowledgement. It cannot decide which background or temporarily hidden uses remain alive or count as viewed. + +**Bare ids in Providers or a global binding revision.** An id does not express generation ownership, and global refresh invalidates unrelated Session consumers. + +**A reference for every action.** The Provider already defines the Conversation's usage lifetime. Reacquiring for every click fragments view ownership into fine-grained sources. Only work that must outlive the Provider acquires another reference. + +**Navigation registration, new cancellation policies, and acquisition-specific recovery UI.** Reference ownership requires explicit targets, release, and failure propagation, not additional navigation or recovery behavior. + +**Client references retaining Host Agents.** History access and Host execution are independent uses; a long-lived Client view cannot define Host business-operation lifetime. + +## Verification + +- Acquisition tests cover shared initial opening, ordinary and unexpected open failures, independent waiter cancellation, exact-generation replacement, and root teardown reaching quiescence. +- Source tests cover multiple sources, several references from one source, failed acquisition rollback, idempotent release, catalog refresh/removal, and late release of an ended generation without changing its replacement. +- Retain-info hook tests cover explicit identities, Provider-bound defaults, unbound and nested scopes, source updates across generations, and reads that create neither references nor history requests. +- Scope and Gateway tests cover synchronous Context acquisition before catalog discovery without history I/O, reported acquisition delegation, rejected handler replies, ownership through cancellation and settlement, and shared owned-value markers across bundles. +- UI status tests cover pending precedence and teardown, event transitions lost by snapshot batching, initial and reconnect baselines, main-source acknowledgement, unrelated-source retention, and no Provider-presence event. +- View tests cover explicit Providers for two different Sessions and for repeated uses of one Session, scoped presets, descriptor updates, and Sidebar occurrence/adoption lifetimes without new recovery controls. +- Assembled browser scenarios preserve main-area highlighting, blank rows, onboarding, Workspace defaults, titles, and the defined navigation/error behavior. Existing recorded model turns remain the behavioral input when only Client ownership changes. +- The public `retain` and `using` consumers, generated API catalogs, package contracts, and type checks agree on options and ownership. The helper keeps references through callback settlement and propagates both acquisition and operation failures. + +## Consequences + +Consumers must keep references until their real completion point; an unreleased reference still leaks within a live Client root. Retaining after final release creates a new generation and can reopen history. Source counts describe ownership, not visibility, task success, or authority; using the main marker as a business fallback would recreate implicit current-Session coupling. + +Final release discards non-persisted binding-owned state, including loaded history pages, Chat scroll anchors, preview wrapping, Files expansion, composer attachments, and undo history. Only persisted Session-keyed Store values or a generation kept alive by another reference survive a view switch; the runtime does not clear those persisted values. + +Every public `retain`, including the temporary references used by Session rename and fork-title assignment, starts the generation's shared initial history opening. These metadata operations await `reference.ready` before using the Session and therefore pay that history I/O for a cold generation. + +The catalog, UI status, and view target have separate owners and can publish independently. Their consumers must not infer a lifecycle transition solely from notification order. The main view remains an ordinary reference owner, while its navigation and presentation rules stay in UI rather than the reference allocator. diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md new file mode 100644 index 0000000000..34fa6526a5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md @@ -0,0 +1,226 @@ +# Agent Note: Client Session 引用、引用来源与 UI 状态 + +Status: implemented + +[English](2026-09-15-client-session-references.md) | 中文 + +## 问题 + +Session 目录、活跃 Client 对象、视图与异步操作具有不同生命周期。目录成员关系不能证明仍在使用。借用的 binding 无法保护异步工作,也无法区分同一 Session ID 的不同 Client 代。全局 current Session 会使独立绑定的组件操作另一个视图的 Session。 + +引用计数能表明仍在使用,但不能识别使用方。主区域高亮、Sidebar 所有权与后台操作需要使用方来源信息。待处理交互与完成提醒也需要统一的 UI 读取接口,避免 Workspace 和 Conversation 各自组合相同状态。 + +Client 历史访问与 Host Agent(智能体)执行具有独立生命周期。历史打开可能失败;获取本地 Context 不一定需要读取历史。显式所有权必须保持导航、加载、错误处理与恢复行为,不添加无关 UI 策略。 + +## 决策 + +### 范围与所有权 + +Client Session 对象、Agent 作用域的 Client Context、引用、使用方来源元数据、UI 状态与显式 Provider 组合遵循下述所有权规则。Host Session 和 Agent 生命周期、SlotFactory、activity 列表视图、持久 Session 格式及两个 SDK 的 Host 协议保持独立。 + +| 所有者 | 职责 | +| --- | --- | +| Client Session Controller | 目录、活跃代、引用、来源计数、binding、历史窗口与既有 Session 控制状态 | +| `ui-session` | 显式 Session Provider 集成与统一 UI 状态来源 | +| 视图或操作 | 自己的引用、目标、来源标识与释放时机 | +| Workspace UI | 主区域目标与引用、导航、持久目标及创建流程 | +| UI 组合边界 | 将所有者提供的引用交给一个明确的 `SessionProvider` | +| Conversation 与 Sidebar 子树 | 只消费所在 Provider 的 Session,不读取主区域引用或全局选择 | +| Client Gateway | 处理 Host 事件时调用期的 Context 所有权 | + +[Client 分层设计](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md)定义数据、适配器、渲染器与展示层的单向依赖。引用来源统计不会使 Controller 依赖 UI 包。 + +本决策部分取代 [Web Client Session scope 与 provide channel 决策](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中由 list 选择驱动的 scope 生命周期;后者保留显式 Provider 所有权下的 blank Session 与收养语义理由。 + +### 地址、binding 与引用 + +`SessionTarget` 是已知 `SessionId` 或持久的直接父子 `SubagentAddress`。它标识要获取的目标,不持有任何对象。Controller 解析显式地址时不要求预先加载 parent catalog,Host 则在打开历史时校验其 parent、child 与 mode。子会话发现与已知子会话地址均不等同于持有该子会话。导航使用同一种目标表示,不增加另一套导航地址。 + +`SessionBinding` 是共享的 Client 代,包含 `sessionId`、Session 接口、事件来源与作用域 Context。同一活跃代的多个引用共享这个 binding。同 ID 的新一代具有不同的 binding 和 Context。 + +`SessionReference` 持有某个确切代的一次使用。它公开只读 `sessionId` 和 `binding`、表示共享首次历史打开的 `ready` Promise,以及幂等 `release()` 与 `Symbol.dispose`。对应 `Session.open()` 尝试解析时,`ready` 解析为该确切 binding,包括以状态表示的 Remote failure 结果。引用释放或其代清理后,读取 `binding` 会失败。获取、释放或查看引用均不创建持久 Session,也不启动、停止或持有 Host Agent。 + +`SessionBinding` 是借用值,不构成持有。只有未释放的 `SessionReference` 计入来源与总引用数。同步代码可以在拥有者引用的生命周期内借用 binding;需要越过该生命周期的工作必须拥有自己的引用。 + +| API | 返回结果与调用方义务 | +| --- | --- | +| `sessions.retain(target, options)` | `SessionReference`;立即返回,调用方持有该引用直到释放 | +| `sessions.using(target, options, operation)` | `Promise`;依次等待 `reference.ready` 与 `operation(reference)`,释放自己的引用,并返回操作结果 | +| `sessions.retainInfo(id)` | 本地引用计数的稳定只读 observable,不获取引用、不创建作用域,也不执行历史 I/O | +| `sessions.scope(id)` / `sessions.binding(id)` | 借用已存在的活跃代或返回 `undefined`,不打开它,也不延长其生命周期 | +| `sessions.sessionOf(ctx)` | 返回匹配的活跃 Session 接口或 `undefined`;已结束的 Context 不能解析为同 ID 替代代 | +| `sessions.create(...)` / `sessions.fork(...)` | 既有 Host 操作,返回 Session 身份;持有与展示仍需显式执行 | + +`SessionRetainOptions` 包含必填 `source: SessionReferenceSource` 与可选 `signal: AbortSignal`。两个获取方法均接受 `target: SessionTarget` 和这些 options。来源键由使用方定义,通过声明合并扩展;不提供运行时来源注册协议,也不默认使用主视图来源。来源是使用标签,不是另一种 Session 地址,也不授予操作权限。 + +### 获取、失败与释放 + +获取操作同步解析目标,创建或持有本地 Session、Context、fiber 和 binding,记录一份引用及其来源计数,启动该代共享的首次历史打开,然后返回引用。并发获取共享这次打开,但分别获得独立引用与就绪等待。共享的 `Session.open()` 尝试解析时,`reference.ready` 解析为仍然有效的确切 binding。 + +未知 Session id 的寻址在返回引用前失败;显式 subagent 地址由 Host 在打开期间校验。抛出的打开失败、调用方取消、引用释放或该代结束会拒绝该引用的 `ready`;取消一个等待方不会取消其他所有者共享的打开操作。以 `openState: 'error'` 表示的 Remote failure 跟随 `Session.open()` 语义并解析就绪,错误仍可通过 binding 渲染。调用方持有已返回的引用直到释放,而 `sessions.using` 会在就绪或操作失败时释放自己的引用。 + +`using reference = sessions.retain(target, options)` 在所在作用域退出时释放;需要等待首次历史尝试结算的代码等待 `reference.ready`。`sessions.using` 是面向回调调用方的辅助方法:它先等待该次结算,再调用操作并等待操作返回的普通值或 Promise,最后释放。返回值不能依赖辅助方法已经释放的引用仍可使用;需要更长生命周期的使用方自行获取引用。辅助方法传播被拒绝的就绪与操作失败,不提供兜底结果、重试或错误展示。模型选择的代际检查与错误状态更新保留在 ModelSelection。 + +`ready` Promise 成功表示首次 `Session.open()` 尝试已经结算;它不保证 `openState: 'open'`,也不保证连接永不中断。以状态表示的打开失败与之后的流失败继续通过既有 Session 状态公开。调用方保留对这些错误的既有处理与展示。 + +释放只移除该引用及其来源计数。最后释放在清理 Session 与 fiber 前撤回该代的准入和 ID 映射。后续 retain 可以立即创建新一代;旧代清理不能移除替代代。`release()` 同步发起本地清理,根清理等待尚未结束的异步释放工作。 + +所属 Client 根在关闭时使全部引用失效。它拒绝新的获取,撤回活跃映射,并汇合作用域清理与 Session 流释放。引用不能使已清理的 Client 根继续存活。仅移除目录记录不会清理仍被引用持有的代。 + +### 引用来源与 Session 列表记录 + +Controller 将来源计数与每个活跃代的引用共同管理。每个来源的计数等于该代尚未释放且带有该来源的引用数。同一 Session 可以有多个来源,同一来源可以持有多份引用。分配器不赋予任何来源特殊生命周期行为。 + +每条已有 Session 列表记录公开只读 `retainedBy`,由来源键映射到正数引用计数。未被持有的记录使用空对象;零计数键不存在。获取失败与释放更新该投影,包括删除最后一个来源键。Host 元数据刷新时,来源计数仍是本地事实,Host 响应不能覆盖它。 + +即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 为每个活跃代合成本地兜底记录,以便 Provider 与所有权使用方解析它;`ids` 仍表示 Host 列表成员关系与顺序。兜底记录不是 Host 元数据。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。 + +例如,`retainedBy = { mainView: 1, gateway: 2 }` 表示主视图与两个 Host 调用持有三份引用。释放主视图引用只移除 `mainView`;Gateway 调用继续持有该代。示例中的来源名是使用方键,不是 Controller 内封闭的枚举。 + +`current` 表示主视图占用:记录的 `mainView` 来源计数为正数时,就具有这个标记。这只是通用来源记录的一种用途,不是独立的 `sessions.current` 值或 Session 选择服务。其他使用方可以从自己的来源键派生标记。引用系统不要求全局唯一使用方,也不从多个来源中选择一个 Session。 + +主区域所有者管理自己的目标与引用切换。列表不根据恰好挂载了多少 Provider 推断选择。来源记录反映实际所有权,包括获取期间的短暂重叠;UI 导航仍负责自己的目标,不把选择权交给分配器。 + +窗口级使用方可以查看来源标记。Session 业务操作仍使用传入的作用域或显式引用,不得从目录中查找 `mainView` 来补齐缺失的操作目标。后台引用只能证明所有权,不能证明用户查看过 Session。 + +`SessionRetainInfo` 包含 `referenceCount` 与只读 `retainedBy` 记录。`sessions.retainInfo(id)` 独立于目录成员关系观察本地所有权,并在同 ID 换代时保持稳定。没有活跃代的身份具有零引用和空来源记录;读取该值不代表该身份在 Host 上存在。 + +`ui-session` 通过 `useSessionRetainInfo(sessionId, selector)` 读取明确身份,通过 `useSessionRetainInfo(selector)` 读取外围 Provider 绑定的 Session。未绑定作用域向后一种形式提供缺失值,不回退到主区域 Session。两种形式均由渲染器从相同的裸 retain-info 来源构造。使用方检查 `mainView` 来源计数以识别原 current-Session 角色,其他来源键也可同等查询。读取或订阅不会持有 Session。 + +### 统一的 UI Session 状态 + +`ui-session` 拥有不依赖 React 的 `sessionStatus` 来源,通过标准 `useSessionStatus` 钩子公开。快照按 Session 身份索引,为每个已知 Session 提供一条 UI 状态记录。它组合下列独立事实,不将它们压缩成互斥的单一阶段: + +| 字段 | 取值 | 含义与所有者 | +| --- | --- | --- | +| `running` | `boolean` 或 `undefined` | 最新已知的 Session 运行事实;缺少基线不表示已确认 idle | +| `pendingInteraction` | `SessionPendingInteraction` 或 `undefined` | 领域拥有的有效请求;没有待处理请求时缺失 | +| `completionUnread` | `boolean` | 已观察到停止、但尚未确认的 UI 提醒 | + +来源计数以 `SessionListState.byId[id].retainedBy` 为准;UI 状态读取它以执行确认策略,不维护另一套引用注册表。标题、Workspace 关联、历史、队列和投影数据保留在各自的既有所有者中。 + +待处理领域保留 `SessionPendingInteractionMap`、请求身份、优先级、发布清理函数与卸载委托。统一状态包含同一个有效请求对象,不复制请求,也不创建第二套待处理注册表。Workspace 状态指示器与 Conversation composer 选择读取 `useSessionStatus`,不再分别读取 `useSessionPendingInteraction` 和 `useCompletedSessionIds` 钩子。 + +完成跟踪订阅已有 `api-session/status` 事件,避免 running 到 idle 的变化在合批目录快照中丢失。目录快照建立初始与重连基线。pending 状态下的空目录不能证明 Session 已消失。更新规则如下: + +- 初始 idle 基线不产生完成提醒。 +- 观察到 running 时清除旧提醒,并记录运行基线。 +- 从已知 running 变为 idle 时,仅在 Session 没有主视图持有的情况下设置 `completionUnread`。 +- 获得主视图持有时清除提醒;无关来源的 retain 不清除提醒。 +- 释放主视图持有不会为更早的一次停止补造提醒。 +- 移除 Session 时清除其完成提醒与运行基线;待处理请求的清理仍属于请求领域。 + +提醒表示观察到的一次停止,不代表任务成功,也不代表某条排队消息完成。即使全局面板暂时隐藏 Conversation,主视图所有权仍保持基于选择的确认语义。本设计不提供 `ui-session/view-presence` 事件、已挂载 Provider 索引或 Provider 挂载时的隐式确认。 + +Session Controller 发布运行与引用来源事实,但不拥有完成提醒集合、`consumeCompletion` 方法或待处理交互展示。引用获取不执行完成提醒业务逻辑。 + +### 显式 Provider、并行 Conversation 与缓存身份 + +唯一的 `SessionProvider` 可以继承外层 binding,也可以用显式 `session={reference | undefined}` 覆盖本子树。它不获取或释放所有权。根 Provider 从 `mainView` 所有权标记解析主 binding,不维护另一份 current;并列或嵌套 Provider 只影响各自子树。显式缺失保持缺失,不回退到主区域。 + +Provider 注入选定 binding,但不为整个 body 设置 key。严格 `session` entry 在 binding Context 改变时重新挂载。空白 `session-maybe` entry 接受首个 binding 时不重新挂载;接受后,切换到另一个 binding Context 或回到缺失状态会创建新的组件 incarnation。 + +`uiWorkspace` 持有来源为 `mainView` 的主区域引用,`ui-session` 从该引用在 Session 记录中的所有权标记建立根 Provider。Provider 下的 Conversation、右 Sidebar、preset、命令、输入与模型组件只能读取 Provider 绑定的标准数据。它们不得读取主区域引用或全局选择,也不得按 Session ID 重新寻找一个可能属于其他 Provider 的 binding。 + +每个 Provider occurrence 以传入的 `SessionReference` 建立独立渲染作用域。两个引用可以指向不同 Session,也可以共享同一 `SessionBinding`;不同 binding 的业务与观看数据完全分离,同一 binding 的 Provider 共享 Session、Conversation、输入等 Session 级数据,但保留各自的组件局部状态。主区域与 Sidebar 可以同时挂载两个 Conversation,任一 Provider 的替换或卸载不改变另一棵子树的目标。 + +`ui-session` 为每个活跃 `SessionBinding` 复用稳定的业务 observable。Conversation assembly、input shell、command popup、input-trigger controller 与 model directory 等代际缓存以 binding 为弱键,不再以 Session ID 为键;需要枚举活跃值的缓存使用 `util-values` 的 `WeakMapWithValues`,由弱键表和强值集合共同维护身份查询与值遍历。该容器不执行清理;订阅、控制器、URL 和其他资源仍通过 `binding.ctx.effect()` 确定性释放。Provider occurrence 的观看状态随该 Provider 的渲染作用域释放,最终 generation 清理由 binding Context 负责。 + +Descriptor 变化先组装替代来源再发布,不重建 Session 代。Provider 在读取 reference 时校验它仍有效;已经释放、来自其他 Controller 或不再对应活跃 binding 的 reference 不能创建作用域。 + +| 使用方 | 获取与释放 | +| --- | --- | +| 主 Conversation | `uiWorkspace` 获取导航目标;`ui-session` 从 `mainView` 标记建立根 Provider,Conversation 子树只消费 Provider 绑定 | +| 关联右 Sidebar | `RightbarRoot` 继承根 Provider,不读取主区域引用 | +| 独立 Session 视图 | 自己的所有者持有目标,并以自己的引用建立 Provider;与主区域同时运行 | +| Client 中的 Host 事件 handler | 持有本地 Context 引用直到 handler 与回复结算 | + +Conversation 的引用属于其视图所有者,不属于 Chat、Trajectory 或某次点击。Chat、Trajectory、命令、输入、上传、图片读取与模型选择在 Provider 生命周期内借用同一 binding;它们不按行动重复获取引用。切换或关闭该 Conversation 可以结束仍在途的本地工作。Sidebar tab 的布局与资源所有权保持独立;只有承载 Conversation 的 Sidebar 视图所有者需要自己的 Session 引用。空布局与 guide 占位页不持有引用。 + +作用域业务对象从 Provider 捕获自己的 binding,不把旧 Context 或目录重新解释成同 ID 的新一代。ModelSelection 借用 Provider binding,并保留自己的选择代际与错误规则。编辑器脱离回调可能与作用域清理重叠;可选 trigger 和 popup 对已结束的 Context 返回空值,不解析其他 generation。 + +### 主区域导航与展示 + +`uiWorkspace.openSession`、`openWorkspace`、`forkSession` 和 `startSession` 仍是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。 + +现有 `uiWorkspace` 实现直接持有来源为 `mainView` 的主区域引用与目标。导航方法直接更新该所有者,不调用 Conversation 注册的接收者。`ui-session` 根据来源标记把该引用对应的 binding 注入根 Provider;主 Conversation 和关联右栏只继承 Provider,既不依赖 `uiWorkspace`,也看不到主引用。独立 Sidebar Conversation 以自己的 reference 建立嵌套 Provider,并覆盖本子树的 binding。主引用不是全局标准 prop、子树 Hook 或按 ID 查询的默认值。 + +主视图在 `dsh.sessions.current` 下私下持久化目标身份与子会话地址,不保存引用。启动恢复、初始 Workspace 选择与归档主目标后的清空仍属于 UI。归档或移除目录元数据不撤销其他使用方持有的独立引用。 + +| UI 行为 | 最终规则 | +| --- | --- | +| Session 列表高亮与空白记录处理 | 从 `retainedBy.mainView` 派生主区域占用,不按已挂载 Session Provider 的数量判断 | +| New Session 的 Workspace | 优先使用显式 Workspace,其次按既有查找规则使用主 Session 的 Workspace,最后使用既有最近 Workspace 策略 | +| Onboarding | 判断主区域 Session 是否缺失或为空白,不判断所有历史 Session | +| 浏览器文档标题 | Conversation 显示 Session 标题与产品标题;全局面板显示产品标题 | +| Chat/Trajectory 恢复 | 为显式选中的主目标恢复视图;独立绑定的视图保留自己的状态 | +| Cordis inventory 面板 | 使用不区分 current/other 的单一列表;runner 不提供主区域选择的公开 getter | + +DOM 焦点移动或全局面板隐藏仍被持有的视图时,来源元数据不变。[全局主面板设计](../../implemented/architecture/2026-09-08-global-main-panels.zh.md)拥有面板选择与布局;Session 引用所有权不替代它。 + +Conversation 保留 `hero`、`settling`、`active` 组合与既有历史加载和 `openError` 处理。获取引用不增加外层 loading/error 阶段展示、额外隐藏 composer 的条件、Retry 按钮或替换 Sidebar 内容的恢复面板。已有错误处理方继续处理自己的错误;没有错误展示的调用点不增加展示。Promise 拒绝与正确释放引用不意味着额外增加 UI 处理方。 + +Workspace 连接和 fork 保持既有导航检查与面板切换失效规则。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。 + +### 预设与创建流程 + +预设目录与部署默认值可以共享。已绑定 Session 的预设通过 Provider 的 binding 读取或修改;预设控制器按 `SessionBinding` 缓存,不由根级 current-Session 跟随器管理。Hero 的 preset seat 使用 `session-maybe` Provider:没有 Session 时显示创建流程选择,绑定空白 Session 后操作该确切 Session。标题标签读取同一 Provider 绑定 Session 的投影。 + +Session 创建前选择的 preset 保留在主 Conversation 的 `session-maybe` preset surface 中。Workspace 创建或复用空白 Session 并建立主 Provider 后,该 surface 将选择应用到 Provider 绑定的 Session。设置页修改默认 preset 或 picker 设置时,preset 服务从 Provider 已建立的 binding 缓存中选择带 `mainView` 所有权标记的空白 Session,并更新该 Session。非空白主 Session、Sidebar 的独立 Provider 与其他后台引用均不受该设置动作影响;preset 子树不读取主引用,也不通过全局 current follower 寻找目标。 + +### Host 事件 Context 所有权与 Typert + +经校验的 Host waterfall(瀑布式事件)身份可以先于目录发现到达。Client Context 解析器必须保持同步。它带上 Gateway 的来源获取本地代引用,返回 `TypertOwnedValue`,不打开历史,也不刷新子目录。需要历史的 handler 另行获取公开引用并等待其 `ready` Promise,或使用 `sessions.using`。 + +Gateway 持有本地引用,直到 handler 使用与回复结算均结束。Context 获取失败保留既有的记录错误并委托语义,handler 失败产生拒绝回复。取消通过已有 signal 到达 handler,并抑制晚回复,但不会在 Context 仍被使用时提前释放。插件关闭汇合当前连接代的在途 handler;Connection 只在该来源结算后启动替代代。 + +`TypertOwnedValue` 跨通用 Gateway 传递值及其清理操作。它没有额外引用计数,也不要求 Gateway 理解 Session 专用所有权。独立 Client bundle 共享 owned-value 标记。Client 发请求的 `identity(ctx)` 保持同步;Host Context 解析与 Host 生命周期不变。 + +## 考虑过的替代方案 + +**以目录成员关系作为所有权。** 发现数据无法证明视图或操作仍在使用,而且部分 Context 身份先于目录成员关系到达。 + +**隐式主 Session 加显式替代路径。** 两套目标解析规则会使可复用组件依赖其渲染位置。显式 Provider 提供目标,通用来源记录服务于窗口级观察。 + +**Provider 子树继续读取 `mainSession`。** 同一组件会同时拥有 Provider 与窗口级目标,Sidebar 的独立 Conversation 也会被主区域变化重定向。主引用只参与 Provider 装配,子树只读取 Provider。 + +**以 Session ID 为代际缓存键。** 最终释放允许同 ID 新代在旧清理结束前出现,ID 键会复用旧对象或让旧清理删除新对象。业务缓存使用弱引用的 binding 身份,资源清理由 binding Context 负责。 + +**只在历史打开后解析的异步 `retain`。** 它会把 Provider 安装与主视图导航推迟到历史到达之后,导致既有加载状态无法立即渲染。同步引用把所有权与显式的 `ready` 结果分开。 + +**在同步 Context 解析中执行历史 I/O。** Host 事件派发需要作用域生命周期,不一定需要历史窗口;耦合两者会在目录发现之前延迟或阻止 handler。 + +**专用 current 标志。** 一种使用方专用标志无法描述 Sidebar 与后台所有权。主视图标记可以从通用的按来源计数中派生。 + +**Session Controller 中的完成状态,或独立的待处理与完成提醒钩子。** 完成确认属于 UI 策略。统一 UI 状态来源组合独立事实,同时保留领域拥有的待处理对象与 Controller 拥有的运行事实。 + +**以 Provider 呈现决定确认或释放。** 挂载既不等于所有者生命周期,也不等于基于选择的确认。它无法决定哪些后台或暂时隐藏的使用仍应存活,或应当算作已查看。 + +**Provider 中的裸 ID 或全局 binding revision。** ID 不表达代际所有权,全局刷新会使无关 Session 使用方失效。 + +**每个行动各自获取引用。** Provider 已经定义 Conversation 的使用生命周期;为每次点击重复持有会把视图所有权拆成大量细粒度来源。只有明确需要越过 Provider 生命周期的工作才另行持有引用。 + +**导航注册、新的取消策略与获取专用恢复 UI。** 引用所有权要求显式目标、释放与失败传播,不要求额外导航或恢复行为。 + +**Client 引用持有 Host Agent。** 历史访问与 Host 执行是独立使用需求,长时间打开的 Client 视图不能定义 Host 业务操作生命周期。 + +## 验证 + +- 获取测试覆盖共享首次打开、普通与意外打开失败、独立等待方取消、确切代替换,以及根清理达到完全停稳。 +- 来源测试覆盖多个来源、同来源多份引用、获取失败回滚、幂等释放、目录刷新或移除,以及旧代的晚释放不改变替代代。 +- Retain-info 钩子测试覆盖明确身份、Provider 绑定默认值、未绑定与嵌套作用域、跨代来源更新,以及读取不创建引用或请求历史。 +- 作用域与 Gateway 测试覆盖目录发现前不执行历史 I/O 的同步 Context 获取、获取失败的记录与委托、handler 的拒绝回复、取消与结算期间的所有权,以及跨 bundle 共享的 owned-value 标记。 +- UI 状态测试覆盖待处理优先级与清理、会被快照合批丢失的事件变化、初始与重连基线、主来源确认、无关来源持有,以及不存在 Provider 呈现事件。 +- 视图验证覆盖两个不同 Session 和同一 Session 多次使用的并行显式 Provider、互不重定向的 Conversation、独立 Slot store、作用域 preset、descriptor 更新,以及没有新恢复控件的 Sidebar 生命周期。 +- 实际组合浏览器场景保持主区域高亮、空白记录、onboarding、Workspace 默认值、标题与约定的导航或错误行为。只有 Client 所有权改变时,已有模型轮次录制仍作为行为输入。 +- 公开 `retain`、`using`、Provider、生成 API 目录、包约定与类型检查对 options 和所有权保持一致。Provider 子树不读取主区域引用,独立所有者各自释放自己的引用。 + +## 后果 + +使用方必须把引用保留到真实结束点;在活跃 Client 根内,未释放引用仍会泄漏。最后释放后再次 retain 会创建新一代,并可能重新打开历史。来源计数描述所有权,不描述可见性、任务成功或操作权限;以主标记作为业务兜底会重新引入隐式 current-Session 耦合。 + +最后一份 reference 释放后,未持久化的 binding 自有状态会被丢弃,包括已加载的历史页、Chat 滚动锚点、预览换行、Files 展开状态、composer 附件和 undo 历史。只有持久化的 Session-keyed Store 值或由另一份 reference 保活的 generation 能跨视图切换保留;runtime 不会清除这些持久值。 + +每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括 Session 重命名和 fork 标题设置所用的临时引用。这些元数据操作在使用 Session 前等待 `reference.ready`,因此冷 generation 会承担这次历史 I/O。 + +目录、UI 状态与视图目标由不同所有者管理,可以独立发布。使用方不能仅从通知顺序推断生命周期变化。主视图仍是普通引用所有者,其导航与展示规则留在 UI,不进入引用分配器。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml index 1a76ed8846..3c89776191 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.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-09-15-desktop-native-fatal-recovery.md -2026-09-15-desktop-native-fatal-recovery.md: b2a0dfff1a630d1eedaa898e4a4c8144ff7ae776 -2026-09-15-desktop-native-fatal-recovery.zh.md: 0fee729e3be791d10966050d21482c5132b14fe6 +2026-09-15-desktop-native-fatal-recovery.md: 94ead425f5483ba3e6ed5d9d595edb4483254c27 +2026-09-15-desktop-native-fatal-recovery.zh.md: 2138fb11405d00d51775350bf60d2702621aaa19 diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md index b2a0dfff1a..94ead425f5 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md @@ -10,18 +10,20 @@ A recovery document depends on the renderer and preload whose failure can preven ## Decision -Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal. +Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests retain local error handling, and the shared Web plugin manager owns package-operation failures. Expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal. -The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report. +The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report. -The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry. +The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. Backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry. -This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [in-place profile decision](2026-09-09-desktop-in-place-profile.md) still owns package transactions and partial changes. +This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [Web wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared plugin management and native profile preparation. ## Alternatives considered A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control. +**Disable bundles while retaining the active profile patch.** A malformed patch or a patch that inserts a broken plugin can still prevent startup. Renaming preserves the user’s exact bytes for manual repair while removing that layer from startup; unique backup names preserve earlier recovery attempts. + ## Consequences -Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation. +Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. A malformed home-level patch still blocks startup after profile recovery and requires manual repair. Backups accumulate in the profile directory without automatic pruning; users remove them when no longer needed. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation. diff --git a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md index 0fee729e3b..2138fb1140 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md @@ -10,18 +10,20 @@ Status: implemented ## Decision -Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。 +Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。 -首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。 +首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。 -Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。 +Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。 -这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。 +本决策取代[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页面和重置行为;其立即显示与直接启动 Host 的理由仍有效。[Web 薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享插件管理和原生 profile 准备。 ## Alternatives considered Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。 +**禁用 bundle,但保留生效的 profile patch。** 损坏的 patch 或插入故障插件的 patch 仍可阻止启动。重命名保留用户原始内容供手动修复,同时将该层移出启动过程;唯一备份名保留此前恢复操作的备份。 + ## Consequences -恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。 +恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。损坏的 home 级 patch 在 profile 恢复后仍会阻止启动,需要手动修复。备份在 profile 目录中累积,不会自动清理;用户在不再需要时删除。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml new file mode 100644 index 0000000000..7ba1bf8a3e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md +2026-09-15-platform-office-engines.md: 4272054672c6163f22b910ab64b6472a9dbdfb4c +2026-09-15-platform-office-engines.zh.md: d0a317333049734415ab9c0c05a5624b63b27447 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md new file mode 100644 index 0000000000..4272054672 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md @@ -0,0 +1,31 @@ +# Agent Note: One Office engine per platform + +Status: implemented + +English | [中文](2026-09-15-platform-office-engines.zh.md) + +## Problem + +Installing WASM beside a usable native engine adds a second LibreOffice payload to application downloads and installed resources. The original fallback policy in [independent kit ownership](2026-09-14-independent-libreoffice-kit.md) requires that extra payload even on fixed-platform Desktop and Python distributions. + +## Decision + +Harness selects one engine from the installed kit API’s `optionalDependencies`. A declared `@deepseek-ai/libreoffice-kit-${platform}-${arch}` requires that native package; other targets require the shared WASM package. The supported native target set belongs to the kit release, not an operating-system branch in Harness. A missing declared native package is an incomplete installation and never selects WASM. The provider has no direct WASM dependency. + +Python sidecar assembly copies only the selected engine and its dependency closure. Wheel packaging and runtime lookup select from the same kit manifest; the relocated conversion smoke checks the selected backend. Desktop filters its npm installation to that engine before signing and integrity sealing. Engine compilation, resolver compatibility, npm platform metadata, qualification, and publication remain in the kit repository. + +## Alternatives considered + +**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every distribution with native support. Fixed-platform distributions require their declared native engine to be present and tested instead. + +**Remove WASM on every platform.** Targets without a released native engine still need conversion. The shared WASM package serves those targets without introducing a new native release target. + +**Make the Python Office sidecar optional.** The runtime carries the shared `dsh` CLI and its Web profile as well as the default SDK profile. Requiring the target engine gives the installed wheel a complete shipped profile set and reports an incomplete payload before launch. SDK and headless users also pay the engine download and installed-size cost. + +## Consequences + +Distributions with a declared native target omit WASM assets. Other targets retain WASM resource and font requirements. The pinned kit declares macOS/Windows ARM64 and x64 native packages, so current Linux distributions select WASM; a kit release can add a native Linux target without changing Harness’s selection rule. This does not expand Harness’s supported release platforms. Harness sidecar, wheel, and runtime-resolution tests cover both declared native targets and WASM selection, including missing native packages. New package bytes require kit qualification and matching dependency integrity records before publication. + +A new engine package identity also requires updates to `LIBREOFFICE_PACKAGES` in `scripts/gen-third-party-notices.ts`, any applicable `minimumReleaseAgeExclude` entry in `pnpm-workspace.yaml`, and the package list in the [kit ownership note](2026-09-14-independent-libreoffice-kit.md). The license allowlist remains explicit. + +The [public Python release workflow](../../../../.github/workflows/python-release.yml) rejects any wheel at or above 100,000,000 bytes. Selecting one engine reduces payload size but does not establish that a runtime wheel meets this limit; npm engine publication and local conversion are separate from wheel upload eligibility. diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md new file mode 100644 index 0000000000..d0a3173330 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 每个平台使用一个 Office 引擎 + +Status: implemented + +[English](2026-09-15-platform-office-engines.md) | 中文 + +## Problem + +在可用的原生引擎之外安装 WASM,会为应用下载和安装资源增加第二份 LibreOffice 载荷。[独立 kit 归属](2026-09-14-independent-libreoffice-kit.zh.md)中最初的回退策略要求固定平台的 Desktop 和 Python 分发物也携带这份额外载荷。 + +## Decision + +Harness 从已安装 kit API 的 `optionalDependencies` 中选择一个引擎。声明了 `@deepseek-ai/libreoffice-kit-${platform}-${arch}` 的目标要求该原生包,其余目标要求共享 WASM 包。受支持的原生目标集合由 kit 发布版本决定,不由 Harness 中的操作系统分支决定。已声明的原生包缺失表示安装不完整,不会改选 WASM。provider 不直接依赖 WASM。 + +Python sidecar 组装仅复制所选引擎及其依赖闭包。wheel 打包和运行时查找依据同一份 kit 清单选择;迁移目录后的转换冒烟检查所选后端。Desktop 在签名和完整性封装前将 npm 安装结果过滤为该引擎。引擎编译、解析器兼容性、npm 平台元数据、资格验证与发布仍由 kit 仓库负责。 + +## Alternatives considered + +**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个支持原生引擎的分发物。固定平台分发物要求其已声明的原生引擎存在且经过测试。 + +**在所有平台移除 WASM。** 没有已发布原生引擎的目标仍需转换能力。共享 WASM 包为这些目标提供转换,无需引入新的原生发布目标。 + +**将 Python Office 伴随目录设为可选。** 运行时承载共享的 `dsh` CLI 及其 Web profile,而不只有默认 SDK profile。强制携带目标引擎使已安装 wheel 具备完整的内置 profile 集合,并在启动前报告载荷不完整。SDK 和 headless 用户也承担引擎的下载与安装体积。 + +## Consequences + +声明了原生目标的分发物省去 WASM 资源,其余目标保留 WASM 的资源和字体要求。锁定的 kit 声明了 macOS/Windows ARM64 和 x64 原生包,因此当前 Linux 分发物选择 WASM;kit 发布版本可以新增 Linux 原生目标,无需修改 Harness 的选择规则。这不扩展 Harness 支持的发布平台。Harness 的 sidecar、wheel 和运行时解析测试覆盖已声明原生目标与 WASM 选择,包括原生包缺失。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 + +新增引擎包标识还需要更新 `scripts/gen-third-party-notices.ts` 中的 `LIBREOFFICE_PACKAGES`、`pnpm-workspace.yaml` 中适用的 `minimumReleaseAgeExclude` 条目,以及 [kit 归属记录](2026-09-14-independent-libreoffice-kit.zh.md)中的包列表。许可证允许列表仍使用明确的包标识。 + +[公开 Python 发布工作流](../../../../.github/workflows/python-release.yml)拒绝任何大于等于 100,000,000 字节的 wheel。只选择一个引擎会减少载荷,但不能据此认定运行时 wheel 已满足此限制;npm 引擎发布、本地转换与 wheel 上传资格是不同的验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml new file mode 100644 index 0000000000..ecb789a14d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md +2026-09-16-desktop-cos-upload-transport.md: 84504b32fd02d1fbaf8276b4f1baf7d85f7dcbd5 +2026-09-16-desktop-cos-upload-transport.zh.md: 2a2080f95b39ab433b4ccb3bec60e5ba61f52dc9 diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md new file mode 100644 index 0000000000..84504b32fd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.md @@ -0,0 +1,43 @@ +# Agent Note: Upload Desktop releases through the Tencent COS SDK + +Status: implemented + +English | [中文](2026-09-16-desktop-cos-upload-transport.zh.md) + +## Problem + +Release objects were uploaded with the AWS S3 client pointed at the Tencent COS endpoint. That client's default checksum configuration can send a streamed request trailer under `Content-Encoding: aws-chunked`, a marker S3 removes before storing but the compatibility path can retain as object metadata. A user-visible application download failed with an HTTP/2 `RST_STREAM` immediately after response headers while an adjacent download on the same connection completed, and objects reported to carry the marker failed segmented downloads. Neither observation identifies an exclusive cause, but the upload transport is the part this repository owns, and a CDN serving metadata no origin request produced is a defect worth removing before chasing it further. + +## Decision + +[desktop-cos.ts](../../../../apps/desktop/scripts/desktop-cos.ts) builds every Desktop COS client from the official `cos-nodejs-sdk-v5` package: HTTPS, no keep-alive, no redirect following, no backup-host switching, no clock-offset correction, and a 900-second inactivity timeout. [upload-target.ts](../../../../apps/desktop/scripts/upload-target.ts) and [installed-update-cos.ts](../../../../apps/desktop/scripts/installed-update-cos.ts) obtain their client from that factory, and `@aws-sdk/client-s3` is no longer a Desktop dependency. + +Every uploaded object — binary, blockmap, and YAML feed, including the small string manifests — travels as one `putObject` whose body is a stream carrying an explicit `ContentLength` and a precomputed `Content-MD5`. The stream is what makes the write unrepeatable: the SDK repeats a request only while the body lacks `pipe`, so a streamed PUT is attempted once and neither uploader adds a retry of its own. The SDK also injects an empty `Cache-Control` header when the caller names none; the factory removes that header so the release uploader still leaves cache policy to deployment infrastructure. + +The qualification transport keeps the same store interface and namespace check as before. It reads objects through `getObject` with a `Writable` output that hashes received bytes, so an object is never held in memory, and a confirmed `NoSuchKey` is the only absence result; every other status, and a transfer that ends before its declared length, fails the operation. + +## Testing + +[cos-loopback.ts](../../../../apps/desktop/tests/cos-loopback.ts) redirects a real SDK instance's `before-send` URL to a per-test loopback origin that records the received bytes, so the transport's serialization — not a mock of it — is what the tests observe. [desktop-upload-run.spec.ts](../../../../apps/desktop/tests/desktop-upload-run.spec.ts) and [installed-update-cos.spec.ts](../../../../apps/desktop/tests/installed-update-cos.spec.ts) assert exact body bytes, `Content-Length`, `Content-MD5`, the absence of transfer and content encodings, the signed `x-cos-forbid-overwrite` header, one request per object on an HTTP 500 and on a dropped connection, read 404/403/truncation handling, and that retained records exclude credentials and raw server messages. + +[cos-operation.spec.ts](../../../../apps/desktop/tests/cos-operation.spec.ts) verifies total deadlines across retries, active response streams, cancellation of unconfirmed PUTs, and closure without interfering with another operation. Virtual deadline timers are combined with real socket and stream observations. + +## Alternatives considered + +**Keep the S3 client with `requestChecksumCalculation: WHEN_REQUIRED` on both paths.** The qualification transport already used that setting, and it suppresses the trailer. It still writes through a compatibility layer for a product whose vendor ships a supported client, and the release path would depend on a configuration staying correct rather than on an encoding never being produced. The official SDK removes the layer instead of tuning it. + +**Send a buffer body for the small YAML manifests.** A buffer is cheaper for a few hundred bytes and needs no stream handling. It also re-enables the SDK's fixed four-attempt retry, so an uncertain feed write could silently repeat a mutable object. Uniform streamed PUTs keep one write path with one guarantee. + +**Use the SDK's `uploadFile` queue or multipart upload.** Those paths parallelize large objects and add progress reporting. They also split one object into parts with per-part retries and upload state, widening the repeat surface for an artifact that must be written exactly once. The release uploader sends one request per object. + +**Replace only the release uploader and leave the qualification transport on S3.** The qualification transport writes to the same bucket family, and leaving it behind would keep the compatibility path alive for the objects most likely to be compared against production behavior. + +## Consequences + +Object writes cannot repeat, which is the property the incident needed, and the transport no longer depends on S3-compatibility behavior. In exchange, each object is one request: a large installer is not parallelized and has no progress reporting, matching the single-request behavior it replaces. Qualification version queries share a 30-second total deadline across all SDK attempts; object reads and PUTs have a 15-minute total deadline. Each operation owns its client and abort signal. [cos-operation.ts](../../../../apps/desktop/scripts/cos-operation.ts) passes that signal to native HTTP requests through the SDK transport and waits for their close events before returning, including on timeout. Continuous response data does not extend the deadline, and an expired signal prevents later retries from opening connections. The release uploader retains its separate inactivity timeout. + +The SDK's dependency tree is a fork of the retired `request` package, pulling in older `http-signature`, `tough-cookie`, and `form-data` releases; the lockfile supply-chain check accepts it, and the alternative was hand-writing COS request signing. + +Existing objects that already carry the retained encoding marker, and their cached copies, are unaffected by this change. Removing or re-uploading them, and any CDN cache purge, remain separate operator actions; this note records no cloud operation. + +The release decision that an uncertain write must stay one inspectable attempt lives in the [packaging and updates decision](2026-08-25-electron-desktop-packaging-and-updates.md); this note changes the transport that implements it. diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md new file mode 100644 index 0000000000..2a2080f95b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 通过腾讯 COS SDK 上传 Desktop 发布产物 + +Status: implemented + +[English](2026-09-16-desktop-cos-upload-transport.md) | 中文 + +## 问题 + +发布对象此前通过指向腾讯 COS 端点的 AWS S3 客户端上传。该客户端默认的校验和配置可以用 `Content-Encoding: aws-chunked` 发送流式请求尾帧;S3 在存储前会移除该标记,而兼容路径可能把它保留为对象元数据。一次面向用户的应用下载在收到响应头后立即以 HTTP/2 `RST_STREAM` 失败,而同一连接上的相邻下载正常完成;被报告带有该标记的对象分段下载失败。这两项观察都不能证明唯一原因,但上传传输是本仓库自己拥有的部分,而 CDN 提供任何源请求都未产生的元数据本身就是缺陷,值得在继续追查之前先消除。 + +## 决策 + +[desktop-cos.ts](../../../../apps/desktop/scripts/desktop-cos.ts)使用官方 `cos-nodejs-sdk-v5` 包构造每个 Desktop COS 客户端:HTTPS、不保持连接、不跟随重定向、不切换备用域名、不做时钟偏移校正,无活动超时为 900 秒。[upload-target.ts](../../../../apps/desktop/scripts/upload-target.ts)与[installed-update-cos.ts](../../../../apps/desktop/scripts/installed-update-cos.ts)都从该工厂获取客户端,`@aws-sdk/client-s3` 不再是 Desktop 依赖。 + +所有上传对象——安装包、blockmap 和 YAML 清单,包括很小的字符串清单——都以一次 `putObject` 发送,其请求体是流,并携带显式 `ContentLength` 与预先算好的 `Content-MD5`。正是流使写入不可重复:SDK 只在请求体没有 `pipe` 时才重发请求,因此流式 PUT 只会尝试一次,两个上传器自身也不再添加重试。当调用方未指定时,SDK 还会注入空的 `Cache-Control` 头;工厂会移除该头,因此发布上传器仍然把缓存策略留给部署基础设施。 + +qualification 传输保持原有 store 接口与命名空间检查不变。它通过 `getObject` 配合一个对已接收字节计算哈希的 `Writable` 输出读取对象,因此对象从不整体驻留内存;只有确认的 `NoSuchKey` 表示对象不存在,其他任何状态码,以及早于声明长度结束的传输,都会让操作失败。 + +## 测试 + +[cos-loopback.ts](../../../../apps/desktop/tests/cos-loopback.ts)把真实 SDK 实例 `before-send` 中的 URL 重定向到每个测试独立的 loopback 源,并记录实际收到的字节,因此测试观察的是传输的序列化结果,而不是它的 mock。[desktop-upload-run.spec.ts](../../../../apps/desktop/tests/desktop-upload-run.spec.ts)与[installed-update-cos.spec.ts](../../../../apps/desktop/tests/installed-update-cos.spec.ts)断言确切的请求体字节、`Content-Length`、`Content-MD5`、不存在传输编码与内容编码、已签名的 `x-cos-forbid-overwrite` 头、HTTP 500 与连接中断时每个对象只发一次请求、读取 404/403/截断的处理,以及保留记录不包含凭据与原始服务端消息。 + +[cos-operation.spec.ts](../../../../apps/desktop/tests/cos-operation.spec.ts)验证跨重试的总截止时间、持续返回数据的响应流、未确认 PUT 的取消,以及关闭请求不影响其他操作。测试组合虚拟截止时间计时器与真实 socket 和流观测。 + +## 考虑过的替代方案 + +**保留 S3 客户端并在两条路径上都设置 `requestChecksumCalculation: WHEN_REQUIRED`。** qualification 传输原本已使用该设置,它能抑制尾帧。但它仍然通过兼容层写入一个厂商已提供受支持客户端的产品,发布路径也会依赖于某项配置始终正确,而不是依赖编码根本不会产生。官方 SDK 消除的是这一层,而不是调整它。 + +**对小体积 YAML 清单改用 Buffer 请求体。** 对几百字节而言 Buffer 更省事,也不需要流处理。但它会重新启用 SDK 固定的四次重试,使一次结果不确定的清单写入可能静默重复一个可变对象。统一的流式 PUT 保持单一写入路径和单一保证。 + +**使用 SDK 的 `uploadFile` 队列或分块上传。** 这些路径可并行处理大对象并提供进度上报。它们也会把单个对象拆成多个分块,带来分块级重试与上传状态,扩大一个必须恰好写入一次的产物的重复面。发布上传器对每个对象只发一次请求。 + +**只替换发布上传器,qualification 传输继续使用 S3。** qualification 传输写入同一系列 bucket,若把它留下,兼容路径就会继续存在于最可能用于与生产行为对照的对象上。 + +## 结果 + +对象写入不可重复,这正是本次故障所需的性质,传输也不再依赖 S3 兼容行为。代价是每个对象只有一次请求:超大安装包不会并行,也没有进度上报,与它替换掉的单请求行为一致。qualification 版本查询的所有 SDK 尝试共享 30 秒总截止时间;对象读取与 PUT 的总截止时间为 15 分钟。每次操作独立拥有客户端与取消信号。[cos-operation.ts](../../../../apps/desktop/scripts/cos-operation.ts)通过 SDK 传输将信号传给底层 HTTP 请求,并在返回前等待请求的关闭事件,超时也不例外。持续返回数据不会延长截止时间,过期信号会阻止后续重试建立连接。发布上传器保留独立的无活动超时。 + +该 SDK 的依赖树源自已停止维护的 `request` 包,会引入较旧的 `http-signature`、`tough-cookie` 和 `form-data` 版本;lockfile 的供应链检查接受它,替代方案是自行实现 COS 请求签名。 + +已经带有该保留编码标记的既有对象及其缓存副本不受本次改动影响。删除或重新上传它们,以及任何 CDN 缓存刷新,仍是单独的操作动作;本记录不包含任何云端操作。 + +「结果不确定的写入必须保持为一次可检查的尝试」这一发布决策由[打包与更新决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责;本记录改变的是实现它的传输。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml new file mode 100644 index 0000000000..d1c3e82cbb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4 +2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md new file mode 100644 index 0000000000..fb330bee44 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -0,0 +1,39 @@ +# Agent Note: Plugin configuration on the Plugins page + +Status: implemented + +English | [中文](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md) + +## Problem + +A plugin's settings lived in Settings, on the Plugins section's configuration tab: four collapsible cards, one per host-plane namespace, beside a read-only inventory tab. The sidebar's Plugins page listed and switched bundles but could not open a plugin's settings, so two surfaces split one object between them, and a bundle installed from outside the repository had no place for a form of its own at all. + +## Decision + +**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens a page headed by the row id. Every entry is rendered in two views the page passes as owner props: `summary` for the one-liner under the title, `page` for the form. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. + +**Attribution is declared by the registrant, in the slot name and key.** No manifest field, no registration metadata, and no owner information from the settings service: an official page registers without a bundle, a bundle's page with its package name, a row's page with the bundle and the row id its patch declares. A community bundle that ships no browser half has no configuration page; the page renders no generic form from a settings schema. + +**Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence. + +**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired. + +**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example. + +## Consequences + +- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own. +- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page. +- Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded. + +## Alternatives considered + +**Registration metadata on the slot entry.** A slot-declared `meta` share (description, bundle, row) would let one `plugins.item` slot carry every case, at the cost of a new concept in `ui-slots`; the three slots say the same with the API that exists. + +**A generic form from the settings schema.** `describe()` already ships each namespace's schemastery schema, so a community namespace without a browser half could get a default form. Deferred: the official pages are curated, a generic form would expose internal namespaces without an allowlist, and attributing a namespace to a bundle needs an owner in the descriptor. + +**Keeping the cards in Settings and linking to them from the page.** Cheapest, but the review asked for the form on the plugin's own page. + +## Testing + +`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the new Official group. diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md new file mode 100644 index 0000000000..65c1f5a8bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -0,0 +1,39 @@ +# Agent Note:插件页上的插件配置 + +状态:已实现 + +[English](2026-09-16-plugin-configuration-on-the-plugins-page.md) | 中文 + +## 问题 + +插件的设置原本放在设置里、插件分区的配置标签页上:四张可折叠卡片,每个宿主平面命名空间一张,旁边是只读的清单标签页。侧栏的插件页能列出并启停组合包,却打不开插件的设置,于是同一个对象被两个界面各持一半;仓库之外安装的组合包更是完全没有地方放自己的表单。 + +## 决策 + +**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开以行 id 为标题的页面。每个条目都按页面以 owner props 传入的两种视图渲染:`summary` 是标题下的一句话简介,`page` 是表单。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。 + +**归属由注册方在 slot 名与键里声明。** 不加 manifest 字段,不加注册元数据,也不从 settings 服务取 owner 信息:官方页面不带组合包注册,组合包的页面带自己的包名注册,行的页面带组合包与其 patch 声明的行 id 注册。没有浏览器半侧的社区组合包就没有配置页;页面不会从 settings schema 渲染通用表单。 + +**只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。 + +**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。 + +**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。 + +## 后果 + +- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。 +- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。 +- 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。 + +## 考虑过的替代方案 + +**slot 条目上的注册元数据。** 由 slot 声明的 `meta` 份额(描述、组合包、行)能让单个 `plugins.item` slot 覆盖所有情况,代价是 `ui-slots` 多一个概念;三个 slot 用现有 API 表达了同样的信息。 + +**从 settings schema 生成通用表单。** `describe()` 已经把每个命名空间的 schemastery schema 发给浏览器,没有浏览器半侧的社区命名空间本可获得一份默认表单。延期:官方页面是精选的,通用表单会在没有白名单的情况下暴露内部命名空间,而把命名空间归属到组合包还需要 descriptor 带 owner。 + +**把卡片留在设置里、从插件页链接过去。** 最省事,但 review 要求表单在插件自己的页面上。 + +## 测试 + +`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录新的官方分组。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml new file mode 100644 index 0000000000..758bca04da --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md +2026-09-16-size-neutral-product-icon-weights.md: 3850fcbddfef6d096f648d5b3efa73790f40f8c1 +2026-09-16-size-neutral-product-icon-weights.zh.md: 84031677e9b13b8dbf3b8e5d959359824e0c94fc diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md new file mode 100644 index 0000000000..3850fcbddf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md @@ -0,0 +1,29 @@ +# Agent Note: Size-neutral product icon weights + +Status: implemented + +English | [中文](2026-09-16-size-neutral-product-icon-weights.zh.md) + +## Problem + +Shared product-icon exports encoded one historical render size in each symbol name even though every component accepted a `size` prop. Consumers also thickened selected icons through local CSS or separate drawings, so emphasis had no named library choice and could drift between features. + +## Decision + +Every shared product glyph exports a size-neutral `Regular` and `Medium` component. `Regular` renders the supplied one-pixel artwork, while `Medium` renders the same paths with a 1.3px inherited stroke; filled regions remain unchanged. The `size` prop controls rendered dimensions, and the former numeric suffix survives only as the default size when a glyph has no 16px source. + +`LinkIconRegular` and `LinkIconMedium`, `ReferenceIconRegular` and `ReferenceIconMedium`, and the three `PermissionIcon*Regular`/`PermissionIcon*Medium` pairs follow the same rule. Repository consumers use a named weight instead of a numeric export. + +Medium weight is reserved for deliberate emphasis: new-Session controls, Settings trigger and navigation icons, Appearance choices, the composer add button, and clickable artifact-link glyphs. Other existing consumers use Regular. The composer command menu uses `PermissionIconFullAccessRegular` for the permission command because that row names permission elevation rather than the current mode. + +## Alternatives considered + +**Keep pixel sizes in export names.** Rejected because the suffix duplicated the runtime `size` prop and created separate names for identical geometry. + +**Expose one component with a `weight` prop.** Rejected because call sites would hide the chosen weight inside props; explicit component names keep visual emphasis searchable and reviewable. + +**Override stroke width in consumer CSS.** Rejected because source SVG elements can carry their own stroke widths, and local overrides would recreate inconsistent weighting outside the library. + +## Consequences + +The icon API is intentionally breaking while pre-stable: every consumer names `Regular` or `Medium`, and former 14px call sites pass an explicit size when they share geometry with a 16px export. New product glyphs add both weights from one geometry definition. Fill-only artwork exposes both names for API consistency even when stroke weight does not change its appearance. The hierarchy choices above remain explicit at their render sites rather than becoming global size or opacity overrides. diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md new file mode 100644 index 0000000000..84031677e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 尺寸无关的产品图标线重 + +Status: implemented + +[English](2026-09-16-size-neutral-product-icon-weights.md) | 中文 + +## 问题 + +共享产品图标的导出名称包含一个历史渲染尺寸,但每个组件已经接受 `size` prop。消费方还会通过本地 CSS 或独立图稿加粗部分图标,视觉强调没有具名的图标库选项,因而可能在不同功能间漂移。 + +## 决策 + +每个共享产品图形都导出尺寸无关的 `Regular` 和 `Medium` 组件。`Regular` 渲染提供的 1px 图稿,`Medium` 使用相同路径并继承 1.3px 描边;填充区域保持不变。`size` prop 控制渲染尺寸;仅当图稿没有 16px 来源时,原数字后缀才作为默认尺寸保留。 + +`LinkIconRegular` 与 `LinkIconMedium`、`ReferenceIconRegular` 与 `ReferenceIconMedium`,以及三组 `PermissionIcon*Regular`/`PermissionIcon*Medium` 遵循同一规则。仓库内消费方使用具名线重,不再使用带数字的导出名。 + +Medium 线重只用于明确强调的场景:新建会话控件、设置入口与导航图标、外观选项、输入框加号按钮,以及可点击产物链接的图标。其他既有消费方使用 Regular。编辑器加号菜单中的权限命令使用 `PermissionIconFullAccessRegular`,因为该行表示权限提升,而不是当前权限模式。 + +## 考虑过的替代方案 + +**在导出名中保留像素尺寸。** 否决,因为后缀重复了运行时 `size` prop,并为相同几何创建了多个名称。 + +**只导出一个带 `weight` prop 的组件。** 否决,因为调用点会把线重选择隐藏在 prop 中;显式组件名便于搜索和审查视觉强调。 + +**在消费方 CSS 中覆盖描边宽度。** 否决,因为源 SVG 元素可能自带描边宽度,本地覆盖会在图标库外重新产生不一致线重。 + +## 后果 + +图标 API 在预稳定阶段有意进行破坏性调整:每个消费方明确选择 `Regular` 或 `Medium`;原 14px 调用点与 16px 导出共享几何时会显式传入尺寸。新增产品图形从同一份几何定义同时提供两种线重。仅填充的图稿也为 API 一致性导出两个名称,即使线重不会改变其外观。上述层级选择继续由对应渲染点显式声明,不会变成全局尺寸或透明度覆盖。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml new file mode 100644 index 0000000000..a005e6d029 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md +2026-09-18-agent-teams-single-bundle.md: 21878a8e481871f5b0eeebd8f1bea707411ff3b7 +2026-09-18-agent-teams-single-bundle.zh.md: 39c6bfb7b365a45ff98d29f91a4d7905b26db008 diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md new file mode 100644 index 0000000000..21878a8e48 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md @@ -0,0 +1,33 @@ +# Agent Note: Enable Agent Teams with one bundle + +Status: implemented + +English | [中文](2026-09-18-agent-teams-single-bundle.zh.md) + +## Problem + +Separate Agent Teams and Agent Teams Web switches require users to discover that tools and their browser controls need both selections. The package split exposes composition details in the plugin page without helping users choose a different Team capability. + +## Decision + +`@deepseek-ai/dsh-experimental-agent-team-profile` carries the Team service, tools, and browser UI in one optional bundle. Its patch retains the `agent-team`, `tool-agent-team`, and `ui-agent-team` row ids, so profile patches can still configure individual rows. The UI package has an inert Host entry; its browser entry mounts only in a Web Client, and headless does not start a Web server. + +The separate `@deepseek-ai/dsh-experimental-agent-team-web-profile` package is absent from the workspace and release family. The plugin page exposes one Team selection, disabled by default. + +This decision supersedes the separate Host/Web composition in the [package publication note](2026-08-18-experimental-agent-teams-packages.md) and the two Team selections in the [optional-bundle note](../process/2026-09-15-shipped-optional-bundles.md). Both remain active for publication, dependency isolation, promotion, and installation ownership. The [archived Web-controls note](../../archived/feature/2026-08-06-agent-teams-web.md) records the original split; its historical text remains frozen. + +## Alternatives considered + +**Keep two switches and explain their dependency.** Users still need two selections for one feature, and either selection alone leaves an incomplete browser experience. + +**Combine the entries in the generic profile loader or plugin manager.** Recognizing Team package names there would make shared loading or presentation code own feature-specific composition. The bundle patch already declares the required rows and dependencies. + +## Consequences + +Users enable tools and browser controls together. Separate installation-level selection of a UI-only Team bundle is unavailable; custom profiles still control individual patch rows. Headless installations include the UI dependency graph even though they do not mount its browser entry. + +Existing profiles that select the removed Web bundle have an upgrade compatibility gap: startup fails when that package cannot be resolved. Bundle composition provides no automatic rewrite of those saved selections. Compatibility handling remains separate from this composition decision. + +## Verification + +The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers task creation; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle. diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md new file mode 100644 index 0000000000..39c6bfb7b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 通过一个组合包启用 Agent Teams + +Status: implemented + +[English](2026-09-18-agent-teams-single-bundle.md) | 中文 + +## 问题 + +独立的 Agent Teams 与 Agent Teams Web 开关要求用户自行发现:工具与浏览器控件需要同时选中两项。包的拆分把组合细节暴露在插件页上,却没有帮助用户选择不同的 Team 能力。 + +## 决策 + +`@deepseek-ai/dsh-experimental-agent-team-profile` 在一个可选组合包中携带 Team 服务、工具与浏览器 UI。其 patch 保留 `agent-team`、`tool-agent-team` 与 `ui-agent-team` 配置行 id,因此 profile patch 仍可单独配置各行。UI 包的 Host 入口不执行逻辑;其浏览器入口只在 Web 客户端中挂载,headless 不会启动 Web 服务。 + +独立的 `@deepseek-ai/dsh-experimental-agent-team-web-profile` 包不在 workspace 与发布系列中。插件页提供一个 Team 选项,默认关闭。 + +本决策取代[包发布记录](2026-08-18-experimental-agent-teams-packages.zh.md)中的独立 Host/Web 组合方式,以及[可选组合包记录](../process/2026-09-15-shipped-optional-bundles.zh.md)中的两个 Team 选项。两条记录仍负责发布、依赖隔离、promotion 与安装方所有权。[已归档的 Web 控件记录](../../archived/feature/2026-08-06-agent-teams-web.md)记载原始拆分方式,其历史文本保持冻结。 + +## 曾考虑的替代方案 + +**保留两个开关并解释依赖关系。** 用户仍须为一个功能选中两项,单独选中任意一项都不能提供完整的浏览器体验。 + +**在通用 profile loader 或插件管理器中合并入口。** 在这些位置识别 Team 包名会让共用的加载或展示代码负责功能特定的组合。组合包 patch 已能声明所需配置行与依赖。 + +## 后果 + +用户同时启用工具与浏览器控件。安装层面不再提供仅选择 Team UI 组合包的入口;自定义 profile 仍能单独控制 patch 配置行。headless 安装包含 UI 依赖图,但不会挂载其浏览器入口。 + +选中了已移除 Web 组合包的已有 profile 存在升级兼容性缺口:无法解析该包时,启动会失败。组合包本身不会自动改写这些已保存的选择。兼容性处理与本组合决策分开。 + +## 验证 + +插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml new file mode 100644 index 0000000000..143d9ccffe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md +2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9 +2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md new file mode 100644 index 0000000000..d990c0cb9a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md @@ -0,0 +1,59 @@ +# Agent Note: Bound turn navigation work and batch historical jumps + +Status: implemented + +English | [中文](2026-09-18-chat-navigation-performance.zh.md) + +## Problem + +A long Session makes three independent costs visible: a turn rail with one DOM mark per known turn, transcript navigation that rediscovers an already known landing, and historical jumps that publish every fetched page. Each intermediate prepend rebuilds growing conversation indexes and exposes another transcript update before the requested turn is available. Combining these concerns into one controller obscures which operations require DOM geometry and which only apply reading or paging policy. + +## Decision + +### Fixed-pitch virtual rail + +[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) uses TanStack React Virtual with a fixed 10px pitch. Only the visible range, overscan, and the focused mark with its neighbors remain mounted. The library owns the size container and absolute mark transforms. The full turn metadata remains resident; this is rail virtualization, not transcript virtualization. + +The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes. Its offset calculation clamps against cached geometry and invokes the supported `scrollBy` operation, avoiding the library's DOM-based maximum-offset calculation used by `scrollToIndex`. The first usable size places the latest committed active turn directly after the body has restored its reading state. A ref exposes independent activation and rail-only scrolling. Native pointer targets replace coordinate hit testing, and mark length changes use transforms. Layout containment belongs to the floating rail frame; it does not promise that browser geometry reads flush only that subtree. + +### Two-layer transcript navigation + +[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) owns DOM reads, clamped writes, native events, and turn anchors. It returns the actual landing with available turn and semantic-position facts. Reading policy consumes those facts without another hit test; the viewport ignores its own unchanged scroll echoes and events from the nested rail. + +[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth. + +### One publication per historical jump + +[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy. + +### Theoretical work model + +Let `T` be known turns, `V` mounted virtual marks, `P` fetched pages, `B` entries per page, and `W0` entries already loaded. These are work-count estimates, not timing measurements. + +| Operation | Previous work | Current work | +|---|---|---| +| Rail DOM and React mark traversal | `O(T)` | `O(V)`; turn metadata and initial index/cache construction remain `O(T)` | +| Active-turn index lookup with unchanged items | Linear search, `O(T)` | Map lookup, expected `O(1)` | +| Rail target-offset calculation | Synchronous DOM extent/viewport reads | Fixed arithmetic and cached sizes; no DOM extent read on this path | +| Known transcript jump | Landing followed by position rediscovery | Reuse the landing; no hit test, but existing anchor-selector and turn lookup costs remain | +| History-driven linear index passes | `O(P * W0 + B * P²)` | `O(W0 + B * P)` for one final pass, plus `O(B * P)` buffer flattening | +| History-driven sorting | Repeated sorting of each growing window | One final-window sort, `O((W0 + B * P) log(W0 + B * P))` | +| Historical prepend publications | `P` | One; live-event and loading-state updates remain independent | + +For illustration, 10,000 turns and about 50 mounted marks imply roughly 99.5% fewer rail marks, not a 200× end-to-end speedup. With 20 equal pages and a negligible initial window, a full linear pass after each page visits `210B` entries in aggregate; one final pass visits `20B`, about 10.5× less work for that pass. Page requests remain 20, and the final transcript still needs assembly, rendering, and layout. + +## Alternatives considered + +**Containment or React batching alone.** Containment does not remove repeated JavaScript work. Suppressing React renders after each published page still leaves conversation assembly and index rebuilds upstream. + +**Patch the virtualizer.** The supported callbacks and relative-scroll operation cover fixed-height placement without maintaining a dependency fork. The native scroll container still needs a total extent; removing that extent would remove native scrolling, not virtualize it. + +**One large scrolling controller.** It centralizes state but couples DOM mechanics to follow and paging policy. Separate owners allow business changes without copying geometry operations. + +**Virtualize the whole transcript.** That introduces renderer lifetime and semantic-anchor constraints outside this change. Batching the requested historical jump removes intermediate publications without changing transcript residency. + +## Consequences + +Loading a distant turn retains the current transcript until the batch lands, instead of revealing intermediate pages. Buffered pages add references proportional to newly fetched entries. Main-transcript geometry reads, semantic-anchor queries, ordinary native-scroll metrics, final layout, and center-width reflow during Sidebar animation remain; none is claimed eliminated by this change. Transform and color changes may still repaint, and assigning `scrollTop` may still deliver a native scroll event. + +[Rail tests](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) cover bounded mounting, first placement, fixed-size controls, and focus retention. [Chat tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover semantic restoration, follow attribution, own-scroll echoes, and navigation without hit testing. [Session tests](../../../../packages/api/session-controller/tests/session.client.spec.ts) cover atomic prepend publication with live delivery, failure, and replacement. [Browser navigation](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers real scrolling and unloaded-turn landing. Functional evidence is separate from the theoretical estimates above. diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md new file mode 100644 index 0000000000..1a330d3b6b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 限制轮次导航工作量并聚合历史跳转 + +Status: implemented + +[English](2026-09-18-chat-navigation-performance.md) | 中文 + +## Problem + +长会话会放大三类独立成本:轮次轨道为每个已知轮次挂载一个 DOM 刻度,正文导航重新查找已知落点,以及历史跳转逐页发布数据。每次中间前插都会重建不断增大的对话索引,并在目标轮次可用前再次更新正文。把这些职责放进一个控制器,会使需要 DOM 几何的操作与只负责阅读、分页策略的操作难以区分。 + +## Decision + +### 固定间距虚拟轨道 + +[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) 使用 TanStack React Virtual,间距固定为 10px。只保留可见范围、预加载项,以及键盘焦点刻度及其相邻项。库负责尺寸容器和绝对定位刻度的变换。全部轮次元数据仍驻留内存;这里虚拟化的是轨道,不是正文。 + +轨道采用 ResizeObserver 提供的视口尺寸和固定条目高度。偏移计算根据缓存几何限制范围,再调用库支持的 `scrollBy`,避开 `scrollToIndex` 使用的基于 DOM 的最大偏移计算。正文恢复阅读状态后,轨道在首次获得可用尺寸时直接定位到最新已提交的活跃轮次。ref 分别提供激活轮次和仅滚动轨道的操作。原生指针目标替代坐标命中测试,刻度长度通过变换改变。布局包含作用于悬浮轨道外框,但不承诺浏览器几何读取只刷新该子树。 + +### 两层正文导航 + +[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 负责 DOM 读取、限制范围后的写入、原生事件和轮次锚点。它返回实际落点及可用的轮次、语义位置信息。阅读策略直接消费这些信息,无需再次命中测试;视口会忽略自身位置未变的滚动回声,以及内嵌轨道发出的事件。 + +[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。 + +### 每次历史跳转统一发布 + +[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。 + +### 理论工作量模型 + +设 `T` 为已知轮次数,`V` 为已挂载虚拟刻度数,`P` 为获取页数,`B` 为每页条目数,`W0` 为原先已加载条目数。以下估算工作量,不代表耗时测量。 + +| 操作 | 原有工作量 | 当前工作量 | +|---|---|---| +| 轨道 DOM 与 React 刻度遍历 | `O(T)` | `O(V)`;轮次元数据与初始索引、缓存构建仍为 `O(T)` | +| 条目未变时查找活跃轮次索引 | 线性搜索,`O(T)` | Map 查找,期望 `O(1)` | +| 轨道目标偏移计算 | 同步读取 DOM 总高度、视口尺寸 | 固定算术与缓存尺寸;此路径不读取 DOM 滚动总高度 | +| 已知正文轮次跳转 | 定位后重新查找位置 | 复用落点;不做命中测试,但既有锚点选择器与轮次查找成本仍在 | +| 历史驱动的线性索引遍历 | `O(P * W0 + B * P²)` | 最终一次遍历为 `O(W0 + B * P)`,另有 `O(B * P)` 暂存展开 | +| 历史驱动的排序 | 对每个不断增大的窗口重复排序 | 对最终窗口排序一次,`O((W0 + B * P) log(W0 + B * P))` | +| 历史前插发布次数 | `P` | 一次;实时事件与加载状态更新仍独立发生 | + +例如,10,000 个轮次只挂载约 50 个刻度,意味着轨道刻度约减少 99.5%,不代表端到端加速 200 倍。若获取 20 个等大页面且初始窗口可忽略,每页到达后做一次全量线性遍历,累计访问 `210B` 个条目;最终只遍历一次访问 `20B`,该遍历工作量约减少到原来的 1/10.5。分页请求仍为 20 次,最终正文仍需装配、渲染和布局。 + +## Alternatives considered + +**只加布局包含或 React 批处理。** 布局包含不会删除重复的 JavaScript 工作。仅抑制每页发布后的 React 渲染,也会保留上游对话装配与索引重建。 + +**修改虚拟化库。** 现有回调和相对滚动操作已能完成固定高度定位,无需维护依赖分支。原生滚动容器仍需总高度;删除总高度会使原生滚动失效,而不是实现虚拟化。 + +**单一大滚动控制器。** 它能集中状态,却把 DOM 操作与跟随、分页策略耦合。拆分负责对象后,业务变更无需复制几何操作。 + +**虚拟化整个正文。** 这会引入本次范围之外的 renderer 生命周期与语义锚点约束。聚合目标历史跳转即可删除中间发布,无需改变正文驻留方式。 + +## Consequences + +跳转远处轮次时,当前正文会保持到整批数据落地,不再逐页展示中间结果。暂存页面增加与新获取条目数成正比的引用。正文几何读取、语义锚点查询、普通原生滚动指标、最终布局,以及侧栏动画改变中间区域宽度时的重排仍然存在;本次不声称消除了这些成本。变换与颜色变化仍可能重绘,赋值 `scrollTop` 仍可能产生原生滚动事件。 + +[轨道测试](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) 覆盖有界挂载、首次定位、固定尺寸控制与焦点保留。[Chat 测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖语义恢复、跟随归因、自身滚动回声与不做命中测试的导航。[Session 测试](../../../../packages/api/session-controller/tests/session.client.spec.ts) 覆盖实时事件并发下的原子前插发布、失败与替换。[浏览器导航](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖真实滚动与未加载轮次落点。功能证据与上述理论估算相互独立。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md deleted file mode 100644 index de5e509c9e..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Resume headers do not repeat system prompts - -Status: implemented - -English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md) - -## Problem - -Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation. - -## Decision - -The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. - -A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. - -Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction. - -## Alternatives considered - -**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain. - -**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison. - -**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory. - -## Consequences - -Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. - -The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md deleted file mode 100644 index 008e25d8d5..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Resume header 不重复系统提示词 - -Status: implemented - -[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文 - -## 问题 - -fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。 - -## 决策 - -持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 - -部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 - -Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。 - -## 考虑过的替代方案 - -**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。 - -**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。 - -**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。 - -## 后果 - -继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 - -单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md deleted file mode 100644 index f3b428dbe0..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: Settle pinned scroll deliveries before layout changes - -Status: implemented - -English | [中文](2026-09-07-pinned-scroll-delivery-before-layout.zh.md) - -## Problem - -A delayed scroll sample compares positions from different layouts. While Chat is pinned, a composer or transcript shrink can move the browser floor; subsequent growth can move the browser position again before `scrollend` or the sampling timer. Deferring follow during that interval leaves the observed-top ledger stale and can classify browser layout movement as reader input, disabling follow without a reader gesture. - -## Decision - -[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) uses the existing observed-top comparison to sample non-reader pinned scroll deliveries synchronously through the same sample operation that clears pending work. This releases layout follow before further growth. Genuine reader movement remains pending until the existing interval or `scrollend`, even within the follow threshold: growth must not erase small gestures before they accumulate into a scroll-away. Immediate pinned samples use scroll metrics, not semantic-row geometry. - -## Alternatives considered - -**Defer every delivery.** Coalescing reduces geometry work while reading history, but a pinned browser position and its floor must be attributed in the same layout. A longer timeout or retry cannot recover ownership once the stale comparison disarms it. - -**Sample every delivery synchronously.** This restores attribution but also repeats semantic-anchor and reading-line measurements throughout an away-reader scroll burst. Only pinned ownership needs the immediate path. - -## Consequences - -Pinned deliveries incur immediate scroll-metric reads. History reading retains its bounded sampling cadence, and explicit return-to-bottom deliveries clear any pending away sample. [Focused tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover shrink/regrowth before scrollend, observer growth without row measurements, repinning with a pending sample, timer and scrollend sampling, and unmount cancellation. The [keyless browser scenario](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers pinned Send, real scroll-away input, streaming, and tool disclosure across the long transcript. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md deleted file mode 100644 index 9b200f4553..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: 在布局变化前处理贴底滚动事件 - -Status: implemented - -[English](2026-09-07-pinned-scroll-delivery-before-layout.md) | 中文 - -## Problem - -延迟的滚动采样会比较来自不同布局的位置。Chat 贴底时,输入框或 transcript(文本记录)收缩可能改变浏览器底部位置;随后的增长又可能在 `scrollend` 或采样定时器触发前改变浏览器位置。在此期间推迟跟随会使已观察顶部位置记录过期,把浏览器布局移动误判为读者输入,在没有读者操作时关闭跟随。 - -## Decision - -[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 使用现有的已观察顶部位置比较,通过同一个清除待处理工作的采样操作,同步采样非读者引起的贴底滚动事件。这会在后续增长前恢复布局跟随。真实读者移动即使位于跟随阈值内,也保持待处理直到现有周期或 `scrollend`:增长不能在小幅操作累积为离底滚动前将其抵消。立即执行的贴底采样只读取滚动指标,不读取语义行几何。 - -## Alternatives considered - -**延迟所有事件。** 合并采样减少阅读历史时的几何计算,但贴底浏览器位置及其底部必须在同一布局中完成归因。过期比较关闭跟随后,延长超时或重试都无法恢复归属。 - -**同步采样所有事件。** 这能恢复归因,却也会在离底读者连续滚动时重复测量语义锚点和阅读线。只有贴底归属需要立即处理。 - -## Consequences - -贴底事件会立即读取滚动指标。历史阅读保留有界采样节奏,显式回到底部的滚动事件会清除任何待处理的离底采样。[聚焦测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖 scrollend 前的收缩与增长、无需行测量的观察器增长、存在待处理采样时重新贴底、定时器与 scrollend 采样,以及卸载取消。[无密钥浏览器场景](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖长 transcript 中贴底发送、真实离底输入、流式输出与工具详情展开。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml deleted file mode 100644 index f6a8425e96..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md -2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba -2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md deleted file mode 100644 index c89c5328ee..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: Keep the room reading independent of hidden split controls - -Status: implemented - -English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md) - -## Problem - -The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in. - -## Decision - -When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before. - -## Alternatives considered - -**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split. - -**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize. - -**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading. - -## Consequences - -The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch. diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md deleted file mode 100644 index edf9948e54..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: Keep the room reading independent of hidden split controls - -Status: implemented - -[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文 - -## Problem - -dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。 - -## Decision - -当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。 - -## Alternatives considered - -**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。 - -**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。 - -**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。 - -## Consequences - -空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md deleted file mode 100644 index c5d778f8fb..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md +++ /dev/null @@ -1,21 +0,0 @@ -# Agent Note: Composer placeholder emptiness - -Status: implemented - -English | [中文](2026-09-09-composer-placeholder-whitespace.zh.md) - -## Problem - -Sharing the whitespace-trimmed submission check with placeholder rendering leaves guidance drawn over a draft containing spaces. - -## Decision - -The Composer hides its placeholder whenever the raw draft is nonempty. Submission keeps its trimmed-content check. Attachments and claimed commands retain their existing placeholder suppression. - -## Alternatives considered - -**Reuse the submission check.** Whitespace has no sendable message content, but it occupies the editor and moves its caret. A shared check conflates these two states. - -## Consequences - -All placeholder variants, including queued-message steering guidance, disappear after whitespace input and return after deletion. A whitespace-only draft without attachments remains unsendable. [Component tests](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) cover visibility, composition, rerendering and submission; the [browser regression](../../../../apps/web/tests/composer-placeholder.e2e.ts) checks keyboard and clipboard gestures against built UI. diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md deleted file mode 100644 index b5f82bcd86..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md +++ /dev/null @@ -1,21 +0,0 @@ -# Agent Note: Composer 占位提示的判空规则 - -Status: implemented - -[English](2026-09-09-composer-placeholder-whitespace.md) | 中文 - -## Problem - -占位提示复用去除首尾空白后的提交判断,会让提示文字覆盖已经包含空格的草稿。 - -## Decision - -原始草稿非空时,Composer 隐藏占位提示。提交仍检查去除首尾空白后的内容。附件和已认领指令沿用现有的占位提示隐藏规则。 - -## Alternatives considered - -**复用提交判断。** 空白字符没有可发送的消息内容,但会占据编辑器并移动光标。共用判断会混淆这两种状态。 - -## Consequences - -所有占位提示,包括排队消息的插话提示,都会在输入空白字符后隐藏,删除后恢复。没有附件的纯空白草稿仍无法发送。[组件测试](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 覆盖显示、输入法组合、重新渲染和提交;[浏览器回归](../../../../apps/web/tests/composer-placeholder.e2e.ts) 使用构建后的界面检查键盘和剪贴板操作。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md deleted file mode 100644 index 01d70e646e..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Transcript width handles stay behind chat content - -Status: implemented - -English | [中文](2026-09-14-transcript-width-handle-layering.zh.md) - -## Problem - -The Conversation shell renders each content-width handle as a full-height absolute strip beside the reading column. A high stacking level placed that strip above wide Markdown tables that legitimately overflow the column. The handle glow covered their content and its pointer target could replace their interaction target. - -## Decision - -The Conversation shell keeps width handles at level zero without creating a body-wide stacking context. Chat raises concrete table elements to level one, while the reading column, column-bounded tool cards, and table breakout wrapper create no new stacking level. A table therefore owns the gutter only where its painted box actually reaches it; a fitting four-column table leaves the adjacent gutter draggable. Fixed message tooltips remain outside the table stacking context and can still paint over the sticky composer. - -This rule relies on the supported Chromium engine not turning ChatView's inline-size query container into an intermediate stacking context. The same engine behavior already lets the back-to-bottom control outrank the sticky composer. The Chromium hit-test scenario pins that assumption; raising the whole Chat root would instead make its transparent full-width box reclaim the gutter. - -A width handle starts resizing only for the primary pointer button. Ordinary wheel input over the handle is normalized from pixel, computed line-height, or page units and forwarded to the direct sibling transcript scrollport, so hovering the gutter does not suspend reading. Ctrl+wheel is not forwarded because it represents browser zoom or a trackpad pinch gesture. - -Each side's hit strip is at most 10px wide. Its hover indicator uses the lower-contrast resting scrollbar tint and is a 2px line with a 16px solid center and 28px fades, for a 72px total visible length. - -Composer chrome retains its higher layer, so its full sticky footer band intentionally does not start a width resize. A handle whose pointer capture began above that band temporarily rises to level eight, keeping the indicator visible until release. A full-view composer takeover continues to hide the handles entirely. - -## Alternatives considered - -**Move the handles farther from the reading column.** Wide tables can use the available transcript width, so a fixed larger inset would only reduce the overlap for some window sizes and would make the handles harder to reach. - -**Disable the handles whenever a transcript contains wide content.** One wide row would remove resizing from the entire Session, including empty gutter beside unrelated rows. - -**Inspect the elements under the pointer in JavaScript.** Dynamic hit testing would duplicate browser stacking and pointer dispatch rules and could still disagree with new plugin renderers. - -## Consequences - -Visible Chat content owns pointer input only where its painted element extends into a width-handle strip, while bare gutter keeps a quieter resize affordance, primary-button drag, and transcript wheel scrolling. Unit tests pin the declared handle geometry, pointer-button behavior, direct scrollport lookup, zoom exclusion, and every DOM delta mode. Chromium `elementFromPoint` coverage proves an overflowing table wins the hit while a fitting `md-table-wide` wrapper does not, and a message-action test proves fixed tooltips still paint above the sticky composer. diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md deleted file mode 100644 index c6e03ec577..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 正文宽度拖拽条位于 Chat 内容下方 - -Status: implemented - -[English](2026-09-14-transcript-width-handle-layering.md) | 中文 - -## 问题 - -Conversation shell 将每个正文宽度拖拽条渲染为阅读列旁的全高绝对定位区域。较高的堆叠层级使该区域盖在会合理溢出阅读列的宽 Markdown 表格之上。拖拽条的高亮会遮住表格内容,其指针目标也可能取代表格自身的交互目标。 - -## 决策 - -Conversation shell 将宽度拖拽条保持在第零层,不创建覆盖整个 body 的堆叠上下文。Chat 只将具体的表格元素提到第一层,阅读列、限定在列内的工具卡片与表格 breakout 包装层都不创建新的堆叠层级。因此只有表格的可见盒子实际延伸进沟槽时才会占有指针;能容纳在正文中的四列表格不会阻断旁边的沟槽拖拽。消息的固定定位 Tooltip 保持在表格堆叠上下文之外,仍可绘制在粘滞 composer 上方。 - -该规则依赖交付的 Chromium 引擎不会把 ChatView 的 inline-size query container 变成中间堆叠上下文。back-to-bottom 控件能高于粘滞 composer 也已依赖相同的引擎行为。Chromium 命中测试固定这一假设;如果改为提高整个 Chat root,其透明全宽盒子反而会重新占用沟槽。 - -宽度拖拽条仅使用主指针按键开始调整。拖拽条上的普通滚轮输入会从像素、计算后的行高或页面单位规整为像素,再转发给直接相邻的 transcript scrollport,因此指针悬停在沟槽时不会中断阅读滚动。Ctrl+滚轮表示浏览器缩放或触控板捏合手势,不会被转发。 - -每侧的命中区域最宽为 10px。悬停指示线使用对比度更低的静止滚动条颜色,线宽 2px,中心实色段长 16px,两端各渐隐 28px,可见总长度为 72px。 - -Composer chrome 保留更高层级,因此完整的粘滞底部区带刻意不会开始宽度调整。如果拖拽条在进入该区带之前已获得指针捕获,它会临时提到第八层,使指示线在松开前保持可见。占据完整 View 的 composer takeover 仍会完全隐藏拖拽条。 - -## 考虑过的替代方案 - -**将拖拽条移到离阅读列更远的位置。** 宽表格可以使用 transcript 的可用宽度,因此固定增加间距只能减少部分窗口尺寸下的重叠,还会让拖拽条更难触达。 - -**只要 transcript 包含宽内容就禁用拖拽条。** 一行宽内容会让整个 Session 都无法调整宽度,包括与其他行相邻的空白沟槽。 - -**在 JavaScript 中检查指针下方的元素。** 动态命中测试会重复浏览器的堆叠与指针分发规则,并且仍可能与新的插件 renderer 不一致。 - -## 后果 - -可见 Chat 内容只在其可见元素实际延伸进宽度拖拽区域时拥有指针输入,裸露沟槽则保留更轻的宽度调整提示、主按键拖拽和 transcript 滚轮滚动。单元测试固定声明的拖拽条尺寸、指针按键行为、直接 scrollport 查找、缩放排除和每种 DOM delta mode。Chromium `elementFromPoint` 覆盖证明溢出表格获得指针命中,而可容纳的 `md-table-wide` 包装层不会;消息操作测试证明固定定位 Tooltip 仍绘制在粘滞 composer 上方。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml index 4ac53a23d4..97d7d9c32f 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.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/bug-fix/2026-09-15-desktop-profile-core-cleanup.md -2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87 -2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c +2026-09-15-desktop-profile-core-cleanup.md: da189664e2b55a9310b36ed02f0b275e2c1b1370 +2026-09-15-desktop-profile-core-cleanup.zh.md: dec12e4fe7f34beb5931961183041c3f642b44ae diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md index 7a5f3eea56..da189664e2 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md @@ -12,7 +12,7 @@ Old Desktop profiles contain installed core packages and local tarball dependenc Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup. -The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged. +The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [bundled runtime decision](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md); direct writes and failure recovery remain unchanged. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md index 4a9cad94a8..dec12e4fe7 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md @@ -12,7 +12,7 @@ Status: implemented 生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。 -实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。 +实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[内置运行时决策](../architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的包保留范围;直接写入和失败恢复保持不变。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml new file mode 100644 index 0000000000..cc6eb7ff26 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml @@ -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/bug-fix/2026-09-15-trajectory-attachment-presentation.md +2026-09-15-trajectory-attachment-presentation.md: 03d737d67e626497bbcb957911b1578e0dadd5fb +2026-09-15-trajectory-attachment-presentation.zh.md: 55c63bf3d97de2e3e102e92730ce4d2637547d6b diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md new file mode 100644 index 0000000000..03d737d67e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -0,0 +1,55 @@ +# Agent Note: Consistent Trajectory attachment presentation + +Status: implemented + +English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) + +## Problem + +A user message can contain text, images, and ordinary files. Trajectory omitted image counts beside text, left ordinary files out of Preview, and mixed file JSON with rendered images in Raw. Readers could not identify the complete attachment set consistently across the ledger and inspector (Issue #4356). + +## Decision + +Trajectory presents every attachment occurrence while keeping visual inspection separate from inspection of recorded fields. + +| Location | Behavior | +|---|---| +| Ledger | Separate nonzero image and ordinary-file counts accompany the text excerpt, including messages without text. Each record stays on one compact line. | +| Summary and Preview | Message text precedes one ordered attachment list. Both tabs use the same renderer; Summary retains its compact spacing and scroll container. | +| Attachment list | Equal-width compact rows share spacing and corners. A file-type icon or contained image thumbnail accompanies the recorded name and available metadata. Repeated references retain their occurrence order. | +| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access. Loading and retry icons fit the thumbnail and expose localized tooltips and accessible names. | +| Raw | Image and file blocks use initially collapsed metadata disclosures with complete recorded fields. Original content-block order and unrendered text remain intact; generated display labels do not enter raw fields. | + +Recorded filenames take precedence over localized numbered image labels. Metadata comes from recorded fields or the filename extension; zero-byte sizes remain visible. Visually truncated names retain their complete accessible text and tooltip. Messages without text still expose all attachments inside the inspector's scroll area. + +### Ownership + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns counts, ordered attachment projection, and inspector composition. Its view data carries typed image and file references separately from the serialized content used by Raw, so display code does not recover file metadata by parsing JSON. + +[ui-attachment](../../../../packages/client/ui-attachment/README.md) owns image loading and the viewer through the existing image slot. [ui-conversation](../../../../packages/client/ui-conversation/README.md) owns the per-session image cache: Chat and Trajectory share one authorized read and browser URL per attachment. Thumbnail sizing and optional display labels affect presentation only; loading still receives the original reference. Chat and tool-result gallery sizing retain their existing behavior. + +This decision uses existing durable attachment references without changing upload, storage, model requests, or the Session format. Ordinary files expose metadata without a content viewer or download action. Product labels use typed locale dictionaries, and styles use the existing semantic tokens. + +The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) owns upload, storage, admission, and ordered message attachments; this record owns Trajectory presentation. The [unreadable attachment quarantine proposal](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) concerns model-request recovery independently of thumbnail loading feedback. + +## Alternatives considered + +**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments. + +**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list accommodates both panel widths. + +**Keep file JSON and image previews together in Raw.** This assigns different meanings to the same tab for two attachment kinds. Metadata disclosures let readers inspect recorded fields while Preview owns visual image presentation. + +## Consequences + +The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry. + +Compact thumbnails require opening the viewer to read detailed diagrams. Missing metadata stays absent, and image-read failures retain a retry control without hiding ordinary files. Changes to the shared image slot also affect Chat and tool results, so their gallery behavior and cache reuse remain regression requirements. + +## Verification + +[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, and complete Raw fields. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer. + +The recorded [mixed-upload browser scenario](../../../../apps/web/tests/file-upload-round.e2e.ts) compares Summary and Preview, verifies keyboard activation and focus restoration, checks collapsed and expanded Raw content, and repeats inspection after reload. Its [Trajectory expectation](../../../../snapshots/web/file-upload-round/trajectory.expected.md) accompanies the unchanged Session fixture. The [assembled image-cache test](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts) also checks reuse between Chat and Trajectory. + +Layout acceptance covers narrow and wide panels, both themes and locales, complete thumbnail containment, and reachable final attachments. The [GIF workflow](../../../skills/record-browser-gif/SKILL.md) requires review evidence from a clean committed tree and one real-server, real-model session showing mixed submission, ledger counts, Summary, Preview image activation, and expanded Raw metadata. Issue #4356 and its linked PR track executed checks and demonstration evidence. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md new file mode 100644 index 0000000000..55c63bf3d9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 统一 Trajectory 附件展示 + +Status: implemented + +[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 + +## 问题 + +用户消息可以包含文本、图片和普通文件。Trajectory 曾在文本旁省略图片数量,在预览中遗漏普通文件,并在原始内容中混排文件 JSON 与渲染后的图片。读者无法在列表和详情面板中一致地确认完整附件集合(Issue #4356)。 + +## 决定 + +Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已记录字段检查。 + +| 位置 | 行为 | +|---|---| +| 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 | +| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 | +| 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 | +| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问。加载与重试图标适配缩略图尺寸,并提供本地化的工具提示和可访问名称。 | +| 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 | + +已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。 + +### 职责 + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。其视图数据将带类型的图片与文件引用和原始内容使用的序列化文本分别保存,展示代码因此无需解析 JSON 来恢复文件元数据。 + +[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)通过现有图片 slot 负责图片加载与查看器。[ui-conversation](../../../../packages/client/ui-conversation/README.zh.md)拥有会话级图片缓存:Chat 与 Trajectory 对每个附件共享一次授权读取和一个浏览器 URL。缩略图尺寸和可选显示标签只影响展示,加载仍接收原始引用。Chat 和工具结果图库保留现有的尺寸行为。 + +本决定使用现有持久附件引用,不改变上传、存储、模型请求或会话格式。普通文件展示元数据,不提供内容查看器或下载操作。产品标签使用带类型的本地化字典,样式使用现有语义 token。 + +[通用文件上传决定](../feature/2026-08-26-generic-file-upload.zh.md)负责上传、存储、准入和有序消息附件;本记录负责 Trajectory 展示。[不可读附件隔离提案](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 + +## 考虑过的替代方案 + +**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 + +**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。单列等宽列表适用于两种面板宽度。 + +**在原始内容中混排文件 JSON 和图片预览。** 这让同一个标签页对两种附件具有不同含义。元数据折叠区用于检查已记录字段,预览负责图片的视觉展示。 + +## 影响 + +共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。 + +紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 slot 的修改也会影响 Chat 和工具结果,因此其图库行为和缓存复用仍需回归验证。 + +## 验证 + +[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表和完整的原始字段。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。 + +已录制的[混合上传浏览器场景](../../../../apps/web/tests/file-upload-round.e2e.ts)比较概述与预览,验证键盘激活和焦点恢复,检查原始内容收起与展开状态,并在刷新后重复检查。其 [Trajectory 预期输出](../../../../snapshots/web/file-upload-round/trajectory.expected.md)配合未改动的会话 fixture(测试前置数据)使用。[组装应用图片缓存测试](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)也检查 Chat 与 Trajectory 之间的复用。 + +布局验收覆盖窄版与宽版面板、两种主题和语言、缩略图完整缩放及末尾附件可达性。[GIF 工作流](../../../skills/record-browser-gif/SKILL.md)要求评审证据基于干净的已提交代码树,在同一个真实服务、真实模型会话中展示混合提交、列表计数、概述、预览中的图片激活和展开的原始元数据。Issue #4356 及其关联 PR(Pull Request)记录已执行的检查和演示证据。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml new file mode 100644 index 0000000000..a5158a3994 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -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/bug-fix/2026-09-16-desktop-window-menus.md +2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd +2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md new file mode 100644 index 0000000000..85ac42aa28 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -0,0 +1,35 @@ +# Agent Note: Desktop standard macOS window menus + +Status: implemented + +English | [中文](2026-09-16-desktop-window-menus.zh.md) + +## Problem + +The Desktop shell replaces Electron's default application menu with a custom template that listed only the application and Edit menus. Electron builds only the roles a template declares, so macOS lost the File and Window menus and the application hide commands the default template supplies, including Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). None of those shortcuts did anything in the Desktop application while every comparable macOS application responds to them (issue #4374). + +## Decision + +On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. + +Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. + +## Alternatives considered + +**Bind ⌘W to minimizing or hiding the window.** macOS reserves Minimize for ⌘M and Hide for ⌘H, and comparable applications close the front window with ⌘W. Binding another command to the reported shortcut would contradict the platform convention the change follows. + +**Declare only the Window menu.** That menu supplies Minimize and Zoom but no Close, leaving ⌘W unbound. + +**Add a bare `{ role: 'close' }` item to the application submenu.** It avoids a File menu containing a single command, but places a window command among the app-wide Plugins, Updates, Hide, and Quit items where no macOS application puts it. + +**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application, turning a window shortcut into an unrequested quit path. + +**Recreate the main window on Dock activation regardless of other windows.** The plugin window can outlive the main window, so gating `activate` on `mainWindow` instead of `BrowserWindow.getAllWindows()` would let a Dock click always restore the main window. That changes window lifecycle beyond restoring the suppressed platform commands, so the existing `activate` condition stays. + +## Consequences + +macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop. + +## Testing + +A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md new file mode 100644 index 0000000000..1376b90058 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Desktop standard macOS window menus + +Status: implemented + +[English](2026-09-16-desktop-window-menus.md) | 中文 + +## 问题 + +Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该模板此前只列出应用菜单和 Edit 菜单。Electron 只构建模板中声明的 role,因此 macOS 失去了默认模板提供的 File、Window 菜单和应用隐藏命令,包括 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。这些快捷键在 Desktop 应用中都没有任何效果,而同类的 macOS 应用都会响应它们(#4374)。 + +## 决策 + +macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。 + +这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 + +## 考虑过的替代方案 + +**把 ⌘W 绑定为最小化或隐藏窗口。** macOS 把最小化留给 ⌘M、把隐藏留给 ⌘H,同类应用都用 ⌘W 关闭最前窗口。给这个快捷键绑定其他命令会违背本改动所遵循的平台惯例。 + +**只声明 Window 菜单。** 该菜单提供 Minimize 和 Zoom,但不提供关闭,⌘W 仍然没有绑定。 + +**在应用子菜单里直接加一个 `{ role: 'close' }` 项。** 这样可以避免只有一个命令的 File 菜单,但会把窗口命令放到 Plugins、Updates、Hide、Quit 这些应用级命令中间,没有 macOS 应用这样排布。 + +**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用,把窗口快捷键变成用户没有要求的退出路径。 + +**让 Dock 激活时无论其他窗口是否存在都重建主窗口。** 插件窗口可以比主窗口存活更久,因此把 `activate` 的判断从 `BrowserWindow.getAllWindows()` 改为 `mainWindow` 能让点击 Dock 总可以恢复主窗口。但这超出了恢复被压掉的平台命令,属于窗口生命周期的行为变更,因此保留现有的 `activate` 判断。 + +## 影响 + +macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。 + +## 测试 + +`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml new file mode 100644 index 0000000000..10665d6e64 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml @@ -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/bug-fix/2026-09-16-movable-mandatory-update-window.md +2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68 +2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md new file mode 100644 index 0000000000..4695074429 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md @@ -0,0 +1,23 @@ +# Agent Note: Movable mandatory-update window + +Status: implemented + +English | [中文](2026-09-16-movable-mandatory-update-window.zh.md) + +## Problem + +The Windows mandatory-update page used a frameless modal overlay sized to the product window. The modal disabled its parent, so the parent's native title bar could not be used to move or maximize either window. Closing the overlay was intercepted, leaving no visible exit control. + +## Decision + +On Windows, mandatory policy uses a separate native framed modal with move, resize, and maximize controls. The parent remains disabled while policy blocks interaction. Closing the modal requests normal application shutdown; it never dismisses policy and resumes the product window. Other platforms retain the existing overlay presentation. The [mandatory-update decision](../feature/2026-09-11-desktop-mandatory-update-client.md) still owns policy and installation authorization. + +## Alternatives considered + +**Keep the full-content overlay and add a drag region.** A drag region would move the disabled parent indirectly and would not restore native maximize or close controls. + +**Let close dismiss the policy page.** That would expose the blocked product window without a fresh no-force policy response. + +## Consequences + +Windows users can place or maximize the update window and exit the application from its close button. Modal blocking and the second installation approval remain intact. Installer-owned quit disposes the modal before Electron closes windows, so its close guard cannot block installation. The native frame replaces the dimmed full-content overlay on Windows; the ordinary update dialog keeps its existing overlay. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md new file mode 100644 index 0000000000..3b416c53dc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 可移动的强更窗口 + +Status: implemented + +[English](2026-09-16-movable-mandatory-update-window.md) | 中文 + +## Problem + +Windows 强更页面原先使用与产品窗口等大的无边框模态覆盖层。模态窗口禁用了父窗口,因此无法使用父窗口的原生标题栏移动或最大化窗口。覆盖层的关闭操作又被拦截,界面上没有可用的退出控件。 + +## Decision + +在 Windows 上,强更策略使用独立的原生有边框模态窗口,支持移动、调整大小和最大化。策略阻塞期间父窗口仍不可操作。关闭模态窗口会请求应用正常退出,不会取消策略并恢复产品窗口。其他平台保留现有覆盖层展示。[强更决策](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)仍负责策略与安装授权。 + +## Alternatives considered + +**保留覆盖整个内容区域的窗口并添加拖动区域。** 拖动区域只能间接移动被禁用的父窗口,也无法恢复原生最大化和关闭控件。 + +**允许关闭时直接取消策略页面。** 这样会在未取得新的无需强更响应时暴露被阻塞的产品窗口。 + +## Consequences + +Windows 用户可以移动或最大化更新窗口,也可通过关闭按钮退出应用。模态阻塞和第二次安装批准保持不变。安装器接管退出时,壳会在 Electron 关闭窗口前释放模态窗口,避免关闭拦截阻止安装。Windows 上的原生边框取代了遮罩整个内容区域的覆盖层;常规更新弹窗仍使用原有覆盖层。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.i18n.yaml index 003ab9ba65..f8d7bd6adf 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.i18n.yaml @@ -1,6 +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/bug-fix/2026-09-14-transcript-width-handle-layering.md -2026-09-14-transcript-width-handle-layering.md: 01d70e646e874d8266633597f1b510ad73fc2287 -2026-09-14-transcript-width-handle-layering.zh.md: c6e03ec5775256f5aac87d2f7c1ed66a12cf0c2a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md +2026-09-16-present-delayed-windows-installer.md: d4ffcfdc1183bbc414cfdd82595053c959a9975c +2026-09-16-present-delayed-windows-installer.zh.md: bf97ed8a4f6c7184d1e048c21f7975c90fb4a9cb diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md new file mode 100644 index 0000000000..d4ffcfdc11 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.md @@ -0,0 +1,23 @@ +# Agent Note: Present delayed Windows installer + +Status: implemented + +English | [中文](2026-09-16-present-delayed-windows-installer.zh.md) + +## Problem + +The native installer hides its window while preparing resources. If the user activates another application during that interval, the welcome page can appear behind that application and seem absent. + +## Decision + +The first welcome-page display moves the installer above ordinary windows without activating it. When another window owns the foreground, the installer also flashes its taskbar button until foreground interaction. Later page changes do not repeat the move. The window never becomes permanently topmost. + +## Alternatives considered + +**Force foreground focus.** Windows may reject a background process's foreground request, and taking focus after the user switches applications interrupts their current action. + +**Keep the installer topmost.** A persistent topmost window obscures applications the user intentionally opens during installation. + +## Consequences + +The welcome page becomes visible when preparation completes, while the user retains control of focus and can cover the installer again. A native window-order test and the signed installer smoke test exercise the first display. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md new file mode 100644 index 0000000000..bf97ed8a4f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 延迟显示的 Windows 安装窗口 + +Status: implemented + +[English](2026-09-16-present-delayed-windows-installer.md) | 中文 + +## Problem + +原生安装程序准备资源时会隐藏窗口。如果用户在此期间激活其他应用,欢迎页可能出现在该应用后面,看起来像没有启动。 + +## Decision + +欢迎页首次显示时,安装窗口会移到普通窗口前方,但不抢占焦点。如果其他窗口位于前台,安装程序还会闪烁任务栏按钮,直至用户进行前台交互。后续页面切换不会重复置前。安装窗口不会永久置顶。 + +## Alternatives considered + +**强制获取前台焦点。** Windows 可能拒绝后台进程的前台请求,而且用户切换应用后再抢回焦点会打断当前操作。 + +**让安装窗口始终置顶。** 持续置顶会遮挡用户在安装期间主动打开的其他应用。 + +## Consequences + +准备完成时欢迎页可见,用户仍能控制焦点,也可以再次用其他窗口覆盖安装程序。原生窗口顺序测试和签名安装程序冒烟测试覆盖首次显示。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.i18n.yaml new file mode 100644 index 0000000000..61b6c1c780 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.i18n.yaml @@ -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/bug-fix/2026-09-16-session-writer-held-feedback.md +2026-09-16-session-writer-held-feedback.md: 922987996c4abd32857cd0f54cec4394566cf97f +2026-09-16-session-writer-held-feedback.zh.md: 6a1aa7993a8f36c50cb5fed0180d6523af44c177 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md new file mode 100644 index 0000000000..922987996c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.md @@ -0,0 +1,25 @@ +# Agent Note: Session writer contention feedback + +Status: implemented + +English | [中文](2026-09-16-session-writer-held-feedback.zh.md) + +## Problem + +A Session can retain its write handle while its Agent is idle. Another Host cannot resume that Session, but a generic internal-error toast gives the user no recovery guidance. The holder can also belong to the same process, so contention alone does not identify another running application. + +## Decision + +Session Controller reports `session/writer-held` with `{ sessionId }` when resume fails with `SessionAlreadyOwnedError` and no reusable Agent exists. Clients discriminate by code, following the [Remote failure vocabulary](../architecture/2026-08-28-ctx-remote-failure-vocabulary.md). The controller recognizes the persistence error by its Error name, matching Session Query's optional-dependency handling; loading the controller requires no persistence implementation or error-class identity. + +Send and model-selection failures show localized guidance that another DSH instance may hold the Session and suggest quitting other instances before retrying. Model selection returns the original Remote result to both UI entries; error classification does not depend on a later read of shared directory state. The [write-lease decision](../feature/2026-08-31-cross-process-session-write-lease.md) continues to own locking and release semantics; this feedback neither takes ownership nor retries writes automatically. + +## Alternatives considered + +**A free-text reason under `session/agent-busy`.** A second string discriminator loses the code-to-details type relationship and mixes write contention with prompt admission failures. + +**A separate directory flag or a required persistence peer.** A flag duplicates the operation's failure and can become stale after a catalog refresh. Making persistence mandatory solely to identify its error removes support for deployments without persistence. + +## Consequences + +The wire gains one typed failure code without changing stored Session data. Recovery guidance cannot identify which process owns the handle. Host tests cover the error name and ordinary failures with and without persistence; Client tests cover both locales and operation-local model failures. The keyless `queue-actions` Web scenario pins the writer-held toast and preserved draft through the shipped composition. `verify-optional-dependency-imports` guards module loading against optional value imports. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.zh.md new file mode 100644 index 0000000000..6a1aa7993a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-session-writer-held-feedback.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 会话写句柄占用反馈 + +Status: implemented + +[English](2026-09-16-session-writer-held-feedback.md) | 中文 + +## Problem + +Agent 空闲时,Session 仍可能持有写句柄。另一个 Host 无法恢复该会话,但通用内部错误 toast 没有提供恢复指引。持有者也可能属于同一个进程,因此仅凭占用不能确定是另一个正在运行的应用。 + +## Decision + +当恢复因 `SessionAlreadyOwnedError` 失败且不存在可复用 Agent 时,Session Controller 返回 `session/writer-held`,携带 `{ sessionId }`。Client 遵循 [Remote 失败词汇](../architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md),按 code 判别。Controller 按 Error 名称识别持久化错误,与 Session Query 的可选依赖处理一致;加载 Controller 不需要持久化实现或错误类身份相同。 + +发送和模型选择失败显示本地化指引,说明可能有其他 DSH 实例占用 Session,并建议退出其他实例后重试。模型选择将原始 Remote 结果返回给两个 UI 入口;错误分类不依赖稍后读取共享目录状态。[写租约决策](../feature/2026-08-31-cross-process-session-write-lease.zh.md) 继续拥有锁定和释放语义;此反馈既不抢占写权限,也不自动重试写入。 + +## Alternatives considered + +**在 `session/agent-busy` 下使用自由文本 reason。** 第二个字符串判别值失去 code 与 details 的类型关系,并将写占用与提示词接纳失败混在一起。 + +**单独的目录标志或必需的持久化 peer。** 标志重复记录操作失败,且可能在目录刷新后过时。仅为识别错误而强制依赖持久化,会移除对无持久化部署的支持。 + +## Consequences + +Wire 新增一个有类型的失败码,不改变已存储的 Session 数据。恢复指引无法确定哪个进程持有句柄。Host 测试覆盖错误名称,以及有、无持久化时的普通失败;Client 测试覆盖两种语言和随模型操作返回的失败。无密钥的 `queue-actions` Web 场景通过随附组合固定写占用 toast 和保留草稿的输出。`verify-optional-dependency-imports` 防止可选依赖的值导入破坏模块加载。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml new file mode 100644 index 0000000000..6377a233cb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml @@ -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/bug-fix/2026-09-17-thinking-markdown.md +2026-09-17-thinking-markdown.md: 6990fc6277d7d1c0a088a50cfb88694b2d9c55c7 +2026-09-17-thinking-markdown.zh.md: 963626088e50a44bcf76c7fc7ec3653e93fcd2c9 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md new file mode 100644 index 0000000000..6990fc6277 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md @@ -0,0 +1,31 @@ +# Agent Note: Compact Markdown for Thinking + +Status: implemented + +English | [中文](2026-09-17-thinking-markdown.zh.md) + +## Problem + +Chat Thinking contains model-authored Markdown, but literal rendering exposes headings, emphasis markers, and code fences. The shared answer typography makes headings larger and more prominent than the secondary reasoning text. Trajectory applies that answer typography to the same reasoning. + +## Decision + +[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../../../../docs/web-styling.md); code retains its monospace font and background. + +Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) above the scrolling content without a second sticky band. + +Chat passes its running state to the existing incremental Markdown renderer. The collapsed summary remains a separate single-line text projection. The parser, frozen-block cache, stored reasoning, and Session format do not change. Trajectory pins Thinking to its inspector’s fixed 13px/20px tier, independent of Chat’s content-size setting. Its answer output retains its existing typography and its [Thinking disclosure behavior](../feature/2026-09-09-ptc-trajectory-code-inspection.md). + +## Alternatives considered + +**Use answer typography unchanged.** Large headings and generous block spacing give reasoning more visual weight than the answer. + +**Style Markdown separately in each consumer.** Chat and Trajectory would need to track the same renderer elements independently; the primitive owns those elements and their compact presentation. + +**Disable tables and formulas.** The existing renderer already supports them. Bounded overflow and inherited font sizing preserve useful content without another parsing mode. + +## Consequences + +Thinking supports structured reading while retaining its secondary emphasis. Semantic headings remain available to assistive technology even though their visible hierarchy is flattened. The shared variant applies to both views; future Markdown element changes must preserve its typography as well as the default answer typography. + +Markdown soft line breaks collapse like answer prose; hard breaks and separate paragraphs require Markdown syntax. Streaming freezing operates on completed blocks, so an unfinished long paragraph remains in the mutable tail and is parsed again as it grows. diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md new file mode 100644 index 0000000000..963626088e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Thinking 的紧凑 Markdown + +Status: implemented + +[English](2026-09-17-thinking-markdown.md) | 中文 + +## 问题 + +Chat Thinking 包含模型编写的 Markdown,但纯文本渲染直接显示标题、强调标记和代码围栏。共享回答排版会让标题比次级推理文本更大、更醒目。Trajectory 对同一推理内容使用了回答排版。 + +## 决策 + +[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../../../../docs/web-styling.zh.md)的次级内容例外;代码保留等宽字体和底色。 + +表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) 位于滚动内容上方,无需第二条 sticky 栏。 + +Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 将 Thinking 固定在检查器的 13px/20px 层级,与 Chat 的内容字号设置无关。其回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 + +## 曾考虑的替代方案 + +**直接使用回答排版。** 大标题和宽松块间距让推理比回答更醒目。 + +**由每个使用方单独设置 Markdown 样式。** Chat 和 Trajectory 将需要分别跟踪相同的渲染元素;基元拥有这些元素及其紧凑展示。 + +**禁用表格和公式。** 既有渲染器已支持二者。限制溢出和继承字号可以保留有用内容,无需增加另一种解析模式。 + +## 后果 + +Thinking 支持结构化阅读,同时保持次级强调。虽然视觉层级被统一,辅助技术仍可使用语义标题。共享变体同时用于两个视图;后续 Markdown 元素变更必须同时保留紧凑排版与默认回答排版。 + +Markdown 软换行与回答正文一样折叠;硬换行和独立段落需要相应 Markdown 语法。流式冻结以完整块为单位,因此尚未结束的长段落仍处于可变尾部,并随增长重新解析。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml similarity index 54% rename from .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml index 5df3dbdf86..0898823b35 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml @@ -1,6 +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/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md -2026-09-07-pinned-scroll-delivery-before-layout.md: f3b428dbe0d2fdc7cbaedf0383d2ab80ea1bfd45 -2026-09-07-pinned-scroll-delivery-before-layout.zh.md: 9b200f45535c67cfbbb75feaf3025c5a3b5c1fa2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md +2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54 +2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md new file mode 100644 index 0000000000..1775276dee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md @@ -0,0 +1,31 @@ +# Agent Note: Replay assistant blocks in Messages user input + +Status: implemented + +English | [中文](2026-09-18-messages-input-history-compatibility.zh.md) + +## Problem + +Saved subagent settlement notices can contain child reasoning and tool calls in parent user messages. Chat Completions and pi-ai omit these blocks from user input, while rejecting them in Messages prevents every later request containing the same history. The [text-only settlement producer](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md) prevents new notices from carrying these blocks but cannot repair already recorded messages. + +## Decision + +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. + +This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content. + +## Alternatives considered + +**Fix only the producer.** New notices are representable, but saved notices still block protocol switching and continuation. + +**Rewrite Session history or flatten reasoning into user text.** Rewriting removes original evidence; flattening sends child reasoning as parent input. Request-only omission preserves the durable record and existing input semantics. + +**Ignore every unsupported block.** Unknown plugin content has no Messages representation defined here. Compatibility for two known assistant block types does not establish one. + +**Move a system update to an earlier retained user turn.** When the corresponding user input is omitted, moving the update before an earlier assistant changes its position in the conversation and rewrites a previously sent prefix. The [documented in-history limitation](../../../../packages/llm/llm-deepseek/README.md#known-limitations-and-deferred-work) remains explicit rather than inventing a user message or relocating the update. + +## Consequences + +Saved notices with retained text can continue through Messages. The provider no longer diagnoses these two block types as user-input producer errors; the settlement producer remains responsible for text-only notices. This does not promise lossless provider input or compatibility for every historical message sequence. + +Verification covers user and tool-result filtering, unchanged durable content, HTTP continuation, and a [headless Session replay](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml). Serialization tests pin rejection of an in-history update when its user input is entirely omitted, both before another assistant and at request end, and acceptance when text or a tool result retains the corresponding turn. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md new file mode 100644 index 0000000000..50367e33c2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 回放 Messages 用户输入中的助手块 + +Status: implemented + +[English](2026-09-18-messages-input-history-compatibility.md) | 中文 + +## 问题 + +已保存的子 Agent 结算通知可能在父级用户消息中包含子级推理和工具调用。Chat Completions 与 pi-ai 会在用户输入中省略这些块,而 Messages 拒绝它们会阻断包含相同历史的所有后续请求。[仅生成文本的结算通知生产方](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md)可以避免新通知携带这些块,但无法修复已经记录的消息。 + +## 决策 + +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 + +这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。 + +## 考虑过的替代方案 + +**只修复生产方。** 新通知可以表示,但已保存的通知仍会阻断协议切换和会话续接。 + +**改写 Session 历史或把推理压平为用户文本。** 改写会移除原始证据;压平会把子级推理作为父级输入发送。只在请求中省略可以保留持久化记录和现有输入语义。 + +**忽略所有不支持的块。** 此处没有定义未知插件内容的 Messages 表示方式。兼容两种已知助手块类型并不意味着其他类型也有对应表示。 + +**把 system 更新移到更早保留的用户轮次。** 对应用户输入被省略时,将更新移到更早的 assistant 之前,会改变它在对话中的位置,并改写已发送的前缀。保留明确记录的[历史内更新限制](../../../../packages/llm/llm-deepseek/README.zh.md#known-limitations-and-deferred-work),不编造用户消息或移动更新位置。 + +## 后果 + +保留文本的已保存通知可以通过 Messages 继续会话。提供方不再将这两种块类型诊断为用户输入生产方错误;结算通知生产方仍负责仅生成文本通知。这不保证提供方输入无损,也不保证兼容所有历史消息序列。 + +验证覆盖用户与工具结果过滤、持久化内容不变、HTTP 续接,以及 [headless Session 回放](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml)。序列化测试固定了历史内更新在对应用户输入被完全省略时的拒绝行为,覆盖下一个 assistant 之前和请求结束两种位置;文本或工具结果保留对应轮次时则可以通过。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index cd51630220..e678639894 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: d0713731de86df639a426ee56c39857c50af6bc7 -2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc +2026-07-27-web-subagent-conversations.md: aacb52eca5680534b74abd42ca07c2b568c23e21 +2026-07-27-web-subagent-conversations.zh.md: 30c41f03703004e3175cee8fad9d11e0cf93f06a diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index d0713731de..aacb52eca5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -16,6 +16,8 @@ The UI must also preserve the membership, modes, and diagnostics of the [durable The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation. +The same header row can open a child in the right Sidebar as `dsh-resource://chat/session/?parent=&mode=`. The open prefers a separate pane and falls back to the current pane when no split is available. The Sidebar tab renders the shared Conversation Component Factory with its width controls omitted, so main and embedded conversations share one assembly without sharing layout chrome. + Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt carries Queue or Steer delivery through `subagent.prompt` and succeeds at inbox acceptance with `{ messageId }`; it does not expose an Activation, wait for completion, or return an outcome. Adjacent-Agent model messages use the separately owned fixed-Steer operation. The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization. @@ -37,7 +39,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies the shared tertiary-grey ongoing loader and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. An ordinary Workspace row with a pending approval, plan review, or question replaces its relative time with the compact label `Approval`, `Plan review`, or `Answer`; hover and assistive details retain the full status and relative time. With neither primary state present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0f4f646748..30c41f0370 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -16,6 +16,8 @@ UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-su Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。 +同一个页头行还可以把 child 作为 `dsh-resource://chat/session/?parent=&mode=` 在右侧 Sidebar 打开。打开时优先使用独立分栏;无法分栏时回退到当前分栏。Sidebar tab 使用共享 Conversation Component Factory 渲染并省略宽度控制,因此主对话与嵌入式对话共用一套组装,而不共享布局界面。 + 每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词通过 `subagent.prompt` 携带 Queue 或 Steer 投递,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会公开 Activation、等待完成或返回结果。相邻 Agent 的模型消息使用单独拥有的固定 Steer 操作。 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。 @@ -37,7 +39,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品约定 -只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示共享的三级灰色 ongoing loading,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。普通 Workspace 行存在待处理的审批、计划审阅或问题时,会用紧凑的「待批准」「计划待审」或「待回答」替换相对时间;悬停与无障碍详情仍保留完整状态和相对时间。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 332515c11d..1369b927e3 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.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/feature/2026-07-28-continuable-subagent-conversations.md -2026-07-28-continuable-subagent-conversations.md: af4382a764a59a2d7c02f3cd5feffb32022441f8 -2026-07-28-continuable-subagent-conversations.zh.md: 886e31fa3d88b78f875d51058bb2ec8c525837fe +2026-07-28-continuable-subagent-conversations.md: a30966c4d614fda837bc2bac95f6b6e411de9791 +2026-07-28-continuable-subagent-conversations.zh.md: 59a02a211e0ca861f819a7524e4d69ad09acdbbc diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index af4382a764..a30966c4d6 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -143,7 +143,7 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior. -It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public residency query, new live-Activation or descendant limit, or runtime cache; the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) added the one public stop operation on top of this lifecycle. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability. +It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public residency query, or runtime cache; the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) added the one public stop operation on top of this lifecycle. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability. ## Alternatives considered @@ -203,7 +203,7 @@ The implementation pins these behaviors: Removing Jobs gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue. -Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but this version adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`. +Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, and [shared Activation capacity](2026-09-15-continuable-activation-capacity.md) bounds live continuable descendants; settled historical Sessions retain no `AgentHandle`. The process-local inbox and ownership graph do not coordinate two harness processes. Deployments allowing concurrent access to one persistence store still require a durable lease and mailbox protocol. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index 886e31fa3d..59a02a211e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -143,7 +143,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳约定,才能支持同样的行为。 -它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.zh.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。 +它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.zh.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。 ## 曾考虑的替代方案 @@ -203,7 +203,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。 -在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但本版本不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。 +在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,而[共享 Activation 容量](2026-09-15-continuable-activation-capacity.zh.md)限制存活的可续接后代数;已结算的历史会话不保留 `AgentHandle`。 进程内 inbox 和所有权图无法协调两个 harness 进程。允许多个进程并发访问同一持久化存储的部署,仍需要持久化 lease 和邮箱协议。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md deleted file mode 100644 index 6e8b4b94c9..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Web sticky collapsible headers — Think and compaction toggles pin while scrolling - -Status: implemented - -English | [中文](2026-08-03-web-sticky-collapsible-headers.zh.md) - -## Problem - -Two conversation blocks render their expanded body uncapped, flowing with the page instead of scrolling inside a bounded surface: the Web Think row (`.thinkBody`) and the compaction marker (`.compactionBody`). Every other tool row caps its body and scrolls it inside its own card, so its disclosure header stays visible. The two uncapped blocks do not. A long chain of thought or a long compaction summary carries its own disclosure header off the top of the viewport, so a reader who wants to collapse the block again must scroll the full body back up to reach the toggle. - -## Decision - -The disclosure header of each uncapped block sticks to the conversation scroll container's top while the block is open. The header remains in normal flow when collapsed, so a collapsed block scrolls away like any other row. - -Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header. - -The pinned header's stacking rank differs by block, because their bodies differ. The Think body is plain text with no sticky descendant, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`). - -The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state. - -No session, wire, durable event, or model-visible contract changes; this is a presentation-only CSS change owned by the existing components. - -## Alternatives considered - -**Cap the two bodies with `max-height` + internal scroll, matching the tool cards.** Rejected: the Think body is deliberately uncapped so reasoning reads as ordinary message prose ([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) and the `.thinkBody` comment own that intent), and the compaction summary is a reading surface. An inner scrollport introduces nested scrolling — the wheel switches from page to box under the cursor — and compresses long technical prose into a small window that is worse to read. Sticky headers keep the flowing-prose reading model and still keep the toggle reachable. - -**Add sticky headers to every collapsible row for consistency.** Rejected: the capped tool rows already keep their header visible because their body scrolls internally, so they have no problem to solve. Making their headers sticky against the page would stack one pinned header per open row at the top of the viewport during a scroll through a run of tool calls, which is visual noise, not consistency. - -**Offset the summary's code banner from `CodeBlock` rather than from the compaction rule.** Rejected: `CodeBlock` could read a property such as `--dsl-code-block-banner-top` that this consumer sets, which would leave the code block the owner of its own geometry and avoid a `:has()` selector reaching into another package's DOM. It also routes every consumer's layout through a property only this block sets, and the offset is this block's presentation concern rather than the code block's: the local rule states the offset beside the toggle that creates it, and `[data-code-block-banner]` is the hook `CodeBlock` already publishes for owner styling. - -**A shared sticky rule on the `DisclosureRow` primitive.** Rejected: `DisclosureRow` backs Think, every tool-call variant, and the context-injection row; a rule there would hit the capped rows too. The behavior belongs only to the uncapped consumers, so each scopes the rule to its own block. - -## Consequences - -The collapse toggle for a long Think block or compaction summary stays reachable without scrolling the body back to its start, while both bodies keep flowing as page prose. The change is CSS-only: no timer, subscription, durable state, DOM structure change, or transport traffic. The compaction rule uses the CSS `:has()` selector, supported across the browsers the Web UI targets. - -## Testing - -The unit specs pin the DOM anchors the selectors key on: `packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` asserts an open Think row nests `[data-disclosure-row]` under `[data-variant='think'][data-expanded] [data-open]` and that a collapsed row has no `[data-open]`; `packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` asserts the compaction body appears under `.compactionRow` only while open. `packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` reads the rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the square corners, the `z-index` rank of each side, the single measurement shared by the toggle's height and the code banner's offset, and the opaque hover token, because jsdom computes no sticky layout and the render specs cannot fail on a changed declaration. - -The real-browser evidence is two keyless Chromium e2e paths. `apps/web/tests/lifecycle-chrome.e2e.ts` expands the settled turn's process row, opens the Think row, and asserts its header computes `position: sticky`, `top: 0px` (and is not sticky while collapsed); that fixture's recorded reasoning is a single line, too short to overflow, so it proves only that the CSS resolves onto the Think header. `apps/web/tests/seeded-history.e2e.ts` carries the pinned-while-scrolling evidence: it seeds a compaction whose summary length this suite controls (a fenced code block plus 40 list items), shrinks the viewport to force overflow, scrolls the marker into its pinned state, and asserts the header computes `position: sticky`, `top: 0px`, a `z-index` greater than the code block banner's, that it holds at the scrollport top after the scroll, that its own center is the topmost hit-tested element (the toggle stays clickable), and that its hover fill stays fully opaque (alpha 1). The same case scrolls the summary's code banner into its own stuck position and asserts the banner stops below the toggle's band and that the banner's Copy control owns its center, so the offset cannot regress into a covered control. The case also writes a keyless geometry golden (`snapshots/web/seeded-history/sticky-geometry.expected.md`) fixing these platform-independent semantic facts, since this is a user-visible CSS behavior that changes no DOM and no accessible name, so the aria goldens cannot capture it. The PR demo GIF, recorded against a real server and a real model round, carries the visual evidence that the pinned header stays at the top while the body scrolls. diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md deleted file mode 100644 index d4e1f69539..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note:Web 可折叠块的钉住标题 —— Think 与压缩标记的折叠按钮在滚动时钉住 - -Status: implemented - -[English](2026-08-03-web-sticky-collapsible-headers.md) | 中文 - -## 问题 - -会话里有两个块的展开正文不封顶,随整页滚动,而不是在有界的表面内部滚动:Web Think 行(`.thinkBody`)和压缩标记(`.compactionBody`)。其他每个工具行都给正文封顶并在自己的卡片内部滚动,所以折叠标题始终可见。这两个不封顶的块做不到。一段很长的思维链或很长的压缩摘要会把自己的折叠标题顶出视口上方,想再次折叠该块的读者必须把整段正文滚回顶部才能够到折叠按钮。 - -## 决策 - -每个不封顶块的折叠标题在块展开时钉在会话滚动容器的顶部。折叠时标题保持在正常文档流中,所以折叠的块会像其他行一样滚走。 - -这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。 - -钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文是纯文本、无 sticky 后代,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。 - -规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。 - -不改动任何 session、wire、durable event 或 model-visible 契约;这是一处纯展示层的 CSS 改动,由既有组件拥有。 - -## 曾考虑的替代方案 - -**用 `max-height` 加内部滚动给这两个正文封顶,与工具卡片一致。** 否决:Think 正文是刻意不封顶的,好让推理读起来像普通消息正文([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) 和 `.thinkBody` 注释拥有这一意图),压缩摘要是一个阅读表面。内层滚动框会引入嵌套滚动,滚轮在光标下从整页切换到框内,并把很长的技术性正文压进一个更难读的小窗口。钉住标题保留了流式正文的阅读模型,同时让折叠按钮依然够得着。 - -**为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。 - -**把摘要内代码栏的偏移交给 `CodeBlock` 承担。** 否决:可以让 `CodeBlock` 读取使用方设置的 `--dsl-code-block-banner-top` 之类的属性,这样代码块继续拥有自己的几何,也避免用 `:has()` 选择器伸进另一个包的 DOM。代价是把所有使用方的布局都接到一个只有这个块会设置的属性上,而这段偏移是这个块的呈现问题、不是代码块的:局部规则把偏移写在造成它的折叠按钮旁边,而 `[data-code-block-banner]` 本来就是 `CodeBlock` 为使用者样式发布的钩子。 - -**在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。 - -## 后果 - -很长的 Think 块或压缩摘要的折叠按钮无需把正文滚回起点就能够到,同时两个正文都保持作为整页正文流动。改动是纯 CSS:没有计时器、订阅、durable state、DOM 结构改动或传输流量。压缩规则使用 CSS `:has()` 选择器,Web UI 所面向的各浏览器均支持。 - -## 测试 - -单元测试钉住选择器所依赖的 DOM 锚点:`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 断言展开的 Think 行在 `[data-variant='think'][data-expanded] [data-open]` 之下嵌套了 `[data-disclosure-row]`,且折叠行没有 `[data-open]`;`packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` 断言压缩正文只在展开时出现在 `.compactionRow` 之下。`packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` 把这两条规则当作 CSS 文本读取,逐条固定钉住所依赖的声明:`position: sticky`、`top: 0`、直角圆角、两侧各自的 `z-index` 级别、折叠按钮高度与代码 banner 偏移共用的那一个量,以及不透明的 hover token——因为 jsdom 不计算 sticky 布局,渲染类测试也无法在某条声明被改动时变红。 - -真实浏览器证据由两条 keyless Chromium e2e 路径承载。`apps/web/tests/lifecycle-chrome.e2e.ts` 展开已结束轮次的 process 行、展开 Think 行,并断言其标题计算出 `position: sticky`、`top: 0px`(折叠时非 sticky);该 fixture 录制的 reasoning 只有一行,太短不足以溢出,所以它只证明 CSS 解析到了 Think 标题。`apps/web/tests/seeded-history.e2e.ts` 承载「钉住态随滚动」的证据:它种入一个摘要长度由本套件控制的压缩(一个围栏代码块加 40 个列表项),把视口压小以强制溢出,滚动到 marker 的钉住态,断言标题计算出 `position: sticky`、`top: 0px`、`z-index` 大于代码块 banner、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击),以及其 hover 底保持完全不透明(alpha 1)。同一用例还把摘要里的代码 banner 滚到它自己的钉住位置,断言 banner 停在标题带下方、且 banner 的 Copy 控件在它的中心点命中自身,使这条偏移不会退化成控件被盖住。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index ab88dedfc2..b0104c7e52 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.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/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: bdf646db9b1617ded7f50f4145887f34a52ae83c -2026-08-05-agent-teams.zh.md: 5386044edb57dad9fabf114d565808c735846464 +2026-08-05-agent-teams.md: d108d4f9833aca2a24c84d18d895e7ec63c0bfc2 +2026-08-05-agent-teams.zh.md: abf2ce3716e44effc8e34c0911cd9561d815aa08 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index bdf646db9b..d108d4f983 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -40,7 +40,7 @@ Fresh children have no inherited conversation. Fork children capture the Lead's Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds. -`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. +`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. @@ -72,6 +72,8 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. +Model-facing member identities and availability follow the [tool projection decision](../simplification/2026-09-15-model-agent-availability-and-team-targets.md); service residency and durable identity remain distinct. + ## Consequences The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 5386044edb..abf2ce3716 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -40,7 +40,7 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前 Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。 -`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 +`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 @@ -72,6 +72,8 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 +模型可见的成员身份与可用状态遵循[工具投影决策](../simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md);服务驻留状态和持久身份仍保持区分。 + ## Consequences Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。 diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml index c79578f47b..a8b84e3d55 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.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/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md -2026-08-06-manager-owned-subagent-settlement-delivery.md: 5553d4c06a6776f0ef025e19ae7f4f67fe277a56 -2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6d7ee756ad531e123508e8667adc4e5f99c2b9ea +2026-08-06-manager-owned-subagent-settlement-delivery.md: 7f6cfc90862b5bb32368b4b0e1fbe068ba0cbf66 +2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 23c5506f79d4d95308b0533e8d507ac856c4d85e diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md index 5553d4c06a..7f6cfc9086 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md @@ -90,7 +90,7 @@ The refusal and interruption wordings are pinned verbatim in unit tests rather t **Filter the canonical child output.** Removing nontext blocks in `AssistantOutputFold` would discard assistant content needed by SDK and UI consumers. Only the parent notice requires a text projection. -**Relax the Messages serializer.** Accepting or silently discarding invalid user-role blocks would conceal the producer's role conversion error. Notice construction supplies content that every parent provider can represent while protocol validation remains strict. +**Rely on serializer tolerance for notice construction.** Notice construction supplies text that every parent provider can represent; adapter filtering cannot replace that producer responsibility. The [saved-input compatibility decision](../bug-fix/2026-09-18-messages-input-history-compatibility.md) supersedes the rejection of Messages input filtering for already recorded notices, which a producer fix cannot change. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md index 6d7ee756ad..23c5506f79 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md @@ -90,7 +90,7 @@ Status: implemented **过滤规范的子级输出。** 在 `AssistantOutputFold` 中删除非文本块,会丢弃 SDK 和 UI 消费方需要的 assistant 内容。只有父级通知需要文本投影。 -**放宽 Messages 序列化器。** 接受或静默丢弃用户角色中的无效块,会掩盖生产方的角色转换错误。通知构建提供每种父级提供方都能表示的内容,协议校验仍保持严格。 +**依赖序列化器容错来构建通知。** 通知构建提供每种父级提供方都能表示的文本;适配器过滤不能替代生产方的责任。[已保存输入兼容决策](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)取代了对已记录通知进行 Messages 输入过滤的拒绝,因为生产方修复无法改变这些通知。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 56fb8a76d2..d5f37e0569 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.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/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: 6d0d2901b14c44930b4342292d317ebe03705730 -2026-08-26-generic-file-upload.zh.md: 2d78811c24ba1a801d43f21f4e63db5646b0431b +2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba +2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index 6d0d2901b1..ddaf92a396 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -48,3 +48,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- ## Testing Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. + +## Related + +The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 2d78811c24..27a73536ec 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -48,3 +48,7 @@ Status: implemented ## 测试 单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 + +## 相关记录 + +[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md deleted file mode 100644 index 6fa6194514..0000000000 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: Web clickable-link language — link alias, dotted hover underline, category glyphs - -Status: implemented - -English | [中文](2026-09-04-web-clickable-link-styles.zh.md) - -## Problem - -Clickable artifact links in the chat transcript wore four different costumes: markdown anchors and prose file mentions were business-primary blue with a solid hover underline, web search/fetch links matched that pair, produced-file chips were grey pills (label-secondary text on interactive-bg-hover, 96px max width), and workflow member links carried a resting solid underline. Nothing marked what a link opens (browser, host app, Finder, in-app view), and link color was coupled to `--dsw-alias-state-business-primary`, which also drives focus rings and state dots, so tuning link color risked unrelated surfaces. - -## Decision - -One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - -- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. -- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. -- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). -- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. - -Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. - -## Alternatives considered - -- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead. -- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category. -- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. -- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. - -## Consequences - -- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). -- Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first. -- mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md deleted file mode 100644 index 0b15a6c72d..0000000000 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: Web 可点击链接语言——链接别名、hover 点状下划线、分类图标 - -Status: implemented - -[English](2026-09-04-web-clickable-link-styles.md) | 中文 - -## 问题 - -会话记录里的可点击产物链接有四套互不一致的样式:Markdown 锚点和正文文件引用是 business-primary 蓝加 hover 实线下划线,网页搜索/抓取链接与之相同,产物 chips 是灰色药丸(interactive-bg-hover 底上的 label-secondary 文字,96px 最大宽度),workflow 成员链接则带常驻实线下划线。没有任何标记说明链接点开后去哪儿(浏览器、宿主应用、Finder、应用内视图),而且链接颜色耦合在 `--dsw-alias-state-business-primary` 上——它同时驱动焦点环和状态点,调链接色会波及无关表面。 - -## 决策 - -会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - -- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 -- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 -- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 -- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 - -覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 - -## 备选方案 - -- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。 -- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 -- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 -- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 - -## 后果 - -- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 -- 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。 -- mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index a2f18c11cb..702e3bf1fc 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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/feature/2026-09-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 0142b325004a1fc018e944ed908d600e75f0ab0d -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: d40ca8c790c9a7208a2243422c0973fc293a5915 +2026-09-05-sidebar-text-preview-and-file-tree.md: 8ddb1a336307011d8f6ce9bbe927f00642a83e60 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 294a2b456008837882f7f82e55e8e9e435254554 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 0142b32500..8ddb1a3363 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -20,7 +20,7 @@ Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the * The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open. -The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. +The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident. @@ -48,7 +48,7 @@ A failed read keeps content already shown and adds a localized failure with a re ### The file tree -`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [guide refinement](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns that presentation choice. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does. +`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes that presentation rule. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does. The root is the session's working directory as the Host reports it in the session list (`useSessions().byId[sessionId].cwd`), labelled by `workspaceTitleOf` from `dsh-util-workspace-path` — the final non-empty path segment — with the root string itself as the label when the path is separator-only. A session without a working directory shows one line (`noWorkspace`) and issues no request. There is no root chooser and no way to browse upward: the Host's `list` refuses paths outside the Session's workspace root, so the one directory the client can list is the one it shows. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index d40ca8c790..294a2b4560 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -20,7 +20,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。 -正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 +正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。 @@ -48,7 +48,7 @@ store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持 ### 文件树 -`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[引导页细化记录](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责这项展示选择。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。 +`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明这项展示规则。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。 根是 Host 在会话列表里上报的会话工作目录(`useSessions().byId[sessionId].cwd`),标签由 `dsh-util-workspace-path` 的 `workspaceTitleOf` 给出——路径最后一个非空段——路径只有分隔符时用根串本身作标签。没有工作目录的会话只显示一行(`noWorkspace`),不发请求。没有根选择器,也不能往上浏览:Host 的 `list` 拒绝会话工作区根之外的路径,所以客户端能列的那一个目录就是它显示的目录。 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml index 6be2091ff6..d4ef512e91 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.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/feature/2026-09-07-composer-session-stats-pills.md -2026-09-07-composer-session-stats-pills.md: 541e5a363d78c4333b1b8e3927928a19754822fa -2026-09-07-composer-session-stats-pills.zh.md: b6ef6da3c37a46d9e07e7ebdf77439e26896fddc +2026-09-07-composer-session-stats-pills.md: 50d3d3294ed012773657ba9849e45835c361d8be +2026-09-07-composer-session-stats-pills.zh.md: 97b91b601e62e8b06ab13a764babb4dd3b25bfce diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md index 541e5a363d..50d3d3294e 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md @@ -10,12 +10,15 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co ## Decision +The [performance and usage preference](2026-09-16-performance-usage-preference.md) governs statistic visibility and removes the completed-turn elapsed-time action. + `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed. -- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission. -- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy stays on the composer's ContextMeter ring, where it already lived beside `StatsLine` — the strip never carried it. +- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) owns the zero cache-write omission. +- **Shared information tier.** Composer stats and per-turn tail metadata use the secondary font size minus one pixel. Assistant-tail metadata uses the action icons' tertiary label color and starts after an extra 8px separation from the action cluster. +- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent. - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing. -- **`data-composer-stats` is a cross-package attribute contract.** The pills' root carries it; ui-conversation's `InputBar.module.css` `:has([data-composer-stats])` rule tightens the composer's bottom clearance to 4px when the row is mounted. The producer side pins the attribute in unit tests, following the `data-trigger-menu` precedent. +- **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding. ## Alternatives considered @@ -27,5 +30,5 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co - `ChatSnapshotBuilder`'s legacy slice now serves StatsPills; the [node-assembly note](../architecture/2026-08-09-client-conversation-node-assembly.md) tracks that consumer rename. - Exact token counts become reachable at all — one click — where `StatsLine` showed only compact totals; the strip itself carries only the two headline readings. -- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin the two-pill structure, and stats-paged-history pins the counts reading alone over a log with no billed tokens. +- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin time, billing, and context order below the submit action, and stats-paged-history pins the counts reading alone over a log with no billed tokens. - The `conversation.composer.dock` occupant in the generated slot catalog is `client-ui-chat StatsPills id 'stats'`. diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md index b6ef6da3c3..97b91b601e 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md @@ -10,12 +10,15 @@ Status: implemented ## 决定 +[性能与用量偏好](2026-09-16-performance-usage-preference.zh.md)管理统计可见性,并移除已完成轮次的耗时操作。 + `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。 -- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。 -- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍在输入框旁的 ContextMeter 圆环上,`StatsLine` 时代它就在那里——统计条从未承载过它。 +- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx)负责缓存写入为零时省略该行的规则。 +- **统一信息层级。** 输入框底部统计与单轮回答末尾信息都使用次级字号减 1px。回答末尾信息使用与操作图标一致的 tertiary 文字颜色,并与操作图标组额外拉开 8px。 +- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。 -- **`data-composer-stats` 是跨包属性契约。** pill 行根元素携带它;ui-conversation 的 `InputBar.module.css` 用 `:has([data-composer-stats])` 在该行挂载时把输入框底部留白收紧到 4px。生产方在单测里钉住该属性,沿用 `data-trigger-menu` 先例。 +- **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。 ## 备选方案 @@ -27,5 +30,5 @@ Status: implemented - `ChatSnapshotBuilder` 的 legacy 切片现在服务于 StatsPills;[节点装配 note](../architecture/2026-08-09-client-conversation-node-assembly.zh.md) 已跟进该消费者更名。 - 精确 token 计数第一次变得可达——一次点击即可;`StatsLine` 只展示过紧凑总量。统计条本身只承载两个头条读数。 -- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住双 pill 结构,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。 +- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住提交操作下方的时间、计费、上下文顺序,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。 - 生成的插槽目录中 `conversation.composer.dock` 占用者为 `client-ui-chat StatsPills id 'stats'`。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index ddba9ebf1e..961abe5984 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: c667ec3afdd9956282a532e748ad2e2488d26f5b -2026-09-07-deepseek-messages-adapter.zh.md: 0e3425e41d8a9585a1638eedeefcf6ead76a5a78 +2026-09-07-deepseek-messages-adapter.md: caec59feb3a84140427bd3ae8b738274c3bc5c47 +2026-09-07-deepseek-messages-adapter.zh.md: 030c91cb9a5f566ce09892bb054b63b1d243f6bb diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index c667ec3afd..caec59feb3 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -36,6 +36,6 @@ Both transports use the existing [request-extension registry](../architecture/20 ## Consequences -The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. +The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 0e3425e41d..030c91cb9a 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -36,6 +36,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU ## 结果 -该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 +该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml index a42e0ee0bd..93a617d08b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.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/feature/2026-09-08-composer-menu-sections-and-localized-rows.md -2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e -2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224 +2026-09-08-composer-menu-sections-and-localized-rows.md: 50b97ce2c7e59d53bf5607fef74a799a359d27c8 +2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 92bcb04bfe9b4f7b5581c80d7c0b843f98096e52 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md index 3cccb3c038..50b97ce2c7 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md @@ -18,9 +18,9 @@ A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/ The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands". -`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. +`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. Aliases and descriptions use a font one pixel below the primary labels. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. -The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. +The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 6 px draggable WebKit rail around a 2 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md index 31c879b17a..92bcb04bfe 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md @@ -18,9 +18,9 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列 「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。 -`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 +`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。别名与说明使用比主标签小 1px 的字号。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 -菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 +菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 6 px 的可拖动区域承载 2 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index bf3876efca..35f8f4dc56 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f -2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2 +2026-09-08-shared-file-type-icons.md: 5dbe0ade14c15e1d509f56eba6e0266c61089c46 +2026-09-08-shared-file-type-icons.zh.md: 9dbccdf10f188b30dcbbbe11d45b238af42ca112 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 1e3fe72679..5dbe0ade14 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -6,25 +6,25 @@ English | [中文](2026-09-08-shared-file-type-icons.zh.md) ## Problem -Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. The package-owned classifier provides the only extension table and deliberately folds detailed file types into six link categories for `LinkIconMedium`, while attachment cards, sent-message attachments, queued files, and workspace file rows use detailed file glyphs. Adding a precise file icon anywhere else would require another extension table or a runtime import between feature plugins. ## Decision `ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. -`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. +`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. The spreadsheet glyph uses the supplied unscaled 1.2px table grid; Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center, and the remaining marked traditional glyphs use 112%. The file body and folded corner retain their source geometry, and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for spreadsheets, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers use the spreadsheet glyph in file cards, rows, and preview titles; their clickable links use the document category. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. -`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. +`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [link styling rules](../../../../docs/web-styling.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIconMedium` because they are link surfaces. ## Alternatives considered -**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. +**Use `LinkIconMedium` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. -**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. +**Keep a second extension table beside `LinkIconMedium`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. **Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. @@ -32,7 +32,7 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac ## Testing -The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the supplied spreadsheet grid, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The `LinkIconMedium` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index fd1f38ebb3..9dbccdf10f 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -6,25 +6,25 @@ Status: implemented ## 问题 -客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。本包拥有唯一一份扩展名分类表,并为 `LinkIconMedium` 将细分文件类型有意折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行使用细分文件图形。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 ## 决策 `ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 -`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 +`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。表格图形使用所提供的未缩放 1.2px 表格网格;Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%。文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,表格使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers 在文件卡片、文件行及预览标题中使用表格图标,其可点击链接使用文档类别。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 -`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 +`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[链接样式规则](../../../../docs/web-styling.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行统一使用 `fileExtension`;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIconMedium`,因为它们属于链接表面。 ## 备选方案 -**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 +**所有文件表面都使用 `LinkIconMedium`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 -**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 +**在 `LinkIconMedium` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 **把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 @@ -32,7 +32,7 @@ Status: implemented ## 测试 -`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、所提供的表格网格、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。`LinkIconMedium` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md deleted file mode 100644 index f0f5cb478d..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Turn duration labels gain an hour unit - -Status: implemented - -English | [中文](2026-09-09-turn-duration-hour-unit.zh.md) - -## Problem - -The Web chat's turn duration labels counted minutes without bound. `formatRunDuration` in [message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) split elapsed milliseconds into seconds and minutes only, so a turn that ran for 90 minutes read `90分05秒` / `90m 05s` in all three places sharing the formatter: the `Deep diving...` running clock, the settled `Ran for {duration}` footer, and the turn-time dialog's total. The archived [turn run time decision](../../archived/feature/2026-08-03-web-turn-run-time.md) fixed the clock's anchor and the shared whole-second floor; it left the formatter at two units, which stops reading correctly once a turn crosses an hour. - -## Decision - -`formatRunDuration` carries an hour branch: elapsed time at or above 3600 seconds renders through `duration.hours` with zero-padded minutes and seconds — `1小时05分03秒` / `1h 05m 03s` — while everything below an hour keeps the existing second and minute branches unchanged. Hours appear only at or above 3600 seconds, so 3599 seconds still reads `59分59秒` and `60分00秒` never appears. Seconds are retained rather than dropped once hours appear, because the running clock ticks every second and a `1小时05分` label would sit still for a minute at a time. Negatives still clamp to zero and partial seconds still floor. `duration.hours` joins both dictionaries in [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts), and `RunDurationTranslate` widens to three keys. - -The change is confined to the turn formatter. `StatsPills.formatDuration` — the session-wide aggregate pill reading `45.2s` / `2m42s` — keeps its own two-unit format. - -## Alternatives considered - -**Pair hours with minutes only.** Dropping seconds at the hour boundary matches the `ui-jobs` job-duration format and keeps the label short. It loses the second-level figure from the settled footer, and it makes the live clock look frozen: the label would change once a minute while the turn is still running. - -**Three units while running, two once settled.** Rejected because both readings come from one function by design — the archived decision pins that — so the same turn would report different precision before and after it settles. - -**Change the aggregate pill in the same change.** `StatsPills.formatDuration` measures session-wide aggregates — LLM time, tool time, and average TTFT — from projections rather than one turn's boundaries. It can exceed an hour too, but folding it in would mix two independent formatters and their tests into one change. - -**Leave minutes unbounded.** `90分05秒` is technically correct and costs nothing to keep, but it is the reading that prompted this change and grows harder to parse the longer a turn runs. - -## Consequences - -Long turns now read in hours without new session events or new timing state; the labels remain derived from the logged `turn/start` and `turn/end` boundaries and keep the 15-second clock delay. The unit spec covers the 3599-second and 3600-second boundary plus the English template. No recorded-session snapshot changes: every shipped `Deep diving...` expectation is captured before the clock appears. diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md deleted file mode 100644 index c9c94eef2a..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 回合耗时标签增加小时单位 - -Status: implemented - -[English](2026-09-09-turn-duration-hour-unit.md) | 中文 - -## 问题 - -Web 聊天的回合耗时标签分钟数无界增长。[message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 里的 `formatRunDuration` 只把毫秒拆成秒和分两级,于是一个跑了 90 分钟的回合在共用一个格式化器的三处都读作 `90分05秒` / `90m 05s`:`Deep diving...` 后的实时计时、收尾的 `Ran for {duration}` 页脚,以及耗时详情弹窗里的总用时。已归档的[回合运行时长决策](../../archived/feature/2026-08-03-web-turn-run-time.md)确定了计时锚点和共用的整秒向下取整;它把格式化器留在两级单位,一旦回合跨过一小时就不再正确。 - -## 决定 - -`formatRunDuration` 增加小时分支:总时长达到或超过 3600 秒时经 `duration.hours` 渲染,分和秒补零——`1小时05分03秒` / `1h 05m 03s`——不足一小时的时长保持原有的秒、分两级不变。小时只在达到或超过 3600 秒时出现,因此 3599 秒仍读作 `59分59秒`,不会出现 `60分00秒`。出现小时后保留秒而不丢弃,因为实时计时每秒都在走,`1小时05分` 这样的标签会整整一分钟静止不动。负值仍然钳到 0,不足一秒仍然向下取整。[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) 的两套字典都新增 `duration.hours`,`RunDurationTranslate` 扩为三个键。 - -改动只限于回合计时器。`StatsPills.formatDuration`——会话级聚合胶囊,读作 `45.2s` / `2m42s`——保留自己的两级格式。 - -## 考虑过的替代方案 - -**小时只与分配对。** 在小时边界丢掉秒与 `ui-jobs` 的任务时长格式一致,标签也更短。代价是收尾页脚失去秒级数字,并且实时计时看起来像卡住了:回合还在跑,标签却每分钟才变一次。 - -**运行中三单位、收尾两单位。** 拒绝,因为两处读数按设计来自同一个函数——归档决策锁定了这一点——同一个回合会在结束前后报告不同的精度。 - -**在同一次改动里改聚合胶囊。** `StatsPills.formatDuration` 统计的是来自投影的会话级聚合值——LLM 耗时、工具耗时与平均 TTFT——而不是单个回合的边界。它同样可能超过一小时,但把它折进来会让两个独立的格式化器及其测试混进同一次改动。 - -**保留分钟无界。** `90分05秒` 在技术上没错,保留也不花成本,但它正是引发这次改动的读数,而且回合跑得越久越难解析。 - -## 后果 - -长回合现在按小时展示,没有新增会话事件或计时状态;标签仍由日志中的 `turn/start` 与 `turn/end` 边界派生,并保留 15 秒后才出现计时的延迟。单元 spec 覆盖 3599 秒与 3600 秒的边界以及英文模板。录制会话快照没有变化:所有已发布的 `Deep diving...` 期望都在计时出现之前就被捕获。 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md deleted file mode 100644 index 3b6b4c7fd0..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Connection indicator state and interaction refinements - -Status: implemented - -English | [中文](2026-09-10-connection-indicator-refinements.zh.md) - -## Problem - -The sidebar connection pill hid its affordance behind a hover swap: outage and retry-attempt states replaced their label with **Reconnect now** on hover or focus, so every state had to reserve the widest supplied label to keep the control from resizing. A retry that resolved in under a second flickered the connecting pill in and out, and state changes and unmounts jumped with no transition. - -## Decision - -**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. - -**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration. - -## Alternatives considered - -**Animating width changes.** A FLIP-style measured pixel transition (remember the old width, pin it, transition to the new measurement) needs a layout effect and imperative style writes; the fade-only change reads calm enough without them. - -**Swapping to the retry glyph only on hover.** Showing the retry glyph permanently states the affordance without requiring any pointer interaction, matching the static label; a hover cross-fade adds interaction-dependent state and conveys nothing extra. - -**Scaling on enter/exit.** A 0.98 scale beside the opacity fades reads as jitter at 12px text, so only opacity animates. - -**Naming manual and automatic attempts differently.** `ConnectionController.emitState` deduplicates repeated `connecting` states across backoff attempts, so the shell cannot observe attempt boundaries: a shell-held manual-retry flag either flips the label mid-hold or sticks across later automatic attempts. Distinguishing the copy correctly requires the connection layer to expose the attempt origin, which this change does not need — both attempt kinds read the same label. - -## Consequences - -`ConnectionIndicator`'s `reconnectLabel` prop and its size-reservation spans are removed from the pre-stable API; the sole consumer (`ui-settings-general`) is updated in the same change. `settings-root.client.spec.tsx` pins the 800ms hold, the single attempt label held steady through the hold, and the visibility-based confirmation window; `atoms.client.spec.tsx` pins the exit-duration unmount; `lifecycle-chrome.e2e.ts` and its ARIA golden replay the recovery flow in a real browser. Both packages' READMEs restate the interaction. diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md deleted file mode 100644 index 0bd5dc3e0b..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 连接指示器状态与交互细化 - -Status: implemented - -[English](2026-09-10-connection-indicator-refinements.md) | 中文 - -## Problem - -侧边栏连接药丸把操作提示藏在悬停切换里:断连与重试状态在悬停或聚焦时把文案替换为**立即重连**,因此每个状态都要为最宽的 label 预留空间以避免控件变形。一次不到一秒就恢复的重试会让连接中药丸闪现闪没,状态切换和消失也没有任何过渡、十分突兀。 - -## Decision - -**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 - -**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。 - -## Alternatives considered - -**给宽度变化加动画。** FLIP 式的像素测量过渡(记住旧宽度、钉住、过渡到新测量值)需要一个 layout effect 和命令式样式写入;纯淡入淡出已足够平静,无需这些。 - -**仅在悬停时切换为重试图形。** 常驻显示重试图形无需任何指针交互就说明了操作,与静态文案一致;悬停交叉渐变引入依赖交互的状态却不传达更多信息。 - -**进出场缩放。** 0.98 的缩放叠加在透明度淡入淡出上,在 12px 文字上读起来像抖动,因此只保留透明度。 - -**手动与自动尝试使用不同命名。** `ConnectionController.emitState` 会去重退避尝试之间重复的 `connecting` 状态,外壳观察不到尝试边界:外壳自持的手动重试标志要么在驻留中途翻转文案,要么在后续自动尝试中一直滞留。要正确区分文案需要连接层暴露尝试来源,而本次变更并不需要——两种尝试显示同一文案。 - -## Consequences - -`ConnectionIndicator` 的 `reconnectLabel` prop 及其占位 span 从 pre-stable API 中移除;唯一消费者(`ui-settings-general`)在同一变更中更新。`settings-root.client.spec.tsx` 固定 800ms 驻留、驻留期间保持不变的单一尝试文案,以及按可见时刻起算的确认窗口;`atoms.client.spec.tsx` 固定退出时长后的卸载;`lifecycle-chrome.e2e.ts` 及其 ARIA golden 在真实浏览器中回放恢复流程。两个包的 README 重述了该交互。 diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml deleted file mode 100644 index dd2b16d82c..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md -2026-09-10-guide-start-page-and-stat-pill-refinements.md: 987e46fd597b11046843a943c60187fdb1a769d0 -2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md: 590f18a2963507a8918a0aca30997aac23c6e477 diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md deleted file mode 100644 index 987e46fd59..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Guide start page and stat pill refinements - -Status: implemented - -English | [中文](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md) - -## Problem - -The right Sidebar's guide tab was a bare list of entry capsules: no visual anchor above them, a capsule could only say its title, and an entry whose type registered no glyph rendered with no icon at all, so a mixed list read as broken rather than sparse. Separately, the session token-usage dialog under the composer printed a `Cache write 0 tok` row for sessions that never wrote cache. - -## Decision - -**The guide is a compass over self-describing capsules.** This refines the guide contracts in [Right Sidebar tab types and navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) and [Sidebar text preview and file tree](2026-09-05-sidebar-text-preview-and-file-tree.md). [GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) draws a muted 56px compass hero over the entry capsules and no heading, as a browser start page shows its doors without a caption. [`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) gains an optional thunked `description` — read fresh on every render like `title`, so language changes need no re-registration. A capsule shows its description under the title only while the guide lists at most `MAX_DESCRIBED_ENTRIES` (4) entries; a longer list drops every description to stay light, so a type must stand on its title. The glyph rides the capsule's height: 22px beside a bare title, 26px beside two lines. - -**Icon-less entries fall back to a shipped cube placeholder.** The fallback is decided at the render site (`entry.icon ?? CubeGlyph`), not at registration, so every contributor — builtin or extension — gets it uniformly and a chain replacement of the body replaces the rule with it. `CubeGlyph` lives beside `CompassGlyph` in [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx): an isometric box in 1.1px straight strokes with rounded joins on `currentColor`, drawn on `--dsw-alias-label-tertiary` — one step quieter than a registered glyph's ink — to mark the slot as unclaimed. The files type registers a description and the shared folder glyph in [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx). - -**The session usage dialog drops the zero cache-write row.** This refines [Composer session stats](2026-09-07-composer-session-stats-pills.md). [StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) renders the `Cache write` row only when `cacheWriteTokens !== 0`, as the per-turn panel already drops its absent optional fields; the always-present buckets (input, cache read, output) keep their rows. - -## Alternatives considered - -**Register the cube in `ui-primitives`.** Its `icons/index.tsx` is the imported figma `ic_ds_*` set, and the cube has a single consumer; `CompassGlyph` set the precedent of package-local guide glyphs. - -**Default the icon at registration time.** A `?? default` inside the registry would hide the fallback from the body and make "registered no glyph" undetectable, losing the quieter placeholder ink; explicit render-site fallback keeps registrations honest. - -**Show `Cache write 0`.** A session on a provider that never writes cache would carry the row forever; zero here means "not a thing", not a measurement. - -## Consequences - -`description` is new pre-stable registry API; every consumer was updated (the files entry registers one). The 4-entry threshold is a shipped constant of the guide body, not configuration. Guide-body specs cover the placeholder (size and ink), the description threshold on both sides, and the registered-glyph path; chat-stats specs cover the dropped and present cache-write row. The `ui-sidebar-right` and `ui-sidebar-files` READMEs restate the guide rules. diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md deleted file mode 100644 index 590f18a296..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 引导起始页与统计 pill 的细化 - -Status: implemented - -[English](2026-09-10-guide-start-page-and-stat-pill-refinements.md) | 中文 - -## Problem - -右侧边栏的引导 tab 只是一列光秃秃的入口胶囊:上方没有视觉锚点,胶囊只能显示标题,而类型没注册图标的入口干脆不画图标,混合列表看起来像坏了而不是稀疏。另外,输入框下方的会话 token 用量弹窗会给从未写入缓存的会话打印一行 `Cache write 0 tok`。 - -## Decision - -**引导页是罗盘压着能自我说明的胶囊。** 本决议细化[右侧 Sidebar tab 类型与导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)和 [Sidebar 文本预览与文件树](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的引导页约定。[GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) 在入口胶囊上方画一枚弱化的 56px 罗盘,不加标题,如同浏览器起始页不给它的入口配说明文字。[`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) 新增可选的 thunk 化 `description`——与 `title` 一样每次渲染重新读取,语言切换无需重新注册。仅当引导页列出的入口不超过 `MAX_DESCRIBED_ENTRIES`(4)个时,胶囊才在标题下显示描述;更长的列表去掉所有描述以保持轻盈,所以类型必须靠标题立得住。图标随胶囊高度变化:单行标题旁 22px,两行旁 26px。 - -**没有图标的入口回退到内置的立方体占位符。** 回退在渲染点决定(`entry.icon ?? CubeGlyph`)而不在注册时,因此每个贡献者——内置或扩展——得到统一的占位符,链式替换 body 时规则随之整体替换。`CubeGlyph` 与 `CompassGlyph` 一起放在 [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx):一只等距视角的盒子,1.1px 直线描边、圆角拼接、走 `currentColor`,用 `--dsw-alias-label-tertiary` 着色——比注册图标的墨色浅一档——标记这个槽位无人认领。files 类型在 [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx) 注册了描述和共享的文件夹图标。 - -**会话用量弹窗去掉为零的缓存写入行。** 本决议细化[输入框下的会话统计](2026-09-07-composer-session-stats-pills.zh.md)。[StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) 仅当 `cacheWriteTokens !== 0` 时渲染 `Cache write` 行,与 per-turn 面板去掉缺失可选字段的做法一致;始终存在的桶(输入、缓存读取、输出)保留各自的行。 - -## Alternatives considered - -**把立方体注册进 `ui-primitives`。** 其 `icons/index.tsx` 是导入的 figma `ic_ds_*` 集合,而立方体只有一个消费者;`CompassGlyph` 已开了引导图标包内自持的先例。 - -**在注册时默认图标。** 注册表内部的 `?? default` 会对 body 隐藏回退,让"没注册图标"无法辨认,浅色占位墨色随之丢失;显式的渲染点回退让注册保持诚实。 - -**显示 `Cache write 0`。** 用不写缓存的 provider 的会话会永远挂着这一行;这里的零意味着"没有这回事",不是一次测量。 - -## Consequences - -`description` 是新的 pre-stable 注册表 API;所有消费者已同步更新(files 入口注册了一个)。4 个入口的阈值是引导 body 的内置常量,不是配置。guide-body 用例覆盖占位符(尺寸与墨色)、描述阈值两侧和已注册图标路径;chat-stats 用例覆盖缓存写入行的去除与保留。`ui-sidebar-right` 与 `ui-sidebar-files` 的 README 重述了引导页规则。 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml new file mode 100644 index 0000000000..16d943e2a3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml @@ -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/feature/2026-09-11-desktop-mandatory-update-client.md +2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f +2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md new file mode 100644 index 0000000000..d66d325736 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md @@ -0,0 +1,67 @@ +# Agent Note: Desktop mandatory-update client and blocking window + +Status: implemented + +English | [中文](2026-09-11-desktop-mandatory-update-client.zh.md) + +## Problem + +A local business server does not deliver remote minimum-version policy. The Desktop shell must independently learn that an update is required, block subsequent interaction without interrupting existing tasks, and retain a recovery path when policy queries or updater preparation fail. + +## Decision + +The shell polls the guest protocol in the [API proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md), independently of business traffic. Configuration and application identity are embedded at packaging; packaged applications ignore environment overrides. The [Desktop README](../../../../apps/desktop/README.md) owns configuration fields and defaults. Packaging requires the policy origin selected by the updater deployment before artifact preparation or signing. Test uses isolated Feishu authentication; production remains anonymous. The test login window opens detached DevTools on F12 while retaining its sandbox, navigation allowlist, and memory-only Session. Only unpackaged development can omit policy configuration. This prevents a missing policy setting from silently producing a release without mandatory checks or a test build querying production policy. + +A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided. + +User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. + +A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available. + +The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval. + +The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target. + +Background readiness requests parent-window taskbar attention on Windows or an informational Dock bounce on macOS and attempts one silent notification. Returning to the current confirmation clears native reminders without granting installation permission. Repeated state, deferral, and focus changes do not repeat a readiness reminder. Policy clearance, installation, and disposal also clear it. Unsupported or denied notifications leave the modal usable; native display requires installed-platform qualification. + +This decision implements the client portion of the [interaction proposal](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.md). That proposal and the API proposal remain active for backend integration, platform release qualification, and unresolved product decisions. The [release policy](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) retains artifact signing and publication requirements; none of these records is fully superseded. + +Host exit and successful task teardown are separate outcomes. The process owner reports confirmed but unsuccessful exit with a dedicated error type. Backend cleanup can complete for that outcome while installation still fails; the shell restores the current-version Host and requires fresh installation confirmation without clearing mandatory policy. The replacement gets a fresh Web origin and authentication cookie, so the existing application URL reloads after Host readiness and obtains new boot data even when the port is unchanged. Other stop failures retain the backend's cleanup failure and prevent replacement, because the old process may still own profile state. + +For confirmed unsuccessful teardown, localized recovery guidance remains separate from expandable technical details in both dialog types. The disclosure is closed initially and does not grant update permission. Diagnostics expose process lifecycle facts rather than arbitrary plugin stderr, which can contain credentials. The mandatory dialog preserves an expanded disclosure on unchanged state and collapses it when its diagnostic changes or clears. + +Task warnings describe running agents, queued input, and running or stopping jobs, not request traffic. After installation approval, the Host refuses new API requests, waits for admitted requests to finish, and checks tasks again. This preserves admitted writes without warning about page reads. A timed-out or superseded drain cannot authorize installation; the shell unlocks admission after failure. + +The product preload publishes semantic updater phase, version, progress, and classified failure fields without localized strings or raw diagnostics. Preparation failures carry a typed cause so native and Web presentations select their own locale's guidance without comparing translated messages. The account-row and collapsed-sidebar presentations resolve that data through the active Web locale, while native dialogs retain the Desktop shell locale. Intentional application quit hides the product window before Host teardown and ignores focus requests until exit, so the expected connection loss cannot appear as recovery work. + +## Alternatives considered + +**Use the system browser without a gateway handoff.** Its cookies are not shared with Electron requests. Explicit test deployments instead use a sandboxed BrowserWindow and a dedicated in-memory Session, without Node integration or a preload. Document navigation permits only the configured HTTPS policy origin and the reviewed Feishu origins; permissions, downloads, new windows, and HTTP authentication are denied. The gateway needs no new token-exchange endpoint. This trades persistent sign-in and unrestricted identity-provider navigation for isolation. The default anonymous protocol and updater transport are unchanged. + +**Keep DevTools disabled in the test login window.** This prevents local inspection of the third-party page but leaves authentication failures difficult to diagnose. F12 is an explicit local action on that window; ordinary login does not open DevTools. + +**Treat a completed login redirect as an update decision.** The Windows gateway probe changed from unauthenticated 401 to application-level 422 and reused its Session after closing the login window. That establishes same-process cookie transport, not compatibility with the Harness policy API. The client rechecks policy after login, retains known blocks on authentication expiry or malformed responses, and never uses the gateway-provided login URL. User confirmation precedes every login window. The initial packaged startup check can explain the authentication requirement before the local backend is ready; later automatic checks stay silent. One operation owns explanation, login, and recheck, so concurrent entry points cannot create competing login windows. + +**Make policy availability a precondition for ordinary update feedback.** A policy transport or protocol failure would suppress a valid updater result and make ordinary package delivery depend on the enforcement service. The two checks therefore share a user trigger but keep separate result handling; only an established block can preempt ordinary presentation. + +**Wait for business APIs to return a version error.** Desktop business traffic is local, so remote policy must have an independent guest request. + +**Use a cancellable ordinary message box.** It does not own persistent download progress, retry, page fallback, and policy refresh. A dedicated modal keeps these actions available without permitting cancellation of the requirement. + +**Terminate tasks when policy arrives.** Policy requires future interaction to stop, not already-running work. Installation approval remains the separate authorization to stop affected tasks. + +**Let the policy select or revoke updater packages.** This creates a second artifact authority and contradicts the approved higher-version replacement strategy. The updater alone selects and validates its version-bound package. + +**Keep technical details only in logs.** In-dialog disclosure lets users inspect a stop failure without locating local logs, while the default recovery message stays readable. + +**Warn for every in-flight API request.** Startup reads produce a task-interruption warning without affected tasks. Separating request draining from task inspection avoids that warning without classifying RPCs by URL or abandoning pending writes. + +**Send localized update strings through the preload.** The Desktop shell locale can differ from the language selected inside the Web application, and transported strings cannot react to a later language change. Semantic presentation data keeps user-selected Web language ownership with the component. + +**Keep the product window visible during ordinary quit.** Closing the owned Host first exposes its expected disconnect to the connection-recovery UI. Hiding the window before teardown preserves graceful process cleanup without presenting a retry action for intentional shutdown. + +## Consequences + +Ordinary check and download failures separate locale-owned summaries from expandable updater diagnostics. Hover feedback contains no raw error text; download hover identifies the operation and target version. Web update status follows the active in-application locale independently of native dialog language. During shell-reported installation, update status takes precedence over connection feedback because stopping the owned backend is expected. An installation failure clears that priority so genuine reconnection remains visible. Ordinary quit removes the product window before the same expected backend disconnect. Long ordinary diagnostics scroll independently of the confirmation action; disclosure does not authorize installation. + +The [local qualification](../testing/2026-09-10-desktop-local-updater-qualification.md) executes the actual Electron modal, restricted preload, renderer button handlers, network policy, and updater download. The main-entry regression verifies retained Host work and rejected business controls. A Chinese owner-local DOM expectation guards visible copy and actions. These tests do not establish production API compatibility, native browser/clipboard integration, complete-workspace visuals, signed installation, or post-restart health; [verification records](../../../../apps/desktop/tests/README.md) identify remaining evidence. diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md new file mode 100644 index 0000000000..fd6e751a40 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md @@ -0,0 +1,67 @@ +# Agent Note: Desktop 强更客户端与阻塞窗口 + +Status: implemented + +[English](2026-09-11-desktop-mandatory-update-client.md) | 中文 + +## 问题 + +本地业务 server 不会传递远程最低版本策略。Desktop 壳必须独立获知强更要求,在不中断现有任务的情况下阻止后续交互,并在策略查询或 updater 准备失败时保留恢复路径。 + +## 决策 + +壳独立于业务流量轮询[接口提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)中的游客协议。配置和应用身份在打包时嵌入;打包应用忽略环境覆盖。[Desktop README](../../../../apps/desktop/README.zh.md)负责配置字段与默认值。打包在准备产物或签名前要求提供由 updater 部署环境选定的策略源站。测试环境使用隔离的飞书鉴权;正式环境保持匿名。测试登录窗口按 F12 会打开独立的 DevTools,同时保留沙箱、导航允许列表和仅驻内存的 Session。只有未打包开发模式可以省略策略配置。这避免因遗漏策略配置而静默产出没有强更检查的发布包,或让测试包查询正式环境策略。 + +协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。 + +用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 + +壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。 + +模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。 + +恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。 + +后台就绪时,Windows 请求父窗口任务栏提醒,macOS 请求信息级 Dock 弹跳,并尝试一次无声通知。返回当前确认界面会清理原生提醒,不授予安装权限。重复状态、稍后更新和焦点变化不会重复提醒同一次就绪。策略解除、安装和资源释放也会清理提醒。通知不受支持或被拒绝时仍可使用弹窗;原生展示需通过安装包平台验收。 + +此决策实现[交互提案](../../proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md)的客户端部分。该提案与接口提案仍负责后端联调、各平台发布验收和未决产品事项。[发布策略](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)保留产物签名和发布要求;这些记录均未被完整取代。 + +Host 退出与任务成功收尾是两个独立结果。进程所有者通过专用错误类型报告已确认但非正常的退出。该结果允许后端完成清理,但安装仍失败;壳恢复当前版本 Host,并要求重新确认安装,不清除强更策略。替代 Host 会获得新的 Web 源站和鉴权 Cookie,因此 Host 就绪后会重新加载原有应用地址,即使端口未变也会重新获取启动数据。其他停止失败会保留后端清理错误并阻止替代进程启动,因为旧进程可能仍持有 profile 状态。 + +对于已确认的非正常收尾,两类弹窗都将本地化恢复提示与可展开的技术详情分开展示。详情初始折叠,不授予更新权限。诊断展示进程生命周期事实,而非可能包含凭据的任意插件 stderr。强更弹窗在状态未变时保留已展开的详情,诊断改变或清除时则折叠。 + +任务警告描述运行中的 agent、排队输入和运行中或停止中的后台任务,而非请求流量。用户批准安装后,Host 拒绝新 API 请求,等待已接收请求结束,再检查任务。这既保护已接收的写操作,也不因页面读取弹出警告。超时或被替代的等待不能授权安装;失败后壳解除准入锁。 + +产品预加载只发布语义化 updater 阶段、版本、进度和失败类别,不携带本地化字符串或原始诊断。准备阶段失败携带类型化原因,原生与 Web 界面分别选用自身 locale 的提示,无需比较翻译后的文案。账户行和侧栏收起状态通过当前 Web locale 解析这些数据,原生弹窗仍使用 Desktop 壳 locale。主动退出应用时,产品窗口会在 Host 收尾前隐藏,并在进程退出前忽略聚焦请求,避免把预期连接中断展示成恢复操作。 + +## 考虑过的替代方案 + +**没有网关交接机制时使用系统浏览器。** 其 Cookie 不会与 Electron 请求共享。显式启用的测试部署改用沙箱 BrowserWindow 和独立内存 Session,不启用 Node 集成或预加载。文档导航只允许配置的 HTTPS 策略源站及已审核的飞书源站;拒绝权限、下载、新窗口和 HTTP 鉴权。网关不需要新增 token 交换接口。这以跨重启登录及不受限的身份提供方导航换取隔离。默认匿名协议及 updater 传输不变。 + +**在测试登录窗口禁用 DevTools。** 这会阻止本地检查第三方页面,却使鉴权失败难以排查。F12 是该窗口上的显式本地操作;正常登录不会自动打开 DevTools。 + +**将登录重定向完成视为更新决定。** Windows 网关探针由未鉴权的 401 变为应用层 422,关闭登录窗口后仍复用 Session。这验证了同进程 Cookie 传输,并不证明与 Harness 策略 API 兼容。客户端在登录后重新检查策略,鉴权过期或响应畸形时保留已知阻塞,且从不使用网关提供的登录 URL。每次登录窗口都先经过用户确认。打包应用的首次启动检查可在本地后端就绪前说明鉴权要求;之后的自动检查保持静默。说明、登录和重新检查由同一个操作负责,使并发入口无法创建相互竞争的登录窗口。 + +**把策略可用作为常规更新反馈的前置条件。** 策略传输或协议失败会压制有效的 updater 结果,并使常规产物分发依赖强更服务。因此两个检查共用用户触发入口,但分别处理结果;只有已建立的强更状态可以抢占常规展示。 + +**等待业务 API 返回版本错误。** Desktop 业务流量在本地,因此远程策略必须有独立游客请求。 + +**使用可取消的常规消息框。** 它不负责持久下载进度、重试、页面兜底和策略刷新。专用模态窗口保留这些操作,同时不允许取消强更要求。 + +**策略到达时终止任务。** 策略要求停止后续交互,而非正在运行的工作。安装批准仍是停止受影响任务的独立授权。 + +**让策略选择或作废 updater 安装包。** 这会增加第二个产物权威,并违背已确认的高版本覆盖策略。只有 updater 选择和校验其版本绑定的安装包。 + +**技术详情仅保留在日志中。** 弹窗内的折叠详情让用户无需查找本地日志即可排查停止失败,同时保持默认恢复提示可读。 + +**对每个在途 API 请求都弹出警告。** 启动读取会在没有受影响任务时触发中断警告。将请求收尾与任务检查分开,可避免这种误报,也无需按 URL 分类 RPC 或丢弃未完成的写操作。 + +**通过预加载传递本地化更新文案。** Desktop 壳 locale 可能不同于 Web 应用内选择的语言,已传递的字符串也无法响应之后的语言切换。语义化展示数据让组件继续拥有用户所选 Web 语言下的文案。 + +**常规退出时保持产品窗口可见。** 先关闭自有 Host 会把预期断开展示到连接恢复界面。收尾前隐藏窗口既保留进程正常清理,也不会为主动关闭展示重试操作。 + +## 后果 + +常规检查和下载失败将语言字典提供的摘要与可展开的 updater 诊断分离。悬停提示不含原始错误文本;下载悬停提示说明操作和目标版本。Web 更新状态独立于原生弹窗语言,跟随应用内当前 locale。壳报告正在安装时,更新状态优先于连接反馈,因为停止自有后端是预期行为。安装失败会清除该优先级,使真实重连保持可见。常规退出会在发生同一预期后端断开前移除产品窗口。普通长诊断独立于确认操作滚动;展开详情不授予安装权限。 + +[本地验证](../testing/2026-09-10-desktop-local-updater-qualification.zh.md)执行真实 Electron 模态窗口、受限预加载、页面按钮处理、网络策略和 updater 下载。主入口回归验证 Host 工作保留和业务控制拒绝。所属测试目录内的中文 DOM 预期输出约束可见文案和操作。这些测试不证明生产 API 兼容性、原生浏览器/剪贴板集成、完整工作区视觉效果、签名安装或重启后健康状态;[验证记录](../../../../apps/desktop/tests/README.zh.md)列出尚缺证据。 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml index d01b653e42..f24e9aa057 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.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/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md -2026-09-13-macos-hidden-titlebar-vibrancy.md: b6b2f249a1843f937cf73d4eb193d47e1b9e8ad5 -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 8d4a4d17bda2e8c7845b66443cc883c6177b7343 +2026-09-13-macos-hidden-titlebar-vibrancy.md: 99ac240698fb229f9a0d66a8000251058b52491e +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 26bbb616485e49de2f70a3d142ea84dfd5bd7cb9 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md index b6b2f249a1..99ac240698 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md @@ -12,13 +12,13 @@ The desktop app drew the stock macOS titlebar: an opaque bar above the web UI th The Electron main process opens the main window on darwin with `titleBarStyle: 'hiddenInset'`, `trafficLightPosition: { x: 16, y: 18 }`, `vibrancy: 'sidebar'`, `visualEffectState: 'active'`, and a transparent `backgroundColor`. `'active'` keeps the material stable behind an unfocused window; `'followWindow'` washed the sidebar out on blur. -Every web-side adjustment keys off `html[data-platform]`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). The plain web and non-darwin desktop render exactly as before. +Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). These rules do not apply to plain Web or other desktop platforms. The [Windows caption decision](2026-09-16-windows-desktop-titlebar.md) owns its separate presentation. **Transparency chain.** Vibrancy shows only through transparent pixels: on darwin `html`/`body` (ui-web base.css) and the AppFrame are transparent, the center column paints `--dsw-alias-bg-base` opaque, and the sidebar column paints a translucent `color-mix` tint of the sidebar fill so the material reads through it. SidebarRoot's own opaque fill moves to the frame column for the same reason. **Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`. -**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen. +**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a single, root-scoped slot `conversation.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The resident header keeps the leading seat mounted on darwin with or without a selected Session so a hidden sidebar always has a reopen control on screen. **Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs. @@ -40,10 +40,10 @@ Every web-side adjustment keys off `html[data-platform]`, which only the desktop - The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets. - The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering. -- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches. +- `conversation.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches. - Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable. - The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash. ## Testing -ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet. +ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call with and without a selected Session. The ui-theme corner-shape and full-round style gates cover the new stylesheet. diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md index 8d4a4d17bd..26bbb61648 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md @@ -12,13 +12,13 @@ Status: implemented Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLightPosition: { x: 16, y: 18 }`、`vibrancy: 'sidebar'`、`visualEffectState: 'active'` 与透明 `backgroundColor` 打开主窗口。`'active'` 让窗口失焦时材质保持稳定;`'followWindow'` 会在失焦时把侧边栏冲淡。 -所有 Web 侧调整均以 `html[data-platform]` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。纯 Web 与非 darwin 桌面的渲染与之前完全一致。 +所有 macOS Web 侧调整均以 `html[data-platform='darwin']` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。这些规则不适用于纯 Web 或其他桌面平台。[Windows 顶栏决策](2026-09-16-windows-desktop-titlebar.zh.md)负责其独立呈现。 **透明链。** 毛玻璃只透过透明像素显现:darwin 上 `html`/`body`(ui-web base.css)与 AppFrame 透明,中间列铺不透明的 `--dsw-alias-bg-base`,侧边栏列铺侧边栏底色的半透明 `color-mix`,让材质透出。SidebarRoot 自身的不透明底色出于同一原因移到框架列。 **原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。 -**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。 +**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:single、root 作用域的 slot `conversation.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上常驻头部在选中或未选中 Session 时均保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。 **拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。 @@ -40,10 +40,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig - macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。 - 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。 -- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。 +- `conversation.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。 - 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。 ## Testing -ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。 +ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 选中或未选中 Session 时对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index c5ffd3e8e7..50233385bf 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.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/feature/2026-09-14-desktop-primary-runtime.md -2026-09-14-desktop-primary-runtime.md: d4dfd5fae400737de5ae852c05e2b6f117dc78f1 -2026-09-14-desktop-primary-runtime.zh.md: e64b6a53d6270f922e477fd844d611f8249581a4 +2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8 +2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index d4dfd5fae4..51bbaac89c 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -18,6 +18,8 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks libra macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. +Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion. + Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes. ## Alternatives considered @@ -28,6 +30,12 @@ Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped depe **Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable. +**Multi-file SignTool requests.** A software-certificate probe with a missing first file still signs the second file before returning an error. One process cannot guarantee stopping hardware operations at the first failure; token signing remains one file per supervised request. + +**Signing only the interpreter or bypassing native smoke.** Windows code integrity also evaluates DLLs and Python extensions. A signed launcher cannot make an unsigned extension load, and skipping execution would hide unusable installed dependencies. Preserving valid upstream signatures avoids unnecessary hardware operations and retains upstream attribution. + +Dependency manifests use electron-builder's own cleanup before inventory sealing. Its archive writer otherwise changes package metadata after signing; pre-applying that transformation keeps the final archive verifiable without replacing the sealed inventory after packaging. + ## Consequences The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index e64b6a53d6..4f27d61b5e 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -18,6 +18,8 @@ Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 +Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。 + Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。 ## Alternatives considered @@ -28,6 +30,12 @@ Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0 **独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。 +**多文件 SignTool 请求。** 软件证书探针在第一个文件不存在时,仍会先给第二个文件签名,再返回错误。单个进程无法保证首个失败后停止硬件操作;令牌签名仍按每个受监督请求处理一个文件。 + +**仅签名解释器或跳过本机 smoke。** Windows 代码完整性也检查 DLL 和 Python 扩展。启动程序有签名不能让未签名扩展被加载,跳过执行会隐藏安装后不可用的依赖。保留有效上游签名既减少硬件操作,也保留上游归属信息。 + +依赖清单在记录完整性清单前使用 electron-builder 自身的清理逻辑。否则其归档写入器会在签名后修改包元数据;提前应用该转换可让最终归档通过校验,无需在打包后替换已封装的完整性清单。 + ## Consequences 应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。 diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml new file mode 100644 index 0000000000..be51075feb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.i18n.yaml @@ -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/feature/2026-09-15-continuable-activation-capacity.md +2026-09-15-continuable-activation-capacity.md: a6403d10fd4bcfb73147a0482293429e108939e2 +2026-09-15-continuable-activation-capacity.zh.md: 75cf6ca7a352d503cfffef29416c0990f98185db diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md new file mode 100644 index 0000000000..a6403d10fd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.md @@ -0,0 +1,35 @@ +# Agent Note: Shared continuable Activation capacity + +Status: implemented + +English | [中文](2026-09-15-continuable-activation-capacity.zh.md) + +## Problem + +Depth limits bound nesting but permit wide concurrent delegation. Background Job limits do not cover continuable children, and a lifetime creation quota prevents useful later work after earlier children finish. + +## Decision + +The subagent service configures `maxActiveSubagents`, defaulting to 8. Each live non-continuable parent owns one process-local pool, shared by reference through uninterrupted continuable parent links. The pool owner itself is excluded. One-shot runs and external-provider work do not enter this pool. A one-shot intermediate parent starts a separate pool for its continuable children; cross-one-shot capacity inheritance is deferred. Delegation depth remains independently configured. + +The Activation registry reserves a unique slot before fresh or cold-resume reconstruction yields. The materialization owns rollback until the Activation owns the slot; unpublished rollback and failed materialization may both release the same token safely. Handle disposal precedes release, which precedes parent settlement notification. Sending to an existing Activation reuses its slot. + +Pool lookup, admission and release take amortized constant time. A weak root map does not retain dead root Agents; each pool holds only occupied tokens. No Session catalog scan, tree traversal, durable counter, or public capacity-query API is added. + +The Host registers the `subagent` settings section over its composition. Each reservation reads the current capacity, so lowering it never evicts resident children or rebuilds their registry. Delegation tools resolve an omitted depth from the same section at each attempt; explicit numeric and provider-managed tool policies retain priority. The GUI stages both numbers and resets each to composition through the existing revision-fenced settings path. Keeping counts in the pool and policy in settings avoids a second live counter or a settings-triggered teardown. + +## Alternatives considered + +A cumulative child count is a separate cost policy and does not release capacity after useful work finishes. Counting model requests would omit waiting parents, queued inbox work, reconstruction and cleanup, all of which retain resources. Scanning resident and pending maps adds work proportional to unrelated live trees. Separate counters require synchronization with slot ownership. + +Waiting for capacity can deadlock when every slot belongs to a parent waiting for a descendant. Full pools reject with an actionable diagnostic instead of retaining queued activation requests. + +## Consequences + +Idle Activations with pending inbox work or owned descendants still occupy slots. Cold resume can fail at capacity even though the historical child exists; browser prompts report this as `subagent/delivery-unavailable`. Slots do not impose a token or cumulative spending budget, and they do not coordinate multiple harness processes. + +The [continuable lifecycle decision](2026-07-28-continuable-subagent-conversations.md) retains ownership of settlement and child-first teardown. This capacity policy extends that lifecycle without changing durable Session data. + +## Verification + +Focused continuation tests exercise the default, invalid configuration, shared multi-level capacity, root isolation, pending creation, failure release, cold resume, resident messages and disposal. A keyless SDK-profile snapshot records successful background creation followed by an over-capacity tool diagnostic while the first child remains live. diff --git a/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md new file mode 100644 index 0000000000..75cf6ca7a3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-continuable-activation-capacity.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 共享的可续接 Activation 容量 + +Status: implemented + +[English](2026-09-15-continuable-activation-capacity.md) | 中文 + +## Problem + +深度限制约束嵌套层数,但仍允许大量并发委派。后台 Job 限制不覆盖可续接子代理,而生命周期累计创建配额会在早期子代理完成后阻止有用的后续工作。 + +## Decision + +Subagent 服务通过 `maxActiveSubagents` 配置容量,默认值为 8。每个存活的非可续接父代理拥有一个进程内池,通过连续的可续接父子关系按引用共享。池的所有者自身不计入。一次性运行和外部提供方工作不进入此池。一次性中间父代理为其可续接子代理建立独立的池;跨一次性代理的容量池继承暂不实现。委派深度仍独立配置。 + +Activation registry 在新建或冷恢复重建首次让出执行前预占唯一名额。在 Activation 接管名额前,由 materialization 负责回滚;未发布回滚和失败的 materialization 可以安全地释放同一个 token。handle 释放先于名额归还,名额归还先于父代理完成通知。向已有 Activation 发送消息复用其名额。 + +池查找、接纳和释放的摊还时间复杂度均为常数。根代理的弱引用映射不会保留已结束的根 Agent;每个池只持有已占用的 token。不增加 Session 目录扫描、树遍历、持久计数器或公开的容量查询 API。 + +Host 在组合配置之上注册 `subagent` 设置分节。每次预占读取当前容量,因此调低上限不会驱逐驻留子代理,也不会重建注册表。委派工具在每次尝试时从同一分节解析省略的深度;显式数值和 provider-managed 工具策略保留优先级。GUI 暂存两个数值,并通过现有的 revision 校验设置路径分别恢复组合默认值。池持有计数、设置持有策略,避免增加第二份在线计数或因设置变更而触发拆除。 + +## Alternatives considered + +累计子代理计数是另一种成本策略,不会在有用工作完成后释放容量。只统计模型请求会遗漏等待后代的父代理、收件箱排队内容、重建和清理,这些阶段都占用资源。扫描驻留和待创建映射会增加与无关存活任务树数量成正比的工作。独立计数器需要与名额所有权同步。 + +当所有名额都属于等待后代的父代理时,等待容量可能造成死锁。池满时返回可操作诊断,不保留排队的激活请求。 + +## Consequences + +有待处理收件箱内容或所拥有后代的空闲 Activation 仍占名额。容量耗尽时,即使历史子代理存在,冷恢复也可能失败;浏览器消息将其报告为 `subagent/delivery-unavailable`。名额不构成 token 或累计费用预算,也不协调多个 Harness 进程。 + +[可续接生命周期决策](2026-07-28-continuable-subagent-conversations.zh.md) 仍负责完成判定和先子后父的清理。本容量策略扩展该生命周期,不修改持久 Session 数据。 + +## Verification + +聚焦的续接测试覆盖默认值、非法配置、多层共享容量、根代理隔离、待完成创建、失败归还、冷恢复、驻留消息和释放。免密 SDK profile 快照记录后台创建成功,以及首个子代理仍存活时再次创建所产生的超限工具诊断。 diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml new file mode 100644 index 0000000000..f6c0f88653 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml @@ -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/feature/2026-09-15-docs-platform-command-tabs.md +2026-09-15-docs-platform-command-tabs.md: 4d44d1191a7ffc1601e15aa4d9f119a90e06b0dd +2026-09-15-docs-platform-command-tabs.zh.md: 450765b52d1507fa9b9e6a20a1854e6861655838 diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md new file mode 100644 index 0000000000..4d44d1191a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md @@ -0,0 +1,31 @@ +# Agent Note: Platform command tabs in the Python SDK guide + +Status: implemented + +English | [中文](2026-09-15-docs-platform-command-tabs.zh.md) + +## Problem + +The [Python SDK guide](../../../../docs/user/guide/python-sdk.md) gives different commands for POSIX shells and Windows PowerShell. Showing both versions in sequence makes readers skip an alternative at each step and can make alternative commands look cumulative. + +## Decision + +Each of the four platform-dependent steps uses VitePress's native `code-group`: Linux/macOS first, Windows PowerShell second. Each group selects independently and starts on Linux/macOS. Credential setup and running the example remain separate sequential steps. Shared Python and YAML examples stay directly visible. + +Both languages retain every command in canonical Markdown. The documentation projector preserves the groups and their code in the website's raw Markdown. Explicit anchors before each group preserve the former platform heading targets; following a platform anchor locates the group without selecting a tab. + +The theme adds a visible keyboard focus outline to native radio labels and displays both command blocks in search excerpts, whose tab controls are unavailable. A Markdown renderer adapter makes each native tab strip a form with submission prevented. Local search copies a section's rendered HTML, including radio names; separate form owners stop those copies from clearing the page's selection. VitePress owns selection, syntax highlighting, and copying the active code block. MPA builds omit those client handlers, so build-only styles display both command blocks and hide the tab strip and copy buttons. There is no shared selection state or stored preference. + +## Alternatives considered + +**Keep vertically repeated platform sections.** Both versions are immediately visible, but the repeated alternatives interrupt the guide's ordered steps. + +**Build a custom tab component with shared or persistent selection.** A Windows reader could select once, but shared state couples independent examples and introduces selection and lifecycle code beyond the required presentation. Native groups keep the interaction local. + +## Consequences + +SPA readers see one command version per step while MPA, repository, and raw-Markdown readers retain both. Windows readers select PowerShell in each group. Existing platform links still reach the corresponding step, although the platform names leave the page outline. + +Verification covers command preservation, bilingual pairing, projected and raw Markdown, and actual browser switching, copying, keyboard focus, search, themes, and narrow layouts. Framework-owned interaction is exercised on the running site; session replay is inapplicable to this documentation-only presentation. + +The focused renderer regression checks radio ownership when local search duplicates a section. The adapter depends on VitePress's native tab-strip markup and fails the build if that markup changes; a framework upgrade must retain the search isolation and browser checks. diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md new file mode 100644 index 0000000000..450765b52d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Python SDK 指南的平台命令标签 + +Status: implemented + +[English](2026-09-15-docs-platform-command-tabs.md) | 中文 + +## 问题 + +[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md)分别提供 POSIX shell 和 Windows PowerShell 命令。连续显示两个版本使读者在每一步都要跳过另一平台,也容易把二选一的命令理解为需要依次执行。 + +## 决策 + +四个平台相关步骤分别使用 VitePress 原生 `code-group`:Linux/macOS 在前,Windows PowerShell 在后。各组独立选择,初始均显示 Linux/macOS。配置凭据和运行示例仍是两个顺序步骤。共用的 Python 和 YAML 示例保持直接可见。 + +两种语言均在规范 Markdown 中保留每条命令。文档投影器在网站原始 Markdown 中保留分组及代码。各组前的显式锚点保留原有平台标题的链接目标;平台锚点定位到分组,但不选择标签。 + +主题为原生单选按钮标签补充可见的键盘焦点轮廓,并在无法操作标签的搜索摘要中显示两个命令块。Markdown 渲染适配器将各原生标签栏设为禁止提交的表单。本地搜索会复制章节渲染后的 HTML,其中包含单选按钮名称;独立的所属表单可避免副本清除页面的选择。VitePress 负责选择、语法高亮和复制当前代码块。MPA 构建不包含这些客户端处理器,因此构建专用样式显示两个命令块,并隐藏标签栏与复制按钮。不引入共享选择状态或持久化偏好。 + +## 考虑过的替代方案 + +**保留纵向重复的平台章节。** 两个版本都立即可见,但重复的备选命令会打断指南的顺序步骤。 + +**自建标签组件并共享或持久化选择。** Windows 读者只需选择一次,但共享状态会耦合独立示例,并引入超出所需展示功能的选择与生命周期代码。原生分组将交互限定在各组内。 + +## 影响 + +SPA 读者每一步只看到一个命令版本,MPA、仓库和原始 Markdown 读者仍能获取两个版本。Windows 读者需要在各组中选择 PowerShell。原有平台链接仍到达对应步骤,但平台名称不再出现在页面大纲中。 + +验证覆盖命令保留、双语配对、投影和原始 Markdown,以及真实浏览器中的切换、复制、键盘焦点、搜索、主题和窄屏布局。框架负责的交互在运行中的站点验收;会话回放不适用于这项纯文档展示变更。 + +聚焦的渲染回归验证本地搜索复制章节时的单选按钮归属。适配器依赖 VitePress 原生标签栏的 HTML,结构变化时会使构建失败;框架升级必须保留搜索隔离和浏览器检查。 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml new file mode 100644 index 0000000000..182ff386d9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml @@ -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/feature/2026-09-15-markdown-file-preview-links.md +2026-09-15-markdown-file-preview-links.md: d9d535afc5b005ae453bd7623e05c411e4d6932b +2026-09-15-markdown-file-preview-links.zh.md: 63c18e284b785ed5686721a0b04cf73c78c0ceb3 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md new file mode 100644 index 0000000000..d9d535afc5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -0,0 +1,33 @@ +# Agent Note: Markdown file preview links + +Status: implemented + +English | [中文](2026-09-15-markdown-file-preview-links.zh.md) + +## Problem + +Assistant explanations link to existing source files that the turn does not modify or deliver. Restricting clickable references to produced-file mentions prevents readers from opening those sources beside the answer. + +## Decision + +Settled Assistant Markdown passes explicit local link destinations to the Chat file opener through `MarkdownDelegateProvider`. The provider owns both file and HTTP(S) navigation callbacks; nested link components read its current callbacks without adding navigation props to intermediate renderers. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path. + +The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert. + +The Web file-reference prompt asks for a link on every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Names default to a basename or clear alias with minimal disambiguating parents. Precise labels use `filename:24` or `filename:24–30`, while destinations keep the parser's `#L24` or `#L24-L30` syntax. The renderer preserves model-authored text; it does not rewrite labels to enforce the prompt. + +## Alternatives considered + +**Relative browser anchors.** They navigate the application URL instead of requesting Session file content. + +**Require a produced-file entry.** This excludes ordinary read-only explanations. + +**Add a preview service.** Chat already supplies the required opener and line parameter. + +**Thread navigation callbacks through renderer props.** Every new navigation capability would enlarge unrelated intermediate interfaces. Reading the scoped delegate at link components also lets cached Markdown observe handler changes. + +**Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation. + +## Consequences + +Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing, shared file and HTTP(S) routing, nested scopes, and callback replacement or removal; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md new file mode 100644 index 0000000000..63c18e284b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Markdown 文件预览链接 + +Status: implemented + +[English](2026-09-15-markdown-file-preview-links.md) | 中文 + +## Problem + +Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若仅允许产出文件提及成为可点击引用,读者就无法在回答旁打开这些源码。 + +## Decision + +落定后的 Assistant Markdown 通过 `MarkdownDelegateProvider` 将显式本地链接目标传给 Chat 文件打开器。Provider 同时拥有文件及 HTTP(S) 导航回调;内部链接组件读取其当前回调,中间渲染器无需增加导航 props。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。 + +现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。 + +Web 文件引用提示词要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。名称默认使用 basename 或清楚的别名,仅添加消除歧义所需的父目录。精确标签使用 `filename:24` 或 `filename:24–30`,目标仍使用解析器支持的 `#L24` 或 `#L24-L30` 语法。渲染器保留模型原文,不通过重写标签强制满足提示词。 + +## Alternatives considered + +**相对浏览器链接。** 这会跳转应用 URL,而非请求 Session 文件内容。 + +**要求产出文件条目。** 这会排除普通只读讲解。 + +**增加预览服务。** Chat 已提供所需打开器和行号参数。 + +**通过渲染器 props 逐层传递导航回调。** 每种新增导航能力都会扩大无关的中间接口。链接组件读取作用域内的 delegate,也让缓存的 Markdown 能响应回调变化。 + +**文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。 + +## Consequences + +源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析、共享的文件与 HTTP(S) 路由、嵌套作用域及回调替换或移除;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。 diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml new file mode 100644 index 0000000000..7614cd0318 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml @@ -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/feature/2026-09-16-open-in-default-app-for-sidebar-files.md +2026-09-16-open-in-default-app-for-sidebar-files.md: fa7506be77e8a62e5dfd97b59de6ebc4be510ae0 +2026-09-16-open-in-default-app-for-sidebar-files.zh.md: af124182dc1b38658ff55736277e499e963a6281 diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md new file mode 100644 index 0000000000..fa7506be77 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md @@ -0,0 +1,29 @@ +# Agent Note: Open Sidebar-previewed files in the default application + +Status: implemented + +English | [中文](2026-09-16-open-in-default-app-for-sidebar-files.zh.md) + +## Problem + +Opening a file in its Host default application or showing it in the file manager existed only on the delivery cards, through routes and a fetch controller private to `ui-deliverables` and addressed by Session, declaration-event sequence, and file index. The right Sidebar's document preview offered no native handoff: a video, an archive, an office document, a file the reader rejected as non-text, or an oversized file dead-ended on an explanation, with Retry as the only control even where a second read could not help (issue #3932). Those files have no delivery event to address, so the existing plumbing could not serve a second surface. A first implementation (PR #4120, by Yifffan) added a new `ui-open-locally` package with its own Host routes and moved the delivery cards onto it; product review asked instead that file opening live in the existing open-in-app feature and accepted an intermediate state for this iteration. + +## Decision + +[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.md) owns the browser controls. The document preview of [ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.md) declares two Session-scoped list child slots whose owner props carry the file’s absolute execution-environment path, rendered only once the file's metadata reports that path: `sidebar.right.tab.document.actions` after the header's own controls, in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` where Retry would stand. The preview classifies an empty-state read failure: `not-text` and `too-large` are readable files the preview cannot render and offer the unpreviewable slot; `not-found` and `not-regular-file` offer only their explanation; every other failure keeps Retry because a second read may resolve it. The unsupported-suffix empty state, which never reads, offers the same slot. + +ui-open-in-app fills both slots: an "Open ▾" split button whose main button opens the file in the default application and whose menu adds the file-manager reveal, and an "Open in default app" empty-state button. Neither control renders until the Host answered that a desktop exists. The controls call the Session Remote the Host already publishes, `session.canOpenWorkspacePath` once per page and `session.openWorkspacePath` per gesture with the absolute path and, for reveal, `action: 'reveal'`; no new Host route exists. Both controls share one gesture hook: only the control the user pressed disables while its call settles, a failed call resolves as a failure kind, and that control announces it once through a transient toast, so no failure state persists on a control. + +## Alternatives considered + +**A new `ui-open-locally` package with its own Host routes** (PR #4120) is where the slot pair, the failure classification, the split-button design, and the toast rule come from. It was set aside because the repository already has an open-in-app feature and a second package for opening things locally duplicates it; the shared Session Remote owns verification of the Host path through the composed filesystem. + +**Host routes in `dsh-host-open-in-app`** serving desktop facts and a path open would let the Host name the file manager and distinguish a missing file from a failed launch. They would make the Host half depend on `sessionController`, `workspaceFiles`, and `fs` for two calls the Session Remote already publishes, so the Remote is used directly and the reveal label stays generic. + +**Moving the delivery cards onto these controls now** would repeat the largest part of PR #4120, including the cross-plugin runtime import its review rejected and the recorded delivery scenarios it had to migrate. The cards keep their own routes this iteration. + +## Consequences + +Every previewed file with a verified Host mapping can be opened locally from the Sidebar, including files that were never delivered. Paths without that mapping are refused before native opening. The delivery cards and the preview run two openers over one Host capability; unifying them, and deciding per surface between the full application list and the default application only, is the next step the product direction names. The reveal menu item says "Show file location" on every platform because the Session Remote reports no file manager. Default-application open carries the Host's file-association execution semantics for what it opens; the guard is the explicit user gesture on a file the Session can already read, and no new Host authority was granted. + +Unit tests cover the failure classification, the preview's rendering of each recourse and its withholding of the seats before a Host path is known, the path controller's desktop read and gesture outcomes, both controls' visibility, busy isolation, and toast, and the plugin's registration and fiber-disposal removal. The keyless Web document-preview scenario drives the unsupported-suffix controls against a stubbed opener on POSIX hosts, and the seeded-history file-preview golden pins the header control. diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md new file mode 100644 index 0000000000..af124182dc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md @@ -0,0 +1,29 @@ +# Agent Note:用默认应用打开 Sidebar 预览的文件 + +Status: implemented + +[English](2026-09-16-open-in-default-app-for-sidebar-files.md) | 中文 + +## 问题 + +用 Host 默认应用打开文件、或在文件管理器中显示文件,此前只存在于交付卡片上,走的是 `ui-deliverables` 私有的路由与 fetch 控制器,按 Session、声明事件序号和文件下标定位。右侧 Sidebar 的文档预览没有任何本地交接:视频、压缩包、office 文档、被读取器判为非文本的文件、过大的文件,都停在一行说明上,唯一的控件是重试,哪怕再读一次也无济于事(issue #3932)。这些文件没有交付事件可供定位,所以现有管线无法服务第二个界面。第一版实现(PR #4120,作者 Yifffan)新增了 `ui-open-locally` 包,自带 Host 路由,并把交付卡片也迁了过去;产品评审要求改为把文件打开放进现有的 open-in-app 功能,并接受本期先落一个中间态。 + +## 决定 + +[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.zh.md) 拥有浏览器侧的控件。[ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 的文档预览声明两个 Session 作用域的 list 子 slot,owner props 携带文件在执行环境中的绝对路径,只在文件元数据报出该路径后渲染:`sidebar.right.tab.document.actions` 在每个显示头部的状态里跟在头部自有控件之后,`sidebar.right.tab.document.unpreviewable` 占据原本放重试的位置。预览对空态的读取失败分类:`not-text` 与 `too-large` 是可读但预览无法渲染的文件,提供 unpreviewable slot;`not-found` 与 `not-regular-file` 只给说明;其余失败保留重试,因为再读一次可能解决。不支持后缀的空态不发起读取,提供同一个 slot。 + +ui-open-in-app 填满这两个 slot:一个「打开 ▾」分体按钮,主按钮用默认应用打开文件,菜单再加文件管理器定位;一个空态的「用默认应用打开」按钮。Host 回答有桌面之前,两个控件都不渲染。控件调用 Host 早已发布的 Session Remote:每页调用一次 `session.canOpenWorkspacePath`,每个手势调用一次 `session.openWorkspacePath`,带上绝对路径,定位时再带 `action: 'reveal'`;没有新增任何 Host 路由。两个控件共用一个手势 hook:只有用户按下的那个控件在调用结算期间禁用,失败的调用解析为一个失败种类,由该控件通过短暂 toast 提示一次,控件上不残留失败状态。 + +## 考虑过的备选 + +**新建自带 Host 路由的 `ui-open-locally` 包**(PR #4120)是这对 slot、失败分类、分体按钮设计和 toast 规则的来源。放弃它是因为仓库已经有 open-in-app 功能,再来一个负责本地打开的包属于重复;共享的 Session Remote 负责通过当前文件系统验证 Host 路径。 + +**在 `dsh-host-open-in-app` 里加 Host 路由**提供桌面信息和按路径打开,能让 Host 报出文件管理器的名字,并区分文件不存在和启动失败。但这会让 Host 半边为了 Session Remote 已经发布的两个调用而依赖 `sessionController`、`workspaceFiles` 与 `fs`,所以直接使用 Remote,定位标签保持通用说法。 + +**现在就把交付卡片迁到这些控件上**会重做 PR #4120 里最大的那部分,包括评审否决的跨插件运行时导入,以及它不得不迁移的交付录制场景。本期卡片保留自己的路由。 + +## 后果 + +每个有经过验证的 Host 映射的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。没有这种映射的路径会在原生打开前被拒绝。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。 + +单元测试覆盖失败分类、预览对每种出路的渲染以及在 Host 路径未知时对座位的保留、路径控制器的桌面读取与手势结果、两个控件的可见性、忙碌隔离与 toast,以及插件的注册与 fiber 释放时的移除。无密钥的 Web 文档预览场景在 POSIX 主机上用桩打开器驱动不支持后缀的控件,seeded-history 的文件预览 golden 固定住头部控件。 diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml new file mode 100644 index 0000000000..2da6cd2bfa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml @@ -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/feature/2026-09-16-performance-usage-preference.md +2026-09-16-performance-usage-preference.md: 36ab30561fccc6c824ee8483f5a533a9178f11c5 +2026-09-16-performance-usage-preference.zh.md: ecfd19c41c861116d18829b355bf8c8df4ea929a diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md new file mode 100644 index 0000000000..36ab30561f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md @@ -0,0 +1,31 @@ +# Agent Note: Performance and usage detail preference + +Status: implemented + +English | [中文](2026-09-16-performance-usage-preference.zh.md) + +## Problem + +Always-visible counts and accounting controls add density to Chat even when readers only need output speed and cache effectiveness. + +## Decision + +Chat owns `ui-chat.performanceUsage` in its existing settings namespace. General Settings offers Compact and Detailed, with Detailed as the default to preserve access to accounting. Both the composer and turn-tail renderer consume the same accepted settings source through framework-bound hooks. Host settings own persistence, validation, and failed-write recovery. + +Compact retains only available output speed and cache-hit percentage under the composer. These readings are plain text with no statistics dialog. Detailed retains session statistic dialogs and per-Turn usage. Neither mode renders the elapsed-time action in the completed-turn footer. This preference changes presentation, never telemetry collection, accounting, or Session events. + +The [composer statistics decision](2026-09-07-composer-session-stats-pills.md) remains authoritative for accounting sources, dialog exclusivity, and composer spacing in Detailed mode. + +The requested footer design prioritizes message actions and token accounting in both modes. Completed turns therefore expose no per-turn elapsed-time control; Detailed restores accounting detail, not that control. The running clock and session-wide timing statistics remain independent of this choice. + +## Alternatives considered + +**Default to Compact.** This would hide accounting controls for existing users without an explicit choice. The request defines both modes but no default, so Detailed preserves their access. + +**Filter telemetry when Compact is selected.** This would discard information needed after switching to Detailed or reopening history. A display preference must not change recorded data. + +## Consequences + +Unavailable speed or cache-hit data is omitted rather than replaced with invented values. The settings scope provides one accepted value to all consumers without duplicating subscription state. Component coverage checks mode-specific visibility; the [recorded-session browser scenario](../../../../apps/web/tests/message-actions.e2e.ts) exercises selection, reload persistence, and Compact output. + +On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers. diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md new file mode 100644 index 0000000000..ecfd19c41c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md @@ -0,0 +1,31 @@ +# Agent Note:性能与用量详细程度偏好 + +Status: implemented + +[English](2026-09-16-performance-usage-preference.md) | 中文 + +## Problem + +当读者只需要输出速度和缓存效果时,始终显示的计数和记账控件会增加聊天界面的密度。 + +## Decision + +Chat 在既有设置命名空间中拥有 `ui-chat.performanceUsage`。通用设置提供简洁和详细选项,默认详细以保留记账入口。输入框和轮次页脚渲染器通过框架绑定的 Hook 消费同一个已接受设置源。Host 设置负责持久化、校验和写入失败恢复。 + +简洁模式仅在输入框下方保留可用的输出速度和缓存命中率。这些读数为纯文本,不提供统计卡片。详细模式保留会话统计卡片和每轮用量。两种模式均不渲染已完成轮次页脚中的耗时操作。该偏好只改变展示,不改变遥测收集、记账或 Session 事件。 + +[输入框统计决策](2026-09-07-composer-session-stats-pills.zh.md)仍负责详细模式中的记账来源、卡片互斥和输入框间距。 + +所要求的页脚设计在两种模式下均优先展示消息操作和 token 记账。因此,已完成轮次不提供单轮耗时控件;详细模式恢复记账详情,不恢复该控件。运行中的时钟和会话级计时统计独立于此选择。 + +## Alternatives considered + +**默认简洁。** 这会在已有用户未明确选择时隐藏记账控件。需求定义了两种模式但未指定默认值,因此详细模式保留既有入口。 + +**选择简洁时过滤遥测。** 这会丢弃切换回详细模式或重新打开历史时需要的信息。展示偏好不能改变记录数据。 + +## Consequences + +不可用的速度或缓存命中数据会被省略,不填入虚构值。设置作用域为所有消费者提供同一个已接受值,无需复制订阅状态。组件覆盖检查不同模式的可见性;[录制会话浏览器场景](../../../../apps/web/tests/message-actions.e2e.ts)验证选择、刷新持久化和简洁模式输出。 + +在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 diff --git a/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml new file mode 100644 index 0000000000..e98c3c4e39 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.i18n.yaml @@ -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/feature/2026-09-16-shared-subagent-settings-card.md +2026-09-16-shared-subagent-settings-card.md: d9457b3e9b9786d77dcced4cac8ca1086914699a +2026-09-16-shared-subagent-settings-card.zh.md: ccde0c97df24239b4e5113fecbaec6e85f19b145 diff --git a/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md new file mode 100644 index 0000000000..d9457b3e9b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.md @@ -0,0 +1,29 @@ +# Agent Note: Shared Subagent settings card + +Status: implemented + +English | [中文](2026-09-16-shared-subagent-settings-card.zh.md) + +## Problem + +Delegation limits and model authorization describe the same Subagent workflow, but separate settings cards make users locate and save them independently. + +## Decision + +One Subagent page groups delegation limits and model selection, with one save button. Leaving the page discards both drafts. Both existing controllers retain ownership of their drafts, validation and namespace writes. The card blocks saving if either draft is invalid or conflicted, disables both sections during saving, and stays open after both settle successfully. A failed section retains its draft for retry. Limit explanations stay behind information buttons beside the field labels, with concrete depth examples and counting rules. Validation errors remain visible so disclosure does not hide a blocked save. + +The plugin registers one `plugins.item` entry while either namespace is served. Its component renders only the available sections, preserving limits-only and model-only deployments without teaching the Plugins page about Subagent grouping. + +## Alternatives considered + +**Keep separate cards.** Independent controls obscure the relationship between delegation policy and model authorization and require separate save gestures. + +**Merge Host namespaces.** UI grouping needs no change to persisted settings, namespace revisions or the different times these policies take effect. The existing namespace controllers preserve those contracts. + +## Consequences + +The user reviews both sections in one place. A save remains separate namespace writes, so partial success is possible; successful drafts clear while rejected drafts remain visible and retryable. Model authorization still writes its switch and routes atomically within its own namespace. The [package reference](../../../../packages/client/ui-settings-plugins/README.md) describes the controls and their applicability. + +## Verification + +Focused component and controller tests cover shared validation, save, discard on leaving, pending writes, partial failure and deployments serving either namespace. The assembled Web scenario saves both sections through one button and reads back the settings document. diff --git a/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md new file mode 100644 index 0000000000..ccde0c97df --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-shared-subagent-settings-card.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 统一 Subagent 设置卡片 + +Status: implemented + +[English](2026-09-16-shared-subagent-settings-card.md) | 中文 + +## Problem + +委派限制与模型授权描述的是同一套 Subagent 工作流程,但分开的设置卡片让用户必须分别查找和保存。 + +## Decision + +一个 Subagent 页面组织委派限制与模型选择,共用一个保存按钮。离开页面会丢弃两个草稿。两个现有控制器仍各自拥有草稿、校验和命名空间写入。任一草稿无效或冲突时,卡片禁止保存;保存期间禁用两个分区;两者均成功完成后仍保持打开。失败的分区保留草稿以供重试。限制说明通过字段名旁的信息按钮按需展开,使用具体层级示例和计数规则。校验错误仍直接显示,避免折叠说明后隐藏阻止保存的原因。 + +插件在任一命名空间可用时注册一个 `plugins.item` 条目。组件仅呈现可用的分区,既保留只提供限制或模型设置的部署,也无需让插件页了解 Subagent 分组。 + +## Alternatives considered + +**保留分开的卡片。** 独立控件使委派策略与模型授权之间的关系不够清晰,也需要分别保存。 + +**合并 Host 命名空间。** UI 分组不需要改变持久化设置、命名空间 revision 或两项策略不同的生效时机。现有命名空间控制器保留这些约定。 + +## Consequences + +用户可以在同一处检查两个分区。保存仍分别写入各自的命名空间,因此可能部分成功;成功的草稿清除,被拒绝的草稿保持可见并可重试。模型授权仍在自己的命名空间内原子写入开关与路由。[包参考文档](../../../../packages/client/ui-settings-plugins/README.zh.md) 描述控件及其生效范围。 + +## Verification + +聚焦的组件和控制器测试覆盖共同校验、保存、离开时丢弃草稿、进行中的写入、部分失败,以及仅提供任一命名空间的部署。组装后的 Web 场景通过一个按钮保存两个分区,并回读设置文档。 diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml new file mode 100644 index 0000000000..b261e98e5b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml @@ -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/feature/2026-09-16-sidebar-browser.md +2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c +2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md new file mode 100644 index 0000000000..3fa4e1dbb7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md @@ -0,0 +1,74 @@ +# Agent Note: Sandboxed Sidebar browser + +Status: implemented + +English | [中文](2026-09-16-sidebar-browser.zh.md) + +## Problem + +The right Sidebar can preview addressed workspace files, but it has no independent surface for visiting a Web page. Opening a page outside the application loses the Sidebar's split, float, and tab lifecycle. Treating an arbitrary page like Document Preview content also obscures a different trust model: a Web page controls a live browsing context, while a document renderer receives bytes selected for one preview. + +A parent page cannot inspect or drive a cross-origin iframe's internal history. The supported behavior must not imply that capability. + +## Decision + +`@deepseek-ai/dsh-client-ui-sidebar-browser` registers the multi-instance `browser` right-Sidebar tab type. `SidebarRightTabParamsMap.browser` accepts an optional initial URL so another Client plugin can open a Browser without importing this package's runtime values. + +`MarkdownDelegateProvider` gives nested Markdown anchors an optional owner callback for ordinary HTTP(S) activation while retaining native modified-click behavior. Chat places one provider around its node list and opens a new `browser` tab with the URL as typed navigation parameters when that type is registered, or uses the system browser otherwise; the Markdown renderer does not import the Browser feature. + +The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface. + +The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy. + +Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component. + +`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record. + +Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities. + +## Web navigation states + +The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. + +| State | Entry | Address and controls | +|---|---|---| +| `empty` | The tab has no controlled target. | The address is empty. Back, Forward, Reload, and external-open are disabled. | +| `loading` | Address submission, an application-history move, or Reload starts a new revision. | The requested URL remains authoritative. Back and Forward follow application-history bounds; Reload remains available; external-open follows the known target protocol. | +| `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. | +| `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. | + +Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable. + +## Deferred Electron carrier + +Electron `` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`. + +The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins. + +The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied. + +The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization. + +Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target. + +## Alternatives considered + +**Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning. + +**Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support. + +**Support `file:` URLs in Browser.** Rejected because local files already belong to Document Preview, while browser navigation has a different trust model. Browser refuses the protocol instead of acquiring filesystem or Workspace Files access. + +**Proxy Web pages through the Host.** Rejected because a compatible proxy would have to rewrite URLs, CSP, cookies, modules, streams, forms, and downloads while turning the Host into a general outbound requester. + +**Implement the Electron carrier in the initial Browser change.** Deferred so the first implementation does not enable a new Electron guest surface without packaged-app evidence for overlay stacking, target lifetime, cookie isolation, and every permission denial. + +## Verification + +Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal. + +## Consequences + +The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior. + +Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage. diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md new file mode 100644 index 0000000000..69d78dbf2b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 沙箱化 Sidebar 浏览器 + +Status: implemented + +[English](2026-09-16-sidebar-browser.md) | 中文 + +## Problem + +右侧 Sidebar 可以预览已寻址的工作区文件,但没有用于访问网页的独立界面。在应用外打开会丢失 Sidebar 的分栏、浮动与 tab 生命周期。把任意网页当成 Document Preview 内容,也会混淆两种不同的信任模型:网页控制一个活跃浏览上下文,而文档 renderer 只接收为一次预览选定的字节。 + +父页面不能检查或驱动跨域 iframe 的内部 history。支持的行为不能暗示具备这种能力。 + +## Decision + +`@deepseek-ai/dsh-client-ui-sidebar-browser` 注册可多开的右侧 Sidebar `browser` tab 类型。`SidebarRightTabParamsMap.browser` 接受可选初始 URL,使其他 Client 插件无须导入本包运行时值即可打开 Browser。 + +`MarkdownDelegateProvider` 为嵌套的 Markdown anchor 提供可选 owner callback,用于委托普通 HTTP(S) 点击,同时保留带修饰键点击的原生行为。Chat 在 node list 外放置一个 Provider;已注册该类型时,它以 URL 作为 typed navigation 参数打开新的 `browser` tab,否则使用系统浏览器;Markdown renderer 不导入 Browser feature。 + +地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。 + +当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。 + +每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。 + +`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。 + +Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。 + +## Web 导航状态 + +对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。 + +| 状态 | 进入条件 | 地址与控件 | +|---|---|---| +| `empty` | tab 没有受控目标。 | 地址为空;后退、前进、刷新与外部打开均禁用。 | +| `loading` | 地址提交、应用 history 移动或刷新启动新的 revision。 | 请求 URL 仍是权威地址;后退和前进遵循应用 history 范围;刷新保持可用;外部打开遵循已知目标的协议。 | +| `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 | +| `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 | + +每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。 + +## Deferred Electron carrier + +Electron `` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。 + +Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。 + +主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。 + +view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。 + +每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。 + +## Alternatives considered + +**增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。 + +**把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。 + +**在 Browser 中支持 `file:` URL。** 不采用,因为本地文件已由 Document Preview 负责,而 browser 导航使用不同的信任模型。Browser 直接拒绝该协议,不获取文件系统或 Workspace Files 能力。 + +**通过 Host 代理网页。** 不采用,因为兼容代理必须重写 URL、CSP、Cookie、module、stream、form 与 download,同时会把 Host 变成通用出站请求器。 + +**在首个 Browser 变更中实现 Electron 载体。** 延期处理,避免在没有打包应用证据覆盖 overlay stacking、target lifetime、Cookie 隔离与全部 permission 拒绝路径时启用新的 Electron guest surface。 + +## Verification + +单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。 + +## Consequences + +Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。 + +站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。 diff --git a/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml new file mode 100644 index 0000000000..d85ac2b2b2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.i18n.yaml @@ -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/feature/2026-09-16-windows-desktop-titlebar.md +2026-09-16-windows-desktop-titlebar.md: 17fd2aac308cff0b4357a0c39c3fe757cfc60f5b +2026-09-16-windows-desktop-titlebar.zh.md: 05903d86960942204d7b18d4155266c144effc1a diff --git a/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md new file mode 100644 index 0000000000..17fd2aac30 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md @@ -0,0 +1,29 @@ +# Agent Note: Windows desktop caption and application menus + +Status: implemented + +English | [中文](2026-09-16-windows-desktop-titlebar.zh.md) + +## Problem + +Windows needs a compact caption that keeps sidebar navigation available when the sidebar is hidden or files fill the content area. A separate Application/Edit menu consumes another row and can show a different language from the application. The shared client must preserve ordinary Web and macOS presentation. + +## Decision + +The Windows main window combines Electron's hidden titlebar with a native window-controls overlay. Only its local application preload publishes `data-windows-titlebar`; shared layout, sidebar, and fullscreen-panel rules require that marker. Native caption colors and context-menu language follow the application document. Renderer messages are accepted only from the primary window's main frame. + +Windows removes the separate native menu row. The application preload mounts localized Application and Edit caption entries in an isolated shadow root, and the main process opens native popup menus for validated requests. Application uses the same command template as other platforms; Edit preserves the Windows native command set with explicit locale-owned labels. Existing sidebar callbacks supply caption navigation without another navigation store, and the collapsed New Session control sits between the sidebar toggle and the menus. The [macOS caption decision](2026-09-13-macos-hidden-titlebar-vibrancy.md) remains independently applicable to macOS. + +## Alternatives considered + +An Alt-revealed native row consumes additional vertical space and hides discovery behind a keyboard convention. Removing its commands entirely loses manual updates and editing commands. Plugin management uses the main application’s Plugins page. Caption entries preserve those operations without the extra row. Native popups retain command execution and keyboard navigation instead of duplicating editor actions in Web components. + +Custom HTML window controls would make the application responsible for native caption interactions. The native overlay preserves those controls while allowing application content beside them. Global CSS changes would affect Web and macOS; the preload-owned marker restricts the presentation to the Windows main window. + +## Consequences + +The caption remains available above fullscreen file panels, and collapsed navigation consumes no vertical rail. Caption menus preserve application and editing commands while ordinary Web documents receive no desktop controls. Editing actions send the corresponding keys to the focused editor so its own undo history applies. Caption entries stay above content modal overlays. Keyboard activation restores the last editor and selection before dispatch; pointer activation preserves editor focus; menu completion clears the active entry. Main-process validation rejects foreign frames, unknown menu names, and invalid anchor coordinates. Native popup appearance follows the operating system. + +## Testing + +Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and the absence of the Desktop Plugins shortcut. Owner-local menu and sidebar snapshots cover localized entries and expanded/collapsed controls; layout tests cover zero-width collapse. Window verification covers native popups, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail. diff --git a/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md new file mode 100644 index 0000000000..05903d8696 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Windows 桌面顶栏与应用菜单 + +Status: implemented + +[English](2026-09-16-windows-desktop-titlebar.md) | 中文 + +## Problem + +Windows 需要紧凑的顶栏,在侧栏隐藏或文件铺满内容区时仍保留侧栏导航。独立的应用/编辑菜单多占一行,且可能与应用语言不同。共享客户端必须保留普通 Web 和 macOS 的呈现。 + +## Decision + +Windows 主窗口组合 Electron 隐藏标题栏与原生窗口按钮覆盖层。只有本地应用 preload 发布 `data-windows-titlebar`;共享布局、侧栏和全屏面板规则均要求该标记。原生顶栏颜色与右键菜单语言跟随应用文档。渲染器消息仅接受主窗口的主框架来源。 + +Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。 + +## Alternatives considered + +按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和编辑命令。插件管理使用主应用的“插件”页面。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。 + +自定义 HTML 窗口按钮会让应用承担原生顶栏交互。原生覆盖层保留这些按钮,同时允许应用内容显示在旁边。全局 CSS 修改会影响 Web 和 macOS;由 preload 持有的标记将呈现限制在 Windows 主窗口。 + +## Consequences + +顶栏在全屏文件面板上方保持可用,收起的导航不再占用纵向轨道。顶栏菜单保留应用与编辑命令,普通 Web 文档不获得桌面控件。编辑操作向当前编辑器发送对应按键,以使用编辑器自身的撤销历史。顶栏入口位于内容弹窗遮罩之上。键盘激活在派发前恢复上一编辑器及选区;指针激活保留编辑器焦点;菜单关闭后清除入口激活态。主进程校验拒绝其他框架、未知菜单名称和无效锚点坐标。原生弹出菜单外观跟随操作系统。 + +## Testing + +桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键的缺失。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。 diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml new file mode 100644 index 0000000000..cf44de1cb6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml @@ -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/feature/2026-09-17-compact-translucent-menu-surfaces.md +2026-09-17-compact-translucent-menu-surfaces.md: 08fcb2d851e4c75ce9069fc797e8dbcb070acc5b +2026-09-17-compact-translucent-menu-surfaces.zh.md: 8e13634995d46b7414f17bbb51443df85cb3ac3c diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md new file mode 100644 index 0000000000..08fcb2d851 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md @@ -0,0 +1,29 @@ +# Agent Note: Compact translucent menu surfaces + +Status: implemented + +English | [中文](2026-09-17-compact-translucent-menu-surfaces.zh.md) + +## Problem + +Shared dropdowns and feature-owned menu panels used the same elevated color token but retained unrelated padding, row height, corner radius, and scrollbar geometry. The opaque menu fill also made nested and portalled surfaces read as solid cards rather than one elevation family, while narrowing individual menus locally would keep those differences distributed across packages. + +## Decision + +`ui-theme` defines `--dsw-specific-menu` as a translucent light or dark fill and defines `--dsw-menu-backdrop-filter` as `blur(40px) saturate(150%)`. Every elevated package surface that paints the menu fill also applies that backdrop filter, uses `border: 0`, and takes an elevation shadow with its rebindable hairline stroke. A surface containing fixed-position overlays paints the fill and filter on an isolated background pseudo-element, because a filtered ancestor would otherwise change those overlays' containing block. Descendant sticky rows may repaint the inherited fill without another filter. Browsers without backdrop filtering still render the theme-owned translucent fill. + +The shared `Menu` and the composer input-trigger menu use the same compact baseline: a 16px outer radius, 3px frame padding, 34px ordinary rows, 13px primary text on a 20px line, 6px icon/text gaps, and 8px row radii. Dense and compact variants reduce from that baseline instead of retaining the former ordinary geometry. The composer menu keeps its feature-owned grouping and 400px height cap; its aliases and descriptions use 12px text on an 18px line. + +The global WebKit scrollbar width is 5px. The composer menu overrides it with a 6px draggable rail and a 2px visible thumb through the existing scrollbar geometry variables; Firefox continues to use its standard thin scrollbar path. Track insets and elevated l2 thumb colors remain surface-owned rebindings. + +## Alternatives considered + +**Keep an opaque shared menu fill.** Rejected because every consumer would need a separate translucent override to share one material, recreating the theme branch outside `ui-theme`. + +**Compact only the composer command menu.** Rejected because the shared `Menu` renders the same action, settings, and navigation vocabulary; keeping its former 40px rows would preserve two densities for the same control family. + +**Use the 6px padded rail for every scrollbar.** Rejected because ordinary scroll regions need the direct 5px thumb, while the command menu needs a wider hit area around a visually quieter thumb. The existing geometry variables express that difference without another scrollbar implementation. + +## Consequences + +Elevated menu-fill consumers must pair the fill and backdrop-filter tokens in the same rule, either on the surface or on its isolated background pseudo-element; the stylesheet gate rejects a material layer that omits the filter. Shared menu geometry changes affect every `Menu` render site, while the composer menu retains its own content and interaction rules. Browser and component snapshots cover assembled structure, and theme specs pin the tokens, compact metrics, scrollbar geometry, and complete menu-filter pairing. diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md new file mode 100644 index 0000000000..8e13634995 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 紧凑半透明菜单表面 + +Status: implemented + +[English](2026-09-17-compact-translucent-menu-surfaces.md) | 中文 + +## Problem + +共享下拉菜单与功能包自有菜单面板使用同一个高层级颜色 token,却保留了不同的内边距、行高、圆角和滚动条几何。不透明菜单填充也让嵌套与 portal 表面像彼此独立的实色卡片;如果只在各功能菜单中局部收紧尺寸,这些差异仍会分散在多个包中。 + +## Decision + +`ui-theme` 将 `--dsw-specific-menu` 定义为明暗主题各自的半透明填充,并将 `--dsw-menu-backdrop-filter` 定义为 `blur(40px) saturate(150%)`。每个绘制菜单填充的包级高层级表面同时应用该 backdrop filter、设置 `border: 0`,并使用带可重绑发丝描边的 elevation 投影。包含 fixed 定位浮层的表面把填充与滤镜绘制在隔离的背景伪元素上,因为带滤镜的祖先会改变这些浮层的包含块。后代 sticky 行可以再次绘制继承的填充,无需重复 filter。不支持 backdrop filtering 的浏览器仍渲染主题持有的半透明填充。 + +共享 `Menu` 与 composer 输入触发菜单使用同一套紧凑基准:16px 外圆角、3px 边框内距、34px 普通行、13px 主文字配 20px 行高、6px 图文间距,以及 8px 行圆角。dense 与 compact 变体在该基准上继续缩小,不再保留旧的普通几何。composer 菜单保留其功能包持有的分组与 400px 高度上限;别名和说明使用 12px 字号与 18px 行高。 + +WebKit 系全局滚动条宽度为 5px。composer 菜单通过既有滚动条几何变量覆盖为 6px 可拖动轨道与 2px 可见滑块;Firefox 继续走标准 thin scrollbar 路径。轨道内缩与高层级 l2 滑块颜色仍由表面自行重绑。 + +## Alternatives considered + +**保留不透明的共享菜单填充。** 否决,因为每个消费方都需要单独覆盖成半透明,导致同一种材质再次在 `ui-theme` 之外分叉。 + +**只收紧 composer 指令菜单。** 否决,因为共享 `Menu` 渲染的是同一套操作、设置与导航词汇;保留原 40px 行会让同一个控件家族继续存在两种密度。 + +**所有滚动条都使用 6px 带内边距轨道。** 否决,因为普通滚动区域需要直接的 5px 滑块,而指令菜单需要在更安静的可见滑块外保留更宽点击区域。现有几何变量可以表达该差异,无需第二套滚动条实现。 + +## Consequences + +高层级菜单填充消费方必须在同一规则中同时使用填充与 backdrop-filter token,规则可以属于表面本身,也可以属于其隔离的背景伪元素;样式表门禁会拒绝漏掉 filter 的材质层。共享菜单几何影响每个 `Menu` 渲染点,composer 菜单仍保留自身内容与交互规则。浏览器与组件快照覆盖装配结构,主题测试固定 token、紧凑尺寸、滚动条几何与完整的菜单 filter 配对。 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml new file mode 100644 index 0000000000..69db5487b8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -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/feature/2026-09-17-developer-tools-settings.md +2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2 +2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md new file mode 100644 index 0000000000..e76cb7f367 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -0,0 +1,29 @@ +# Agent Note: Shared developer-tool settings + +Status: implemented + +English | [中文](2026-09-17-developer-tools-settings.zh.md) + +## Problem + +Diagnostic views, preset selection and changed-file summaries add complexity to routine tasks. Scripted HTML also grants capabilities that a basic document preview does not need. Desktop and Web share these renderers, so independent switches would let the same setting produce different results across clients. + +## Decision + +The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. + +Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. + +Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access. + +## Alternatives considered + +**Limit the switch to desktop.** The selected requirement contains no desktop-only restriction, and the affected UI is shared. A desktop check would leave the Web renderer with inconsistent visibility and preview behavior. + +**Hide controls without changing the active View or iframe.** Previously selected diagnostic content and already-running scripts would survive the toggle. Filtering the available View roster and remounting the HTML context enforce the selected presentation immediately. + +## Consequences + +Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority. + +Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md new file mode 100644 index 0000000000..5a7d6d59db --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 共享开发者工具设置 + +Status: implemented + +[English](2026-09-17-developer-tools-settings.md) | 中文 + +## Problem + +诊断视图、预设选择和改动文件摘要增加了日常任务的复杂度。支持脚本的 HTML 还授予了基础文档预览不需要的能力。桌面端和 Web 共享这些渲染器,分别设置开关会让同一设置在不同客户端产生不同结果。 + +## Decision + +设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 + +关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 + +插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 + +## Alternatives considered + +**仅在桌面端提供开关。** 所选需求没有仅限桌面的约束,相关 UI 也是共享的。桌面端条件判断会让 Web 渲染器的展示和预览行为不一致。 + +**隐藏控件但不改变激活的 View 或 iframe。** 之前选择的诊断内容和已经运行的脚本会在切换后继续存在。过滤可用 View 并重新挂载 HTML 上下文,可以立即执行所选展示策略。 + +## Consequences + +基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。 + +组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml new file mode 100644 index 0000000000..2a7ae98b1e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml @@ -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/feature/2026-09-17-persistent-plan-cards.md +2026-09-17-persistent-plan-cards.md: 6959cf39f4e47dbd552ee14af7c1ebc09b52bef6 +2026-09-17-persistent-plan-cards.zh.md: 0c40cb5caf4b77f5d4b5c30cead5567f21b45cfd diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md new file mode 100644 index 0000000000..6959cf39f4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -0,0 +1,31 @@ +# Agent Note: Persistent plan cards and sidebar reading + +Status: implemented + +English | [中文](2026-09-17-persistent-plan-cards.zh.md) + +## Problem + +A plan review occupies the composer only until the user answers or dismisses it. A completed Turn also folds its tool calls into the process disclosure. Neither lifetime gives a submitted plan a discoverable place to read while implementation continues. + +## Decision + +`ui-plan` accumulates each Turn’s `exit_plan_mode` submissions, including native calls and PTC dispatches. Each invocation contributes a card to the completed Turn’s final artifact area. The `conversation.chat.turnTail` list admits file deliveries and plan cards together. Review dismissal, refusal, and approval do not delete the recorded plans. + +The review intent may carry the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. When an invocation exists, both open the resource identified by its complete Session address and invocation. Subagent addresses retain the direct parent, child, and mode for snapshot reads, pagination, and reload. Sidebar layout retains that address; the resource provider reads the existing Session history, paging backwards when the invocation is older than the opening window. Its temporary follow closes after the opening snapshot. + +A review without an invocation opens a temporary address keyed by browser lifetime, Session, and pending request. The sidebar’s existing navigation parameters hold its Markdown without adding another document store or persisting text in layout. Closing and reopening from the pending card supplies the text again; restored tabs without navigation parameters report an expired preview. + +The document is read-only. The pending review shows its status, View full plan link, title, two-line plain-text summary, and Request changes / Approve buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation or unlogged request, so review remounts preserve manual closure. Historical cards never trigger automatic navigation. The cards use the file-delivery treatment with a Markdown icon, plan title, and Open action. Request changes cancels the pending wait and returns the composer for feedback. Approval stays in the pending-question owner; reopening a historical document does not revive a settled review or authorize implementation. Different submissions remain distinct even when they share a heading. + +## Alternatives considered + +**Keep only the pending review or an expandable tool row.** Both hide the document behind unrelated interaction or process state. The user needs a persistent artifact entry. + +**Write a Markdown file or persist the document in sidebar layout.** The tool arguments already own the exact submitted text. Another durable copy would need synchronization and could disagree with the reviewed plan. The [plan-state decision](../simplification/2026-07-22-plan-specific-collaboration-state.md) remains the authority for that ownership. + +**Select a single owner for the Turn tail.** Plans and file deliveries can occur in the same Turn. An additive list lets both plugins contribute without either knowing the other’s data or rendering. + +## Consequences + +Logged plans survive review closure and browser reload without a new Session event or file. Complete plan arguments retain a card even when the tool rejects the call, including calls outside plan mode; the card does not certify approval or execution. Reads of old plans may require several history pages. The provider validates saved addresses and logged arguments, and reports unavailable history explicitly. The recorded Web plan-review scenario covers both entry points, repeated opening, process collapse, approval, and reload; focused tests cover multiple submissions, PTC deduplication, cropped history, and resource failures. diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md new file mode 100644 index 0000000000..0c40cb5caf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 常驻计划卡片与侧边栏阅读 + +Status: implemented + +[English](2026-09-17-persistent-plan-cards.md) | 中文 + +## Problem + +计划审批只在用户回答或关闭之前占据编辑器。已完成的回合也会把工具调用折叠到运行过程里。这两种生命周期都无法在实施继续时,为已提交的计划提供容易找到的阅读入口。 + +## Decision + +`ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。 + +审批意图可以携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。存在调用标识时,两者都打开由完整会话地址和调用标识确定的资源。子会话地址保留直接父级、子会话及运行模式,用于快照读取、分页和刷新恢复。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 + +没有调用标识的审批打开临时地址,由浏览器生命周期、会话和待处理请求共同标识。侧边栏已有的导航参数保存 Markdown,无需额外的文档 store,也不把正文持久化到布局中。关闭后从待处理卡片重新打开会再次提供正文;恢复的标签页若缺少导航参数,则显示预览已失效。 + +文档只读。待处理审批显示状态、“查看全文”链接、标题、两行纯文本摘要,以及“要求修改”和“同意执行”按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用或无调用标识的请求,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。“要求修改”取消待处理等待并恢复编辑器,供用户输入反馈。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。 + +## Alternatives considered + +**只保留待处理审批或可展开的工具行。** 两者都会让文档受其他交互或运行过程状态控制。用户需要常驻的产物入口。 + +**写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。 + +**只选择一个回合末尾区域的属主。** 计划和文件产物可能出现在同一回合。可追加列表让两个插件都能贡献内容,无需彼此了解数据或渲染。 + +## Consequences + +已记录计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml new file mode 100644 index 0000000000..d86602a99e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml @@ -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/feature/2026-09-17-state-dot-visual-language.md +2026-09-17-state-dot-visual-language.md: 751abbaa33919721c7e71738ad9b6bed744fcad7 +2026-09-17-state-dot-visual-language.zh.md: 9746ec9af79e15fc41ea8e6601cc52c1cfafba02 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md new file mode 100644 index 0000000000..751abbaa33 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot solid marks and loading spinner + +Status: implemented + +English | [中文](2026-09-17-state-dot-visual-language.zh.md) + +## Problem + +`StateDot` mixed two drawing languages: settled states used a translucent halo around a solid core, while `ongoing` used an eight-cell pixel chase. + +## Decision + +`idle`, `done`, `warning`, and `error` render as one solid 6px circle inside the existing 10px layout slot, with no halo. `idle` uses the neutral `--dsw-alias-state-idle-primary` token, `done` remains success green, `warning` remains amber, and `error` remains red. + +`ongoing` is the sole non-dot member. Its default edge is 14px, while solid states retain their 10px layout slot. It renders a 25%-opacity complete ring behind a grey arc using the tertiary label token. The glyph rotates continuously over 1.5 seconds while the arc grows from 12 to 24 dash units around its center and returns to 12; the arc offset is zero at both ends, so the browser does not reset a second circular movement at the loop boundary. Reduced-motion environments retain an intermediate static arc. The explicit size override, `data-state`, and `aria-hidden` behavior do not change. + +Converted compact status-only presentations use this shared mapping instead of local dots or spinners: waiting or blocked is `warning`, active work is `ongoing`, successful completion is `done`, failure is `error`, and inactive or not-yet-started work is `idle`. Tool rows whose leading slot is a business icon retain the ordinary glyph in every lifecycle state; their collapsed summary turns red for failure and amber for `stopped`, preserving any tool-specific interruption text. The framework-free boot page remains separate because its arc reports aggregate loader progress before React and the shared primitive are available. + +## Alternatives considered + +**Keep the pixel chase.** Rejected because its block animation did not share the circular language of the settled markers and read as a decorative activity glyph rather than a conventional loading state. + +**Fill the complete 10px slot.** Rejected because the former visible core was 6px; retaining that diameter preserves row density while removing only the halo. + +## Testing + +The component specs pin the four solid-state elements, the two-circle loading artwork, both animation tracks, both size paths, the no-halo stylesheet, and every state token. Tool, Bash, and Skill row specs pin the retained business glyph plus visible red failure and amber interruption summaries. Workspace, Job, Workflow, Subagent, Deliverables, terminal, plugin, Schedule, Todo, Team, approval, document-preview, Trajectory, and connection suites pin their state mappings and accessible labels. + +## Consequences + +Consumers keep their accessibility labels while status-only presentations share one visual vocabulary. Every settled marker becomes visually quieter and more compact, completion remains green, and every active marker uses the same tertiary-grey rotating loader. Action glyphs such as reconnect and business glyphs such as file or tool types remain available when the icon itself communicates the operation rather than only its state. diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md new file mode 100644 index 0000000000..9746ec9af7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot 纯色标记与旋转 loading + +Status: implemented + +[English](2026-09-17-state-dot-visual-language.md) | 中文 + +## 问题 + +`StateDot` 混用了两套绘制语言:已结算状态在实心核外带半透明光晕,`ongoing` 则使用八格像素追逐动画。 + +## 决策 + +`idle`、`done`、`warning` 与 `error` 在既有 10px 布局槽内渲染一个 6px 纯色圆点,不再带光晕。`idle` 使用中性的 `--dsw-alias-state-idle-primary` token,`done` 保持成功绿色,`warning` 保持琥珀色,`error` 保持红色。 + +`ongoing` 是唯一不是圆点的成员。它的默认边长为 14px,纯色状态仍保留 10px 布局槽。它在透明度为 25% 的完整圆环上方,使用 tertiary label token 渲染灰色圆弧。图形以 1.5 秒周期持续旋转,圆弧围绕中心从 12 个 dash 单位增长到 24 个、再回到 12 个;圆弧偏移在首尾都为零,因此浏览器不会在循环边界重置第二段圆周运动。减少动态效果的环境保留中间长度的静态圆弧。显式 size 覆盖、`data-state` 与 `aria-hidden` 行为均不改变。 + +已转换的紧凑纯状态展示使用这套共享映射,不再自绘圆点或 spinner:等待或阻塞为 `warning`,活动工作为 `ongoing`,成功完成为 `done`,失败为 `error`,未活动或尚未开始为 `idle`。前置槽为业务图标的工具行在所有生命周期状态中都保留普通图标;收起摘要在失败时变红,在 `stopped` 时变为琥珀色并保留工具自有的中断文本。无框架的启动页保持独立,因为它会在 React 与共享原语可用前用圆弧表达 Loader 总体进度。 + +## 考虑过的替代方案 + +**保留像素追逐动画。** 否决,因为方块动画没有沿用已结算标记的圆形语言,看起来更像装饰性活动图形,而不是常规 loading 状态。 + +**填满完整 10px 槽位。** 否决,因为原来的可见实心核就是 6px;保留该直径只移除光晕,同时维持行密度。 + +## 测试 + +组件测试固定四种纯色状态元素、双圆环 loading 图稿、两条动画轨道、两条尺寸路径、无光晕样式和每个状态 token。Tool、Bash 与 Skill 行测试固定保留的业务图形,以及可见的红色失败摘要与琥珀色中断摘要。Workspace、Job、Workflow、Subagent、Deliverables、终端、插件、Schedule、Todo、Team、审批、文档预览、Trajectory 与连接测试固定各自的状态映射和无障碍 label。 + +## 后果 + +消费方保留无障碍 label,纯状态展示共享同一套视觉词汇。所有已结算标记在视觉上更安静、更紧凑,完成态保持绿色,所有活动态使用同一种 tertiary 灰色旋转 loading。重连等动作图标和文件或工具类型等业务图标在图形本身表达操作而不只表达状态时继续保留。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index b6d13dd1aa..23d245c8c7 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b -2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4 +2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d +2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 10123fe199..c2fd2fa6cf 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -32,7 +32,7 @@ The two switches are independent: flip only the one whose platform is degraded. 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. -3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool. +3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Self-hosted coverage uses four single-worker instrumented partitions and two exempt workers; isolated hosted runners derive worker defaults from available CPUs. The Windows switch selects the native Windows pool and retains explicit worker limits on shared self-hosted runners. **Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index cff99e6f64..7fa5887bb5 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -32,7 +32,7 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 +3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。自托管覆盖率使用 4 个单 worker 插桩分区与 2 个豁免 worker;独占的托管运行器根据可用 CPU 计算默认 worker 数。Windows 开关选择原生 Windows 运行器池,并在共享的自托管运行器上保留显式 worker 限制。 **Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。 diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 5b23d66658..c8bfb9d43c 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.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/process/2026-07-26-frozen-agent-note-archive.md -2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1 -2026-07-26-frozen-agent-note-archive.zh.md: 2c2751c56a9b20423b00eac7a90c02286ce3e8b0 +2026-07-26-frozen-agent-note-archive.md: e9ecfa78f53a5912608201654e2c56e399defccd +2026-07-26-frozen-agent-note-archive.zh.md: 9a3ce2e6e21864f2754f627be6f7576bfc1f9076 diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index 0c139c4a5d..e9ecfa78f5 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -10,9 +10,9 @@ Implemented Agent Notes are maintained as current decision records, so every pat ## Decision -Only implemented Agent Notes can be archived. An implemented note moves when its shipped decision is complete and its rationale, alternatives, consequences, negative guarantees, and reintroduction conditions are unlikely to guide future work. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet. +Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted as complete triplets, with inbound links repaired or removed. A small implementation can still carry lasting naming, compatibility, ownership, or behavior decisions; those records remain eligible for retention. Only other implemented Agent Notes can be archived: the shipped decision is complete, its rationale is unlikely to guide future work, and it still has historical decision value. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet. -The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. +The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. Archival admission checks lifecycle metadata and completeness, not title punctuation, blank-line layout, or language-switcher wording; requiring those repairs would rewrite the historical record. The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery. @@ -42,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while substantive historical decisions with little future guidance remain explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index 2c2751c56a..9a3ce2e6e2 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -10,9 +10,9 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 决策 -只有 implemented Agent Note 可以归档。当一份已实施记录的交付决策已经完整落地,且其决策依据、备选方案、后果、否定性保证和重新引入条件不太可能再指导未来工作时,将其移入归档。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 +仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 会作为完整的三文件配对删除,并修复或移除入站链接。小规模实现仍可能承载长期有效的命名、兼容性、归属或行为决策;此类记录仍可保留。只有其他 implemented Agent Note 可以归档:交付决策已经完整落地,其依据不太可能再指导未来工作,但仍具有历史决策价值。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 -归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 +归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 归档准入检查生命周期元数据与完整性,不检查标题标点、空行布局或语言切换栏措辞;要求修复这些格式会改写历史记录。 根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。 @@ -42,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低但具有实质内容的历史决策仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index a542599011..7ad3923251 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5 -2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77 +2026-08-08-native-windows-pull-request-ci.md: 64c4366ee8d60dd0137e6e4eb5473c0450204efe +2026-08-08-native-windows-pull-request-ci.zh.md: 48725cdc58ab7b8db22461da64236c6de3bb3027 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index e4fc7cab8c..64c4366ee8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,11 +14,11 @@ A coverage audit found that stale branch state had restored temporary exclusions The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline. +Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes. -`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts. +`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict. -`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers without a preceding build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. @@ -40,7 +40,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. -**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate. +**Mark every non-blocking native job `continue-on-error`.** Only the observational step uses this setting because Linux owns its blocking verdict. Its containing build job must still fail when the blocking build or production site fails. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate. **Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures. @@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine provides post-merge toolchain evidence. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results. +Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure. -Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup across the build, coverage, and observational workspaces and repeat builds in the build and observational ones, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. +Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks. Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 27ad602c37..48725cdc58 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,11 +14,11 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。 +每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。 -`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。 +`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。 -`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在没有前置构建的情况下调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 @@ -40,7 +40,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 -**将每个非阻断原生作业都标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。 +**将每个非阻断原生作业都标记为 `continue-on-error`。** 只有观测步骤采用该设置,因为它的阻断性判定由 Linux 负责。包含该步骤的构建作业仍必须在阻断性构建或生产网站失败时失败。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。 **排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。 @@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。 +Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。 -尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 +每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。 维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。 diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md deleted file mode 100644 index 696343ddff..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Issue policy module ownership - -Status: implemented - -English | [中文](2026-09-07-issue-policy-module-ownership.zh.md) - -## Problem - -Issue validation and Project lifecycle processing use the same reference parser, metadata rules, and GitHub reads, but they make different decisions and perform different writes. Keeping those responsibilities in the command entry makes it harder to reuse policy rules without also depending on event dispatch and output handling. - -## Decision - -Issue management separates pure decisions, GitHub access, pull-request evaluation, lifecycle mutations, and command dispatch into owner modules. [The owner reference](../../../../.github/issue-management/README.md#module-ownership) maps those responsibilities to source files. Callers and tests import the module that owns the operation rather than using the command entry as an export collection. - -The separation preserves policy results, diagnostics, credentials, API requests, lifecycle mutations, and workflow entry commands. [Selective evaluation](2026-09-07-selective-issue-policy-evaluation.md) remains the independent owner of eligibility, Project-read selection, and event scheduling; this decision does not redefine those behaviors. - -## Alternatives considered - -**Keep one command module.** A single file avoids imports between local owners, but ties reusable decisions and GitHub access to command handling. Separate owners let PR validation and lifecycle processing share their existing rules and reads without sharing command dispatch. - -## Consequences - -Maintainers can locate a rule, network operation, or event handler by responsibility. Reuse occurs through ordinary local ESM imports, not a new package or plugin API. The added modules introduce imports that must stay coordinated when a shared function changes. - -Module separation does not grant stronger credentials, change check authority, serialize Project mutations, or prevent a future behavior regression. Existing lifecycle races and field-management limitations remain documented by their behavior owners. - -## Verification - -[Policy tests](../../../../.github/issue-management/policy.test.mjs) exercise the owner modules and check observable policy and lifecycle behavior. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) cover the command wiring and workflow declarations. Their evidence concerns the checked implementation, not a guarantee that later edits preserve behavior. diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md deleted file mode 100644 index abe539a5ba..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Issue 策略模块归属 - -Status: implemented - -[English](2026-09-07-issue-policy-module-ownership.md) | 中文 - -## 问题 - -Issue 校验与 Project 生命周期处理使用相同的引用解析器、元数据规则和 GitHub 读取,但各自作出不同决策、执行不同写入。将这些职责放在命令入口中,会使复用策略规则时更难摆脱对事件分派与输出处理的依赖。 - -## 决策 - -Issue 管理将纯决策、GitHub 访问、PR(Pull Request)求值、生命周期 mutation 和命令分派分配给各自的所属模块。[所属参考文档](../../../../.github/issue-management/README.zh.md#module-ownership)将这些职责对应到源文件。调用方和测试直接导入拥有该操作的模块,不把命令入口用作导出集合。 - -此职责分离保留策略结果、诊断、凭据、API 请求、生命周期 mutation 和工作流入口命令。[选择性求值](2026-09-07-selective-issue-policy-evaluation.zh.md)仍独立拥有强制范围、Project 读取选择和事件调度规则;本决策不重新定义这些行为。 - -## 考虑过的替代方案 - -**保留单一命令模块。** 单文件可以避免本地所属模块之间的导入,但会把可复用的决策与 GitHub 访问绑定到命令处理。独立的所属模块让 PR 校验和生命周期处理可以共享既有规则与读取,而无需共享命令分派。 - -## 影响 - -维护者可以按职责定位规则、网络操作或事件处理器。复用通过普通的本地 ESM 导入完成,而不是新增包或插件 API。新增模块引入的导入关系需要在共享函数变更时同步维护。 - -模块分离不会授予更强的凭据、更改检查权威来源、串行化 Project mutation,也不能防止未来的行为回归。既有生命周期竞态与字段管理限制仍由各自的行为文档记录。 - -## 验证 - -[策略测试](../../../../.github/issue-management/policy.test.mjs)运行所属模块,并检查可观察的策略与生命周期行为。[工作流测试](../../../../scripts/ci-workflow.spec.ts)覆盖命令接线与工作流声明。这些证据针对被检查的实现,不保证后续编辑仍保留行为。 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml index 384d675f4c..bfd50afb64 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.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/process/2026-09-09-blacksmith-failover-leg.md -2026-09-09-blacksmith-failover-leg.md: d4be1ad25c6c5b66e295439d4609726c8918e137 -2026-09-09-blacksmith-failover-leg.zh.md: cd6e74aefba2cba9481fa1ae9510bb1ce020325d +2026-09-09-blacksmith-failover-leg.md: 4fb4ed4ccb500887622200e01310230393b3de12 +2026-09-09-blacksmith-failover-leg.zh.md: c2c4ee76a3fcd8c9f16c635ac81bc987d40a04e2 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md index d4be1ad25c..4fb4ed4ccb 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md @@ -10,9 +10,9 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp ## Decision -`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. +`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. -Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. +Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch. diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md index cd6e74aefb..c2c4ee76a3 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 +`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 -`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 +`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。 diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml index bd4f67275d..43d5e94cad 100644 --- a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.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/process/2026-09-09-parallel-macos-notarization.md -2026-09-09-parallel-macos-notarization.md: 152da4661207bf130389e600c16398335fd9fe70 -2026-09-09-parallel-macos-notarization.zh.md: d5375e39a6348b49ffe5bb93c2c056151d5abe75 +2026-09-09-parallel-macos-notarization.md: 18358444426575e27a21f0f56541e22e37fcdf2c +2026-09-09-parallel-macos-notarization.zh.md: a69d586eff1413debe096656a7572a53f0938e98 diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md index 152da46612..1835844442 100644 --- a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md @@ -10,11 +10,11 @@ The Desktop release distributes a DMG for installation and a ZIP for updates. Wa ## Decision -The fixed-target installer command signs and verifies one App, then creates two independent copies with `ditto`. The App lane notarizes and staples its copy, verifies its signature, ticket, and Gatekeeper acceptance, and asks electron-builder to create the ZIP and updater metadata. The DMG lane immediately packages its copy, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`. +The fixed-target installer command assembles one App, writes and verifies its `app-update.yml`, then signs it and creates two independent copies with `ditto`. Writing the configuration explicitly is required because electron-builder skips its macOS update-config hook when the initial build has only the directory target, while the later `--prepackaged` invocations do not reassemble the App. The App lane notarizes and staples its copy, verifies its signature, ticket, Gatekeeper acceptance, and update configuration, and asks electron-builder to create the ZIP and updater metadata. The DMG lane verifies its copy's update configuration, immediately packages it, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`. The ZIP contains an individually stapled App. The DMG contains the signed App without an individually stapled ticket; its outer ticket covers the nested code, following Apple's [container guidance](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution). Apple describes [ticket ingestion when Gatekeeper checks the outer container](https://developer.apple.com/forums/thread/125512). Independent extraction of the unstapled App relies on an online or cached ticket; the ZIP supplies an embedded ticket. The directory-only command continues to notarize and staple its App. -Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. An error leaves that record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata. +Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes with the configured update feed allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. Missing or mismatched App update configuration fails before promotion; any error leaves the record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata. This refines the notarization ordering in the [Desktop packaging decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); that note remains the owner of release identity, signatures, update ownership, and publishing requirements. @@ -34,4 +34,4 @@ On 2026-09-09, full arm64 packaging on the same Mac through the same system prox Two temporary App copies and separate artifact directories increase peak disk usage. Two uploads can contend for network bandwidth, and Apple can queue either submission independently; phase timings describe observed behavior rather than a CI latency budget. A failed lane waits for the other lane to finish before cleanup, which can delay failure reporting but avoids deleting files still owned by a child process. -The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, and refusal to promote incomplete artifacts. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work. +The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, update-configuration enforcement, and refusal to promote incomplete artifacts. Focused configuration tests cover writing the fixed feed and rejecting missing, mismatched, or incomplete fields. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work. diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md index d5375e39a6..a69d586eff 100644 --- a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md @@ -10,11 +10,11 @@ Desktop 发布同时提供用于安装的 DMG 和用于更新的 ZIP。等待 Ap ## 决策 -固定目标安装包命令先签名并验证一个 App,再通过 `ditto` 创建两个独立副本。App 路线公证其副本并钉票,验证签名、票据与 Gatekeeper 接受状态,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线立即封装其副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。 +固定目标安装包命令先组装一个 App,写入并验证其中的 `app-update.yml`,随后签名,再通过 `ditto` 创建两个独立副本。必须显式写入该配置,因为初始构建只有目录目标时,electron-builder 会跳过 macOS 更新配置钩子,而后续 `--prepackaged` 调用不会重新组装 App。App 路线公证其副本并钉票,验证签名、票据、Gatekeeper 接受状态与更新配置,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线验证其副本的更新配置,随后立即封装副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。 ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据的 App;根据 Apple 的[容器说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution),外层票据覆盖内嵌代码。Apple 还说明了 [Gatekeeper 检查外层容器时接收票据的行为](https://developer.apple.com/forums/thread/125512)。单独提取未钉票 App 依赖在线或缓存票据;ZIP 则提供内嵌票据。仅生成目录的命令仍会公证 App 并钉票。 -错误传播和临时目录清理前必须等待两路均结束。只有两路都成功,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。发生错误时该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。 +错误传播和临时目录清理前必须等待两路均结束。只有两路均成功且使用已配置更新源时,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。App 更新配置缺失或不匹配会在移入前失败;任何错误都会使该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。 本决策细化了 [Desktop 打包决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的公证顺序;原决策继续负责发布身份、签名、更新归属与发布要求。 @@ -34,4 +34,4 @@ ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据 两个临时 App 副本与独立产物目录增加了磁盘峰值占用。两次上传可能争用网络带宽,Apple 也可能分别排队处理;阶段计时记录实际行为,不构成 CI 延迟预算。一路失败后会等待另一路结束再清理,这可能延迟错误报告,但能避免删除仍由子进程持有的文件。 -[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误,以及拒绝移入不完整产物。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。 +[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误、强制检查更新配置,以及拒绝移入不完整产物。聚焦配置测试覆盖固定更新源的写入,并拒绝缺失、不匹配或字段不完整的配置。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index bd8082bc65..2c5a3f8554 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.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/process/2026-09-15-shipped-optional-bundles.md -2026-09-15-shipped-optional-bundles.md: 6de3eb7d39b86a2e5dd6db756d2bf6561aeb8ed4 -2026-09-15-shipped-optional-bundles.zh.md: 05c23c7c0cdd6a146b938370fec64377652e31d4 +2026-09-15-shipped-optional-bundles.md: c9f560f5c07ec6fb18030b77a0d1f53bd27dc045 +2026-09-15-shipped-optional-bundles.zh.md: e91b6580e29cd7ae142c9c61ed6aac7e5e1476eb diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 6de3eb7d39..c9f560f5c0 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -10,11 +10,11 @@ The Web plugin page manages only the bundles a person installed into the profile ## Decision -The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page lists optional bundles in a built-in group with an official tag beside the profile's own installed bundles. +The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page opens its Official group with the optional bundles, tagged beta where the feature is one, ahead of the profile's own installed bundles. Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. -Agent Teams and Auto review ship this way first, as `@deepseek-ai/dsh-experimental-agent-team-profile`, `@deepseek-ai/dsh-experimental-agent-team-web-profile`, and `@deepseek-ai/dsh-experimental-auto-review`. +The [single-bundle decision](../architecture/2026-09-18-agent-teams-single-bundle.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle. ## Alternatives considered @@ -26,4 +26,4 @@ Agent Teams and Auto review ship this way first, as `@deepseek-ai/dsh-experiment ## Consequences -Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically. +Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index 05c23c7c0c..e91b6580e2 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -10,11 +10,11 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 ## 决策 -启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页把可选组合包放在带官方标签的内置分组里,与 profile 自己安装的组合包并列。 +启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页以可选组合包开启「官方」分组,属于 beta 功能的带 beta 标签,排在 profile 自己已安装的组合包之前。 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 -Agent Teams 与 Auto review 首先以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile`、`@deepseek-ai/dsh-experimental-agent-team-web-profile` 与 `@deepseek-ai/dsh-experimental-auto-review`。 +[单组合包决策](../architecture/2026-09-18-agent-teams-single-bundle.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。 ## 考虑过的替代方案 @@ -26,4 +26,4 @@ Agent Teams 与 Auto review 首先以这种方式交付,即 `@deepseek-ai/dsh- ## 影响 -可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。 +可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。 diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml new file mode 100644 index 0000000000..d05cd07741 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml @@ -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/process/2026-09-16-desktop-release-version-derivation.md +2026-09-16-desktop-release-version-derivation.md: 0f1d917cb0d382453fbb0dca561a1eae7d530d67 +2026-09-16-desktop-release-version-derivation.zh.md: e9576e30232b5eb247de047cb67f51db5fa43ae3 diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md new file mode 100644 index 0000000000..0f1d917cb0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md @@ -0,0 +1,29 @@ +# Agent Note: Derive Desktop test versions from the complete dsh version + +Status: implemented + +English | [中文](2026-09-16-desktop-release-version-derivation.zh.md) + +## Problem + +Replacing a dsh prerelease identifier with the update channel name loses the base release identity and changes SemVer ordering. A date alone cannot distinguish multiple test builds of the same base. + +## Decision + +The [Desktop release rules](../../../../apps/desktop/README.md#release-versions) preserve the complete dsh base for production and derive dated, indexed test versions from that base. Final release-family package versions remain equal. The base is retained separately before manifests are retagged, so another test release cannot append a second date suffix. + +The fixed Nightly feed is a distribution address, independent of version derivation. Existing clients continue to use that address with prerelease updates enabled and downgrades disabled. Test distribution does not publish the unsuffixed base. Operators check existing release records and objects before assigning an index. + +The packaging decision retains runtime/version equality; the installed-update materials decision retains private identities and data isolation. Their Nightly naming examples do not define the test version policy. Historical release receipts and frozen archived records remain evidence of what was actually built. + +## Alternatives considered + +**Replace alpha, beta, or rc with nightly.** This discards the base prerelease and can sort above its later releases, preventing updates to the intended version line. + +**Rename the feed with the prerelease identifier.** Installed clients would continue checking their existing feed and miss the replacement publication. + +**Enable downgrade to repair an incorrectly numbered release.** A global downgrade allowance changes ordinary update safety. Affected installations use a manual installer; correcting the feed alone cannot migrate them. + +## Consequences + +A test build has an unambiguous base and creation date without changing production version identity. Signing and upload still validate the final equal package versions. Installed-update material allocation accepts dated prerelease versions; retained legacy Nightly material remains readable for audit. diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md new file mode 100644 index 0000000000..e9576e3023 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 从完整 dsh 版本派生 Desktop 测试版本 + +Status: implemented + +[English](2026-09-16-desktop-release-version-derivation.md) | 中文 + +## 问题 + +用更新通道名替换 dsh 预发布标识会丢失基础发布身份,并改变 SemVer 排序。只有日期无法区分同一基础版本的多次测试构建。 + +## 决策 + +[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)在 production 中保留完整 dsh 基础版本,并从该基础版本派生带日期和序号的测试版本。最终发布家族包版本保持一致。修改清单版本前单独记录基础版本,避免下一次测试发布追加第二个日期后缀。 + +固定 Nightly feed 是分发地址,与版本派生独立。现有客户端继续使用该地址,允许预发布更新并禁止降级。test 分发不发布无后缀基础版本。操作者分配序号前检查已有发布记录和对象。 + +打包决策继续规定运行时与包版本一致性;安装版更新物料决策继续规定私有身份和数据隔离。其中的 Nightly 命名示例不定义测试版本规则。历史发布回执和已冻结归档保留为实际构建的证据。 + +## 考虑过的替代方案 + +**用 nightly 替换 alpha、beta 或 rc。** 这会丢弃基础预发布标识,并可能排在其后续版本之上,阻止更新到预期版本序列。 + +**按预发布标识重命名 feed。** 已安装客户端仍查询原有 feed,将无法获取替换后的发布。 + +**为修复错误版本号而开启降级。** 全局允许降级会改变常规更新的安全性。受影响安装使用手动安装包;仅修正 feed 无法迁移它们。 + +## 结果 + +测试构建具有明确的基础版本和创建日期,production 版本身份保持一致。签名与上传仍验证最终各包版本相等。安装版更新物料分配接受带日期的预发布版本;保留的旧 Nightly 物料仍可读取以供审计。 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml new file mode 100644 index 0000000000..b2f71a9f4d --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml @@ -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/process/2026-09-17-windows-runtime-signature-cache.md +2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9 +2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md new file mode 100644 index 0000000000..911e027719 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md @@ -0,0 +1,47 @@ +# Agent Note: Windows runtime signature cache + +Status: implemented + +English | [中文](2026-09-17-windows-runtime-signature-cache.zh.md) + +## Problem + +Windows packaging reconstructs unsigned native dependencies on each build. Signing unchanged Python and LibreOffice files repeats hardware-token and timestamp operations. Starting PowerShell for each public-key inspection also repeats process initialization. + +## Decision + +Cache complete signed runtime files by original content, public certificate, SignTool bytes, signing-script bytes and cache format. Keep valid vendor signatures. Restore a cache hit through a private staged copy only after checking the input record, signed-file digest, Windows trust, timestamp and configured certificate; reject invalid entries without automatic repair or hardware fallback. Publish complete entries atomically, and retain an existing valid entry when concurrent publishers collide. + +Share the cache only within the current Windows account. Require a fixed local drive, current-account ownership and a private destination DACL; reject linked or redirected paths. Programs running as that account remain trusted. Explicit migration copies complete integrity-checked entries without modifying its source or replacing an existing valid entry. Different timestamp bytes at the same input/policy key do not require replacement; every actual restoration still verifies Windows trust. + +The default cache lives beside the account's existing signing state under the user profile. AppData is unsuitable for this default because an MSIX launcher can redirect new files into its private package storage, separating otherwise identical worktrees launched from different programs. A validated explicit directory remains available for isolated build storage. + +Serialize each complete signing stage and cache-maintenance operation with an exclusive Windows file handle. The lock covers preflight, primary-runtime signing, application-runtime signing and artifact creation separately, leaving compilation and preparation concurrent. A waiter rechecks the cache after acquisition. Stage ownership and the retained hardware-attempt interlock have different lifetimes: closing the stage handle releases contention, while hardware failures and interruptions retain the existing attempt evidence and never authorize an automatic retry. + +Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request. + +The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation. + +## Alternatives considered + +**Multi-file hardware signing.** The primary-runtime decision records that SignTool can continue after the first file fails. Reducing repeated hardware work through caching preserves the existing stop behavior. + +**Detached signature extraction and attachment.** Complete-file caching avoids adding PE-signature manipulation to packaging. Its additional disk use is measurable and confined to build storage. + +**Whole signed-runtime artifacts only.** They can also avoid extraction and copying, but require assembly and dependency-graph identities. Per-file caching can reuse unchanged native dependencies when application code or one dependency changes. + +**Per-entry locks only.** They avoid duplicate signatures for one key but do not coordinate different files, preflight or installer signing against the same hardware token. Stage-wide ownership covers those calls without holding a lock during unrelated compilation. + +**Cache-age expiry.** Age alone does not establish that a timestamped signature is invalid. Rechecking current Windows trust preserves the validation requirement without periodically repeating unchanged hardware signatures. + +## Consequences + +Repeated builds with unchanged native inputs avoid hardware calls for cached files while retaining preflight and final artifact signing. Changed content or signing policy misses the cache. Corrupt, incomplete, untrusted or incorrectly timestamped entries fail without replacing the target or retrying hardware. Concurrent publication and interrupted processes cannot expose incomplete entries or damage another build's files. + +Complete signed Windows x64 builds on one host measure an empty shared cache and reuse from a separate fresh worktree: hardware calls fall from 379 to 17, elapsed time from 37:03 to 13:45, and all 362 runtime entries hit. Cache restoration takes 132 seconds, including 130 seconds of current trust verification. Both builds pass final runtime smoke and signed-artifact checks. Downloads are prepared before timing; each scenario is one complete run. A separate controlled native-file change produces miss/hit/miss with two hardware calls; it tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate. + +## Risks + +The cache consumes local disk and depends on the build account controlling its files and records. It is not a distribution format for untrusted remote caches. Signature validation still depends on Windows trust services; cache hits do not authorize clearing a signing interlock or retrying a failed token operation. A timestamp-service failure can interrupt a cold build even after a successful preflight. + +No automatic capacity eviction runs. Explicit maintenance waits for stage ownership and retires complete entries before deleting their files; interrupted staging directories remain distinguishable from published entries. Runtime summaries count actual publications separately from retained collisions, and report cache verification time separately from signing time. They do not measure the entire package's verification cost. diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md new file mode 100644 index 0000000000..bb8cdd6bdc --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Windows 运行时签名缓存 + +Status: implemented + +[English](2026-09-17-windows-runtime-signature-cache.md) | 中文 + +## Problem + +Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 Python 和 LibreOffice 文件签名会重复执行硬件令牌操作和时间戳请求。每个公钥验签任务单独启动 PowerShell,也会重复进程初始化。 + +## Decision + +按原始内容、公开证书、SignTool 字节、签名脚本字节和缓存格式缓存完整已签名运行时文件。保留有效厂商签名。缓存命中后,通过私有暂存副本核对输入记录、已签名文件摘要、Windows 信任、时间戳和配置的证书,再恢复目标文件;无效缓存项直接失败,不自动修复或回退到硬件签名。完整缓存项原子发布,并发发布冲突时保留已有有效项。 + +缓存仅在当前 Windows 账户内共享。要求固定本地磁盘、当前账户所有权及私有目标目录 DACL;拒绝带链接或被重定向的路径。同账户运行的程序仍受信任。显式迁移复制通过完整性检查的完整缓存项,不修改源目录,也不替换已有有效项。同一输入和策略键下的时间戳字节不同,不构成替换理由;每次实际恢复仍验证 Windows 信任。 + +默认缓存位于用户目录下,与账户已有签名状态放在一起。AppData 不适合作为此默认位置,因为 MSIX 启动器可能将新文件重定向到私有包存储,使不同程序启动的相同 worktree 使用不同缓存。独立构建存储仍可指定经过验证的显式目录。 + +通过 Windows 独占文件句柄串行执行每个完整签名阶段及缓存维护操作。锁分别覆盖预检、主要运行时签名、应用运行时签名及产物生成,编译和准备仍可并行。等待者在获取锁后重新检查缓存。阶段所有权与持久保留的硬件尝试互锁具有不同生命周期:关闭阶段句柄释放竞争锁,而硬件失败或中断保留现有尝试证据,绝不构成自动重试授权。 + +公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。 + +[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。 + +## Alternatives considered + +**多文件硬件签名。** 主要运行时决策记录了 SignTool 在首个文件失败后仍继续处理的行为。通过缓存减少重复硬件操作,可以保留现有停止语义。 + +**分离签名的提取与附加。** 缓存完整文件可以避免给打包流程新增 PE 签名处理代码。其额外磁盘占用可测量,且限于构建存储。 + +**仅复用整个已签名运行时制品。** 该方案还可以省去解压和复制,但需要标识组装流程及依赖图。逐文件缓存可以在应用代码或单个依赖变化时复用其余未变化的原生依赖。 + +**仅使用逐项锁。** 它能避免同一键的重复签名,却无法协调不同文件、预检或安装器签名对同一硬件令牌的访问。阶段级所有权覆盖这些调用,不在无关编译期间持锁。 + +**按缓存年龄过期。** 年龄本身不能说明带时间戳的签名无效。重新检查当前 Windows 信任可以保留验证要求,同时避免周期性重复未变化的硬件签名。 + +## Consequences + +原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。 + +同一主机上的完整 Windows x64 签名构建测量空共享缓存与另一个全新 worktree 的复用:硬件调用从 379 次降至 17 次,耗时从 37:03 降至 13:45,362 个运行时缓存项全部命中。缓存恢复耗时 132 秒,其中当前信任验证占 130 秒。两轮均通过最终运行时 smoke 及签名产物检查。下载内容在计时前准备好;每种场景均为一次完整运行。另一次受控原生文件变更产生未命中、命中、未命中的结果,合计两次硬件调用;它验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。 + +## Risks + +缓存占用本地磁盘,并依赖构建账户控制缓存文件及记录。它不是用于不可信远程缓存的分发格式。签名验证仍依赖 Windows 信任服务;缓存命中不构成清除签名互锁或重试失败令牌操作的授权。即使预检成功,时间戳服务故障仍可能中断冷构建。 + +不执行自动容量淘汰。显式维护等待阶段所有权,在删除文件前先将完整缓存项移出已发布名称;中断的暂存目录仍可与已发布项区分。运行时摘要区分实际发布和冲突保留,并将缓存验证耗时与签名耗时分别记录;它不测量整个安装包的验证成本。 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml new file mode 100644 index 0000000000..c8a392f733 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml @@ -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/process/2026-09-17-windows-signature-completion.md +2026-09-17-windows-signature-completion.md: e9c81531633a41c87ea10c9e4af2999923aa4470 +2026-09-17-windows-signature-completion.zh.md: 3142749db309b638676c171d4b1da8bd34729fe4 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md new file mode 100644 index 0000000000..e9c8153163 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md @@ -0,0 +1,35 @@ +# Agent Note: Separate Windows hardware signing from timestamp completion + +Status: implemented + +English | [中文](2026-09-17-windows-signature-completion.zh.md) + +## Problem + +A combined SignTool command reports timestamp failure as signing failure. Retrying it repeats hardware access, while treating every timestamp error as an uncertain private-key outcome requires administrator recovery. Public timestamp-only requests can fail independently of the token. + +## Decision + +Sign a private PE copy once without a timestamp. Release the hardware interlock only after successful execution and Windows verification of the configured primary signature. Hardware or primary-verification failures retain the interlock; no path automatically recovers it. Appended signatures are unsupported. + +Timestamp fresh copies of the frozen signed file without signing credentials. Only normal failure or warning exits permit another attempt: at most three calls, with one- and two-second delays. Launch failures, uncertain termination, storage changes and verification failures stop immediately. The preflight's overall 60-second deadline includes these operations and does not guarantee three full subprocess deadlines. + +Require Windows trust, timestamp and certificate identity. Remove unsigned signature attributes from private copies with the configured SignTool and compare their complete normalized bytes. A removal warning is accepted only when all bytes remain unchanged. Certificate equality alone cannot exclude different content signed by the same certificate; full-file equality avoids a custom PE or ASN.1 canonicalizer. + +SignTool uses short filenames with the original extension in a private temporary directory. Its longest generated path is checked before hardware access, and the journal records the original-to-staged mapping. The target may occupy another volume: copy verified bytes into a private directory beside it, compare hashes, recheck the original and rename within that volume. Verify committed bytes afterward. The build account must own trusted storage and exclusive target access. + +Exhausted timestamp attempts stop packaging and queued work without another hardware call. Failure evidence is copied to the supervised run before staging cleanup; evidence or audit failure preserves staging. Final artifact verification and release completion remain mandatory. The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) owns runtime contents and vendor signatures. + +## Alternatives considered + +**Retry combined signing or classify diagnostic text.** Exit codes and localized text cannot establish the private-key outcome. Separate operations avoid repeated hardware access. + +**Reuse failed timestamp output.** A failed process may partially modify its file. Every attempt starts with identical verified primary-signature bytes. + +**Stage beside each target for all operations.** Nested dependencies can exceed SignTool's path limit even when the original fits. Short tool paths and same-volume publication address separate requirements. + +## Consequences + +Fault tests cover lock retention, isolated timestamp retries, queue rejection, credential exclusion, substituted content, changed targets, long paths and evidence failures. Public-only qualification covers EXE, DLL, PYD and NODE files, including failure followed by success and cross-volume publication. A real supervised signing preflight has passed; complete installer comparisons remain separate qualification. This decision does not identify or repair the timestamp service's network failures. + +DigiCert [supports separate signing and timestamping](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems). Microsoft documents [SignTool commands and exit codes](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). The configured tool and normalization implementation participate in signature-cache identity. diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md new file mode 100644 index 0000000000..3142749db3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md @@ -0,0 +1,35 @@ +# Agent Note:分离 Windows 硬件签名与时间戳处理 + +Status: implemented + +[English](2026-09-17-windows-signature-completion.md) | 中文 + +## 问题 + +合并的 SignTool 命令把时间戳失败报告为签名失败。重试会重复访问硬件,而把所有时间戳错误都视为私钥操作结果不确定,又需要管理员恢复。公开时间戳请求可以在不使用令牌的情况下独立失败。 + +## 决策 + +对私有 PE 副本仅签名一次,不请求时间戳。只有命令成功且 Windows 验证配置证书的主签名后,才释放硬件互锁。硬件或主签名验证失败保留互锁;任何路径都不自动恢复互锁。不支持追加签名。 + +从固定的已签名文件生成新副本并请求时间戳,不传递签名凭据。只有正常退出但返回失败或警告时才允许再次尝试:最多调用三次,间隔为一秒和两秒。启动失败、终止状态不确定、存储变化和验签失败会立即停止。预检整体 60 秒期限包含这些操作,不保证每次子进程都能获得完整期限。 + +要求 Windows 信任、时间戳和证书身份均通过验证。使用配置的 SignTool 删除私有副本的未签名属性,再比较完整的规范化字节。删除警告仅在所有字节未变时接受。仅比较证书无法排除由同一证书签署的不同内容;全文件相等检查避免自行实现 PE 或 ASN.1 规范化器。 + +SignTool 在私有临时目录中使用保留原扩展名的短文件名。访问硬件前检查生成的最长路径,日志记录原文件与暂存文件的对应关系。目标可以位于另一个卷:将已验证字节复制到目标旁的私有目录,比较摘要,重新检查原文件,再在同一卷内重命名。随后验证提交后的字节。构建账户必须拥有受信任的存储和目标独占访问权。 + +时间戳尝试耗尽后,停止打包和排队任务,不再次调用硬件。清理暂存目录前,将失败证据复制到受监督运行目录;证据保存或审计失败时保留暂存目录。最终产物验签和发布完成检查仍是必需步骤。[第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)负责运行时内容与上游签名。 + +## 考虑过的替代方案 + +**重试合并签名命令或按诊断文本分类。** 退出码和本地化文本无法确定私钥操作结果。分离操作避免重复访问硬件。 + +**复用失败的时间戳输出。** 失败进程可能已部分修改文件。每次尝试均从相同的已验证主签名字节开始。 + +**所有操作都在目标旁暂存。** 即使原路径未超限,嵌套依赖的暂存路径也可能超出 SignTool 限制。短工具路径和同卷发布分别满足不同要求。 + +## 影响 + +故障测试覆盖互锁保留、独立时间戳重试、队列拒绝、凭据隔离、内容替换、目标变化、长路径和证据保存失败。仅使用公开操作的验收覆盖 EXE、DLL、PYD 和 NODE 文件,包括先失败后成功及跨卷发布。真实受监督签名预检已通过;完整安装包对比仍需单独验收。此决策不确定或修复时间戳服务的网络故障。 + +DigiCert [支持分开签名与添加时间戳](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems)。Microsoft 记录了 [SignTool 命令及退出码](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool)。配置的工具和规范化实现均参与签名缓存身份计算。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml rename to .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml index d7871098ba..165022b7dc 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml @@ -1,6 +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/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md -2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d -2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md +2026-09-15-model-agent-availability-and-team-targets.md: e1f7f95cfa333be6d05677b4b19775c744f2817c +2026-09-15-model-agent-availability-and-team-targets.zh.md: 0447d7d4059eb75d1fb9169389cd83a54292aadf diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md new file mode 100644 index 0000000000..e1f7f95cfa --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md @@ -0,0 +1,29 @@ +# Agent Note: Model agent availability and Team targets + +Status: implemented + +English | [中文](2026-09-15-model-agent-availability-and-team-targets.zh.md) + +## Problem + +Team operations accept member names, but returning both names and Session UUIDs invites the model to choose an unusable address. Loaded and stored agents also have distinct internal statuses despite requiring the same model action to continue work. + +## Decision + +The Team tool adapter returns `target` from the existing member name and omits member Session IDs from creation and listing results. Messaging, interruption, and task assignment consume that same name. Task `ownerName`, task IDs, and message IDs retain their existing meanings. Internal services, Web views, and persisted Team records retain Session identity. + +Normal subagent tool results and Team service views project loaded-between-turns and stored agents as `inactive`. Only an executing turn is `running`; Team retains `provisioning` and `failed` for member creation, and ordinary subagent diagnostic rows retain their read-failure reasons. Interruption projects its previous status by the same rule. Availability does not report task outcomes or waiting dependencies. + +The Team service owns roster and interruption availability for both tools and Web. The ordinary subagent projection belongs to its list tool. Runtime loading, cold resume, interruption authority, and mailbox delivery retain their existing behavior. Ordinary subagents continue to use `agent_id`; stable subagent names and historical-data handling are deferred. No persistence type or Session format changes. + +## Alternatives considered + +**Add stable names to ordinary subagents now.** This requires a durable naming policy and explicit handling of historical children without names. It is independent of removing misleading Team identities and model status distinctions. + +**Merge internal runtime states.** Residency determines whether delivery starts a loaded agent or restores one from storage. Underlying Agent status and registry presence retain that distinction; Team views expose only turn availability. + +## Consequences + +Models can copy a Team result's target directly into applicable operations. They lose member UUIDs and residency details from these tool results; neither is needed for Team addressing. Ordinary subagent and Team addressing remain different until a separate naming decision. + +Focused service and tool tests cover every status projection, returned-target operations, diagnostic rows, and live versus stored children. Recorded headless Team and SDK subagent scenarios pin the assembled model output and schemas. The [Agent Teams decision](../feature/2026-08-05-agent-teams.md) continues to own roster durability, scoping, and authority. diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md new file mode 100644 index 0000000000..0447d7d405 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 模型可见的 Agent 可用状态与 Team 目标 + +Status: implemented + +[English](2026-09-15-model-agent-availability-and-team-targets.md) | 中文 + +## 问题 + +Team 操作接受成员名称,但同时返回名称和 Session UUID 容易使模型选择不可用的地址。已加载与仅存储的 agent 也有不同的内部状态,尽管模型继续其工作时采取相同操作。 + +## 决策 + +Team 工具适配器从现有成员名称生成 `target`,并从创建和列表结果中省略成员 Session ID。消息、中断和任务分配使用同一名称。任务的 `ownerName`、任务 ID 和消息 ID 保持现有含义。内部服务、Web 视图和持久化 Team 记录保留 Session 身份。 + +普通 subagent 工具结果与 Team 服务视图把已加载但处于轮次之间的 agent 和仅存储的 agent 都投影为 `inactive`。只有正在执行轮次时才是 `running`;Team 保留表示成员创建状态的 `provisioning` 与 `failed`,普通 subagent 的诊断行保留读取失败原因。中断结果按同一规则投影之前的状态。可用状态不报告任务结果或等待依赖。 + +Team 服务为工具和 Web 统一提供 roster 与中断的可用状态。普通 subagent 的投影归其列表工具所有。运行时加载、冷恢复、中断权限和邮箱投递保持现有行为。普通 subagent 继续使用 `agent_id`;稳定的 subagent 名称和历史数据处理暂缓。不修改持久化类型或 Session 格式。 + +## 考虑过的替代方案 + +**立即为普通 subagent 增加稳定名称。** 这需要持久化命名策略,以及对历史无名称子级的明确处理。它与移除误导性的 Team 身份和模型状态区别相互独立。 + +**合并内部运行状态。** 驻留状态决定投递是启动已加载的 agent,还是从存储恢复。底层 Agent 状态与注册表中的存在性保留该区别;Team 视图仅暴露轮次可用状态。 + +## 影响 + +模型可以把 Team 结果的 target 直接复制到适用操作。这些工具结果不再提供成员 UUID 和驻留细节;Team 寻址不需要它们。在另行决定命名方案之前,普通 subagent 与 Team 的寻址方式仍不同。 + +定向服务与工具测试覆盖所有状态投影、返回目标的操作、诊断行和在线与仅存储子级。录制的 headless Team 与 SDK subagent 场景固定组装后的模型输出和 schema。[Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续拥有 roster 持久化、作用域和权限规则。 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 867520af75..bc0def0cce 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.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/testing/2026-09-06-frontend-performance-budgets.md -2026-09-06-frontend-performance-budgets.md: 2ccc992add9a1f7fc7824a3e0ed604a9582def56 -2026-09-06-frontend-performance-budgets.zh.md: bfe31fd4c8c61b3b89336a8d9d8234d1e76ba040 +2026-09-06-frontend-performance-budgets.md: ea91edf8d39d838ab58204f8516d9d1e9d50109f +2026-09-06-frontend-performance-budgets.zh.md: 84c115ef09f55babf96eb1ef8d2f472a139dd55d diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 2ccc992add..ea91edf8d3 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -68,6 +68,8 @@ Run 34036109842, job 101494445658 records open samples of 875.306861/1083.683529 A controlled mouse-refocus delay waits for the real DONE marker without pausing replay: the mouse path rejects a trusted input after DONE, while Enter submission and keyboard-only draft input pass all three samples under the same control. The delay is diagnostic-only. A clean three-sample run on arm64 Node 24.19.0 / Chromium 149.0.7827.55 reports first-reply/input/complete-wall medians of 288.823/418.868/2567.328 ms, with actual overlap and post-DONE rejection in every sample. This proves removal of the mouse-action scheduling dependency, not the cause of a particular hosted stall; all workload constants and budgets remain fixed. +The current Trajectory acceptance limit is 650 ms, represented by a 520 ms target with the shared 1.25× headroom. Run 35095609422 records medians of 666.651723 ms and 630.843184 ms on its original attempt and retry. This explicit 4% relaxation of the former 625 ms limit accepts the retry median but still rejects the original median; it is a budget decision, not a measured product speedup. Recorded retry samples exercise the accepted range, and the same verdict assertion rejects 651 ms. Other endpoint budgets and workload parameters remain unchanged. + ## Alternatives considered **Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index bfe31fd4c8..84c115ef09 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -68,6 +68,8 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式 受控的鼠标重新聚焦延迟等待真实 DONE 标记,不暂停重放:鼠标路径拒绝 DONE 之后的真实输入,而 Enter 提交与纯键盘草稿输入在相同对照下通过全部三个样本。该延迟仅用于诊断。在 arm64 Node 24.19.0 / Chromium 149.0.7827.55 上,不含延迟的三个样本报告首段回复/输入/完整壁钟中位数 288.823/418.868/2567.328 ms,每个样本均满足实际重叠并拒绝 DONE 之后的输入。这证明移除了鼠标操作调度依赖,并不证明某次托管停顿的原因;全部工作负载常量和预算保持固定。 +当前 Trajectory 验收上限为 650 ms,由 520 ms 目标与共享的 1.25× 余量表示。运行 35095609422 的首次执行与重试分别记录中位数 666.651723 ms 和 630.843184 ms。相较于原先的 625 ms 上限,这次明确的 4% 放宽接受重试中位数,但仍拒绝首次中位数;这是预算决策,不代表测得产品提速。记录的重试样本覆盖新增的接受区间,同一个判定断言拒绝 651 ms。其他终点预算与工作负载参数保持不变。 + ## 考虑过的替代方案 **用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。 diff --git a/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml new file mode 100644 index 0000000000..55faf9d686 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.i18n.yaml @@ -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/testing/2026-09-10-desktop-local-updater-qualification.md +2026-09-10-desktop-local-updater-qualification.md: 10c0a384106cc06aaae50d8782b58114ea86fb79 +2026-09-10-desktop-local-updater-qualification.zh.md: 9d554bc5001fc53f60ba9cd0d08b02e975f8261f diff --git a/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md new file mode 100644 index 0000000000..10c0a38410 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md @@ -0,0 +1,51 @@ +# Agent Note: Qualify Desktop update downloads with an isolated local server + +Status: implemented + +English | [中文](2026-09-10-desktop-local-updater-qualification.zh.md) + +## Problem + +Desktop update interaction depends on feed parsing, network failures, download integrity, and platform preparation. Simulated updater events cannot establish that these operations work together. Requiring cloud cache configuration and hardware signing for each feedback cycle makes local product validation depend on release infrastructure. + +## Decision + +The [Windows local qualification command](../../../../apps/desktop/README.md) runs the built production coordinator inside Electron with the real `ElectronHttpExecutor` and `NsisUpdater`. An isolated application adapter supplies a test version and private update configuration. A loopback server supplies Nightly YAML, mandatory policy, and inert binary bytes. The installer, external browser, and clipboard calls are replaced with observations. Publisher verification is absent only from this private fixture configuration, never from production configuration. + +Each invocation atomically allocates a temporary directory and an operating-system-assigned loopback port. Request barriers establish concurrency without sleeps. Server close and child exit are awaited before temporary files are removed. Independent processes can run concurrently without sharing updater cache or user data. The command requires built Desktop modules and stays separate from source-plane unit tests. + +This testing decision supplements the [release policy](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); it does not supersede signing, publication integrity, installed-artifact qualification, or the pending [mandatory update proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md). Those records remain active. + +## Alternatives considered + +**Use only simulated updater events.** They cover state transitions but cannot establish actual YAML parsing, Electron transport, downloaded bytes, or checksum rejection. Local qualification uses real dependency implementations; native-dialog and renderer component tests remain separate. + +**Require signed cloud-hosted packages for every local iteration.** That couples ordinary interaction debugging to signing hardware and CDN configuration. Release qualification still requires those systems, but local download tests do not. + +**Relax production signing or execute a dummy installer.** Neither is needed to observe download readiness and installation authorization. The fixture never loads production update configuration and records installation without launching downloaded bytes. + +## Consequences + +The executable scenarios cover no update, downgrade rejection, HTTP 404/408 and invalid YAML, stalled-feed and stalled-download deadlines, same-address feed replacement, one in-flight check or download, explicit download authorization, SHA-512 mismatch, interrupted transfers, explicit retry, prepared-version retention, restart refusal, shutdown failure, and disposal during a pending check. Native-dialog regressions cover checking feedback, check failure, download refusal, and separate installation action; account-row tests own progress and persistent retry presentation. + +The ordinary polling regression uses the real coordinator with a fake clock, instance-local random samples, and deferred network completion. It verifies completion-based jitter, capped exponential backoff, successful reset, manual failure visibility while joining an automatic check, retained download failures and prepared packages, and no timer rearming after disposal. Main-entry tests connect the same deadline to focus, resume, explicit IPC, and application shutdown. Removing failure backoff makes the timing assertion fail. These deterministic tests establish request timing, not fleet capacity or startup-burst distribution. + +The actual [mandatory-update window](../feature/2026-09-11-desktop-mandatory-update-client.md), sandboxed preload, and renderer button handlers run against the policy server and updater. A stalled policy request reaches its real deadline without clearing the block. Owner-local DOM expectations pin Chinese text and actions. Screenshot failures are recorded separately; missing screenshots cannot establish visual acceptance. + +The [workspace browser scenario](../../../../apps/web/tests/desktop-updates.e2e.ts) checks built sidebar composition in both locales against the production presentation function. A page-local carrier supplies update state and records actions; the Host, client plugins, styles, and connection remain real. This separates slot placement and click-guard evidence from Electron IPC, task authorization, and installation evidence. Screenshots and result files use an invocation-specific ignored directory and do not replace release qualification. + +The [Host qualification runner](../../../../apps/desktop/scripts/test-host-updates.ts) loads the built Host and standard agent preset in a private profile. Real task registries, queued messages, pending tool questions/approvals, and Node jobs establish task-detection and admission-lock evidence without replacing Host composition. Scripted model output and held human answerers control the waiting points; cancellation and process exit establish completion. This evidence excludes the development launcher's dependency projection, Electron installation confirmation, and failed task shutdown. Concurrent invocations own separate home, project, and session directories. + +The [Electron workspace runner](../../../../apps/desktop/scripts/test-workspace-updates.ts) couples the compiled main entry and actual preload to a separate real Host. A private profile plugin controls real queued tasks and deliberately holds teardown; local HTTP delivery replaces release infrastructure, and the updater's installer call is intercepted. It verifies confirmation-time task changes, refused installation after failed teardown, replacement Host readiness, and fresh confirmation under ordinary and mandatory policy. Renderer actions wait for finite layout animations and reject obscured targets; window-listener cleanup retains web contents independently of destroyed windows. The runner does not prove bundled-main deployment or operating-system composition of overlapping windows. + +The runner launches Electron without hiding its GUI and asserts main-window visibility before input or screenshots. Hidden or occluded renderers can suspend frame callbacks even when DOM queries complete. Each DOM, click-layout, and screenshot operation temporarily disables background throttling on its own WebContents, then restores and checks the original setting. Real animations and click-obscuration assertions remain enabled; production window configuration is unchanged. A main-process deadline and invocation-local trace bound each operation; failure diagnostics record window visibility and animation state with their own deadline. Diagnostic capture failure cannot replace the original test failure. Re-enabling Windows process hiding fails the visibility assertion, and concurrent unprotected windows reproduce a suspended frame wait. + +The production `DesktopUpdateHttpExecutor` retains the library transport and adds an inactivity deadline to actual Electron requests. Electron 44 emits writable `close` before response headers, so that event cannot indicate HTTP completion. Response completion, abort, and errors release the timer; received bytes refresh it. Both stalled headers and stalled payloads fail and recover through an explicit retry in the local fixture. + +The [signed-download runner](../../../../apps/desktop/scripts/test-signed-updates.mjs) complements inert-byte tests with existing signed and unsigned executables, a public certificate, and real Windows Authenticode verification. Its private feed uses a synthetic version, and installation is intercepted. Correct-hash files with a wrong publisher or no signature are rejected and removed from cache; corrupt transfer bytes fail checksum verification first. Automatic checks retain the failure without another request, while explicit retry with a signed payload reaches readiness and requires separate restart approval. Inputs remain unchanged. Each process owns its port and cache; the deadline stops its complete Electron/PowerShell process tree before runtime cleanup. Missing publisher configuration makes the rejection assertion fail. + +Optional old-installer input enables real differential reconstruction from private cache and original blockmaps. The server supports single and multipart byte ranges, with explicit missing-blockmap and rejected-range failures. Hash and signature verification cover reconstructed and full-fallback results; request records require actual byte reuse and reject full fallback as differential success. Disabling differential download fails the blockmap-request assertion. Historical input files remain read-only, and independent concurrent runs retain separate caches and ports. + +The local download fixture injects `ENOSPC` into its executable write stream after writing partial bytes to disk. The actual updater must clear the partial executable, refuse installation, retain explicit retry, and download verified bytes after the original writer is restored. Only the private download path is intercepted, and stream closure is awaited. This qualifies handling of a filesystem write failure without exhausting a shared host volume. + +This evidence does not establish actual installer execution, post-restart health, production CDN Range behavior, macOS updater preparation, or actual volume exhaustion. The test servers publish nothing and never read cloud credentials; production delivery and signed installed-version upgrades remain separate qualification. diff --git a/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md new file mode 100644 index 0000000000..9d554bc500 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 使用隔离的本地服务器验证 Desktop 更新下载 + +Status: implemented + +[English](2026-09-10-desktop-local-updater-qualification.md) | 中文 + +## 问题 + +Desktop 更新交互依赖清单解析、网络失败、下载完整性和平台准备。模拟 updater 事件无法证明这些操作能够协同工作。每轮反馈都要求配置云端缓存并完成硬件签名,会让本地产品验证依赖发布基础设施。 + +## 决策 + +[Windows 本地验证命令](../../../../apps/desktop/README.zh.md) 在 Electron 中运行构建后的生产协调器,并使用真实的 `ElectronHttpExecutor` 和 `NsisUpdater`。隔离的应用适配器提供测试版本和私有更新配置。回环服务器提供 Nightly YAML、强更策略和不可执行的二进制字节。安装器、外部浏览器和剪贴板调用被替换为观测记录。发布者验证仅在此私有 fixture(测试前置数据)配置中缺省,生产配置不受影响。 + +每次调用都原子分配临时目录和由操作系统分配的回环端口。请求屏障在不使用休眠的情况下建立并发条件。移除临时文件之前,测试会等待服务器关闭和子进程退出。独立进程可以并发运行,互不共享 updater 缓存或用户数据。此命令依赖构建后的 Desktop 模块,与源码侧单元测试分开运行。 + +此测试决策补充[发布策略](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md),并不取代签名、发布完整性、已安装产物验收或待实施的[强制更新提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)。这些记录保持有效。 + +## 考虑过的替代方案 + +**仅使用模拟 updater 事件。** 它们覆盖状态转换,但无法证明真实的 YAML 解析、Electron 传输、下载字节或校验和拒绝。本地验证使用真实依赖实现;原生弹窗和渲染器组件测试仍独立执行。 + +**每轮本地迭代都要求云端托管的签名包。** 这会让常规交互调试依赖签名硬件和 CDN 配置。发布验收仍需要这些系统,但本地下载测试不需要。 + +**放宽生产签名要求或执行伪安装器。** 观测下载就绪和安装授权不需要这两种做法。fixture 从不加载生产更新配置,只记录安装,不启动下载的字节。 + +## 后果 + +可执行场景覆盖无更新、拒绝降级、HTTP 404/408 和无效 YAML、清单与下载停滞截止时间、同地址清单替换、单一在途检查或下载、显式下载授权、SHA-512 不匹配、传输中断、显式重试、已准备版本保留、拒绝重启、关闭失败,以及检查未完成时的 dispose(资源释放)。原生弹窗回归覆盖检查反馈、检查失败、拒绝下载和独立安装操作;账户行测试负责进度和持久重试展示。 + +常规轮询回归使用真实协调器、模拟时钟、实例私有随机样本和可控的网络完成时机。它验证从完成时刻起算的抖动、有上限的指数退避、成功重置、手动复用自动检查时的失败可见性、下载失败和已准备安装包保留,以及 dispose 后不重新启动计时器。主入口测试将同一期限连接到窗口聚焦、系统恢复、显式 IPC 和应用退出。移除失败退避会使计时断言失败。这些确定性测试证明请求时序,不证明客户端总体容量或启动峰值分布。 + +真实的[强更窗口](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)、沙箱预加载和页面按钮处理会对接策略服务器与 updater。停滞的策略请求达到真实截止时间后不会清除阻塞。所属测试目录内的 DOM 预期输出约束中文文案和操作。截图失败单独记录;缺少截图不能证明视觉验收通过。 + +[工作区浏览器场景](../../../../apps/web/tests/desktop-updates.e2e.ts) 使用生产展示函数检查两种语言下构建后的侧栏组合。页面私有的载体提供更新状态并记录操作;Host、客户端插件、样式和连接保持真实。slot 位置与点击保护的证据因此与 Electron IPC、任务授权和安装证据分开。截图和结果文件使用每次调用独有的忽略目录,不替代发布验收。 + +[Host 验证运行器](../../../../apps/desktop/scripts/test-host-updates.ts) 在私有 profile 中加载构建后的 Host 和 standard agent 预设。真实任务注册表、排队消息、等待答复的工具提问/审批和 Node job 提供任务探测与准入锁定证据,不替换 Host 组合。脚本化模型输出和暂停的人工答复器控制等待点;取消和进程退出确立完成状态。这些证据不包含开发启动器的依赖投影、Electron 安装确认和任务停止失败。并发调用各自拥有独立的 home、项目和会话目录。 + +[Electron 工作区运行器](../../../../apps/desktop/scripts/test-workspace-updates.ts) 将编译后的主入口和真实预加载连接到独立的真实 Host。私有 profile 插件控制真实排队任务并刻意阻塞停止过程;本地 HTTP 分发替代发布基础设施,updater 的安装器调用被拦截。它验证确认期间任务变化、收尾失败后拒绝安装、替代 Host 就绪,以及普通和强更策略下的重新确认。页面操作等待有限布局动画完成,并拒绝被遮挡的目标;窗口监听清理独立保留 web contents,不依赖已销毁窗口。运行器不证明打包主入口部署或重叠窗口的操作系统合成效果。 + +运行器启动 Electron 时不隐藏 GUI,并在输入或截图之前断言主窗口可见。隐藏或被遮挡的渲染器即使能够完成 DOM 查询,也可能暂停帧回调。每次 DOM、点击布局和截图操作仅临时关闭自身 WebContents 的后台节流,随后恢复并检查原设置。真实动画和点击遮挡断言保持启用;生产窗口配置不变。每项操作都有主进程截止时间及本次运行独有的跟踪记录;失败诊断在独立截止时间内记录窗口可见性和动画状态。诊断捕获失败不能替换原始测试失败。重新启用 Windows 进程隐藏会使可见性断言失败,未保护的并发窗口可复现帧等待停滞。 + +生产使用的 `DesktopUpdateHttpExecutor` 保留依赖库传输,并为真实 Electron 请求添加无活动截止时间。Electron 44 会在响应头到达前触发可写流的 `close`,因此该事件不能代表 HTTP 完成。响应完成、中止和错误释放计时器;收到字节会刷新计时器。响应头停滞与负载停滞均在本地 fixture 中失败,并通过显式重试恢复。 + +[签名下载运行器](../../../../apps/desktop/scripts/test-signed-updates.mjs) 使用已有的签名及未签名可执行文件、公开证书和真实 Windows Authenticode 验签,补充不可执行字节测试。私有清单使用合成版本号,安装调用被拦截。哈希正确但发布者错误或未签名的文件被拒绝,并从缓存移除;传输损坏的字节先被校验和验证拒绝。自动检查保留失败,不发送新请求;显式重试签名文件后进入就绪,并要求单独批准重启。输入文件保持不变。每个进程独占端口和缓存;截止时间触发时先停止完整的 Electron/PowerShell 进程树,再清理运行时目录。缺少发布者配置会使拒绝断言失败。 + +可选的旧安装器输入启用真实差分重建,使用私有缓存和原始 blockmap。服务器支持单段与多段字节范围,并显式提供缺少 blockmap 和拒绝 Range 的故障。哈希与签名验证覆盖重建及全量回退结果;请求记录要求实际复用字节,不允许全量回退冒充差分成功。禁用差分下载会使 blockmap 请求断言失败。历史输入文件保持只读,独立并发运行各自拥有缓存和端口。 + +本地下载 fixture 在向磁盘写入部分字节后,为其可执行文件写入流注入 `ENOSPC`。真实 updater 必须清理不完整的可执行文件、拒绝安装、保留显式重试,并在恢复原始写入器后下载通过校验的字节。只拦截私有下载路径,且等待流关闭。这验证文件系统写入失败的处理,不耗尽共享主机卷。 + +这些证据不能证明真实安装器执行、重启后健康状态、生产 CDN Range 行为、macOS updater 准备或真实卷耗尽。测试服务器不发布任何内容,也不读取云端凭据;生产分发和签名已安装版本升级仍需独立验收。 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml similarity index 55% rename from .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml rename to .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml index 7d4eb35fa1..6ebebc30d8 100644 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml @@ -1,6 +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/feature/2026-09-10-connection-indicator-refinements.md -2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b -2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md +2026-09-14-desktop-installed-update-journal.md: 2a0ecbde408d1207102ba206ada333de6d123711 +2026-09-14-desktop-installed-update-journal.zh.md: 129efa7c123b6945e857e55e3632b18850ceb67d diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md new file mode 100644 index 0000000000..2a0ecbde40 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md @@ -0,0 +1,29 @@ +# Agent Note: Preserve installed Desktop update evidence across restarts + +Status: implemented + +English | [中文](2026-09-14-desktop-installed-update-journal.zh.md) + +## Problem + +An installed update replaces application files and exits the process that observed the download. Terminal output alone cannot connect a failed transfer, explicit retry, installation authorization, and the next version's startup. Raw updater diagnostics can contain private URLs or credentials. + +## Decision + +The Desktop main entry accepts an opt-in absolute `DSH_DESKTOP_UPDATE_JOURNAL_DIR`. Qualification packages must retain the same external directory across versions. Each process exclusively creates a separate JSONL file and flushes whitelisted state and action records before continuing. The installed version, PID, sequence, and UTC time identify records. Integer progress changes limit repeated writes; raw errors, URLs, request headers, and chat content are omitted. Known error tokens produce fixed classifications instead of copied diagnostics. + +Without the variable, the journal is disabled. Explicit qualification storage failures propagate rather than silently claiming complete evidence. Startup and workspace readiness are observations, not proof of installer success or preserved user data. The [local qualification decision](2026-09-10-desktop-local-updater-qualification.md) remains active: this journal does not supersede its isolation, intercepted-installation limits, or hardware restrictions. + +The [installed-run tool](../../../../apps/desktop/scripts/installed-update-qualification.ts) allocates a private test manifest without credentials or remote operations. Its read-only inspector requires an ordered failure, manual retry, readiness, confirmation, and quit in one original process, followed by successor startup and workspace readiness. Unknown fields, partial records, sequence gaps, and mixed process identities are rejected. Even complete recorded flow retains independent operator checks for publication timing, network recovery, installer completion, and preserved data; a collection of startup logs cannot substitute for those observations. + +## Alternatives considered + +**Keep only terminal output or installation-directory files.** Installation replaces application files, and the new process can outlive the terminal. The evidence directory remains outside the installation tree. + +**Persist arbitrary logs and redact known secrets.** Unknown authentication values and private paths cannot be exhaustively enumerated. Field whitelisting retains less detail but excludes those strings. + +## Consequences + +Journal collection snapshots validated bytes once and derives both retained files and milestone reports from those bytes. Re-reading a changing source separately for each output could report observations absent from the saved logs. Each collection uses a new directory and hashes its files; earlier collections and source journals remain unchanged. Fixed diagnostic values are checked at the file reader, not only at the writer, before bytes can enter a collection. Invalid input refuses collection; storage failure preserves partial evidence rather than claiming completion. A complete milestone sequence still leaves operator acceptance pending. + +Journal and main-entry tests cover persistence, retry milestones, separate version files, omitted private fields, and unavailable storage. Installed restart, installation completion, data preservation, and network fault injection still require operator qualification. Logs have no automatic deletion; the operator owns retention and must reject missing or incomplete evidence. The journal never clears signing locks or authorizes installation. diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md new file mode 100644 index 0000000000..129efa7c12 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 跨重启保留已安装 Desktop 的更新证据 + +Status: implemented + +[English](2026-09-14-desktop-installed-update-journal.md) | 中文 + +## 问题 + +已安装应用的更新会替换应用文件,并退出观测下载的进程。仅有终端输出无法串联传输失败、显式重试、安装授权与下一版本启动。原始 updater 诊断可能包含私有 URL 或凭据。 + +## 决策 + +Desktop 主入口接受显式启用的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`。验收包必须在不同版本间保留同一个外部目录。每个进程独占创建单独的 JSONL 文件,并在继续执行前刷新白名单内的状态与操作记录。已安装版本、PID、序号和 UTC 时间标识记录。整数进度变化限制重复写入;原始错误、URL、请求头和聊天内容均被省略。已知错误标记产生固定分类,不复制诊断文本。 + +未设置该变量时不启用日志。显式启用的验收存储失败会向上传播,不会静默声称证据完整。启动和工作区就绪只是观测结果,不能证明安装成功或用户数据保留。[本地验收决策](2026-09-10-desktop-local-updater-qualification.zh.md)继续有效:本日志不取代其隔离要求、安装拦截的局限或硬件限制。 + +[安装版批次工具](../../../../apps/desktop/scripts/installed-update-qualification.ts)创建独立 test 清单,不读取凭据或执行远程操作。其只读检查器要求同一原进程内按序记录失败、手动重试、就绪、确认和退出,再记录后继版本启动与工作区就绪。未知字段、不完整记录、序号缺口与混合进程身份均被拒绝。即使记录流程完整,也保留发布时间、网络恢复、安装器完成和数据保留的独立人工检查;一组启动日志不能代替这些观测。 + +## 考虑过的替代方案 + +**仅保留终端输出或安装目录内的文件。** 安装会替换应用文件,新进程也可能在终端结束后继续运行。证据目录保留在安装目录树之外。 + +**保存任意日志并对已知秘密脱敏。** 未知认证值和私有路径无法穷尽枚举。字段白名单保留的细节较少,但排除了这些字符串。 + +## 结果 + +日志收集只读取一次通过验证的字节,并从这些字节生成保存文件和里程碑报告。分别为各输出重读变化中的源文件,可能报告保存日志中不存在的观测。每次收集使用新目录并记录文件哈希;旧收集和源日志不变。在字节进入收集目录前,文件读取器也检查固定诊断值,而非仅依赖写入器。输入无效时拒绝收集;存储失败保留部分证据,不宣称完成。完整的里程碑序列仍将人工验收保持为待确认。 + +日志与主入口测试覆盖持久化、重试标记、不同版本的独立文件、私有字段省略和存储不可用。已安装应用重启、安装完成、数据保留与网络故障注入仍需人工验收。日志不会自动删除;操作者负责保留记录,并必须拒绝证据缺失或不完整的结果。日志不会清除签名锁定或授权安装。 diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml new file mode 100644 index 0000000000..0c1184b114 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml @@ -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/testing/2026-09-14-desktop-installed-update-materials.md +2026-09-14-desktop-installed-update-materials.md: 1813c6d9bbdf12697e32f7440e4b654a33bb2384 +2026-09-14-desktop-installed-update-materials.zh.md: b92bb70b32e6549b8f2aed48f6aa9b2b8364fe54 diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md new file mode 100644 index 0000000000..1813c6d9bb --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md @@ -0,0 +1,49 @@ +# Agent Note: Isolate installed-update materials from releases and user data + +Status: implemented + +English | [中文](2026-09-14-desktop-installed-update-materials.zh.md) + +## Problem + +An installed-update walkthrough needs two increasing versions with a shared application identity. Reusing normal release paths can expose test metadata to unrelated clients, while storing test state inside an installation directory loses evidence during replacement. A successful copy does not establish that a signed installer exists. + +## Decision + +Version derivation follows the [release version decision](../process/2026-09-16-desktop-release-version-derivation.md); this record continues to govern material, application identity, and user-data isolation. + +The [material preparer](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts) accepts a retained test-only manifest with a random identity and distribution namespace. It copies a verified source runtime into separate derived test versions, changes only release-family versions and matching dependency references, and seals and verifies both inventories. The original runtime is verified again and its descriptor hash must remain unchanged. Existing output directories are not overwritten; a failed copy retains a failure record and cannot create a completion receipt. + +The generated bootstrap validates the installed package identity before importing production main. Each version assigns the same application-data subdirectory to its test Electron profile, Harness home, and external journals. Environment values inherited from the first launch do not determine those paths. The manifest reader rejects changed test destinations, identities, and directory locations. The [operator guide](../../../../apps/desktop/tests/installed-update/README.md) distinguishes source preparation, private runtime copies, and signed installed-app qualification. + +The [journal decision](2026-09-14-desktop-installed-update-journal.md) and [local updater qualification decision](2026-09-10-desktop-local-updater-qualification.md) remain active: material preparation neither proves an installed upgrade nor changes hardware authorization. Signing, upload, installation, and network mutation are absent from these preparation tools. + +## Alternatives considered + +**Retag the repository's package manifests between builds.** That changes the working release version and can affect other tasks or release commands. Private runtime copies and package-specific metadata keep the source checkout's versions unchanged. + +**Use the production identity or terminal-only data overrides.** Production identity shares registration and data with ordinary installs; terminal overrides may not survive installer-triggered startup. A private identity and packaged bootstrap retain isolation across versions. + +**Resume by overwriting a partially prepared version.** That can mix source generations and discard failure evidence. An existing output or preparation record refuses reuse; a clean attempt requires a new run. + +**Infer upload failure from a denied bucket-configuration query.** Object access and bucket configuration have separate permissions. An added preflight can reject credentials whose object operations work. Diagnose the exact API and compare existing successful evidence before asking for broader permissions; no attempted PUT means no observed upload failure. + +## Consequences + +Feed publication reuses a completed binary-upload result whose retained plan exactly matches the current verified distribution, including run identity, version, destinations, sizes, and hashes. It records the receipt and plan hashes and reads only the feed remotely. Initial binary upload retains full public-byte verification. Repeating that transfer for every feed write adds latency and traffic without changing immutable artifacts; the retained receipt establishes prior delivery, not a guarantee against later external deletion. Missing, failed, or mismatched receipts refuse publication rather than silently triggering another large download. + +The separate [publication entry](../../../../apps/desktop/tests/installed-update/publication/README.md) defaults to local receipt and byte verification. Binary upload cannot advertise a version; successor feed publication requires original-version startup evidence and a matching previous feed. The test-only transport disables SDK write retries, requires an authoritative unversioned-bucket check before immutable-object protection, and verifies exact public bytes. A local lock does not provide distributed exclusion: one operator must own the mutable feed across machines. Each authorized operation retains independent stage records; uncertain writes require inspection, not automatic repetition. Matching objects can be reconciled without replacement. In-memory sequencing and substituted HTTP transport tests do not establish real COS publication or installed-app startup. + +The separate [operator packaging entry](../../../../apps/desktop/scripts/package-installed-update.ts) checks the durable signing interlock before loading credentials and rechecks after interactive confirmation. Each execution owns one version and a never-reused packaging directory. It uses the existing supervisor with a 15-minute stage deadline, retains redacted output and selected source/tool hashes, and compares those inputs after building. Upload credentials never reach the builder; publication and installation are absent. Inert subprocess tests cover refusal, redaction, retained failure, and no reuse; actual signed output and package verification remain separate evidence. An overall deadline cannot limit CSP-internal PIN attempts or authorize unattended signing. + +Application preparation freezes built main/preload modules, renderer files, and the generated bootstrap once per run, records SHA-256 values, and rejects changed, missing, or additional frozen files. The [qualification builder configuration](../../../../apps/desktop/scripts/installed-update-builder.ts) reuses the ordinary configuration factory with the selected private runtime, so resource verification and signing use the same runtime version. It preserves installer hooks and publisher checks while selecting a shared test identity, package entry, and fixed test feed. Dependency trees and build tools remain separate mutable inputs and require final-build records. Configuration validation uses the pinned builder with a substituted signer, not hardware or installed-app evidence. + +The read-only [distribution planner](../../../../apps/desktop/scripts/installed-update-distribution.ts) checks each version's generated YAML against local installer bytes and separates immutable binary objects from one mutable feed URL. It reconstructs feed fields from validated values instead of preserving arbitrary input fields. The output explicitly carries file-integrity-only status and no publication authority; credentials, signatures, package identity, and version 1 startup are outside this check. + +The separate [signature reader](../../../../apps/desktop/scripts/installed-update-signature.mjs) requires the real updater verifier, valid Authenticode and timestamp attributes, and unchanged executable bytes. It runs verification children with scrubbed credentials and without inherited PowerShell module paths; incompatible module overrides cannot be treated as successful verification. The publisher comes from the trusted public certificate rather than remote YAML. Read-only verification never calls SignTool, accesses the signing token, or executes the inspected file. Signature success cannot establish package contents or a completed installation. + +The [package verifier](../../../../apps/desktop/scripts/verify-installed-update-package.ts) extracts the signed installer itself rather than trusting a neighboring unpacked directory. Its archived identity, update configuration, frozen application files, and Harness runtime must match the retained run, and its mandatory test policy must be valid. It reads runtime files from ASAR, accepts only electron-builder's dependency-manifest transformation, and checks executable signatures in `app.asar.unpacked`. Unsigned runtime executable changes cannot pass through a regenerated inventory alone. Independent immutable records preserve each check's stages and failures. Final input hashes prevent an installer/feed change from inheriting an earlier result. Real ASAR and runtime fixtures cover mismatches while substituting only external verification and archive processes; actual signed packages still require their own receipts. Installer registration and installed-app behavior are outside this file inspection and remain operator checks. + +The [network fault procedure](../../../../apps/desktop/tests/installed-update/network/README.md) binds a program-specific outbound rule to the verified original executable hash and random run identity. It rejects changed bytes before creation and after confirmation, and removes only a matching owned rule. Recovery remains available if the executable disappears. The operator controls both mutations; stages persist before and after each operation, and failures stop without retry. Firewall-cmdlet substitutes exercise the real script, but a rule's recorded presence or absence never proves traffic interruption or successful download recovery. Adapter-wide or VPN changes are excluded because they can disconnect the operator's remote control. + +Generated-entry tests execute both versions with only Electron's path API substituted and verify the shared directories before main imports. Runtime tests verify dependency retagging, unchanged third-party versions and source bytes, refusal of damaged input, and retained partial failures. Copies consume extra disk space. Source preparation can boot the source runtime, but copied-version startup, final package contents, signatures, publication, and installation require separate evidence; completion receipts name those limits rather than inferring success. diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md new file mode 100644 index 0000000000..b92bb70b32 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 将安装版更新物料与发布及用户数据隔离 + +Status: implemented + +[English](2026-09-14-desktop-installed-update-materials.md) | 中文 + +## 问题 + +安装版更新演练需要两个递增版本共用应用身份。复用正常发布路径可能向无关客户端暴露测试元数据,而把测试状态放在安装目录中会在替换时丢失证据。复制成功不能证明已有签名安装包。 + +## 决策 + +版本派生遵循[发布版本决策](../process/2026-09-16-desktop-release-version-derivation.zh.md);本记录继续规定物料、应用身份和用户数据的隔离。 + +[物料准备器](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts)接受保留的 test 专用清单,其中包含随机身份和分发命名空间。它将已验证的原始运行时复制为独立的派生测试版本,仅修改发布家族版本和匹配的依赖引用,再生成并验证两个完整性清单。原始运行时再次验证,其描述文件哈希必须保持不变。已有输出目录不被覆盖;复制失败保留失败记录,不能生成完成回执。 + +生成的启动入口在导入生产主程序前验证已安装包身份。两个版本将相同的应用数据子目录用于测试 Electron profile、Harness home 和外部日志。首次启动继承的环境变量不决定这些路径。清单读取器拒绝已改变的 test 目标、身份和目录位置。[人工指南](../../../../apps/desktop/tests/installed-update/README.zh.md)区分源码准备、独立运行时副本和签名安装版验收。 + +[日志决策](2026-09-14-desktop-installed-update-journal.zh.md)与[本地 updater 验收决策](2026-09-10-desktop-local-updater-qualification.zh.md)继续有效:物料准备既不证明已安装应用升级通过,也不改变硬件授权。这些准备工具不包含签名、上传、安装或网络改动。 + +## 考虑过的替代方案 + +**在两次构建之间修改仓库包清单的版本。** 这会改变工作区发布版本,可能影响其他任务或发布命令。独立运行时副本与包专属元数据保持源码工作区版本不变。 + +**使用生产身份或仅通过终端覆盖数据目录。** 生产身份与普通安装共享注册和数据;终端覆盖不一定在安装器触发启动时保留。独立身份与包内启动入口维持跨版本隔离。 + +**覆盖准备到一半的版本来继续执行。** 这可能混合不同源码代际并丢弃失败证据。已有输出或准备记录拒绝复用;全新尝试需要新批次。 + +**从桶配置查询被拒绝推断上传失败。** 对象访问与桶配置使用不同权限。新增前置检查可能拒绝对象操作原本可用的凭据。申请扩大权限前,先定位准确 API 并对照已有成功证据;没有尝试 PUT,就没有观察到上传失败。 + +## 结果 + +feed 发布复用已完成的二进制上传结果,其保留计划必须与当前已验证分发计划完全匹配,包括批次身份、版本、目标地址、大小和哈希。发布记录回执及计划哈希,仅在远端读取 feed。首次二进制上传保留完整公网字节验证。每次写 feed 都重复传输会增加延迟和流量,却不改变不可变物料;保留回执证明此前分发成功,不保证对象此后未被外部删除。回执缺失、失败或不匹配时拒绝发布,不隐式触发另一次大文件下载。 + +独立的[发布入口](../../../../apps/desktop/tests/installed-update/publication/README.zh.md)默认仅验证本地回执与字节。二进制上传不能公布版本;后继版本 feed 发布要求原版本启动证据及匹配的旧 feed。test 专用传输禁用 SDK 写入重试,在使用不可变对象保护前要求权威查询确认存储桶未启用版本控制,并验证准确的公网字节。本地锁不提供分布式排他:必须由一个操作者跨机器独占可变 feed。每次授权操作保留独立阶段记录;写入结果不确定时要求检查,不能自动重试。匹配对象可核对而不替换。内存顺序测试和替代 HTTP 传输测试不证明真实 COS 发布或已安装应用启动通过。 + +独立的[人工打包入口](../../../../apps/desktop/scripts/package-installed-update.ts)在加载凭据前检查持久签名保护锁,并在交互确认后再次检查。每次执行只拥有一个版本和一个不复用的打包目录。它使用现有监督程序与 15 分钟阶段期限,保留脱敏输出和选定的源码/工具哈希,并在构建后比较这些输入。上传凭据绝不传给构建器;入口不包含发布和安装。无功能子进程测试覆盖拒绝、脱敏、失败保留与不复用;实际签名产物和包验证仍需独立证据。整体期限不能限制 CSP 内部 PIN 尝试,也不能授权无人值守签名。 + +应用准备按批次冻结一次已构建的主程序/预加载模块、界面文件和生成的启动入口,记录 SHA-256,并拒绝冻结文件被修改、缺失或额外增加。[验收打包配置](../../../../apps/desktop/scripts/installed-update-builder.ts)将选定的独立运行时传给常规配置工厂,使资源校验与签名使用同一运行时版本。它保留安装器钩子和发布者检查,同时选择共享测试身份、包入口和固定 test feed。依赖树和构建工具仍是独立的可变输入,需要最终构建记录。配置验证使用固定版本构建器和替代签名器,不是硬件或已安装应用证据。 + +只读[分发规划器](../../../../apps/desktop/scripts/installed-update-distribution.ts)逐版本核对生成的 YAML 与本地安装包字节,并将不可变二进制对象与一个可变 feed URL 分开。它从已验证的值重建 feed 字段,不保留任意输入字段。输出明确标识仅验证文件完整性、没有发布授权;凭据、签名、包身份与版本 1 启动不属于本检查。 + +独立的[签名读取器](../../../../apps/desktop/scripts/installed-update-signature.mjs)要求真实 updater 验签、有效 Authenticode 与时间戳属性,以及未变的可执行文件字节。验签子进程移除凭据和继承的 PowerShell 模块路径;不兼容模块覆盖不能被当作验签成功。发布者来自可信公钥证书,而非远端 YAML。只读检查绝不调用 SignTool、访问签名令牌或执行被检查文件。签名通过不能证明包内容或安装完成。 + +[安装包检查器](../../../../apps/desktop/scripts/verify-installed-update-package.ts)解开签名安装包本身,而非信任相邻的解包目录。归档内身份、更新配置、冻结应用文件和 Harness 运行时必须与保留批次一致,测试强更策略也必须有效。检查器从 ASAR 读取运行时文件,只接受 electron-builder 对依赖 manifest 的转换,并在 `app.asar.unpacked` 中检查可执行文件签名。未签名的运行时可执行文件变化不能仅靠重新生成清单就通过。独立且不可覆盖的记录保留每次检查的阶段与失败。最终输入哈希防止安装包/feed 变化继承旧结果。真实 ASAR 和运行时夹具覆盖不符情况,仅替换外部验签和归档进程;实际签名包仍需各自回执。安装器注册和已安装应用行为不属于文件检查,保留为人工检查项。 + +[网络故障步骤](../../../../apps/desktop/tests/installed-update/network/README.zh.md)将指定程序的出站规则绑定到已验证原版本可执行文件哈希及随机批次身份。它在创建前和确认后拒绝变化的字节,仅移除匹配的自有规则。可执行文件消失后仍可恢复。两次改动均由操作者控制;每次操作前后持久记录阶段,失败即停、不重试。防火墙命令替身执行真实脚本,但记录规则存在或不存在绝不证明流量中断或下载恢复成功。不改网卡或 VPN,因为这可能断开操作者的远控。 + +生成入口测试只替换 Electron 路径 API,执行两个版本并在主程序导入前核对共享目录。运行时测试验证依赖版本替换、第三方版本和原始字节不变、损坏输入拒绝以及失败现场保留。副本额外占用磁盘。源码准备可启动原始运行时,但副本版本启动、最终包内容、签名、发布和安装均需独立证据;完成回执说明这些局限,不推断成功。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml new file mode 100644 index 0000000000..a928ba5315 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md +2026-09-08-desktop-mandatory-update-api.md: f88216065fe09221c7a14805e4d25fb97a259abc +2026-09-08-desktop-mandatory-update-api.zh.md: cb3e631040d221b87979b7fcd379c8881dba36bd diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md new file mode 100644 index 0000000000..f88216065f --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.md @@ -0,0 +1,148 @@ +# Agent Note: Desktop mandatory-update API + +Status: proposed + +English | [中文](2026-09-08-desktop-mandatory-update-api.zh.md) + +## Problem + +Desktop requires mandatory-update decisions when its business server is local and when the user is not signed in. Mobile ordinary-update responses do not define Desktop artifact installation. Backend and Desktop owners need a self-contained protocol with explicit success, blocking, and error semantics, separate from updater artifact selection. + +## Proposal + +Use a guest-accessible policy endpoint. This document owns its Desktop request and response fields; the [initial update proposal](2026-09-08-desktop-update-policy-and-installation.md) owns client scheduling, UI, release ordering, and installation. Backend integration is in progress; API origins and gateway details are deployment inputs, not evidence of a live service. No sibling repository or local document is needed to interpret this protocol. + +### Endpoint and access + +The [client decision](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns the implemented polling and blocking UI. This API proposal remains active for backend deployment and live integration; local fixtures do not certify the service. + +```http +GET /api/v0/check_client_update +``` + +The query itself must return the complete mandatory response; do not rely on intercepting unrelated business APIs. Preserve Android and iOS behavior without requiring existing mobile versions to send new Desktop fields. Desktop receives mandatory policy only, not ordinary-update prompts, installer metadata, updater target versions, device IDs, installation IDs, or per-installation rollout assignments. + +### Request + +```http +GET /api/v0/check_client_update?scenario=launch +x-client-platform: desktop-win +x-client-version: 0.1.3-rc.2 +x-client-bundle-id: com.deepseek.dsh +x-client-locale: zh-CN +x-client-arch: x64 +x-client-update-channel: nightly +x-client-bundled-dsh-version: 0.1.3-rc.2 +``` + +All listed headers are required for Desktop. Values describing installed software come from the application and its release metadata, not editable UI fields. + +| Header | Meaning and allowed values | +|---|---| +| `x-client-platform` | `desktop-win` or `desktop-mac` | +| `x-client-version` | Full Desktop SemVer, retaining prerelease identifiers; initially equal to bundled dsh | +| `x-client-bundle-id` | Application identity, for example `com.deepseek.dsh`; distinguishes Harness from Chat | +| `x-client-locale` | UI locale, for example `zh-CN`; selects localized content, not region | +| `x-client-arch` | Windows `x64`; macOS `x64` or `arm64` | +| `x-client-update-channel` | Initially always `nightly`, independent of version suffix | +| `x-client-bundled-dsh-version` | Full bundled dsh version from release metadata | + +| Query | Client behavior | Initial backend behavior | +|---|---|---| +| `scenario` | Carry the query source; `launch` is a startup example | Ignore; final desktop enumeration does not block integration | +| `region` | Optional; send only when trustworthy region information exists | Ignore; do not infer it from UI locale | + +Neither query affects initial policy matching or parameter validation. Changing or omitting either query must not change the decision for otherwise identical client conditions. Lifecycle triggers and query enum values need not have a one-to-one mapping; enumeration and analytics can be agreed before backend use is enabled. + +### No mandatory update + +```json +{ + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": null + } +} +``` + +This means the current client does not require a mandatory update, not that its installed version is latest. A fresh valid response for the current client conditions may clear a known block. Desktop does not consume mobile ordinary-update payloads in this response. + +### Mandatory update + +```json +{ + "code": 40005, + "msg": "Client version too low", + "data": { + "show_content": { + "title": "请更新 DeepSeek Harness", + "detail": "当前版本已停止支持,请下载并安装新版本。" + }, + "desktop_app_link": "https://example.com/harness/download" + } +} +``` + +The example URL is a placeholder, not an approved deployment destination. Desktop uses the flattened `data` fields, with no `alt_app` or `biz_data` wrapper. No Desktop has shipped, so no compatibility parser for the earlier nested proposal is required. Mobile `alt_app`, Android links, and iOS app identifiers retain their existing format. + +| Field | Requirement | +|---|---| +| `code` | `40005` is the response-body code, not an HTTP status | +| `msg` | Diagnostics only, not dialog copy | +| `data.show_content.title` | Required localized plain-text title | +| `data.show_content.detail` | Required localized plain-text detail | +| `data.desktop_app_link` | Required HTTPS page matching product, platform, architecture, and channel; must satisfy the client allowlist | + +Do not add `mode`, `force_update`, `show_key`, `target_version`, button copy, or updater metadata initially. `40005` determines that upgrading is mandatory; updater metadata determines the actual version and package. The client does not perform an additional mandatory-target version check from this API. Client state selects localized actions, and every download still requires user action. The page remains a fallback after in-app updater integration. The HTTP status mapping follows gateway integration and must be confirmed; the gateway must preserve the JSON body instead of replacing it with generic text or HTML. + +### Errors and policy matching + +Missing required headers, invalid SemVer, and unsupported platform/architecture/channel combinations return explicit parameter errors, not no-force success or a mandatory policy. Prefer the existing business meanings of `biz_code = 1` for a missing version and `biz_code = 2` for an invalid version; final error allocation belongs to the backend. Service failures must not masquerade as success because success may remove an existing block. + +Match application identity, platform, architecture, Desktop version, bundled dsh version, and channel using server-owned ranges and precedence. Use complete SemVer, not lexical sorting or truncated prerelease values. Initially the two versions are equal and channel is fixed Nightly; independent revisions and channel switching are deferred. Do not require a downgrade or stop returning `40005` merely because a client has queried or displayed it before. + +### Policy publication and current decisions + +Every active policy requires deterministic matching conditions, localized title/detail, and a valid destination. The server evaluates current client conditions on every query. These are server configuration requirements, not extra response fields or an artifact revocation mechanism. + +- Publish and verify a higher release that resolves the mandatory requirement before enabling its policy; the matching page and platform package must be available. +- For clients with in-app mandatory updating, publish the resolving version to their updater feed before activating the mandatory requirement. Release owners coordinate this ordering; the API does not supply a separate installer target, artifact eligibility response, or ordinary-update rollout. +- Match links to the product, platform, architecture, and channel; reject policy activation when content or an allowed HTTPS destination is missing. +- Return no-force success when the current client no longer matches, including after upgrading. Continue returning mandatory policy while it still matches. + +Handle a defective release by publishing a higher fixed version and updating updater metadata. Do not add a revoked-version list, pre-install artifact-revocation query, or target-version validation to this API. A fresh no-force response can clear the UI block but does not select, replace, or invalidate an updater package. Artifact hash/signature checks remain required independently of the mandatory decision. + +### Capacity and caching + +Capacity and rate limits must account for recurring guest requests, client-side coalescing, and failure backoff. Policy delivery does not depend on chat requests or SSE. Online delivery latency depends on polling and connectivity; offline clients cannot receive new policy immediately. Prefer `Cache-Control: no-store`. A gateway must not share responses across client versions, platforms, architectures, or channels based only on URL; future caching must specify all policy/content keys and decision freshness. + +## Alternatives considered + +**Relying on remote business interception.** Local dsh requests need not reach the remote gateway; an independent guest query is necessary. + +**Reusing the mobile nested payload for Desktop.** The backend agreement flattens Desktop fields. Preserve mobile compatibility by platform, without making new Desktop clients support an undistributed nested variant. + +**Adding installation identity and ordinary-update metadata.** Initial policy needs neither per-installation rollout nor a second installer feed. Keep artifact discovery in the updater. + +## Acceptance criteria + +| Case | Expected result | +|---|---| +| Unauthenticated Desktop | Query works without business login | +| Windows x64, macOS x64 and arm64 | Correct platform policy and page | +| No policy match | `code = 0`, `biz_code = 0`, `biz_data = null` | +| Policy match | Top-level `40005`, direct content and page under `data` | +| Changed or omitted query parameters | Same decision for otherwise identical conditions | +| Equal prerelease Desktop/dsh versions, fixed Nightly | Full SemVer matching, no downgrade or channel-switch requirement | +| Client no longer matches, including after upgrading | No-force success; does not invalidate updater artifacts | +| Unavailable resolving release, page, or applicable feed | Policy activation rejected | +| Invalid required fields or service failure | Explicit error, not false no-force success | +| Sequential requests from different client conditions | No cross-client cache leakage | +| Existing mobile requests | Existing requirements and response structure preserved | + +## Risks + +Backend owners must provide test/production origins, guest gateway access, final HTTP/error-code mapping, limits, real page destinations, allowed domains, fallback locale, and policy configuration ownership. Release owners must coordinate available updater releases and mandatory-policy activation per platform. These inputs remain pending and must not be filled with developer credentials or guessed URLs. diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md new file mode 100644 index 0000000000..cb3e631040 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md @@ -0,0 +1,148 @@ +# Agent Note: Desktop 强制更新接口 + +Status: proposed + +[English](2026-09-08-desktop-mandatory-update-api.md) | 中文 + +## 问题 + +Desktop 在业务 server 为本地进程、用户尚未登录时也需要获取强制更新判定。移动端普通更新响应不定义 Desktop 产物安装。后端与 Desktop 需要一份自包含协议,明确成功、阻断和错误语义,与 updater 的产物选择分开。 + +## 提案 + +使用未登录可访问的策略接口。本文负责 Desktop 请求响应字段,[一期更新提案](2026-09-08-desktop-update-policy-and-installation.zh.md)负责客户端调度、界面、发布顺序与安装。后端正在接入;API 地址与网关细节是部署输入,不表示服务已经可用。理解本协议不依赖旁边的仓库或本机文档。 + +### 接口与访问 + +[客户端决策](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责已实现的轮询和阻塞界面。本接口提案仍负责后端部署与真实联调;本地 fixture(测试前置数据)不能认证服务。 + +```http +GET /api/v0/check_client_update +``` + +查询接口本身必须返回完整强制响应,不能依赖拦截其他业务接口。保持 Android 与 iOS 既有行为,不要求旧移动端补传 Desktop 新字段。Desktop 仅接收强制策略,不接收普通更新提示、安装包元数据、updater 目标版本、设备 ID、安装实例 ID 或按实例灰度分配。 + +### 请求 + +```http +GET /api/v0/check_client_update?scenario=launch +x-client-platform: desktop-win +x-client-version: 0.1.3-rc.2 +x-client-bundle-id: com.deepseek.dsh +x-client-locale: zh-CN +x-client-arch: x64 +x-client-update-channel: nightly +x-client-bundled-dsh-version: 0.1.3-rc.2 +``` + +以上 header 对 Desktop 均为必填。描述已安装软件的值来自应用与发布元数据,不来自可编辑 UI 字段。 + +| Header | 含义与取值 | +|---|---| +| `x-client-platform` | `desktop-win` 或 `desktop-mac` | +| `x-client-version` | 完整 Desktop SemVer,保留预发布标识;一期等于内置 dsh | +| `x-client-bundle-id` | 应用身份,例如 `com.deepseek.dsh`,区分 Harness 与 Chat | +| `x-client-locale` | UI 语言,例如 `zh-CN`;选择本地化内容,不代表区域 | +| `x-client-arch` | Windows 为 `x64`;macOS 为 `x64` 或 `arm64` | +| `x-client-update-channel` | 一期固定 `nightly`,独立于版本后缀 | +| `x-client-bundled-dsh-version` | 发布元数据中的完整内置 dsh 版本 | + +| Query | 客户端行为 | 后端一期行为 | +|---|---|---| +| `scenario` | 携带查询来源;`launch` 是启动示例 | 忽略;桌面最终枚举不阻塞接入 | +| `region` | 可选;仅在有可信区域信息时携带 | 忽略;不从 UI 语言推断 | + +两个 query 都不参与一期策略匹配或参数校验。其他客户端条件相同时,改变或省略任一 query 不得改变判定。生命周期触发点与 query 枚举不必一一对应;后端启用使用前再约定枚举与统计。 + +### 无需强制更新 + +```json +{ + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": null + } +} +``` + +这表示当前客户端无需强制更新,不表示已安装版本最新。针对当前客户端条件的新鲜有效响应可以清除已有阻断。Desktop 不消费此响应中的移动端普通更新载荷。 + +### 需要强制更新 + +```json +{ + "code": 40005, + "msg": "Client version too low", + "data": { + "show_content": { + "title": "请更新 DeepSeek Harness", + "detail": "当前版本已停止支持,请下载并安装新版本。" + }, + "desktop_app_link": "https://example.com/harness/download" + } +} +``` + +示例 URL 是占位符,不是获准部署的目标地址。Desktop 使用拍平的 `data` 字段,不包裹 `alt_app` 或 `biz_data`。尚无已分发 Desktop,因此不需要兼容较早嵌套提案的解析器。移动端 `alt_app`、Android 链接与 iOS 应用标识保持既有格式。 + +| 字段 | 要求 | +|---|---| +| `code` | `40005` 是响应体错误码,不是 HTTP 状态 | +| `msg` | 仅用于诊断,不作弹窗文案 | +| `data.show_content.title` | 必填,本地化纯文本标题 | +| `data.show_content.detail` | 必填,本地化纯文本正文 | +| `data.desktop_app_link` | 必填,匹配产品、平台、架构与通道的 HTTPS 页面,必须符合客户端允许列表 | + +一期不增加 `mode`、`force_update`、`show_key`、`target_version`、按钮文案或 updater 元数据。`40005` 决定必须升级,实际版本与安装包由 updater 元数据决定。客户端不依据本接口另做强制目标版本校验。客户端状态决定本地化操作,每次下载仍需用户操作。接入应用内 updater 后,页面仍作为兜底。HTTP 状态映射按网关联调确认;网关必须保留 JSON 错误体,不能替换为通用文本或 HTML。 + +### 错误与策略匹配 + +缺少必填 header、非法 SemVer 或不支持的平台/架构/通道组合返回明确参数错误,不能伪装为无强制成功或强制策略。优先沿用 `biz_code = 1` 表示缺少版本、`biz_code = 2` 表示版本非法的既有业务含义;最终错误码分配由后端负责。服务异常不得伪装成成功,因为成功可能解除已有阻断。 + +按应用身份、平台、架构、Desktop 版本、内置 dsh 版本与通道匹配,由服务端维护版本范围和优先级。使用完整 SemVer,不按字符串排序或截断预发布部分。一期两个版本相等、通道固定 Nightly,独立修订与通道切换延后。不得要求降级,也不得仅因客户端曾查询或展示过就停止返回 `40005`。 + +### 策略发布与当前判定 + +每条生效策略需要可确定的匹配条件、本地化标题正文和有效目标页面。服务端每次查询均判定当前客户端条件。这些是服务端配置要求,不增加响应字段,也不是安装包撤回机制。 + +- 启用策略前发布并验证能解除强制要求的更高版本,确保匹配的页面与平台安装包可用。 +- 对启用应用内强制更新的客户端,先向其 updater feed 发布能解除要求的版本,再启用强制要求。发布负责人协调该顺序;API 不提供独立安装目标、产物适用性响应或普通更新灰度。 +- 链接匹配产品、平台、架构与通道;缺少文案或获准 HTTPS 目标时拒绝启用策略。 +- 当前客户端不再命中时返回无强制成功响应,包括升级后;仍命中时持续返回强制策略。 + +问题版本通过发布更高修复版本并更新 updater 元数据处理。不向本接口增加作废版本清单、安装前产物撤回查询或目标版本校验。新鲜的无强制响应可以解除 UI 阻断,但不选择、替换或作废 updater 安装包。产物哈希/签名校验仍独立于强制判定执行。 + +### 容量与缓存 + +容量与限流需覆盖持续未登录请求,并考虑客户端合并与失败退避。策略触达不依赖聊天请求或 SSE(Server-Sent Events)。在线触达延迟受轮询和网络影响,离线客户端无法立即获知新策略。建议 `Cache-Control: no-store`。网关不得仅按 URL 跨客户端版本、平台、架构或通道共用响应;后续缓存必须定义所有策略与文案维度和判定新鲜度。 + +## 考虑过的替代方案 + +**依赖远程业务拦截。** 本地 dsh 请求不一定到达远程网关,需要独立的未登录查询。 + +**Desktop 复用移动端嵌套载荷。** 后端约定拍平 Desktop 字段。按平台保留移动端兼容,不让新 Desktop 支持从未分发的嵌套变体。 + +**增加安装身份与普通更新元数据。** 初期策略不需要按安装实例灰度或第二个安装器 feed。产物发现继续由 updater 负责。 + +## 验收标准 + +| 用例 | 预期结果 | +|---|---| +| 未登录 Desktop | 无需业务登录即可查询 | +| Windows x64、macOS x64 和 arm64 | 正确的平台策略与页面 | +| 不命中策略 | `code = 0`、`biz_code = 0`、`biz_data = null` | +| 命中策略 | 顶层 `40005`,文案与页面直接位于 `data` 下 | +| 改变或省略 query 参数 | 其他条件相同时判定一致 | +| 相等的 Desktop/dsh 预发布版本、固定 Nightly | 完整 SemVer 匹配,不要求降级或切换通道 | +| 客户端不再命中,包括升级后 | 返回无强制成功,不作废 updater 产物 | +| 可解除要求的发布、页面或适用 feed 不可用 | 拒绝启用策略 | +| 必填字段非法或服务失败 | 明确错误,不伪装为无强制成功 | +| 不同客户端条件连续请求 | 不跨客户端串用缓存 | +| 现有移动端请求 | 保持既有要求与响应结构 | + +## 风险 + +后端需提供测试/生产地址、未登录网关访问、最终 HTTP/错误码映射、限流、真实页面、允许域名、兜底语言和策略配置负责人。发布负责人需逐平台协调 updater 版本可用与强制策略启用。这些输入仍待提供,不得用开发者凭据或猜测 URL 填充。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml new file mode 100644 index 0000000000..7119122de4 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md +2026-09-08-desktop-update-extensions.md: 6c8a2ebe4a38809f6c3a7fb4741b3520111402c6 +2026-09-08-desktop-update-extensions.zh.md: 06096c8b28136ab2e22325857021926e06b3b17e diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md new file mode 100644 index 0000000000..6c8a2ebe4a --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.md @@ -0,0 +1,113 @@ +# Agent Note: Deferred Desktop update extensions + +Status: proposed + +English | [中文](2026-09-08-desktop-update-extensions.zh.md) + +## Problem + +Frequent releases may eventually benefit from independently revised Desktop packages, a stable subscription, next-launch installation, or replacing an older pending update. These choices introduce additional release and installer states that are not needed to ship the initial fixed-Nightly, explicitly approved installation flow. + +## Proposal + +Keep these candidates separate from the [initial update design](2026-09-08-desktop-update-policy-and-installation.md). None is an initial acceptance requirement or authorization to change dependencies. Product approval and platform qualification are required before implementation. The [mandatory-update API](2026-09-08-desktop-mandatory-update-api.md) remains independent; automatic installation does not add installer fields to it. + +### Independent revisions + +If Desktop-only fixes become necessary on a prerelease line, evaluate the reserved `.dsk.N` format. Keep full SemVer comparison so introducing it does not require a custom version parser. The intended ordering is: + +```text +0.1.3-rc.2 +< 0.1.3-rc.2.dsk.1 +< 0.1.3-rc.2.dsk.2 +< 0.1.3-rc.3 +``` + +Such a release would record Desktop `0.1.3-rc.2.dsk.1` separately from bundled dsh `0.1.3-rc.2`. Introduce independent `desktopVersion`, `bundledDshVersion`, and `releaseChannel` records only with corresponding build, runtime descriptor, profile reconciliation, publisher, and backend-policy validation. The initial build continues to require equal versions. + +Do not use `0.1.3-dsk.1` to repair stable `0.1.3`: SemVer orders it below the stable release. Stable fixes require a higher shared dsh/Desktop patch version, such as `0.1.4`. Independent prerelease revisions are an exceptional release path, not a routine per-build counter. + +### Stable subscription and channel switching + +| User option | Feed | Release contents | +|---|---|---| +| Stable | `latest.yml` / `latest-mac.yml` | Stable only | +| Nightly | `nightly.yml` / `nightly-mac.yml` | Alpha, rc, and stable | + +The [initial publication rule](2026-09-08-desktop-update-policy-and-installation.md) adds the stable feed when shared stable releases exist, without switching fixed-Nightly clients. A user-facing stable subscription and selector remain deferred. If enabled, persist the selected channel; never infer it from the installed version. Preserve `allowDowngrade = false` when assigning the updater channel. Moving from Nightly to stable keeps a higher installed Nightly until a higher stable version exists, without downgrading Desktop or bundled dsh. + +Channel switching may immediately check; download behavior must follow the approved setting. Ignore old-channel responses and reconfirm downloaded artifacts against the new channel before installation. A channel change does not clear mandatory blocking without a valid policy response for the new conditions. Display a persistent Nightly explanation under the selector, with candidate Chinese copy “提前体验新功能,版本可能不稳定。”; hide it after selecting stable, while separately explaining any wait for a higher stable version. + +Design compatibility only for Desktop packages actually distributed. If a future migration has real clients reading `alpha.yml` or `rc.yml`, publish a higher bridge version to the old feed and Nightly until the old entry can be retired. No such bridge is required initially. + +### Automatic-installation setting + +The candidate setting is automatic installation, potentially enabled by default only after product approval and measured platform behavior. Candidate Chinese helper text is “关闭后仍会提示更新,需手动升级。” If next-launch installation is not acceptable, evaluate a download-only setting and retain explicit installation confirmation. + +| Setting | Newer version discovered | Later complete app launch | +|---|---|---| +| Enabled | Download and prepare automatically, including discovery from a manual check | Apply a valid pending update when installation conditions hold | +| Disabled | Continue checking and prompting; wait for explicit download | Do not install automatically; keep the manual path | + +Automatic download and installation in this table are deferred product choices; the initial release requires user action for every download and separate installation approval. Turning a future automatic setting off prevents new automatic downloads and installations; an active download may complete without a new cancel button. Determine whether platform-staged installation can be cancelled before promising immediate effect. Never proactively exit a running app or bypass task-impact approval. Preserve initial check triggers and feedback, without adding network-recovery checks or unsolicited global indicators. + +### Next-launch installation + +Target a complete process launch, not reopening a window or reloading the renderer. Prefer installation before restoring business tasks or starting local dsh; surviving managed resources require explicit shutdown approval. A cached ZIP or installer is not proof of completed native preparation. Surface extraction, verification, and preparation honestly without fabricated progress. + +Use the already prepared target instead of delaying launch for a fresh package download. A bounded check may discover a newer candidate and defer this installation while the current app opens, but only if the platform still permits abandoning the staged target. Native installer handoff may prevent cancellation or retargeting; never simulate it by deleting native staging directories. Measure download-to-ready, click-to-exit, replacement, and new-version profile reconciliation and Host startup separately. + +### Updater dependency qualification + +The initial dependency declaration remains in [Desktop package metadata](../../../../apps/desktop/package.json). A coordinated electron-builder 27 / electron-updater 7 migration is an evaluation candidate, not a dependency bump authorized here. The following API and platform items require verification against the selected release and real packages; this proposal does not claim they have been exercised locally. + +| Candidate item | Required qualification | +|---|---| +| `autoInstallEvent` | Verify `manual`, `onQuit`, and `onNextLaunch`; migration alone must retain manual installation | +| Object-form `quitAndInstall` | Verify `isSilent` and `isForceRunAfter`, plus macOS-specific semantics | +| Next-launch cache | Re-fetch metadata and validate artifact hashes and platform signatures before installing | +| Windows per-user NSIS | Verify next-launch behavior without UAC; do not assume per-machine installs are silent | +| Windows session ending | Verify shutdown/restart/logoff does not launch unsafe on-quit installation; NSIS is not assumed atomic | +| macOS Squirrel staging | Verify native preparation, quit/relaunch replacement, signing, notarization, and both architectures | +| Metadata and security defaults | Inspect modern `files[]`, legacy `path`/`sha512` consumers, `sha2` retirement, and NSIS Web Installer defaults | +| Build-tool migration | Verify Node engine requirements, including the candidate 22.12 floor, native ESM, configuration, signing hooks, and publisher compatibility | +| Distributed old clients | Exercise their real feeds and payloads before changing metadata; do not infer compatibility from dependency version alone | + +### Multiple pending versions + +The candidate policy follows the latest eligible version without exposing a package collection to users. Own only one effective download/install target; temporary files do not promise an older installable fallback. + +| Situation | Candidate behavior | +|---|---| +| A downloading when C is found | Do not repeatedly cancel A; handle newer candidates in a subsequent check | +| A ready, automatic setting enabled, C found | Prepare C and replace A's install entry with C progress | +| A ready, automatic setting disabled, C found | Show C; start its download only on user action | +| D appears during C download | Do not interrupt C; consider D later | +| Installation and task shutdown approved | Lock the chosen target against background replacement | +| C preparation fails after replacing A | Keep running the installed app, show error/retry, and do not promise installation of A | + +Reconfirm platform support before abandoning a prepared target. Cache identity includes artifact metadata and hashes, not just a version string. Replacement after readiness is application coordination, not an assumed automatic capability of the updater library. It must be separately tested with channel changes, new releases, retries, and installer handoff. + +### Cache invalidation and revocation + +Reuse verified complete cache; do not promise partial-download resumption or preservation of an older installable package. Publish a higher fix for a defective release and update the feed. Metadata mismatch may invalidate some cache but is not strict revocation: already staged or installing artifacts may still apply. Continuing to run the installed app after failure is different from installing an older cache. Do not implement an independent artifact revocation list or pre-install revocation interception. The mandatory API decides whether upgrading is required, not which updater artifact to install or invalidate. + +## Alternatives considered + +**Enable every extension in the first release.** This adds unqualified installer and release paths before a Desktop distribution exists. Fixed Nightly and explicit installation provide the smaller initial scope. + +**Treat changing a setting as cancellation.** Native staging may already own installation. Product wording and state transitions must reflect verified cancellation capability. + +## Acceptance criteria + +- Approve each extension independently; retain initial behavior until its acceptance criteria are satisfied. +- Test independent revision ordering and all consumers of split versions before releasing `.dsk.N`. +- Verify both channel publication, persistent selection, stale-response rejection, cache revalidation, and no downgrades. +- Measure real Windows per-user and macOS x64/arm64 update preparation, termination, next launch, native staging, profile reconciliation, and Host startup. +- Prove the disabled setting's effect on active and staged updates before promising immediate revocation. +- Cover A-to-C replacement, C failure, intervening D, channel switching, and locked installation targets. +- Preserve task approval, honest progress, silent automatic-check failures, and recovery to the usable installed app; do not block normal startup on network failure. + +## Risks + +Native installer ownership limits cancellation and replacement. Faster startup cannot be guaranteed from API availability alone, and metadata replacement is not a universal rollback mechanism. Library API candidates, security defaults, build-tool engines, and old-client compatibility need release-specific verification; none is a verified product capability merely because it appears in this proposal. diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md new file mode 100644 index 0000000000..06096c8b28 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-extensions.zh.md @@ -0,0 +1,113 @@ +# Agent Note: Desktop 更新后续扩展 + +Status: proposed + +[English](2026-09-08-desktop-update-extensions.md) | 中文 + +## 问题 + +高频发布后续可能需要 Desktop 独立修订、稳定版订阅、下次启动安装或替换较旧待安装更新。这些选择引入额外发布与安装器状态,初期固定 Nightly、明确确认安装的流程并不需要它们。 + +## 提案 + +将这些候选与[一期更新设计](2026-09-08-desktop-update-policy-and-installation.zh.md)分开。它们不是一期验收要求,也不授权修改依赖;实现前需要产品批准与平台验证。[强制更新接口](2026-09-08-desktop-mandatory-update-api.zh.md)继续独立,自动安装不向其中增加安装器字段。 + +### 独立修订 + +预发布线上确有 Desktop 独立修复需求时,评估预留的 `.dsk.N` 格式。保持完整 SemVer 比较,使引入该格式不需要自定义版本解析器。预期排序为: + +```text +0.1.3-rc.2 +< 0.1.3-rc.2.dsk.1 +< 0.1.3-rc.2.dsk.2 +< 0.1.3-rc.3 +``` + +这类发布将 Desktop `0.1.3-rc.2.dsk.1` 与内置 dsh `0.1.3-rc.2` 分别记录。只有同时补齐构建、运行时描述、profile 校准、发布工具与后端策略校验,才引入独立的 `desktopVersion`、`bundledDshVersion` 和 `releaseChannel` 记录。一期构建继续要求版本相等。 + +不能用 `0.1.3-dsk.1` 修复稳定版 `0.1.3`,因为 SemVer 将其排在稳定版之前。稳定版修复需要更高的共用 dsh/Desktop 补丁版本,例如 `0.1.4`。独立预发布修订是特殊发布路径,不是每次构建的常规计数器。 + +### 稳定版订阅与通道切换 + +| 用户选项 | Feed | 发布内容 | +|---|---|---| +| 稳定版 | `latest.yml` / `latest-mac.yml` | 仅稳定版本 | +| Nightly | `nightly.yml` / `nightly-mac.yml` | alpha、rc 和稳定版本 | + +[一期发布规则](2026-09-08-desktop-update-policy-and-installation.zh.md)在共用稳定版本出现时增加稳定 feed,不切换固定 Nightly 客户端。面向用户的稳定版订阅与选择器仍延后。启用后持久化所选通道,不从已安装版本反推。设置 updater 通道时保持 `allowDowngrade = false`。从 Nightly 切到稳定版时,较高的已安装 Nightly 持续运行,直到出现更高稳定版;不得降级 Desktop 或内置 dsh。 + +切换通道可以立即检查,下载行为必须遵循获准设置。忽略旧通道响应,安装前按新通道重新确认已下载产物。通道改变不能在缺少新条件有效策略响应时解除强制阻断。选择器下方常驻 Nightly 说明,候选中文为“提前体验新功能,版本可能不稳定。”;选择稳定版后隐藏,必要时另行说明需等待更高稳定版。 + +仅为实际分发过的 Desktop 安装包设计兼容。后续迁移若存在读取 `alpha.yml` 或 `rc.yml` 的真实客户端,应向旧 feed 与 Nightly 发布更高桥接版本,直到可淘汰旧入口。一期不需要这类桥接。 + +### 自动安装设置 + +候选设置为自动安装;仅在产品批准与平台实测后考虑默认开启。候选中文辅助文案为“关闭后仍会提示更新,需手动升级。” 若下次启动安装体验不满足要求,则评估仅自动下载的设置,并保留明确安装确认。 + +| 设置 | 发现更高版本 | 后续完整启动应用 | +|---|---|---| +| 开启 | 自动下载与准备,包含手动检查发现的版本 | 安装条件满足时应用有效待安装更新 | +| 关闭 | 继续检查与提示,等待明确下载操作 | 不自动安装,保留手动入口 | + +表中的自动下载与安装是后续产品选择;一期每次下载都需要用户操作,安装另行确认。关闭未来的自动设置后不发起新的自动下载与安装;在途下载可以完成,不新增取消按钮。承诺立即生效前需确认能否取消平台已暂存安装。不得主动退出使用中的应用或绕过任务影响确认。沿用一期检查触发与反馈,不新增网络恢复检查或未经确认的全局提示。 + +### 下次启动安装 + +目标是完整进程启动,不是重开窗口或刷新渲染层。优先在恢复业务任务或启动本地 dsh 前安装;仍存活的受管资源需要明确关闭确认。已缓存 ZIP 或安装器不证明原生准备完成。如实展示解压、校验和准备,不伪造进度。 + +使用已经准备好的目标,不为下载新包延迟启动。限时检查若发现更高候选,且平台仍允许放弃暂存目标,可以推迟本次安装并打开当前应用;原生安装器接管后可能无法取消或改换目标,不得通过删除原生暂存目录模拟。分别测量下载到就绪、点击到退出、应用替换,以及新版本 profile 校准与 Host 启动耗时。 + +### updater 依赖验证 + +一期依赖声明仍由 [Desktop 包元数据](../../../../apps/desktop/package.json)负责。electron-builder 27 / electron-updater 7 配套迁移是评估候选,不是本文授权的依赖升级。以下 API 与平台项目需针对选定版本和真实安装包核验;本提案不宣称已在本机执行验证。 + +| 候选项目 | 必需验证 | +|---|---| +| `autoInstallEvent` | 核验 `manual`、`onQuit` 与 `onNextLaunch`;仅迁移依赖必须保留手动安装 | +| 对象参数形式的 `quitAndInstall` | 核验 `isSilent`、`isForceRunAfter` 及 macOS 特定语义 | +| 下次启动缓存 | 安装前重新读取元数据,校验产物哈希与平台签名 | +| Windows 每用户 NSIS | 验证无需 UAC 的下次启动行为;不假定每机器安装可静默 | +| Windows 会话结束 | 验证关机/重启/注销不启动不安全的退出安装;不假定 NSIS 原子性 | +| macOS Squirrel 暂存 | 验证原生准备、退出/重启替换、签名、公证与两种架构 | +| 元数据与安全默认值 | 检查现代 `files[]`、旧 `path`/`sha512` 消费者、`sha2` 淘汰与 NSIS Web Installer 默认值 | +| 构建工具迁移 | 核验 Node 引擎要求,包括候选 22.12 下限,以及原生 ESM、配置、签名 hook 与发布工具兼容 | +| 已分发旧客户端 | 改元数据前实测其真实 feed 与载荷,不仅按依赖版本推断兼容 | + +### 多个待安装版本 + +候选策略跟随最新适用版本,不向用户提供安装包集合管理。同一时刻仅持有一个有效下载/安装目标;暂存文件不承诺可安装的旧版本兜底。 + +| 场景 | 候选行为 | +|---|---| +| A 下载时发现 C | 不反复取消 A,后续检查处理更新候选 | +| A 就绪、自动设置开启、发现 C | 准备 C,用 C 进度替换 A 安装入口 | +| A 就绪、自动设置关闭、发现 C | 显示 C,仅在用户操作后开始下载 | +| C 下载时出现 D | 不打断 C,之后再考虑 D | +| 已确认安装与任务关闭 | 锁定所选目标,后台不能替换 | +| 替换 A 后准备 C 失败 | 继续运行已安装应用,显示错误重试,不承诺安装 A | + +放弃已准备目标前重新确认平台支持。缓存身份包含产物元数据与哈希,不仅是版本字符串。就绪后的替换是应用协调逻辑,不是假定 updater 库自动提供的能力;必须结合通道变化、新发布、重试与安装器交接单独测试。 + +### 缓存失效与撤回 + +复用已校验完整缓存,不承诺部分下载续传或保留旧可安装包。问题版本通过发布更高修复版本并更新 feed 处理。元数据不匹配可能使部分缓存失效,但不是严格撤回:已暂存或安装中的产物仍可能被应用。失败后继续运行已安装应用不同于安装旧缓存。不实现独立的安装包撤回清单或安装前撤回拦截。强制 API 判定是否必须升级,不决定安装或作废哪个 updater 产物。 + +## 考虑过的替代方案 + +**首版启用全部扩展。** 在尚无 Desktop 分发时增加未经验证的安装与发布路径。固定 Nightly、明确确认安装提供较小一期范围。 + +**将修改设置视作取消。** 原生暂存可能已经持有安装操作。产品文案与状态转换必须反映经过验证的取消能力。 + +## 验收标准 + +- 独立批准每项扩展;满足对应验收前保持一期行为。 +- 发布 `.dsk.N` 前测试独立修订排序及所有版本拆分消费者。 +- 验证两个通道发布、选择持久化、拒绝陈旧响应、重新校验缓存和禁止降级。 +- 实测 Windows 每用户安装与 macOS x64/arm64 的更新准备、终止、下次启动、原生暂存、profile 校准与 Host 启动。 +- 承诺即时撤销前证明关闭设置对在途及暂存更新的实际影响。 +- 覆盖 A 替换为 C、C 失败、期间出现 D、通道切换与安装目标锁定。 +- 保留任务确认、真实进度、自动检查失败静默和恢复到可用已安装应用的行为;不因网络失败阻断正常启动。 + +## 风险 + +原生安装器归属限制取消与替换。API 存在本身不能保证启动更快,元数据替换也不是通用回滚机制。库 API 候选、安全默认值、构建引擎与旧客户端兼容性需要按发布版本核验;写入本提案不代表已验证为产品能力。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml new file mode 100644 index 0000000000..838cbee511 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md +2026-09-08-desktop-update-policy-and-installation.md: fa88a7dd798e050d658969ad586bd86e0ac323df +2026-09-08-desktop-update-policy-and-installation.zh.md: d9dc74f66a72dceeafd119fac9e491b3c2a29aa1 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md new file mode 100644 index 0000000000..fa88a7dd79 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md @@ -0,0 +1,59 @@ +# Agent Note: Desktop update policy and installation + +Status: proposed + +English | [中文](2026-09-08-desktop-update-policy-and-installation.zh.md) + +## Problem + +Desktop usually runs a local dsh server, so remote business errors cannot reliably deliver mandatory-update policy. Users need automatic discovery, user-initiated downloads, visible preparation state, and separate restart approval that accounts for running tasks. + +## Proposal + +This proposal records outstanding release and backend work; the [implemented client decision](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.md) and [Desktop README](../../../../apps/desktop/README.md) own current behavior. + +| Document | Owns | +|---|---| +| This proposal | Outstanding release, backend, CDN, and product decisions | +| [Mandatory-update API](2026-09-08-desktop-mandatory-update-api.md) | Request and response fields, server policy requirements, backend integration checklist | +| [Deferred extensions](2026-09-08-desktop-update-extensions.md) | Independent Desktop revisions, channel switching, automatic installation, replacement of pending updates | + +### Release and backend qualification + +Release owners must qualify signed Windows x64 and macOS x64/arm64 upgrades end to end: discovery, download, hash and signature checks, task-safe shutdown, installation, restart, new-version Host startup, and profile reconciliation. Publish immutable packages and blockmaps before the mutable Nightly feed. The [packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns version and artifact rules. The [API proposal](2026-09-08-desktop-mandatory-update-api.md) owns policy fields; production integration must verify guest access, force/no-force responses, errors, platform selection, approved origins, and rate limits. Publish a resolving updater release before enabling mandatory policy. + +### Open product choices + +Persisting a mandatory block across a same-version offline restart requires product confirmation. If newer C appears while A downloads or waits for installation, retain A until product owners approve replacement; never silently turn approval for A into installation of C. Installed Windows and macOS attention remains subject to notification permissions, focus modes, minimized windows, and stale clicks. The [deferred extensions](2026-09-08-desktop-update-extensions.md) own automatic replacement and installation. + + + +### CDN and capacity qualification + +The following release and operations choices are pending; they are not active CDN settings or completed load qualification. + +- [ ] Operations: separate `/dsh-desk/feeds/*` from `/dsh-desk/bin/*`; do not retain a blanket cache bypass for large production downloads. For feeds, evaluate client revalidation with `max-age=0`, a 30–60-second edge TTL, and a purge on publication. Agree on the maximum propagation delay and measure fixed-URL replacement across regions; a purge is not a guarantee of immediate global visibility. +- [ ] Operations and release owner: evaluate a 30-day to one-year edge TTL for versioned or hash-named packages and blockmaps, without overwriting their URLs. Upload and verify binaries, prewarm them, then publish the feed; confirm retention covers older clients' differential-update inputs. +- [ ] Operations and Desktop maintainers: verify node TTL separately from client Cache-Control, using repeat-request cache status, hit ratio, and COS origin metrics. Check whether updater-added query parameters fragment the cache key or bypass caching; ignore only parameters proven irrelevant to content. Verify Range/206, Content-Range, complete-file hashes, and feed freshness through the actual updater. See Tencent's [node TTL](https://cloud.tencent.com/document/product/1552/70777), [browser TTL](https://cloud.tencent.com/document/product/1552/70758), and [cache configuration](https://cloud.tencent.com/document/product/1552/95263) documentation. +- [ ] Desktop maintainers and product owner: confirm startup and overdue-resume burst handling. Periodic jitter and bounded failure backoff are implemented and tested; startup and overdue wakeups still check immediately. Adding a short randomized delay to those triggers needs product confirmation. Mandatory-policy polling remains a separate API and scheduling policy. +- [ ] Operations and release owner: size request and bandwidth budgets using online clients, startup/manual/retry peaks, package sizes, and expected download participation. At evenly distributed ten-minute polling, 100,000 online clients average about 167 checks/second and 1,000,000 about 1,667, before extra triggers. CDN caching reduces origin load, not client download traffic charges; a functional probe is not a load test. +- [ ] Operations: configure cache-hit, origin-QPS, error-rate, bandwidth, and cost alerts with agreed thresholds and an on-call owner. Qualify abuse protection without breaking updater requests or shared-NAT clients; updater endpoints must not require an interactive browser challenge. Record an incident response procedure before launch. + +## Alternatives considered + +**Automatic package predownload.** This consumes bandwidth before the user requests a download. Automatic discovery remains useful, but every initial transfer and retry requires user action independently of later installation approval. + +**Automatic restart or installation on ordinary quit.** This bypasses explicit task-impact approval. Automatic installation requires separate product authorization and platform verification. + +**One remote API controlling all updates.** Mandatory policy and ordinary artifact discovery have different responsibilities. Keep the policy page separate from updater metadata and reuse only the installation coordinator when qualified. + +## Acceptance criteria + +- Signed installed-version upgrades pass on Windows x64 and macOS x64/arm64, including new-version Host startup and retained data. +- Deployed policy integration passes the API proposal’s guest, force/no-force, error, and platform matrix without local fixtures. +- Release owners record CDN freshness, Range and hash behavior, request and bandwidth budgets, alert thresholds, and an incident owner. +- Product owners decide offline mandatory persistence and target replacement before either behavior is promised. + +## Risks + +Local updater and UI checks do not prove installed-upgrade compatibility, deployed policy availability, or CDN performance. Enabling mandatory policy before a resolving artifact exists can block users without an in-app upgrade path. diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md new file mode 100644 index 0000000000..d9dc74f66a --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md @@ -0,0 +1,59 @@ +# Agent Note: Desktop 更新策略与安装 + +Status: proposed + +[English](2026-09-08-desktop-update-policy-and-installation.md) | 中文 + +## 问题 + +Desktop 通常运行本地 dsh server,无法依靠远程业务错误可靠触达强制更新策略。用户需要自动发现、主动下载、可见的准备状态,以及考虑运行中任务影响的独立重启确认。 + +## 提案 + +本文记录尚未完成的发布与后端工作;[已实施的客户端决策](../../implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)和[桌面 README](../../../../apps/desktop/README.zh.md)负责当前行为。 + +| 文档 | 负责内容 | +|---|---| +| 本提案 | 未决发布、后端、CDN 与产品决策 | +| [强制更新接口](2026-09-08-desktop-mandatory-update-api.zh.md) | 请求响应字段、服务端策略要求与后端联调清单 | +| [后续扩展](2026-09-08-desktop-update-extensions.zh.md) | Desktop 独立修订、通道切换、自动安装及待安装更新替换 | + +### 发布与后端验收 + +发布负责人须端到端验收真实签名 Windows x64 与 macOS x64/arm64 升级:发现、下载、哈希与签名检查、任务安全的关闭、安装、重启、新版本 Host 启动及 profile 校准。先发布不可变安装包和 blockmap,再发布可变 Nightly 清单。[打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责版本与产物规则。[接口提案](2026-09-08-desktop-mandatory-update-api.zh.md)负责策略字段;生产联调须验证游客访问、强制/无需强更响应、错误、平台选择、获准源站和速率限制。启用强更策略前先发布能解除要求的 updater 版本。 + +### 未决产品选择 + +同版本离线重启后持久保留强更阻断仍需产品确认。若 A 正在下载或等待安装时出现新版 C,在产品负责人批准替换前保留 A;不能把安装 A 的授权静默改为安装 C。已安装 Windows 与 macOS 的提醒仍受通知权限、专注模式、最小化窗口及过期点击影响。[后续扩展](2026-09-08-desktop-update-extensions.zh.md)负责自动替换和安装。 + + + +### CDN 与容量验收 + +以下发布与运维选择尚待确认,不代表已生效的 CDN 配置或已完成的负载验收。 + +- [ ] 运维:分别配置 `/dsh-desk/feeds/*` 与 `/dsh-desk/bin/*`,生产大文件下载不应长期沿用全路径绕过缓存。清单建议评估客户端通过 `max-age=0` 重新验证、CDN 节点缓存 30~60 秒、发布时刷新。约定最长传播延迟,并跨区域测量固定 URL 覆盖后的可见时间;刷新不保证全球立即可见。 +- [ ] 运维与发布负责人:对带版本号或哈希的安装包和 blockmap,评估 30 天至一年的节点缓存,不覆盖其 URL。先上传并验证安装包、完成预热,再发布清单;确认保留周期覆盖旧客户端差分更新所需的输入。 +- [ ] 运维与 Desktop 维护者:将节点 TTL 与客户端 Cache-Control 分开验证,检查重复请求的缓存状态、命中率和 COS 回源指标。确认 updater 附加的查询参数是否分散缓存键或绕过缓存;只忽略已证明不影响内容的参数。通过实际 updater 验证 Range/206、Content-Range、完整文件哈希和清单及时更新。参阅腾讯云[节点 TTL](https://cloud.tencent.com/document/product/1552/70777)、[浏览器 TTL](https://cloud.tencent.com/document/product/1552/70758) 与[缓存配置](https://cloud.tencent.com/document/product/1552/95263)文档。 +- [ ] Desktop 维护者与产品负责人:确认启动及到期恢复的突发请求处理。周期抖动和有上限的失败退避已实现并测试;启动和到期唤醒仍立即检查。为这些触发增加短暂随机延迟需要产品确认。强更策略轮询仍使用独立 API 与调度策略。 +- [ ] 运维与发布负责人:依据在线客户端数、启动/手动/重试峰值、安装包大小和预计下载比例制定请求量与带宽预算。按均匀分布的十分钟轮询,10 万在线客户端平均约 167 次检查/秒,100 万约 1667 次,尚未计入额外触发。CDN 缓存减少回源压力,不消除客户端下载流量费用;功能探测不是负载测试。 +- [ ] 运维:配置缓存命中率、回源 QPS、错误率、带宽及费用告警,约定阈值与值班负责人。验证异常请求防护不会破坏 updater 请求或误伤共享 NAT 客户端;updater 端点不能要求交互式浏览器验证。上线前记录故障处置流程。 + +## 考虑过的替代方案 + +**自动预下载安装包。** 会在用户请求下载前消耗流量。保留自动发现,但一期每次传输与重试都需要用户操作,与后续安装授权分开。 + +**自动重启或普通退出时安装。** 会绕过明确的任务影响确认。自动安装需要单独的产品授权与平台验证。 + +**一个远程接口控制全部更新。** 强制策略与普通产物发现职责不同。策略页面与 updater 元数据分离,验证通过后仅复用安装协调器。 + +## 验收标准 + +- 真实签名 Windows x64 与 macOS x64/arm64 安装版升级通过,包括新版本 Host 启动及数据保留。 +- 已部署策略服务通过接口提案的游客、强制/无需强更、错误和平台矩阵,不以本地 fixture 替代。 +- 发布负责人记录 CDN 及时更新、Range 与哈希行为、请求和带宽预算、告警阈值及故障负责人。 +- 产品负责人在承诺离线强更持久化或目标替换前作出决定。 + +## 风险 + +本地 updater 与界面检查不能证明已安装升级兼容性、线上策略可用性或 CDN 性能。若在能解除要求的产物可用前启用强更策略,用户可能被阻断且无法在应用内升级。 diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml new file mode 100644 index 0000000000..35fe5b54d9 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md +2026-09-17-defer-office-background-scheduling.md: 82311022a015671b9fb4cf3a0f3eba2714f325cd +2026-09-17-defer-office-background-scheduling.zh.md: ada0146f204fa40e71d48251c231c1b68c5fa5a1 diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md new file mode 100644 index 0000000000..82311022a0 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md @@ -0,0 +1,40 @@ +# Agent Note: Defer Office background scheduling until a consumer needs it + +Status: proposed + +English | [中文](2026-09-17-defer-office-background-scheduling.zh.md) + +## Problem + +The [conversion queue](../../../../packages/document/office-to-pdf/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits. + +The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeToPdf`, `OfficeToPdfRequest`, `OfficeToPdfPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed. + +The [bounded conversion decision](../../implemented/architecture/2026-09-15-bounded-office-conversion.md) deliberately reserves capacity for foreground consumers. That remains the correct rule if speculation exists, but the current caller inventory does not justify maintaining the speculative request class and its lifecycle interactions. + +## Proposal + +Remove `OfficeToPdfPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit. + +Keep the provider, reusable converters, deferred authorized reads, all remaining resource limits, source-version checks, content sharing, bounded result cache, independent reader cancellation, and teardown that waits for actual work to settle. This proposal changes admission policy, not conversion ownership or engine selection. + +Update the request/config definitions, package README pair, subsystem and generated catalogs, and conversion fixtures together. Delete priority-only assertions; retain resource and cancellation regressions embedded in mixed priority tests by expressing them with ordinary requests. The existing bounded conversion note remains active because its cache, authorization, and lifetime rationale still applies; acceptance would replace only its scheduling policy. + +## Alternatives considered + +**Keep priority for a future prewarm consumer.** This avoids a later API change, but retains scheduling states and configuration before a product consumer can define their required behavior. Reintroduce priority with a concrete prewarming caller and measured foreground contention. + +**Disable background conversions by default.** This avoids speculative execution but retains the public request class, configuration, and all scheduling branches. + +**Remove the whole shared provider or cache.** That exceeds the evidence: independent conversion and bounded sharing remain coherent capabilities, and the Loader smoke exercises them without presentation plugins. + +## Acceptance criteria + +- Resolve the scheduling needs of delivery prewarming PR #4064 before implementation, then repeat the caller search. Retain priority if that consumer needs foreground protection; do not remove it merely because the consumer has not merged. +- Priority types, fields, configuration, and scheduling branches disappear from maintained source, tests, documentation, and generated catalogs. Decision records may describe the removed policy. +- Focused queue/provider tests preserve bounded admission, source/digest sharing, cancellation isolation, output ownership, and delayed capacity release. The real conversion Loader smoke still passes. +- Verify the foreground Office preview path; update its recorded-session snapshot if affected. Run the relevant type checks, generated-reference checks, documentation checks, and lint. + +## Risks + +Out-of-tree callers can use the exposed pre-stable API even though repository searches find no background consumer. Removing priority and its config field requires callers to update; this proposal offers no compatibility alias. Speculative callers would lose foreground protection and must stop prewarming until an explicit scheduling requirement is implemented. No runtime behavior changes until this proposal is accepted and implemented. diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md new file mode 100644 index 0000000000..ada0146f20 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 待调用方需要时再引入 Office 后台调度 + +Status: proposed + +[English](2026-09-17-defer-office-background-scheduling.md) | 中文 + +## 问题 + +[转换队列](../../../../packages/document/office-to-pdf/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。 + +[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeToPdf`、`OfficeToPdfRequest`、`OfficeToPdfPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。 + +[有界转换决策](../../implemented/architecture/2026-09-15-bounded-office-conversion.zh.md) 有意为前台消费者预留容量。存在推测性任务时,这条规则仍然正确,但当前调用方清单不足以支持维护推测性请求类别及其生命周期交互。 + +## 提案 + +移除 `OfficeToPdfPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。 + +保留提供者、可复用转换器、延迟授权读取、其余所有资源限制、源版本校验、内容共享、有界结果缓存、读取方独立取消,以及等待实际工作结束的清理。这项提案改变接纳策略,不改变转换所有权或引擎选择。 + +同步更新请求及配置定义、包 README 双语文档、子系统与生成目录,以及转换 fixture(测试前置数据)。删除仅针对优先级的断言;将混合优先级测试中的资源与取消回归改写为普通请求,并保留覆盖。现有有界转换记录继续有效,因为其缓存、授权和生命周期理由仍然成立;采纳本提案只会替换其中的调度策略。 + +## 考虑过的替代方案 + +**为未来的预热调用方保留优先级。** 这可以避免后续 API 变更,但在产品调用方能够定义所需行为之前,就保留了调度状态与配置。应在有具体预热调用方和实测前台资源争用时重新引入优先级。 + +**默认禁用后台转换。** 这可以避免推测性执行,但仍保留公开请求类别、配置及全部调度分支。 + +**删除整个共享提供者或缓存。** 这超出了证据支持的范围:独立转换和有界共享仍是完整的能力,Loader 冒烟测试也在不依赖展示插件的情况下验证它们。 + +## 验收标准 + +- 实施前先明确交付文件预热 PR #4064 的调度需求,再重复调用方搜索。若该消费者需要前台保护,则保留优先级;不能仅因消费者尚未合入而删除。 +- 从维护中的源码、测试、文档和生成目录移除优先级类型、字段、配置及调度分支。决策记录可以描述被移除的策略。 +- 聚焦队列及提供者的测试保留有界接纳、源及摘要共享、取消隔离、输出所有权和延迟容量释放覆盖。真实转换的 Loader 冒烟测试仍然通过。 +- 验证前台 Office 预览路径;若受影响,同步更新其记录会话快照。运行相关类型检查、生成引用检查、文档检查及 lint。 + +## 风险 + +仓库外调用方可能使用已暴露的稳定前 API,即使仓库搜索未发现后台消费者。移除优先级及其配置字段要求调用方更新;本提案不提供兼容别名。推测性调用方会失去前台保护,必须停止预热,直到明确的调度需求得到实现。本提案被接受并实现之前,运行行为不变。 diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index ef20de8097..6052cbf1fc 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-archive-agent-notes -description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. +description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, deletes small UI and purely mechanical records, classifies other implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. --- # Archive DeepSeek Harness Agent Notes @@ -13,14 +13,15 @@ Read [the Agent Note rules](../../notes/README.md), [the archive instructions](. ## Check supersession when adding a note -Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. +Before drafting, apply the [creation criteria](../../notes/README.md#when-to-write-one). Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. ## Classify by future value Apply these lifecycle-specific outcomes: -- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Length does not matter. -- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current behavior is obvious elsewhere. +- **Implemented — delete:** delete complete triplets that only describe small UI adjustments or purely mechanical changes, and repair or remove inbound links. Local bug fixes, performance changes, new capabilities, and substantive behavior or ownership decisions do not qualify merely because they are implemented. Apply this criterion before keep/archive classification. +- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Classify the decision, not the mechanics of its implementation: a mechanical rename or type extraction can still record lasting naming, compatibility, or ownership rules. Length does not matter. +- **Implemented — archive:** archive a substantive historical decision when it is complete and unlikely to guide future work, but its historical rationale still warrants preservation. Do not archive records that meet the direct-deletion criterion. - **Proposed — never archive:** keep a live proposal active; if it is no longer worth pursuing, reject it with an honest reason and satisfy the rejected lifecycle format. - **Rejected — keep only as a guardrail:** retain a rejection only when the losing proposal remains a tempting, meaningful mistake and the note explains why it loses. - **Rejected — delete:** delete the whole triplet when the rejected idea is obsolete, superseded, no longer plausible, or unlikely to prevent re-litigation. Repair or delete inbound links. @@ -31,11 +32,10 @@ Do not archive toward a quota. Inspect every note in scope, classify analogous g These examples set the bar; the word counts demonstrate that size is not the test. -Archive implemented notes such as: +Delete implemented notes such as: - collapsed sidebar control rail — 533 words: closed, minor UI behavior; -- Commander argument adapter — 1,498 words: substantial implementation detail with little future design leverage; -- documentation graph atlas — 920 words: completed documentation machinery whose current generators are authoritative. +- issue-policy module ownership — completed function relocation and import rewiring with unchanged behavior. Keep implemented notes such as: @@ -55,7 +55,7 @@ For rejected notes: 1. Move the complete `foo.md`, `foo.zh.md`, and `foo.i18n.yaml` triplet from `implemented//` to `archived//`; `implemented` is deliberately absent from the archive path. 2. Make no body edits. Insert only `Archived: YYYY-MM-DD` immediately below `Status: implemented` in both language files, using the archival date and the same value on both sides. -3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note. +3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note. Existing title punctuation, blank-line layout, and language-switcher wording are preserved, not prerequisites for archival. 4. Search for inbound links from active prose. Redirect them to current authority, retarget them to the archived path only when the historical snapshot is intentionally cited, or delete them. Never verify or repair links out of the archived note. 5. Run `pnpm run verify-archived-agent-notes --write`. Its append-only mode first proves every existing seal still matches, then adds only the new triplet hashes. Run the normal verifier afterward. @@ -65,4 +65,4 @@ After the triplet is sealed, never edit, move, translate, reformat, or delete it Run the archive verifier's focused test, `pnpm run verify-archived-agent-notes`, `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; select any additional evidence through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). -Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. +Report active implemented notes kept, implemented notes deleted or archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 6ee736796c..bce148ad18 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -93,7 +93,7 @@ Reject or downgrade a candidate when: Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit. -Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Low-future-value implemented notes move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. +Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted directly; other completed notes with historical decision value but little future guidance move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. Follow the deletion rule in the [Agent Note rules](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: diff --git a/.github/issue-management/README.i18n.yaml b/.github/issue-management/README.i18n.yaml index 7d31f8418d..beaf888252 100644 --- a/.github/issue-management/README.i18n.yaml +++ b/.github/issue-management/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .github/issue-management/README.md -README.md: a936b9358810fd3a8b8104609a5523e305e1aaa5 -README.zh.md: df743cac581c2125df9ac9e76f04d2528204eb74 +README.md: 49e91859f4b22fda13d0e93c6e6ea492272f6951 +README.zh.md: 8424f90cd2e3a59ba1212f64f3f1081a984b55f8 diff --git a/.github/issue-management/README.md b/.github/issue-management/README.md index a936b93588..49e91859f4 100644 --- a/.github/issue-management/README.md +++ b/.github/issue-management/README.md @@ -61,7 +61,7 @@ Lifecycle processing is event-driven, not a reconciler. Omitted events do not re ## Module ownership -Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures. The [module-ownership decision](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md) explains this separation. +Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures.
Implementation owners diff --git a/.github/issue-management/README.zh.md b/.github/issue-management/README.zh.md index df743cac58..8424f90cd2 100644 --- a/.github/issue-management/README.zh.md +++ b/.github/issue-management/README.zh.md @@ -61,7 +61,7 @@ PR 打开时,工作流按配置时区中的 PR 创建日期,为每个被引 ## 模块归属 -维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。[模块归属决策](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md)解释此职责分离。 +维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。
实现职责 diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs index 00f447e682..5792299c46 100644 --- a/.github/review-ownership/check-approval.mjs +++ b/.github/review-ownership/check-approval.mjs @@ -319,7 +319,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw for (const reviewId of delegation.reviewIds) { const dismissed = await api(`/repos/${pull.repository}/pulls/${pull.number}/reviews/${reviewId}/dismissals`, { method: 'PUT', - body: { message: `This is by automated Angry Turtle Cyborg, not a human. @${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' }, + body: { message: `@${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' }, }) if (!isRecord(dismissed) || dismissed.id !== reviewId || dismissed.state !== 'DISMISSED') { throw new Error('delegated review dismissal was not confirmed') diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs index e4e8c1ce10..b5d0b6cbb3 100644 --- a/.github/review-ownership/check-approval.test.mjs +++ b/.github/review-ownership/check-approval.test.mjs @@ -915,7 +915,7 @@ test('delegation dismisses only the sender old decisions and stays active across }) assert.deepEqual(calls.map(call => call.method), ['PUT', 'PUT']) assert.equal(calls[0].body.event, 'DISMISS') - assert.equal(calls[0].body.message, 'This is by automated Angry Turtle Cyborg, not a human. @turtle1999 delegated approval to @writer via /delegate.') + assert.equal(calls[0].body.message, '@turtle1999 delegated approval to @writer via /delegate.') assert.deepEqual(result.blockers, ['other']) assert.equal(result.delegations.length, 1) assert.deepEqual(result.delegations[0].reviewIds, []) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5042778b72..e53d524ead 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -152,14 +152,15 @@ jobs: if-no-files-found: error retention-days: 7 + # Blacksmith x64 CI builds use 2 vCPU for compilation, bundling, and wheel smokes. build: needs: [plan, sdk-wheel] name: ${{ matrix.target }} runs-on: >- ${{ inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64' - && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-2vcpu-ubuntu-2404' || inputs.ci && !inputs.release && matrix.target == 'node24-win-x64' - && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' || matrix.runner }} timeout-minutes: 45 strategy: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2139231c06..32b58e9bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: # vm-backup pool and re-running the failed jobs is the entire switch — # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. # Setting the variable to 'blacksmith' instead routes the same jobs onto - # Blacksmith's hosted runners at the matching vCPU size (see + # Blacksmith's hosted runners sized per workload (see # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The # in-house pool's readiness is re-proven on every master push by the # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover @@ -42,14 +42,15 @@ jobs: node-24: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404' || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} name: node 24 / static env: - DSH_GATE_CONCURRENCY: '8' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} # Stop the aggregate at the first blocking gate failure so a red run # does not keep burning enterprise runner time on the remaining gates. DSH_GATE_FAIL_FAST: '1' @@ -116,11 +117,10 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / coverage env: - # Partitioning replaces the instrumented share; this budget gives the - # exempt-heavy gate two workers on both hosted and failover runners. - DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '4' - DSH_GATE_CONCURRENCY: '3' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} # Managed-scope teardown cases run past the default per-test budget when # this lane's partitions, workers, and sibling gates share one host: the # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on @@ -246,17 +246,17 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / snapshots and artifacts env: - DSH_GATE_CONCURRENCY: '10' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '10' || '' }} + DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + DSH_WEB_SNAPSHOT_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '' }} DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' - DSH_OXLINT_THREADS: '8' - DSH_PUBLINT_CONCURRENCY: '8' - DSH_WEB_SNAPSHOT_WORKERS: '6' # A failing gate aborts its running siblings: a failing build stops the # independent Node compatibility smoke, and a failing reader (e.g. # publint) stops the remaining artifact consumers. DSH_GATE_FAIL_FAST: '1' - # Failover halves snapshot concurrency for the shared 64-core VM. - DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: # Runner cleanup owns tool caches and fixtures, including cancelled runs. - name: Use runner-owned temporary storage @@ -491,12 +491,12 @@ jobs: secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # Every pull request also gets real Windows-kernel signals. The former - # monolithic windows-native job is split into smaller jobs so one slow - # coverage gate does not hold up build/static results, while the total - # per-job process count stays lower. Observational checks are non-blocking. + # Native builds and process tests block pull requests independently of coverage. + # Observational checks reuse the successful build and remain non-blocking. # Dependabot PRs are excluded from the self-hosted pool and stay queued for # the hosted runner. + # Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage + # and the shared build/observational workspace. windows-build: if: github.event_name == 'pull_request' runs-on: >- @@ -505,7 +505,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} - name: windows node 24 / build + name: windows node 24 / build & observational timeout-minutes: 60 env: # A failing build or site aborts the sibling gate on the same runner. @@ -541,6 +541,23 @@ jobs: - name: Run blocking Windows builds shell: pwsh run: pnpm run check:ci:windows-blocking + - name: Run Windows observational gates on built artifacts + id: observational + continue-on-error: true + timeout-minutes: 15 + shell: pwsh + env: + DSH_GATE_FAIL_FAST: '' + DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + run: pnpm run check:ci:windows-observational-ready + - name: Report Windows observational failures + continue-on-error: true + if: steps.observational.outcome == 'failure' + shell: pwsh + run: | + $message = 'Windows observational checks failed; see the observational step for diagnostics.' + Write-Output "::warning::$message" + $message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append windows-coverage: if: github.event_name == 'pull_request' @@ -553,10 +570,11 @@ jobs: name: windows node 24 / coverage timeout-minutes: 120 env: - DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '4' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' - DSH_GATE_CONCURRENCY: '3' # A gate failure aborts the sibling gate instead of waiting out its # multi-minute instrumented run. DSH_GATE_FAIL_FAST: '1' @@ -622,7 +640,7 @@ jobs: windows-native-tests: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') @@ -668,51 +686,6 @@ jobs: packages/workflow/tool-ralph/tests/integration.spec.ts packages/subprocess/subprocess-local/tests/process-exit.spec.ts - windows-observational: - if: github.event_name == 'pull_request' - continue-on-error: true - runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' - || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' - && github.event.pull_request.user.login != 'dependabot[bot]' - && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') - || 'dsh-windows-2025-16core' }} - name: windows node 24 / observational - timeout-minutes: 60 - env: - DSH_PUBLINT_CONCURRENCY: '8' - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) - shell: pwsh - # See 2026-08-30-windows-refs-store-block-clone-install for the - # ReFS block-clone rationale; detect the workspace filesystem and - # pass --package-import-method=clone only on ReFS. - run: >- - $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); - $fs = (Get-Volume -DriveLetter $drive).FileSystem; - if ($fs -eq 'ReFS') { - corepack pnpm install --frozen-lockfile --package-import-method=clone - } else { - pnpm install --frozen-lockfile - } - - name: Run Windows observational gates - shell: pwsh - run: pnpm run check:ci:windows-observational - # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in diff --git a/.gitignore b/.gitignore index 71a5aba6f6..7ad33230ad 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ CLAUDE.local.md .env apps/desktop/.env.windows apps/desktop/.env.macos +apps/desktop/*.p12 node_modules/ lib/ *.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index ce7a07db07..ce14413b4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/ - Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers. - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). +- **Web browser automation and GIF recording:** launch with `pnpm dsh web --patch apps/web/tests/pin-browse-picker.overlay.yml` to use the [in-page directory picker](apps/web/tests/pin-browse-picker.overlay.yml); omit this override only when testing native picker behavior explicitly. ## Secrets / .env @@ -146,7 +147,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. -- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). +- **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d0b5d7c6a4..ec698dca33 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,6 +36,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | | [`@babel/code-frame`](https://github.com/babel/babel) | MIT | | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | +| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | @@ -75,6 +76,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`commander`](https://github.com/tj/commander.js) | MIT | | [`compression`](https://github.com/expressjs/compression) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`execa`](https://github.com/sindresorhus/execa) | MIT | @@ -110,6 +112,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`semver`](https://github.com/npm/node-semver) | ISC | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | +| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | | [`tsx`](https://github.com/privatenumber/tsx) | MIT | | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | @@ -145,13 +148,19 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac | [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +## LibreOffice conversion kit + +`@deepseek-ai/libreoffice-kit`, `@deepseek-ai/libreoffice-kit-wasm`, `@deepseek-ai/libreoffice-kit-darwin-arm64`, `@deepseek-ai/libreoffice-kit-darwin-x64`, `@deepseek-ai/libreoffice-kit-win32-arm64`, `@deepseek-ai/libreoffice-kit-win32-x64` declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations. + +The [kit repository](https://github.com/deepseek-harness/libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices. + + ## Development-only npm dependencies External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. | 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/get`](https://github.com/electron/get) | MIT | | [`@electron/notarize`](https://github.com/electron/notarize) | MIT | @@ -187,6 +196,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@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 | +| [`cos-nodejs-sdk-v5`](https://github.com/tencentyun/cos-nodejs-sdk-v5) | ISC | | [`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 | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 1914398ea8..026b49d50b 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 68500e54372d16a9ead8e548eed5a7e4836be3fb -README.zh.md: a1002812c3782f898d89793d89a50a1ab4ea4e1e +README.md: 46c6a66336839db443a825c66ebfd71dcce398af +README.zh.md: 14838114b04977e7668e33479d1ba84a8ddc8434 diff --git a/apps/cli/README.md b/apps/cli/README.md index 68500e5437..46c6a66336 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -55,6 +55,6 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh ` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. -The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown. +The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor for runtime package resolution while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown. The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a1002812c3..14838114b0 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -55,6 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。 -`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。 +`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 为运行时包解析指定自己的安装锚点,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。 [Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 82383295bc..b3cff4af5d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -48,8 +48,6 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^", - "@deepseek-ai/dsh-experimental-auto-review": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 9da0f6e87e..b2068897e1 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: dd8256fbe81d8a0e3d6993875f577cbc6f8b027e -README.zh.md: a7d9417227feb59cfca9bb04167e5258a6840d69 +README.md: 9ff0dce30f6de58c7b84f54186f7780900b319fc +README.zh.md: e1ed9a9165b993cb4ea8a82af6641d5befd77a07 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index dd8256fbe8..9ff0dce30f 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,7 +8,7 @@ This reference defines the profile, plugin-management, and config-dump command m `dsh ` abbreviates `dsh --profile ` and boots the profile at `$DSH_HOME/profiles/`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode. +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and installs the resulting immutable generation into Node's runtime resolvers. Startup creates no shared or profile-owned fallback links. Profile-installed packages keep native priority; profile initialization and package-manager writes remain separate from runtime resolution. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile add `. @@ -50,6 +50,15 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. + +## Startup diagnostics + +A required plugin activation failure prints failed plugins and their original stacks, followed by pending plugins and missing services. Required plugins appear first in the pending list. The final `Full diagnostics:` line names a unique `startup--.log` file directly under `$DSH_HOME/logs/` (default `~/.dsh/logs/`). The CLI completes report and stderr writes before explicitly exiting with code 1, even if plugin handles remain open; it does not overwrite earlier reports or delete them automatically. + +The report includes DSH and Node versions, platform, profile, root configuration path, every inactive plugin's module and state, original errors, and startup warning/error arguments, including import errors that have no Fiber. Node inspection preserves nested causes, aggregate members, circular references, non-enumerable properties, and Symbol properties with depth, string, and array limits disabled. Custom inspectors are disabled and accessors are described without evaluating them. The collector includes asynchronous failure cleanup and stops when boot settles. It does not collect environment variables or configuration contents independently of logged errors. Raw plugin errors can contain configuration or credential values; the report header warns readers to review it before sharing. Values are not redacted. + +New directories and files request modes `0700` and `0600` on POSIX. The CLI prints a file path only after writing succeeds. If the logs directory or file cannot be written, stderr includes the write error and full report, and the exit code remains 1. Optional-only activation warnings retain their normal output without creating a report. + ## Plugin management `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index a7d9417227..e1ed9a9165 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -10,7 +10,7 @@ `dsh ` 是 `dsh --profile ` 的简写,启动位于 `$DSH_HOME/profiles/` 的 profile。简写中的名称必须紧跟 `dsh`;`plugin` 仍为插件管理命令,因此启动同名 profile 时须使用 `dsh --profile plugin`。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并将生成的不可变 generation 安装到 Node 的运行时解析器中。启动不会创建共享或 profile 自有的 fallback 链接。profile 已安装包保留原生优先级;profile 初始化和包管理器写入与运行时解析相互独立。 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 @@ -52,6 +52,15 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 + +## 启动诊断 + +必需插件激活失败时,CLI 先输出失败插件及其原始堆栈,再列出等待中的插件和缺失服务。等待列表中的必需插件排在前面。末尾的 `Full diagnostics:` 行指向直接位于 `$DSH_HOME/logs/`(默认 `~/.dsh/logs/`)下的唯一 `startup--.log` 文件。CLI 完成报告和 stderr 写入后会明确以退出码 1 结束,即使插件仍有打开的句柄;不会覆盖以前的报告,也不会自动删除它们。 + +报告包含 DSH 和 Node 版本、平台、profile、根配置路径、每个未激活插件的模块与状态、原始错误,以及启动期间的警告和错误参数,包括尚无 Fiber 的导入错误。Node 检查输出保留嵌套原因、聚合成员、循环引用、不可枚举属性和 Symbol 属性,并关闭深度、字符串及数组长度限制。自定义检查函数被禁用,访问器只描述而不求值。收集器包含失败后的异步清理日志,并在启动结算后停止。它不会独立于已记录错误额外收集环境变量或配置内容。插件原始错误可能包含配置或凭据值;报告开头会提醒读者在分享前检查内容。报告不脱敏。 + +在 POSIX 上,新目录和文件分别请求 `0700` 和 `0600` 权限。CLI 仅在写入成功后输出文件路径。如果日志目录或文件无法写入,stderr 会包含写入错误及完整报告,退出码仍为 1。只有可选插件激活异常时,维持正常警告输出,不创建报告。 + ## 插件管理 `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 4f249511c4..7ca9f302e1 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -8,8 +8,10 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { parseDshArgs } from './args.ts' +import { reportStartupFailure } from './startup-diagnostics.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit // one directory under apps/cli, so the checked-in manifest resolves with the @@ -26,18 +28,25 @@ function readVersion(): string { * @returns a promise that settles when the selected command mode finishes. */ export async function runCli(): Promise { - const invocation = parseDshArgs(process.argv.slice(2), readVersion()) + const version = readVersion() + const invocation = parseDshArgs(process.argv.slice(2), version) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - fromDefaultProfile: invocation.fromDefaultProfile, - patchFiles: invocation.patches, - args: invocation.args, - }) + try { + await runProfile({ + environment: loadLayeredEnv('dsh'), + profile: invocation.profile, + fromDefaultProfile: invocation.fromDefaultProfile, + patchFiles: invocation.patches, + args: invocation.args, + }) + } catch (error) { + if (!(error instanceof StartupError)) throw error + await reportStartupFailure(error, { home: resolveDshHome(), version, profile: invocation.profile }) + process.exit(1) + } break } case 'plugin': { diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 9b6d0618ea..ededd48a87 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -191,6 +191,7 @@ interface ComposedProfile { * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. + * @param resolutionMode - runtime lookup, disk links, or dual verification of both. * @param fromDefaultProfile - shipped template for a missing named profile. * @param resolvedProfile - application-owned profile and installation. * @returns the profile and its patch layers. @@ -237,7 +238,7 @@ export interface RunProfileOptions { args: readonly string[] /** Application-owned package runtime, scoped to plugin package operations. */ packageManager?: ProfileContext['packageManager'] - /** Module fallback backend; pkg executables always use runtime resolution. */ + /** Module fallback backend; defaults to runtime. Plain Node callers may override it; pkg executables always use runtime. */ resolutionMode?: ProfileResolutionMode } @@ -259,7 +260,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ) const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined - const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link' + const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'runtime' const app: { current?: Context } = {} let disposal: Promise | undefined const dispose = (): Promise => disposal ??= (async () => { diff --git a/apps/cli/src/startup-diagnostics.ts b/apps/cli/src/startup-diagnostics.ts new file mode 100644 index 0000000000..dbf6b03313 --- /dev/null +++ b/apps/cli/src/startup-diagnostics.ts @@ -0,0 +1,68 @@ +/** Save original startup diagnostics while keeping the terminal report concise. */ + +import { randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { inspect } from 'node:util' +import type { StartupError } from '@deepseek-ai/dsh-app-boot' + +/** Launcher-owned context; no environment values or plugin configurations are collected. */ +interface StartupDiagnosticContext { + home: string + version: string + profile: string +} + +/** Wait for stderr to finish the write before the failed process exits. */ +function writeStderr(text: string): Promise { + return new Promise((resolve, reject) => { + process.stderr.write(text, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + +/** + * Print the startup summary and save a private, uniquely named report under DSH_HOME/logs. + * Failed writes print the complete report to stderr instead of claiming a saved path. + * @param error - startup audit failure retaining plugin metadata and original errors. + * @param context - resolved Harness home, application version, and selected profile. + * @param write - terminal output sink; awaited before returning, defaults to stderr. + * @returns after saving or printing the report and completing terminal writes. + */ +export async function reportStartupFailure( + error: StartupError, + context: StartupDiagnosticContext, + write: (text: string) => void | Promise = writeStderr, +): Promise { + const now = new Date().toISOString() + const report = 'WARNING: Raw diagnostics may contain configuration or credential values from plugin errors. Review before sharing.\n\n' + inspect({ + timestamp: now, + dshVersion: context.version, + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + profile: context.profile, + error, + }, { + depth: null, + maxArrayLength: null, + maxStringLength: null, + showHidden: true, + customInspect: false, + getters: false, + colors: false, + }) + '\n' + await write(`${error.message}\n`) + const logDir = join(context.home, 'logs') + const logPath = join(logDir, `startup-${now.replaceAll(':', '-')}-${randomUUID()}.log`) + try { + await mkdir(logDir, { recursive: true, mode: 0o700 }) + await writeFile(logPath, report, { flag: 'wx', mode: 0o600 }) + } catch (writeError) { + await write(`\ndsh: warning: could not write startup diagnostics: ${String(writeError)}\nFull diagnostics:\n${report}`) + return + } + await write(`\nFull diagnostics: ${logPath}\n`) +} diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 781396262a..dbaf904569 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -984,7 +984,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', try { await waitForFile(fixture.ready) expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') - expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(true) + expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(false) requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index dc6ee23ad9..a7326e5860 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -37,7 +37,8 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur process.send({ type: 'booting', packageManager: options.packageManager }); return new Promise((resolve, reject) => process.once('disconnect', () => { if (${String(fail)}) { reject(new Error('fixture boot failure')); return; } - resolve({ ctx: { plugin: async () => {}, connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } }, + resolve({ ctx: { plugin: async () => {}, effect: () => {}, on: () => {}, + connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } }, shutdown: { shutdown: async () => writeFileSync(${JSON.stringify(join(root, 'stopped'))}, 'stopped') } }); })); } diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 1f82c665b0..7019b87f71 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -1,15 +1,19 @@ import { createServer } from 'node:http' -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { execa } from 'execa' import { describe, expect, it } from 'vitest' -const binScript = fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) +const launch = resolveExampleLaunch({ + srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), + mode: 'lib', +}) const decompress = promisify(zstdDecompress) /** Frame one text or tool response from the local Messages endpoint. */ @@ -86,16 +90,15 @@ describe('Python SDK dsh profile keyless smoke', () => { if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') // The line-predicate protocol driving below is the genuinely custom part; // execa owns spawn, the deadline, and exit settlement around it. - const child = execa(process.execPath, [ - '--import', - 'tsx/esm', - binScript, + const child = execa(launch.command, [ + ...launch.args, '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { + ...launch.env, DSH_HOME: join(root, '.dsh'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', @@ -236,16 +239,15 @@ describe('Python SDK dsh profile keyless smoke', () => { await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) const address = modelServer.address() if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') - const child = execa(process.execPath, [ - '--import', - 'tsx/esm', - binScript, + const child = execa(launch.command, [ + ...launch.args, '--profile', 'sdk-minimal', ...(editorEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { + ...launch.env, DSH_HOME: join(root, '.dsh'), DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', @@ -285,7 +287,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const event = params?.event as Record | undefined return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) - expect(turnEnd).toMatchObject({ + expect(turnEnd, `${JSON.stringify(turnEnd)}\n${stderr}`).toMatchObject({ params: { event: { data: { reason: { kind: 'completed' } } } }, }) @@ -334,18 +336,58 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) + it.each([false, true])('exits after startup failure with stdin open (logs blocked: %s)', async (blocked) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-startup-exit-')) + const home = join(root, '.dsh') + const patch = join(root, 'failure.yml') + await mkdir(home) + if (blocked) await writeFile(join(home, 'logs'), 'blocked') + await writeFile(patch, '- id: agent-loop\n config:\n maxParallelToolCalls: 0\n') + const child = execa(launch.command, [ + ...launch.args, '--profile', 'sdk', '--patch', patch, + ], { + cwd: repoRoot, + env: { ...launch.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' }, + stdin: 'pipe', + stripFinalNewline: false, + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + }) + try { + const result = await child + expect(result.timedOut, result.stderr).toBe(false) + expect(result.signal, result.stderr).toBeUndefined() + expect(result.exitCode, result.stderr).toBe(1) + expect(result.stderr).toContain('startup failed:') + expect(result.stderr).toContain('maxParallelToolCalls') + if (blocked) { + expect(result.stderr).toContain('Full diagnostics:\nWARNING: Raw diagnostics') + expect(result.stderr.trimEnd()).toMatch(/\}$/u) + } else { + const files = await readdir(join(home, 'logs')) + expect(files).toHaveLength(1) + expect(result.stderr).toContain(`Full diagnostics: ${join(home, 'logs', files[0]!)}\n`) + } + } finally { + child.stdin.end() + child.kill('SIGKILL') + await child + await rm(root, { recursive: true, force: true }) + } + }, 30_000) + it('rejects an invalid max-token success env value', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-')) try { - const { exitCode, stdout, stderr } = await execa(process.execPath, [ - '--import', - 'tsx/esm', - binScript, + const { exitCode, stdout, stderr } = await execa(launch.command, [ + ...launch.args, '--profile', 'sdk', ], { cwd: repoRoot, env: { + ...launch.env, DSH_HOME: join(root, '.dsh'), DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', @@ -358,9 +400,8 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') - expect(stderr).toContain('plugin tree failed to load') - expect(stderr).toContain('required startup failure') - expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError') + expect(stderr).toContain('startup failed:') + expect(stderr).toContain('sdk-jsonrpc-server (required)\n Package: @deepseek-ai/dsh-sdk-jsonrpc-server\n SyntaxError') expect(stderr).toContain('sometimes') } finally { await rm(root, { recursive: true, force: true }) diff --git a/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts index f8485a7de7..fa7d64a0e9 100644 --- a/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import type { Readable } from 'node:stream' import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' @@ -210,7 +210,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () : 'inject: [webProbeMissingRequiredService]' const diagnostic = failure === 'disabled expression' ? 'disabled expression failed: SyntaxError' - : 'pending (waiting for service: webProbeMissingRequiredService)' + : 'webProbeMissingRequiredService' writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n ${patch}\n`) try { const result = await execa(process.execPath, [ @@ -238,18 +238,20 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () expect(result.signal).toBeUndefined() expect(result.exitCode).toBe(1) expect(result.stdout).not.toContain('dsh web: http://') - expect(result.stderr).toContain('required startup failure') - expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): ${diagnostic}`) + expect(result.stderr).toContain('startup failed:') + expect(result.stderr).toContain(`${id} (required)`) + expect(result.stderr).toContain(diagnostic) expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n') } finally { rmSync(fixture.root, { recursive: true, force: true }) } }) - it('fails the full Web profile when its required HTTP server cannot bind', async () => { + it.each([false, true])('fails the full Web profile when its required HTTP server cannot bind (logs blocked: %s)', async (logsBlocked) => { const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-')) const home = join(root, 'home') mkdirSync(home) + if (logsBlocked) writeFileSync(join(home, 'logs'), 'blocked') const blocker = createServer() await new Promise((resolve, reject) => { const fail = (error: Error): void => { reject(error) } @@ -289,8 +291,36 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () expect(result.signal).toBeUndefined() expect(result.exitCode).toBe(1) expect(result.stdout).not.toContain('dsh web: http://') - expect(result.stderr).toContain('required startup failure') - expect(result.stderr).toContain('EADDRINUSE') + expect(result.stderr).toContain('startup failed:') + expect(result.stderr).toContain('dsh: startup failed: 2 required plugins did not activate') + expect(result.stderr).toContain('Failed plugins (1):') + expect(result.stderr).toContain(' webserver (required)\n Package: @deepseek-ai/dsh-host-webserver') + expect(result.stderr).toContain('Plugins waiting for services (') + expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u) + expect(result.stderr).toContain('at Server.setupListenHandle') + const summary = result.stderr.split(/\n\n(?:Full diagnostics:|dsh: warning:)/u)[0]! + expect(summary.match(/EADDRINUSE/gu)).toHaveLength(1) + expect(summary).not.toMatch(/dsh: warning:|\[cause\]|at boot \(|at runCli \(|Node\.js v/u) + let report: string + if (logsBlocked) { + expect(result.stderr).toContain('dsh: warning: could not write startup diagnostics:') + expect(result.stderr).not.toMatch(/Full diagnostics: [^\r\n]/u) + report = result.stderr.split('Full diagnostics:\n')[1]! + expect(readFileSync(join(home, 'logs'), 'utf8')).toBe('blocked') + } else { + const path = /Full diagnostics: ([^\r\n]+)/u.exec(result.stderr)?.[1] + expect(path).toBeDefined() + expect(dirname(path!)).toBe(join(home, 'logs')) + report = readFileSync(path!, 'utf8') + } + expect(report).toContain("profile: 'web'") + expect(report).toContain('nodeVersion:') + expect(report).toContain('dshVersion:') + expect(report).toContain('configurationPath:') + expect(report).toContain("code: 'EADDRINUSE'") + expect(report).toContain(`port: ${String(address.port)}`) + expect(report).toContain("module: '@deepseek-ai/dsh-client-connection'") + expect(report).toContain('at auditStartupEntries') } finally { await new Promise((resolve, reject) => { blocker.close((error) => { if (error === undefined) resolve(); else reject(error) }) diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts index ab82394118..0c5b4c26d4 100644 --- a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts @@ -178,7 +178,7 @@ describe.skipIf(!built)('Web process failure matrix', () => { const result = await app.child exit(result, 1) expect(result.stdout).not.toContain('dsh web: http://') - expect(result.stderr).toContain('required startup failure') + expect(result.stderr).toContain('startup failed:') expect(app.events()).toBe('witness apply 1\nwitness dispose 1\n') } else { await app.serves() @@ -213,7 +213,7 @@ describe.skipIf(!built)('Web process failure matrix', () => { } else writeFileSync(f.patch, f.render(id, undefined, 2)) await app.wait(() => app.events().includes(`target apply ${failure === 'dependency' ? 1 : 2}\n`)) await app.serves() - expect(app.stderr()).not.toContain('required startup failure') + expect(app.stderr()).not.toContain('startup failed:') } finally { const result = await app.close() exit(result, 0) @@ -288,7 +288,7 @@ describe.skipIf(!built)('Web process failure matrix', () => { const app = start(f) try { await app.serves() - expect(app.stderr()).not.toContain('required startup failure') + expect(app.stderr()).not.toContain('startup failed:') expect(app.stderr()).not.toContain('failed to import') } finally { exit(await app.close(), 0) } }) @@ -323,7 +323,7 @@ describe.skipIf(!built)('Web process failure matrix', () => { await app.serves() writeFileSync(f.patch, f.render('matrix-optional') + `- id: webserver\n config:\n host: 127.0.0.1\n port: ${address.port}\n`) await app.wait(() => app.logs().includes('EADDRINUSE') && app.events().includes('witness apply 1\n')) - expect(app.stderr()).not.toContain('required startup failure') + expect(app.stderr()).not.toContain('startup failed:') expect(app.events()).not.toContain('witness dispose 1\n') expect(existsSync(f.serverUrl)).toBe(false) writeFileSync(f.patch, f.render('matrix-optional', undefined, 2)) @@ -413,7 +413,7 @@ export function apply(ctx, config) { writeFileSync(f.patch, pending) await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n')) expect(app.events()).not.toContain('witness dispose 2\n') - expect(app.stderr()).not.toContain('required startup failure') + expect(app.stderr()).not.toContain('startup failed:') writeFileSync(f.patch, pending + `- insert: ${JSON.stringify([{ id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 3), provider: 'matrixMissingWebDependency' }, }])}\n`) diff --git a/apps/cli/tests/resolved-profile-boot.spec.ts b/apps/cli/tests/resolved-profile-boot.spec.ts index 8976305adf..e968f91504 100644 --- a/apps/cli/tests/resolved-profile-boot.spec.ts +++ b/apps/cli/tests/resolved-profile-boot.spec.ts @@ -1,32 +1,44 @@ /** Application-owned profiles share the named profile launch lifecycle. */ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import { boot, composeEntries, healIsolatedProfileModuleFallback, type Profile } from '@deepseek-ai/dsh-app-boot' +import { + boot, composeEntries, createProfileResolutionGeneration, healIsolatedProfileModuleFallback, + PluginPackages, type Profile, +} from '@deepseek-ai/dsh-app-boot' import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' import { afterEach, describe, expect, it, vi } from 'vitest' import { runProfile } from '../src/profile-boot.ts' -vi.mock('@deepseek-ai/dsh-app-boot', async importOriginal => ({ - ...await importOriginal(), - boot: vi.fn(), - healIsolatedProfileModuleFallback: vi.fn(), - installFailLoud: vi.fn(), -})) +vi.mock('@deepseek-ai/dsh-app-boot', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + boot: vi.fn(), + createProfileResolutionGeneration: vi.fn(actual.createProfileResolutionGeneration), + healIsolatedProfileModuleFallback: vi.fn(actual.healIsolatedProfileModuleFallback), + installFailLoud: vi.fn(), + } +}) vi.mock('@deepseek-ai/dsh-http-proxy', () => ({ installProxyFromEnvironment: vi.fn() })) const homes: string[] = [] afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() - vi.clearAllMocks() + vi.resetAllMocks() for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) }) describe('runProfile with an application-owned profile', () => { - it.each(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)('releases startup resources after a %s failure', async (stage) => { + it.each( + (['link', 'runtime'] as const).flatMap(resolutionMode => + (['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const) + .map(stage => ({ resolutionMode, stage }))), + )('releases startup resources after a $stage failure in $resolutionMode mode', async ({ resolutionMode, stage }) => { const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-')) homes.push(home) mkdirSync(join(home, 'runtime')) @@ -50,7 +62,7 @@ describe('runProfile with an application-owned profile', () => { await setup?.(ctx) throw failure }) - if (stage === 'composition') vi.mocked(healIsolatedProfileModuleFallback).mockImplementationOnce(() => { throw failure }) + if (stage === 'composition') vi.mocked(createProfileResolutionGeneration).mockRejectedValueOnce(failure) const profile: Profile = { name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'), patches: [], layers: [], @@ -58,6 +70,7 @@ describe('runProfile with an application-owned profile', () => { try { const application = runProfile({ environment: createLaunchEnvironmentSnapshot([]), profile: 'desktop', patchFiles: [], args: ['--no-open'], + resolutionMode, resolvedProfile: { profile, installAnchor: join(home, 'runtime/package.json') }, }) if (stage === 'both-cleanups') { @@ -70,23 +83,36 @@ describe('runProfile with an application-owned profile', () => { await expect(application).rejects.toBe(failure) } expect(disposeProxy).toHaveBeenCalledOnce() + expect(boot).toHaveBeenCalledTimes(stage === 'composition' ? 0 : 1) expect(dispose).toHaveBeenCalledTimes(stage === 'composition' ? 0 : 1) } finally { await ctx.fiber.dispose() } }) - it.each(['link', 'runtime'] as const)('uses shared layers, %s resolution, and shutdown', async (resolutionMode) => { + it.each([ + { selection: 'default', options: {}, mode: 'runtime' }, + { selection: 'link', options: { resolutionMode: 'link' }, mode: 'link' }, + { selection: 'dual', options: { resolutionMode: 'dual' }, mode: 'dual' }, + { selection: 'runtime', options: { resolutionMode: 'runtime' }, mode: 'runtime' }, + ] as const)('uses shared layers, $selection resolution, and shutdown', async ({ options, mode }) => { const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-')) homes.push(home) mkdirSync(join(home, 'runtime')) - writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0"}') - writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0"}') + writeFileSync(join(home, 'runtime/package.json'), '{"name":"test-runtime","version":"1.0.0","exports":"./index.cjs"}') + writeFileSync(join(home, 'runtime/index.cjs'), 'module.exports = "installation"\n') + writeFileSync(join(home, 'package.json'), '{"name":"test-bundle","version":"1.0.0","dependencies":{"test-local":"*"}}') + const localPackageDir = join(home, 'node_modules/test-local') + mkdirSync(localPackageDir, { recursive: true }) + const localManifest = '{"name":"test-local","version":"1.0.0","exports":"./index.cjs"}' + writeFileSync(join(localPackageDir, 'package.json'), localManifest) + writeFileSync(join(localPackageDir, 'index.cjs'), 'module.exports = "profile"\n') vi.stubEnv('DSH_HOME', home) vi.stubEnv('DSH_TELEMETRY_DISABLED', '1') vi.spyOn(process, 'on').mockReturnValue(process) const oldExitCode = process.exitCode const ctx = new Context() + const plugin = vi.spyOn(ctx, 'plugin') // The real context supplies services; this test substitutes tree mounting and filesystem watchers. ctx.provide('loader', { create: vi.fn() }) ctx.provide('hmr', {}) @@ -119,15 +145,30 @@ describe('runProfile with an application-owned profile', () => { const runtime = { profile, installAnchor: join(home, 'runtime/package.json') } try { const { shutdown } = await runProfile({ - environment, profile: 'desktop', resolvedProfile: runtime, resolutionMode, + environment, profile: 'desktop', resolvedProfile: runtime, ...options, patchFiles: [overlay], args: ['--port', '0', '--no-open'], }) expect(installProxyFromEnvironment).toHaveBeenCalledWith(environment, expect.any(Function)) - if (resolutionMode === 'link') { + if (mode !== 'runtime') { expect(healIsolatedProfileModuleFallback).toHaveBeenCalledWith({ profile, installAnchor: runtime.installAnchor }) } else { expect(healIsolatedProfileModuleFallback).not.toHaveBeenCalled() } + const generation = vi.mocked(createProfileResolutionGeneration).mock.settledResults + .find(result => result.type === 'fulfilled')?.value + expect(generation?.profileDir).toBe(home) + expect(plugin).toHaveBeenCalledWith(PluginPackages, mode === 'link' ? {} : { + generation, + behavior: mode === 'dual' ? 'verify' : 'enforce', + }) + expect(existsSync(join(home, 'profiles/node_modules'))).toBe(false) + expect(existsSync(join(home, '.dsh-module-fallback'))).toBe(mode !== 'runtime') + expect(existsSync(join(home, 'node_modules/test-runtime'))).toBe(mode !== 'runtime') + expect(lstatSync(localPackageDir).isDirectory()).toBe(true) + expect(readFileSync(join(localPackageDir, 'package.json'), 'utf8')).toBe(localManifest) + const requireFromProfile = createRequire(join(home, 'package.json')) + expect(requireFromProfile('test-runtime')).toBe('installation') + expect(requireFromProfile('test-local')).toBe('profile') expect(readFileSync(join(home, 'cordis.yml'), 'utf8')).not.toContain('stale') expect(ctx.cmdlineArgs!.get()).toEqual(['--port', '0', '--no-open']) const ready = vi.fn() diff --git a/apps/cli/tests/startup-diagnostics.spec.ts b/apps/cli/tests/startup-diagnostics.spec.ts new file mode 100644 index 0000000000..5953196a15 --- /dev/null +++ b/apps/cli/tests/startup-diagnostics.spec.ts @@ -0,0 +1,140 @@ +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { inspect } from 'node:util' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { StartupError } from '@deepseek-ai/dsh-app-boot' +import { reportStartupFailure } from '../src/startup-diagnostics.ts' + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-startup-diagnostics-')) + onTestFinished(() => rm(dir, { recursive: true, force: true })) + return dir +} + +function startupError(reason: unknown): StartupError { + const error = new StartupError('dsh: startup failed: 1 required plugin did not activate', [ + { id: 'webserver', module: './webserver.mjs', required: true, fiberState: 3, outcome: { kind: 'failed', error: reason } }, + { id: 'waiting', module: './waiting.mjs', required: false, fiberState: 0, outcome: { kind: 'pending', missing: ['webServer'] } }, + ]) + error.startup = { + configurationPath: '/example/cordis.yml', + messages: [{ ts: 1, name: 'loader', type: 'error', args: [reason] }], + } + return error +} + +describe('startup diagnostic files', () => { + it('prints the summary and saved path to stderr by default', async () => { + const dir = await home() + const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => { + if (typeof callback === 'function') callback() + return true + }) + onTestFinished(() => { write.mockRestore() }) + await reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' }) + expect(write).toHaveBeenCalledWith(expect.stringContaining('dsh: startup failed:'), expect.any(Function)) + expect(write).toHaveBeenCalledWith(expect.stringContaining(`Full diagnostics: ${join(dir, 'logs')}`), expect.any(Function)) + }) + + it('waits for stderr completion before resolving', async () => { + const dir = await home() + const pending: Array<() => void> = [] + const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => { + if (typeof callback === 'function') pending.push(() => { callback() }) + return false + }) + onTestFinished(() => { write.mockRestore() }) + let finished = false + const report = reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' }) + .then(() => { finished = true }) + expect(pending).toHaveLength(1) + pending.shift()!() + await vi.waitFor(() => { expect(pending).toHaveLength(1) }) + expect(finished).toBe(false) + pending.shift()!() + await report + expect(finished).toBe(true) + }) + + it('rejects when stderr cannot complete the write', async () => { + const dir = await home() + const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => { + if (typeof callback === 'function') callback(new Error('stderr closed')) + return false + }) + onTestFinished(() => { write.mockRestore() }) + await expect(reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' })) + .rejects.toThrow('stderr closed') + }) + + it('retains original error properties, causes, aggregate members, cycles, and long values', async () => { + const dir = await home() + const chunks: string[] = [] + const large = 'x'.repeat(12_000) + 'END_OF_VALUE' + const leaf = Object.assign(new Error('cannot listen'), { code: 'EADDRINUSE', payload: large }) + Object.defineProperty(leaf, 'hiddenDetail', { value: 'non-enumerable detail' }) + const symbol = Symbol('diagnostic-field') + Object.assign(leaf, { [symbol]: 42n, self: leaf, values: Array.from({ length: 105 }, (_, i) => `value-${i}`) }) + let inspected = false + let getterRead = false + Object.defineProperty(leaf, inspect.custom, { value: () => { inspected = true; return 'hidden by custom inspector' } }) + Object.defineProperty(leaf, 'lazy', { get: () => { getterRead = true; return 'evaluated getter' } }) + const aggregate = new AggregateError([leaf, { transport: 'closed' }], 'activation failed', { cause: leaf }) + const error = startupError(aggregate) + + await reportStartupFailure(error, { home: dir, version: '1.2.3', profile: 'web' }, (text) => { chunks.push(text) }) + + const files = await readdir(join(dir, 'logs')) + expect(files).toHaveLength(1) + expect(files[0]).toMatch(/^startup-[\w.-]+\.log$/u) + const path = join(dir, 'logs', files[0]!) + expect(chunks.join('')).toBe(`${error.message}\n\nFull diagnostics: ${path}\n`) + const report = await readFile(path, 'utf8') + expect(report.startsWith( + 'WARNING: Raw diagnostics may contain configuration or credential values from plugin errors. Review before sharing.\n\n', + )).toBe(true) + for (const text of [ + 'dshVersion: \'1.2.3\'', "profile: 'web'", process.version, 'configurationPath:', '/example/cordis.yml', + "module: './waiting.mjs'", 'required: false', 'fiberState: 0', "missing: [ 'webServer'", 'messages:', + 'AggregateError: activation failed', '[cause]', '[errors]', 'EADDRINUSE', '[hiddenDetail]', + 'non-enumerable detail', 'Symbol(diagnostic-field)', '42n', '[Circular', large, 'value-104', '[Getter]', + 'transport:', 'closed', 'at startupError', + ]) expect(report).toContain(text) + expect(inspected).toBe(false) + expect(getterRead).toBe(false) + if (process.platform !== 'win32') { + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(join(dir, 'logs'))).mode & 0o777).toBe(0o700) + } + }) + + it('creates distinct files for concurrent failures without replacing earlier reports', async () => { + const dir = await home() + await Promise.all(['first', 'second'].map(reason => reportStartupFailure( + startupError(reason), { home: dir, version: '1.2.3', profile: 'web' }, () => {}, + ))) + const files = await readdir(join(dir, 'logs')) + expect(files).toHaveLength(2) + const reports = await Promise.all(files.map(file => readFile(join(dir, 'logs', file), 'utf8'))) + expect(reports.filter(report => report.includes("error: 'first'"))).toHaveLength(1) + expect(reports.filter(report => report.includes("error: 'second'"))).toHaveLength(1) + }) + + it('prints the complete report when the logs directory cannot be created', async () => { + const dir = await home() + await writeFile(join(dir, 'logs'), 'blocked') + const chunks: string[] = [] + await reportStartupFailure(startupError({ code: 'CUSTOM', value: 'original details' }), { + home: dir, version: '1.2.3', profile: 'web', + }, (text) => { chunks.push(text) }) + const output = chunks.join('') + expect(output).toContain('dsh: startup failed:') + expect(output).toContain('dsh: warning: could not write startup diagnostics:') + expect(output).toContain('Full diagnostics:\n') + expect(output).toContain('original details') + expect(output).toContain('CUSTOM') + expect(output).not.toContain('Full diagnostics: ') + expect(await readFile(join(dir, 'logs'), 'utf8')).toBe('blocked') + }) +}) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index b694f11e5a..092e3c2813 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -12,10 +12,12 @@ "dependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-skill-office": "workspace:^" } diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index f2b53c5060..573ef11541 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -8,6 +8,8 @@ import type {} from '@deepseek-ai/dsh-host-webserver' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import * as desktopOffice from './office.ts' +import { installDesktopUpdateTaskControl } from './update-tasks.ts' + async function main(): Promise { const runtimeDir = process.argv[2] as string const projectDir = process.argv[3] as string @@ -32,17 +34,38 @@ async function main(): Promise { }, }), }) - const stop = async (): Promise => { + let stopping: Promise | undefined + const control: { updateTasks?: ReturnType } = {} + const send = (message: object): Promise => new Promise((resolve, reject) => { + if (!process.connected || process.send === undefined) { resolve(); return } + process.send(message, (error) => { if (error === null) resolve(); else reject(error) }) + }) + const stop = (): Promise => stopping ??= (async () => { // Startup failure is reported by main; shutdown only owns a tree that booted. const running = await application.catch(() => undefined) await running?.shutdown.shutdown(0) + await send({ type: 'shutdown-complete' }) if (process.connected) process.disconnect() - } - process.on('message', (message: { type?: string } | null) => { - if (message?.type === 'shutdown') void stop() + })() + process.on('message', (message: unknown) => { + if (typeof message !== 'object' || message === null || !('type' in message)) return + if (message.type === 'shutdown') { void stop(); return } + if (message.type !== 'update-tasks' || !('requestId' in message) || !Number.isSafeInteger(message.requestId) + || !('action' in message) || !['inspect', 'lock', 'unlock'].includes(String(message.action))) return + void (async () => { + try { + if (stopping !== undefined || control.updateTasks === undefined) throw new Error('desktop update: Host is unavailable') + const active = await control.updateTasks(message.action as 'inspect' | 'lock' | 'unlock') + await send({ type: 'update-tasks', requestId: message.requestId, active }) + } catch (error) { + await send({ type: 'update-tasks', requestId: message.requestId, active: true, + error: error instanceof Error ? error.message : String(error) }) + } + })().catch((error: unknown) => { console.error(error) }) }) process.once('disconnect', () => { void stop() }) const { ctx } = await application + control.updateTasks = installDesktopUpdateTaskControl(ctx) await ctx.plugin(desktopOffice, { source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'), root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'), diff --git a/apps/desktop-host/src/update-tasks.ts b/apps/desktop-host/src/update-tasks.ts new file mode 100644 index 0000000000..106869532f --- /dev/null +++ b/apps/desktop-host/src/update-tasks.ts @@ -0,0 +1,51 @@ +/** Desktop installation admission and task inspection for the shared Web Host. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-jobs' +import type {} from '@deepseek-ai/dsh-client-connection' + +/** + * Register update admission on the owning Host context. + * @param ctx - Booted Desktop profile context; disposal removes the request listener. + * @returns Task inspector whose lock refuses new API requests, drains admitted requests, and rechecks work. + */ +export function installDesktopUpdateTaskControl(ctx: Context): (action: 'inspect' | 'lock' | 'unlock') => Promise { + let locked = false + let lockGeneration = 0 + let stopped = false + ctx.effect(() => () => { stopped = true }) + const pendingRequests = new Set>() + ctx.on('connection/request', async (_request, response, next) => { + if (locked) { + response.writeHead(503) + response.end() + return + } + const finished = Promise.withResolvers() + pendingRequests.add(finished.promise) + try { await next() } + finally { pendingRequests.delete(finished.promise); finished.resolve() } + }) + return async (action) => { + if (stopped) throw new Error('desktop update: Host is stopping') + if (action === 'unlock') { locked = false; lockGeneration++ } + const agents = ctx.get('agents') + const jobs = ctx.get('jobs') + if (agents === undefined || jobs === undefined) throw new Error('desktop update: task services are unavailable') + if (action === 'lock') { + locked = true + const generation = ++lockGeneration + // Read requests are not tasks; admitted writes must finish before the final work check. + await Promise.all(pendingRequests) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Disposal can run while admitted requests drain. + if (stopped) throw new Error('desktop update: Host is stopping') + if (generation !== lockGeneration) throw new Error('desktop update: admission lock was superseded') + } + const liveAgents = agents.list() + return liveAgents.some(agent => agent.status === 'running' + || agent.inbox.nextTurn.length > 0 || agent.inbox.nextStep.length > 0) + || [undefined, ...liveAgents].some(agent => jobs.list(agent) + .some(job => job.status === 'running' || job.status === 'stopping')) + } +} diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 7b6eaba077..89780f7b94 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { "path": "../../vendor/cordis" }, + { "path": "../../packages/core/agent" }, + { "path": "../../packages/jobs/jobs" }, { "path": "../../packages/skill/skill-office" }, { "path": "../../packages/util/home-paths" }, { "path": "../../packages/core/tools" }, diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index da940493a7..53c3945f60 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -3,23 +3,33 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= + +# The selected mandatory-update origin is required, including unsigned and preparation builds. +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com +DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com +# Optional polling and fallback-page options; origin and authentication are selected automatically. +# The fallback-page allowlist defaults to the selected service origin. +# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}' + DSH_DESKTOP_MACOS_SIGNING_IDENTITY= DSH_DESKTOP_MACOS_TEAM_ID= +# Required local p12 containing the Developer ID Application certificate and private key. +# CSC_KEY_PASSWORD is the p12 export password, not an Apple account or login password. +# An explicitly empty password is accepted for an unencrypted p12. +CSC_LINK= +CSC_KEY_PASSWORD= + # Choose exactly one notarization strategy; leave the others commented out. -APPLE_KEYCHAIN_PROFILE= +APPLE_API_KEY= +APPLE_API_KEY_ID= +APPLE_API_ISSUER= +# APPLE_KEYCHAIN_PROFILE= # APPLE_KEYCHAIN= -# APPLE_API_KEY= -# APPLE_API_KEY_ID= -# APPLE_API_ISSUER= # APPLE_ID= # APPLE_APP_SPECIFIC_PASSWORD= # APPLE_TEAM_ID= -# Optional electron-builder certificate import instead of an existing keychain identity. -# CSC_LINK= -# CSC_KEY_PASSWORD= - # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index f90f20d547..80ecebb7a5 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -1,13 +1,24 @@ # Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces. -# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded. +# Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded. DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= + +# The selected mandatory-update origin is required, including unsigned and preparation builds. +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://harness-test.deepseek.com +DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com +# Optional polling and fallback-page options; origin and authentication are selected automatically. +# The fallback-page allowlist defaults to the selected service origin. +# DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"intervalMs":600000}' + DSH_DESKTOP_WINDOWS_CER_FILE= DSH_DESKTOP_WINDOWS_SIGNTOOL= DSH_DESKTOP_WINDOWS_KEY_CONTAINER= DSH_DESKTOP_WINDOWS_TOKEN_PIN= +# Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1. +# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1 + # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d2820dfcc4..191e1afaf1 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: c16e56f511cee6a875fcf2a5e8871c8e174bd2a2 -README.zh.md: 42d687ad46ff47f6032e2c322d64e04ecd88ac28 +README.md: 5de3550de473cb71704025e1d60057611b15987c +README.zh.md: 057094c80e1197e0216f660ea788dabc0d436573 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c16e56f511..5de3550de4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. +The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. + Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. @@ -16,7 +18,7 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r ### Bundled workspace dependencies -The current Windows Python payload contains unsigned native extensions. Smart App Control blocked `_decimal`, `pyexpat`, `_lzma` and `_uuid` during local validation; XML and LZMA operations fail on that host. Successful numpy/pandas smoke checks do not establish compatibility for every extension. Office-library compatibility under Smart App Control is unvalidated: lxml and Pillow also carry unsigned extensions, and blocking lxml prevents python-docx and python-pptx imports. +Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. @@ -34,8 +36,8 @@ Node prepares the bundled interpreters and Python libraries without a system Pyt | Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. | | Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | -| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. The shell protocol serves plugin management. | -| Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. | +| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. | +| Plugin changes | Desktop and Web need the same installation and activation behavior. | The main application uses the shared Web Plugin Manager and bundled pnpm. | | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md) owns shared Web behavior and Desktop adapters. The [Electron packaging and update decision](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns release identity, signing, and update qualification. @@ -44,13 +46,15 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. +Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. -Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy. +Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy. + +On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus. ### Runtime and plugin activation @@ -59,18 +63,17 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version 1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. 2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm. 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm. -4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable. -5. Plugin changes stop the backend before modifying the current profile. The Host restarts after every attempted package change, including failed package operations. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies. +4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration. +5. The shared manager owns installation errors, activation, and restart requirements. Native recovery can disable third-party bundles even when the Host cannot start. -CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency. +The [Web plugin UI](../../packages/client/ui-plugin-manager/README.md) owns the management interface. Desktop profile initialization and recovery preserve installed plugin files. -Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic. +Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. The shared Web plugin manager reports package-operation errors; Host startup failures after plugin changes enter native recovery. There is no startup timeout heuristic. A listener failure containing `listen EADDRINUSE` replaces the diagnostics and reinstall advice with guidance to quit other running DSH instances, and offers only Exit and Restart. Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely. -Recovery waits for Host shutdown before changing plugin activation. The native recovery action disables third-party bundles by writing the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document. +Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. -Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist. ## Develop @@ -88,13 +91,32 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. Development and packaged profiles both use normal bundle resolution, including linked packages. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. +Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package + + +### Release versions + +Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not change release-family manifests or start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice. + +Record the current dsh version as the base before changing any manifests. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead. + +| dsh base | Production Desktop | Test Desktop example | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Derive once from the recorded base, not from a manifest already carrying a test suffix. The final root, Desktop, bundled dsh, private Desktop Host, and other release-family manifests must all carry the same derived version. Test distribution does not publish the corresponding unsuffixed base. + +Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [version decision](../../.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. + Packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging. -Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading. macOS checks the identity, Team ID, one complete notarization strategy, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Run the same checks separately: +Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Run the same checks separately: ```sh pnpm --dir apps/desktop run check:package @@ -120,9 +142,11 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t ### Runtime file selection -Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. +Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. +The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. + +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. @@ -134,7 +158,11 @@ The Windows installer extracts the new version beside the installation directory ### 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//`, where `target` is `mac-arm64`, `mac-x64`, or `win-x64`. +Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. + +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. + +`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`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Feed directories are `dsh-desk/feeds//`; versioned packages and blockmaps live in `dsh-desk/bin//`. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. The update destination and upload credentials follow the selected deployment: @@ -150,9 +178,11 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` in the target dotenv file 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 target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. -The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. 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 -- `. +Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. + +The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job. 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 -- `. macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. @@ -172,7 +202,7 @@ The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, inc The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; new destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running. -The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the installation flow described above. First-launch profile preparation remains a separate Desktop operation. +The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the installation flow described above. First-launch profile preparation remains a separate Desktop operation. Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. The [installer decision](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md) records the NSIS integration and release checks. @@ -180,8 +210,26 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an ### Windows EV signing +Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits. + +Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. + +Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. + +Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. + +Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. + +Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. + +Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](tests/README.md) for real-file verification and its limits. + For this project's SafeNet token, `SignTool Error: No private key is available.` indicates an incorrect PIN. Stop all signing attempts immediately and wait for the user to correct the PIN before continuing. Five incorrect PIN attempts lock the token. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure. +Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. + +Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. + Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. @@ -192,9 +240,11 @@ Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certi 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 release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. +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, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release 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 runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. +Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). + +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and 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. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: @@ -211,15 +261,65 @@ 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 dsh content. -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. + +macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. ## Updates -A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur. -Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. +`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. + +The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. Ordinary quit hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path. + +If task teardown fails after confirmed Host exit, installation is refused and the shell restores the current-version Host before allowing another restart confirmation. Installer launch failure after a clean Host stop uses the same recovery. After the replacement Host authenticates, the shell reloads the existing application URL so the Web page obtains its current port, cookie, and boot injections; page-load failure opens native fatal recovery. An unconfirmed process exit never permits a replacement Host. The downloaded target remains available for retry. A known mandatory policy remains blocking throughout recovery; unsuccessful Host restoration opens the native fatal-recovery dialog. + +Confirmed Host exit without successful task teardown displays localized recovery guidance in both ordinary and mandatory update dialogs. A typed preparation cause selects that guidance in each locale; changing translated wording cannot reclassify the failure. “View technical details” is collapsed by default and exposes only exit status, signal, shutdown acknowledgement, and deadline facts, not plugin stderr. Expanding it neither retries nor authorizes installation. + +### Mandatory update policy + +The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md) owns policy polling and the blocking window. Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=test` (the default) selects `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`; `production` selects `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`. Templates use `https://harness-test.deepseek.com` and `https://harness.deepseek.com`, respectively. The selected origin is required before preparation or signing, including unsigned and preparation-only builds; the unselected origin is optional. These settings never fall back to the parent environment or the other deployment. Packaging embeds the selected policy with the application ID; packaged applications ignore runtime overrides. + +Optional `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON supplies polling and download-page options; packaging rejects `origin` and `authentication` inside it. The page allowlist defaults to the selected service origin; explicitly allow other approved download-page origins when needed. Test builds select `feishu-test`, and production selects `anonymous`. Policy requests reject redirects; only test authentication carries gateway cookies. Unpackaged development instead reads a complete policy JSON from this variable and requires `DSH_DESKTOP_APP_ID`; absent JSON disables development policy queries, and only anonymous development permits HTTP `127.0.0.1`. A user-initiated ordinary check triggers policy work concurrently but never waits for or reports a policy failure. Only a confirmed blocking decision cancels ordinary dialogs. Test authentication waits until the active ordinary dialog finishes, and cancellation or failure does not discard the updater result. + +| Resolved policy field | Meaning and default | +|---|---| +| `origin` | Required HTTPS API origin, without credentials, path, query, or fragment; requests use `/api/v0/check_client_update` | +| `allowedPageOrigins` | Nonempty array of exact HTTPS origins; packaging defaults to the selected API origin; subdomains and alternate ports are not implied | +| `authentication` | Packaging selects `feishu-test` for test and `anonymous` for production; unpackaged development defaults to `anonymous` | +| `intervalMs` | Polling interval; default `600000` | +| `timeoutMs` | Request deadline; default `15000` | +| `maxBackoffMs` | Maximum failed-request interval including jitter; default `3600000`, at least `intervalMs` | +| `jitter` | Random additional interval fraction; default `0.2`, allowed range `0` through `1` | + +Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, exact shell and bundled dsh versions, application ID, locale, and fixed Nightly. It uses no business login credentials or installation ID. + +With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually. + +Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. + +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. + +Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. + +Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required. + +### Local updater qualification + +Ordinary update HTTP requests have a per-connection inactivity deadline: no response headers or no further response bytes for `60000` ms fails the operation. `DSH_DESKTOP_UPDATE_HTTP_IDLE_TIMEOUT_MS` accepts an integer from `1000` through `2147483647` to adjust it; active downloads have no total-duration deadline. Failed downloads retain the retry indicator and require another user action. + +On Windows with workspace dependencies installed, run this command from the repository root: + +```sh +node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:updates:local +``` + +The command builds the Desktop shell and runs its coordinator with real Electron HTTP requests and `NsisUpdater` against a private loopback server. It checks user-authorized full downloads, SHA-512 rejection, explicit retry, concurrent request coalescing, feed replacement, and installation handoff. It also opens the real mandatory-update renderer with its sandboxed preload and checks button actions, close/Esc prevention, text-only content, a stalled policy request, and policy clearance. Success prints `LOCAL_UPDATER_RESULT` and exits with code zero; functional failure exits nonzero. Each invocation owns a random port and temporary user-data/cache directory, closes the listener, waits for Electron exit, and removes its temporary files. Reports and available screenshots remain in a unique `.desktop-build/qualification/local-updater-*` directory. Screenshot failure is recorded separately, never reported as visual acceptance. No COS or signing credentials are required. + +The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, background blur, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged. ## Low-level development overrides @@ -230,3 +330,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment. - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog. - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. + +## Dev Note + +Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 42d687ad46..057094c80e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,6 +4,8 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 +应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 + Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 @@ -16,7 +18,7 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含 ### 内置工作区依赖 -当前 Windows Python 产物包含未签名的原生扩展。本机验证中,Smart App Control 阻止了 `_decimal`、`pyexpat`、`_lzma` 和 `_uuid`;该主机上的 XML 和 LZMA 操作失败。numpy/pandas 冒烟检查通过,不代表所有扩展都兼容。Office 库在 Smart App Control 下的兼容性尚未验证:lxml 和 Pillow 也包含未签名扩展,阻止 lxml 会导致 python-docx 和 python-pptx 导入失败。 +Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 @@ -34,8 +36,8 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 | 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 | | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 | | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | -| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。shell 协议提供插件管理页面。 | -| 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 | +| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。 | +| 插件变更 | Desktop 与 Web 需要一致的安装和激活行为。 | 主应用使用共享 Web 插件管理器和内置 pnpm。 | | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [薄壳决策](../../.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 行为与 Desktop 适配。[Electron 打包与更新决策](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责发布身份、签名及更新验收。 @@ -44,13 +46,15 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 +应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 -Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单、原生对话框和插件管理渲染器使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码。 +Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 -Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。 +Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 + +macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 ### 运行时与插件激活 @@ -59,16 +63,16 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、 1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。 2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。 -4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。 -5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。 +4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。 +5. 共享管理器负责安装错误、激活和重启要求。即使 Host 无法启动,原生恢复仍可禁用第三方 bundle。 -CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。 +[Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。 -主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。 +主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 -恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 +恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。 @@ -88,13 +92,32 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。开发与打包 profile 都使用正常的 bundle 解析,包括链接包。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 +Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 + + +### 发布版本 + +每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前,不得修改发布家族清单或启动打包;仅选择部署环境不代表用户已认可版本号。 + +修改任何清单前,记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。 + +| dsh 基础版本 | production Desktop | test Desktop 示例 | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。只从记录的基础版本派生一次,不从已有测试后缀的清单继续追加。最终根包、Desktop、内置 dsh、私有 Desktop Host 和其他发布家族清单必须全部使用同一个派生版本。test 分发不发布对应的无后缀基础版本。 + +版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[版本决策](../../.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md)解释为什么不能用通道名替换预发布标识。 + 打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。 -每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置。macOS 检查身份、Team ID、一套完整公证凭据以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。单独运行相同检查: +每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。单独运行相同检查: ```sh pnpm --dir apps/desktop run check:package @@ -120,9 +143,11 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS ### 运行时文件筛选 -生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。 +Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 +[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 + +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。 Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 @@ -134,7 +159,11 @@ Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用 ### 上传更新 -`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` 为 `mac-arm64`、`mac-x64` 或 `win-x64`。 +test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 + +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 + +`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。清单目录为 `dsh-desk/feeds//`;带版本的安装包和 blockmap 位于 `dsh-desk/bin//`。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 更新目标与上传凭据都与所选环境对应: @@ -150,9 +179,11 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -生产发布需在打包前在目标 `.env` 中设置 `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 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。 +内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 -macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 + +macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 @@ -172,7 +203,7 @@ pnpm run package:desktop:win:x64:unsigned Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。 -主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循上文描述的安装流程。首次启动的配置档案准备仍属于独立的 Desktop 操作。 +主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循上文描述的安装流程。首次启动的配置档案准备仍属于独立的 Desktop 操作。 Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。[安装界面决策](../../.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md)记录 NSIS 接入方式和发布验证要求。 @@ -180,8 +211,26 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI ### Windows EV 签名 +运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。 + +预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 + +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 + +使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 + +Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 + +Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 + +签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](tests/README.zh.md)。 + 本项目使用的 SafeNet Token 出现 `SignTool Error: No private key is available.` 时,说明 PIN(密码)错误。立即停止所有签名尝试,等待用户处理 PIN 后再继续。PIN 输错达到五次会锁定 Token。遇到该错误后,不得重试打包或签名探针。签名器串行执行 Token 操作,首次失败后拒绝所有排队任务。 +Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 + +硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 + Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 @@ -192,9 +241,11 @@ NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自 pnpm run package:desktop:win:x64 ``` -打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 +打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 +时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 + +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: @@ -211,15 +262,65 @@ pnpm run prepare:desktop 这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 + +macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 ## 更新 -打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。 +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 -签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 +`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 + +左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。常规退出会在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。 + +若任务收尾失败但已确认 Host 退出,安装会被拒绝,壳会在允许再次确认重启前恢复当前版本的 Host。Host 正常停止后的安装器启动失败使用同一恢复路径。替代 Host 启动并完成认证后,壳重新加载原有应用地址,让 Web 页面获取当前端口、Cookie 和启动注入数据;页面加载失败时打开原生致命故障恢复弹窗。未确认进程退出时,绝不允许启动替代 Host。已下载目标保留以供重试。已知强更策略在恢复过程中继续阻塞;Host 恢复失败打开原生致命故障恢复弹窗。 + +已确认 Host 退出但任务未成功收尾时,常规与强更弹窗均展示本地化恢复提示。两种语言都根据类型化的准备失败原因选择提示,翻译文案变化不会改变失败分类。“查看技术详情”默认折叠,仅展示退出状态、信号、关闭确认和截止时间事实,不展示插件 stderr。展开详情既不重试,也不授权安装。 + +### 强制更新策略 + +[强更客户端决策](../../.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md)负责策略查询和阻塞窗口。打包读取 `.env.windows` 或 `.env.macos`:`DSH_DESKTOP_AUTO_UPDATE_ENV=test`(默认值)选择 `DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN`;`production` 选择 `DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN`。模板分别使用 `https://harness-test.deepseek.com` 和 `https://harness.deepseek.com`。在准备产物或签名前,所选源站必须配置,包括未签名和仅准备构建;未选环境的源站可不填。这些配置不会回退到父进程环境或另一部署环境。打包将选定策略与应用 ID 写入元数据;打包应用忽略运行时覆盖。 + +可选的 `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG` JSON 提供轮询和下载页面选项;打包拒绝其中的 `origin` 和 `authentication`。页面白名单默认只包含所选服务源站;需要其他已批准下载页面源站时应显式配置。测试包选择 `feishu-test`,正式包选择 `anonymous`。策略请求拒绝重定向;仅测试鉴权携带网关 Cookie。未打包开发模式则从此变量读取完整策略 JSON,并要求 `DSH_DESKTOP_APP_ID`;缺少 JSON 会禁用开发模式策略查询,仅匿名开发允许 HTTP `127.0.0.1`。用户发起常规检查时会并发触发策略检查,但不会等待或展示策略失败。只有已确认的强更决定可以关闭常规弹窗。测试环境鉴权会等待当前常规弹窗结束,取消或失败不会丢弃 updater 结果。 + +| 解析后的策略字段 | 含义与默认值 | +|---|---| +| `origin` | 必填 HTTPS API 源站,不含凭据、路径、查询或片段;请求使用 `/api/v0/check_client_update` | +| `allowedPageOrigins` | 非空的精确 HTTPS 源站数组;打包时默认只包含所选 API 源站;不隐含子域名或其他端口 | +| `authentication` | 打包时测试环境选择 `feishu-test`,正式环境选择 `anonymous`;未打包开发模式默认为 `anonymous` | +| `intervalMs` | 轮询间隔;默认 `600000` | +| `timeoutMs` | 请求截止时间;默认 `15000` | +| `maxBackoffMs` | 含抖动的失败请求最大间隔;默认 `3600000`,不小于 `intervalMs` | +| `jitter` | 随机增加的间隔比例;默认 `0.2`,范围为 `0` 至 `1` | + +时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、完整壳与内置 dsh 版本、应用 ID、语言和固定 Nightly。不使用业务登录凭据或安装 ID。 + +启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应会在用户主动检查和打包应用首次启动检查时提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。 + +登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 + +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 + +失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 + +后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。 + +### 本地 updater 验证 + +常规更新 HTTP 请求具有逐连接的无活动截止时间:`60000` 毫秒内未收到响应头或后续响应字节会使操作失败。`DSH_DESKTOP_UPDATE_HTTP_IDLE_TIMEOUT_MS` 接受 `1000` 至 `2147483647` 的整数进行调整;活跃下载没有总时长限制。下载失败保留重试提示,并要求用户再次操作。 + +在已安装工作区依赖的 Windows 上,从仓库根目录运行: + +```sh +node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:updates:local +``` + +此命令构建 Desktop 壳,让其协调器通过真实 Electron HTTP 请求和 `NsisUpdater` 访问私有回环服务器。它验证用户授权的完整下载、SHA-512 拒绝、显式重试、并发请求合并、清单替换和安装交接。它还打开使用沙箱预加载的真实强更页面,检查按钮操作、关闭/Esc 拦截、纯文本内容、策略请求停滞和策略解除。成功时打印 `LOCAL_UPDATER_RESULT` 并以零退出码结束;功能失败时返回非零退出码。每次调用独占随机端口和临时用户数据/缓存目录,关闭监听器、等待 Electron 退出,并移除临时文件。报告和可用截图保存在唯一的 `.desktop-build/qualification/local-updater-*` 目录中。截图失败单独记录,绝不当作视觉验收通过。不需要 COS 或签名凭据。 + +下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、背景模糊、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。 ## 底层开发覆盖项 @@ -230,3 +331,7 @@ pnpm run prepare:desktop - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。 - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 + +## 开发备注 + +上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 3aefaec12f..f7cfd63f42 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -1,3 +1,5 @@ +import type { AfterPackContext, BeforePackContext } from 'app-builder-lib' + /** Electron-builder fields asserted by the Desktop release tests. */ export interface DesktopElectronBuilderConfig { readonly appId: string @@ -12,8 +14,12 @@ export interface DesktopElectronBuilderConfig { { readonly from: string, readonly to: 'dsh', readonly filter: readonly ['**/*'] }, { readonly from: string, readonly to: 'dsh/node_modules', readonly filter: readonly ['**/*'] }, ] + readonly extraMetadata: { readonly dshDesktopAppId: string } readonly asarUnpack: readonly string[] - readonly extraResources: readonly [{ readonly from: string, readonly to: 'runtime' }] + readonly extraResources: readonly [ + { readonly from: string, readonly to: 'runtime' }, + { readonly from: string, readonly to: 'icon.png' }, + ] readonly mac: { readonly identity: string | undefined readonly forceCodeSigning: boolean @@ -24,6 +30,14 @@ export interface DesktopElectronBuilderConfig { readonly sign: boolean readonly writeUpdateInfo: boolean } + readonly win: { + readonly forceCodeSigning: boolean + readonly signtoolOptions: { + readonly publisherName: string | undefined + readonly sign: ((configuration: { path: string, hash: string, isNest: boolean }) => Promise) | undefined + readonly signingHashAlgorithms: readonly string[] + } + } readonly nsis: { readonly include: string readonly oneClick: false @@ -33,6 +47,9 @@ export interface DesktopElectronBuilderConfig { readonly installerLanguages: readonly ['en_US', 'zh_CN'] } readonly beforeBuild: () => Promise + readonly beforePack: (context: BeforePackContext) => Promise + readonly afterPack: (context: AfterPackContext) => Promise + readonly afterSign: (context: AfterPackContext) => Promise readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise | undefined readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null } @@ -42,12 +59,14 @@ export interface DesktopElectronBuilderConfig { * @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. + * @param preparedRuntime - Verified private qualification runtime; ordinary releases use target-owned resources. * @returns electron-builder configuration. */ export function createElectronBuilderConfig( env?: NodeJS.ProcessEnv, hostPlatform?: NodeJS.Platform, hostArch?: string, + preparedRuntime?: string, ): DesktopElectronBuilderConfig declare const electronBuilderConfig: DesktopElectronBuilderConfig diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index ca846818b6..4b2a131627 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,154 +1,5 @@ -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' -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, - scrubWindowsSigningEnvironment, -} from './scripts/windows-sign.mjs' -import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs' -import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs' -import { installWindowsDirectoryInstaller } from './scripts/windows-directory-installer.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 - if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) { - throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1') - } - const unsigned = env.DSH_DESKTOP_UNSIGNED === '1' - if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows') - const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') - const packagesWindows = targetPlatform === 'win32' - if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller() - const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined - if (packagesMacOS) resolveMacOSNotarizationEnvironment(env) - const windowsSigner = packagesWindows && !unsigned - ? 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 = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) - const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) - return { - appId, - productName: 'DeepSeek Harness', - artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', - directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, - asar: true, - electronDist: buildPaths.electron, - electronFuses: { runAsNode: true }, - beforeBuild: async () => { - if (resolvedPlatform !== 'win32') return true - await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', - fileURLToPath(new URL('./scripts/prepare-windows-installer.ps1', import.meta.url)), - '-OutputDirectory', join(buildPaths.root, 'installer-ui')], { - env: scrubWindowsSigningEnvironment(env), windowsHide: true, - }) - if (windowsSigner !== undefined) { - await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false }) - } - // A falsy result tells electron-builder to omit its production node_modules collection. - return true - }, - files: [ - 'lib/*.js', - 'lib/*.cjs', - 'renderer/**/*', - 'package.json', - { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] }, - // electron-builder excludes a source directory's root node_modules. - { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, - ], - asarUnpack: [ - '**/*.{node,dylib,dll,so,exe}', - '**/*.so.*', - '**/spawn-helper', - '**/@vscode/ripgrep/bin/rg', - ], - extraResources: [ - { from: buildPaths.runtime, to: 'runtime' }, - ], - mac: { - icon: fileURLToPath(new URL('./resources/icon-macos.png', import.meta.url)), - category: 'public.app-category.developer-tools', - identity: macOSSigning?.signingIdentity, - forceCodeSigning: true, - hardenedRuntime: true, - // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. - signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], - notarize: true, - target: ['dmg', 'zip'], - }, - dmg: { - sign: true, - writeUpdateInfo: false, - }, - afterSign: async 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: { - icon: fileURLToPath(new URL('./resources/icon-windows.png', import.meta.url)), - forceCodeSigning: !unsigned, - signtoolOptions: { - sign: windowsSigner, - signingHashAlgorithms: ['sha256'], - }, - target: ['nsis'], - }, - linux: { - category: 'Development', - target: ['AppImage'], - }, - nsis: { - installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), - uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), - include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)), - oneClick: false, - perMachine: false, - allowElevation: false, - allowToChangeInstallationDirectory: false, - installerLanguages: ['en_US', 'zh_CN'], - differentialPackage: true, - }, - publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }], - } -} +/** Ordinary release entry; qualification imports the environment-independent factory. */ +import { createElectronBuilderConfig } from './scripts/electron-builder-config.mjs' +export { createElectronBuilderConfig } export default createElectronBuilderConfig() diff --git a/apps/desktop/installer/pages.nsh b/apps/desktop/installer/pages.nsh index 18dee26d8b..13ce171cc3 100644 --- a/apps/desktop/installer/pages.nsh +++ b/apps/desktop/installer/pages.nsh @@ -167,6 +167,9 @@ Function InstallerCreate System::Call 'user32::SetPropW(p $HWNDPARENT, w "HarnessInstaller.Ready", p 1)' ShowWindow $InstallerDialog 5 ShowWindow $HWNDPARENT 5 + ${If} $InstallerPhase == "welcome" + System::Call '$PLUGINSDIR\window-frame.dll::InstallerPresentWelcome(p $HWNDPARENT) i.r0 ?c' + ${EndIf} nsDialogs::Show ${NSD_KillTimer} InstallerValidateEditedPath ${NSD_FreeImage} $InstallerImage diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index f0ffdb98c5..3ec9c201c8 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -242,3 +242,16 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); return result; } + +// Present the first interactive page after resource preparation without keeping the installer topmost. +extern "C" __declspec(dllexport) BOOL __cdecl InstallerPresentWelcome(HWND window) { + if (!IsWindowVisible(window)) return FALSE; + if (!SetWindowPos(window, HWND_TOP, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE)) return FALSE; + if (GetForegroundWindow() != window) { + FLASHWINFO flash = {sizeof(flash), window, FLASHW_TRAY | FLASHW_TIMERNOFG, 0, 0}; + FlashWindowEx(&flash); + } + SetPropW(window, L"HarnessInstaller.Presented", reinterpret_cast(1)); + return TRUE; +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b4fd9830b0..3d6133cb75 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -11,8 +11,11 @@ "build": "tsc -b && tsdown", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", + "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "prepare:primary-runtime": "tsx scripts/prepare-primary-runtime.ts", + "sign:primary-runtime": "tsx scripts/sign-primary-runtime.ts", + "cache:windows-signatures": "node scripts/manage-windows-signature-cache.mjs", "prepare:packages": "tsx scripts/prepare-package-set.ts", "prepare:dsh": "tsx scripts/prepare-dsh.ts", "prepare:package": "tsx scripts/package-target.ts --prepare-only", @@ -36,7 +39,6 @@ "semver": "^7.8.5" }, "devDependencies": { - "@aws-sdk/client-s3": "3.1067.0", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@electron/get": "^5.1.0", @@ -45,11 +47,13 @@ "@types/node": "^22.20.0", "@types/semver": "^7.8.0", "app-builder-lib": "26.15.3", + "cos-nodejs-sdk-v5": "3.0.0", "electron": "^44.0.0", "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", "fflate": "^0.8.2", "js-yaml": "^4.2.0", + "koffi": "^3.1.0", "pnpm": "11.7.0", "tar": "^7.5.0", "typescript": "^6.0.3" diff --git a/apps/desktop/renderer/mandatory-update.css b/apps/desktop/renderer/mandatory-update.css new file mode 100644 index 0000000000..89cf0cf0d5 --- /dev/null +++ b/apps/desktop/renderer/mandatory-update.css @@ -0,0 +1,12 @@ +#status, #version { margin-top: 12px; color: #555962; } +#status:empty, #version:empty { display: none; } +#error { margin-top: 12px; color: #d93025; } +#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; } +#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; } +#update, #refresh { border-radius: 100px; } +#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; } +#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; } +#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; } +#copy-message { color: #d93025 !important; } +#manual-copy { display: block; margin-top: 8px; } +#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; accent-color: #4d6bfe; } diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html new file mode 100644 index 0000000000..673c5d0953 --- /dev/null +++ b/apps/desktop/renderer/mandatory-update.html @@ -0,0 +1,37 @@ + + + + + + + + + + + +
+

+

+

+

+ + + +
+ + + + +
+ +
+ + + diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js new file mode 100644 index 0000000000..24e1f88041 --- /dev/null +++ b/apps/desktop/renderer/mandatory-update.js @@ -0,0 +1,114 @@ +/** Text-only policy rendering; the main process owns task inspection and every privileged action. */ +const api = window.dshMandatoryUpdate +let current +let busy = false +let localError +const element = id => document.getElementById(id) +const format = (message, values) => message.replaceAll(/\{([^{}]+)\}/gu, (match, key) => values[key] ?? match) + +function render(view) { + document.body.classList.toggle('visible', view.policy.blocking) + if (!view.policy.blocking) return + const wasConfirming = current?.confirmation !== undefined + current = view + const { locale: { id, messages }, policy, update, confirmation, navigation } = view + const confirming = confirmation !== undefined + const failed = update.phase === 'error' + const ready = update.phase === 'ready' || (failed && update.failedOperation === 'install') + const downloadable = update.phase === 'available' || (failed && update.failedOperation === 'download') + const authenticationRequired = policy.error === 'authentication-required' + const preparationMessages = { + 'stop-failed': messages.updateStopFailed, + 'tasks-changed': messages.updateTasksChanged, + 'tasks-unavailable': messages.updateTasksUnavailable, + } + const fallback = failed || update.phase === 'idle' || view.error !== undefined + let title = policy.title ?? messages.mandatoryTitle + let detail = policy.detail ?? messages.mandatoryDetail + let primary = '', action = '', status = '' + if (downloadable) { primary = failed ? messages.updateRetry : messages.updateDownload; action = 'download' } + if (ready) { primary = view.deferred ? messages.mandatoryContinue : messages.updateRetry; action = 'install' } + if (view.deferred) { title = messages.mandatoryReady; detail = messages.mandatoryDeferred } + if (update.phase === 'downloading') status = format(messages.updateDownloading, { percent: String(Math.floor(update.percent ?? 0)) }) + if (update.phase === 'verifying') status = messages.updateVerifying + if (update.phase === 'installing') status = messages.mandatoryInspecting + if (update.phase === 'checking' || (policy.checking && ['idle', 'error'].includes(update.phase))) status = messages.updateChecking + if (confirming) { + title = confirmation.active ? messages.updateActiveTasks : messages.mandatoryReady + detail = confirmation.active ? messages.updateActiveTasksDetail : messages.mandatoryReadyDetail + primary = confirmation.active ? messages.updateStopTasks : messages.installAndRestart + action = 'install'; status = '' + } + if (view.restart !== undefined) { + title = messages.updateInstalling + detail = view.restart === 'stopping-tasks' ? messages.mandatoryStopping : messages.mandatoryRestarting + status = ''; primary = ''; action = '' + } + const error = localError ?? view.error ?? (authenticationRequired ? messages.policyLoginRequired : failed + ? update.failedOperation === 'download' ? messages.mandatoryDownloadFailed + : update.failedOperation === 'install' + ? preparationMessages[update.preparationFailure] ?? messages.mandatoryInstallFailed + : messages.mandatoryUnavailable + : update.phase === 'idle' && !policy.checking ? messages.mandatoryNoRelease : undefined) + const technicalDetails = failed ? update.technicalDetails ?? update.message ?? '' : '' + document.documentElement.lang = id + document.title = messages.mandatoryTitle + element('title').textContent = title + element('detail').textContent = detail + element('status').textContent = status + element('version').textContent = update.version === undefined ? '' : format(messages.mandatoryVersion, { version: update.version }) + element('progress').hidden = update.phase !== 'downloading' + element('progress').value = update.percent ?? 0 + element('progress').setAttribute('aria-label', status) + element('error').textContent = error ?? '' + element('error').hidden = error === undefined || confirming + if (element('technical-details-content').textContent !== technicalDetails) element('technical-details').open = false + element('technical-details').hidden = technicalDetails === '' || confirming + element('technical-details-label').textContent = messages.updateTechnicalDetails + element('technical-details-content').textContent = technicalDetails + element('update').hidden = !primary + element('update').textContent = primary + element('update').dataset.action = action + element('update').disabled = busy && !confirming + element('later').textContent = messages.updateLater + element('later').hidden = !confirmation?.active + element('refresh').textContent = authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh + element('refresh').hidden = confirming || view.restart !== undefined || (!authenticationRequired && (!!primary || !fallback)) + element('refresh').disabled = busy || policy.checking + element('page').textContent = navigation ? messages.mandatoryReopen : messages.mandatoryPage + element('page').hidden = !fallback || confirming || policy.page === undefined + element('actions').hidden = [...element('actions').children].every(child => child.hidden) + element('fallback').hidden = !navigation || !fallback || confirming || policy.page === undefined + element('browser-message').textContent = navigation?.page === 'failed' ? messages.mandatoryPageFailed : messages.mandatoryOpenHelp + element('copy').textContent = navigation?.copy === 'copied' ? messages.mandatoryCopied : messages.mandatoryCopy + element('copy-message').textContent = navigation?.copy === 'failed' ? messages.mandatoryCopyFailed : '' + element('manual-copy').hidden = navigation?.copy !== 'failed' + element('address-label').textContent = messages.mandatoryAddress + element('address').value = navigation?.copy === 'failed' ? policy.page ?? '' : '' + // A download button can become an install button while a key remains held. + if (confirming && !wasConfirming && document.activeElement?.id === 'update') document.activeElement.blur() +} + +async function act(action) { + const navigation = ['page', 'copy'].includes(action) + const confirmation = current?.confirmation !== undefined && ['install', 'later'].includes(action) + if (current === undefined || (busy && !navigation && !confirmation)) return + if (!navigation) busy = true + localError = undefined + render(current) + try { await api.action(action, current.confirmation?.version ?? current.update.version, current.confirmation?.revision) } + catch { localError = current.locale.messages.mandatoryActionFailed } + finally { if (!navigation) busy = false; render(current) } +} + +for (const action of ['refresh', 'page', 'copy', 'later']) element(action).addEventListener('click', () => { void act(action) }) +element('update').addEventListener('click', event => { + if (event.detail <= 1) void act(element('update').dataset.action) +}) +document.addEventListener('keydown', event => { + if (event.key === 'Escape' || (event.repeat && ['Enter', ' '].includes(event.key))) event.preventDefault() +}) +let changed = false +const unsubscribe = api.subscribe(view => { changed = true; render(view) }) +window.addEventListener('pagehide', unsubscribe, { once: true }) +void api.status().then(view => { if (!changed) render(view) }) diff --git a/apps/desktop/renderer/plugin-manager.css b/apps/desktop/renderer/plugin-manager.css deleted file mode 100644 index 89d1c1f939..0000000000 --- a/apps/desktop/renderer/plugin-manager.css +++ /dev/null @@ -1,108 +0,0 @@ -:root { - color-scheme: light dark; - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: Canvas; - color: CanvasText; -} - -body { - margin: 0; -} - -main { - max-width: 680px; - margin: 0 auto; - padding: 32px; -} - -header, -.install-row, -li { - display: flex; - align-items: center; - gap: 12px; -} - -header { - justify-content: space-between; -} - -h1, -h2, -p { - margin-top: 0; -} - -header p { - color: GrayText; -} - -form, -section { - margin-top: 28px; -} - -label { - display: block; - margin-bottom: 8px; - font-weight: 600; -} - -input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid ButtonBorder; - border-radius: 7px; - background: Field; - color: FieldText; -} - -button { - padding: 9px 14px; - border: 1px solid ButtonBorder; - border-radius: 7px; - background: AccentColor; - color: AccentColorText; - cursor: pointer; -} - -button.quiet, -li button { - background: ButtonFace; - color: ButtonText; -} - -button:disabled, -input:disabled { - opacity: 0.55; - cursor: wait; -} - -#status { - min-height: 1.5em; - margin-top: 16px; - color: GrayText; -} - -ul { - margin: 0; - padding: 0; - list-style: none; -} - -li { - justify-content: space-between; - padding: 12px 0; - border-bottom: 1px solid ButtonBorder; -} - -.package-version { - color: GrayText; - margin-left: 8px; -} - -.package-actions { - display: flex; - gap: 8px; -} diff --git a/apps/desktop/renderer/plugin-manager.html b/apps/desktop/renderer/plugin-manager.html deleted file mode 100644 index 5d229648f5..0000000000 --- a/apps/desktop/renderer/plugin-manager.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - -
-
-
-

-

-
- -
-
- -
- - -
-
- -

-
-

-
    -

    -
    -
    - - - diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js deleted file mode 100644 index 6b6c4c542f..0000000000 --- a/apps/desktop/renderer/plugin-manager.js +++ /dev/null @@ -1,130 +0,0 @@ -const api = window.dshDesktop - -async function main() { - const locale = await api.locale() - const messages = locale.messages - const message = (key, values = {}) => messages[key].replaceAll(/\{([^{}]+)\}/gu, (placeholder, name) => values[name] ?? placeholder) - document.documentElement.lang = locale.id - document.querySelector('#page-title').textContent = messages.pluginManagerTitle - document.querySelector('#title').textContent = messages.pluginManagerTitle - document.querySelector('#description').textContent = messages.pluginManagerDescription - document.querySelector('#refresh').textContent = messages.refresh - document.querySelector('#package-label').textContent = messages.npmPackage - document.querySelector('#install').textContent = messages.install - document.querySelector('#installed-heading').textContent = messages.installed - document.querySelector('#empty').textContent = messages.noPlugins - - - const list = document.querySelector('#plugins') - const empty = document.querySelector('#empty') - const status = document.querySelector('#status') - const form = document.querySelector('#install-form') - const input = document.querySelector('#package-spec') - const refresh = document.querySelector('#refresh') - const updateForm = document.querySelector('#update-form') - const updateVersion = document.querySelector('#update-version') - let updateName = '' - let updateTrigger - document.querySelector('#update-submit').textContent = messages.update - document.querySelector('#update-cancel').textContent = messages.cancel - - function setBusy(busy, statusMessage = '') { - for (const control of document.querySelectorAll('button, input')) control.disabled = busy - status.textContent = statusMessage - } - - async function render() { - const plugins = await api.plugins.list() - list.replaceChildren(...plugins.map(plugin => { - const item = document.createElement('li') - const identity = document.createElement('span') - const version = document.createElement('span') - version.className = 'package-version' - version.textContent = plugin.enabled ? plugin.version : `${plugin.version} · ${messages.disabled}` - identity.append(document.createTextNode(plugin.name), version) - const remove = document.createElement('button') - remove.type = 'button' - remove.textContent = messages.remove - remove.addEventListener('click', () => void run( - () => api.plugins.remove(plugin.name), - message('removing', { name: plugin.name }), - )) - const update = document.createElement('button') - update.type = 'button' - update.textContent = messages.update - update.addEventListener('click', () => { - updateName = plugin.name - updateTrigger = update - document.querySelector('#update-label').textContent = message('targetVersion', { name: plugin.name }) - updateVersion.value = plugin.version - updateForm.hidden = false - updateVersion.focus() - updateVersion.select() - }) - const actions = document.createElement('span') - actions.className = 'package-actions' - const toggle = document.createElement('button') - toggle.type = 'button' - toggle.textContent = plugin.enabled ? messages.disable : messages.enable - toggle.addEventListener('click', () => void run( - () => api.plugins.toggle(plugin.name, !plugin.enabled), messages.changingActivation, - )) - actions.append(toggle, update, remove) - item.append(identity, actions) - return item - })) - empty.hidden = plugins.length !== 0 - } - - async function run(operation, statusMessage) { - setBusy(true, statusMessage) - try { - await operation() - await render() - status.textContent = messages.operationComplete - } catch (error) { - status.textContent = error instanceof Error ? error.message : String(error) - } finally { - setBusy(false, status.textContent) - } - } - - async function load(statusMessage, success) { - setBusy(true, statusMessage) - try { - await render() - status.textContent = success - } catch (error) { - status.textContent = error instanceof Error ? error.message : String(error) - } finally { - setBusy(false, status.textContent) - } - } - - form.addEventListener('submit', (event) => { - event.preventDefault() - const spec = input.value.trim() - if (spec === '') return - void run(async () => { - await api.plugins.add(spec) - input.value = '' - }, message('installing', { spec })) - }) - updateForm.addEventListener('submit', (event) => { - event.preventDefault() - const version = updateVersion.value.trim() - if (version === '') return - const name = updateName - updateForm.hidden = true - void run(() => api.plugins.update(name, version), message('updating', { name })) - }) - document.querySelector('#update-cancel').addEventListener('click', () => { - updateForm.hidden = true - updateTrigger.focus() - }) - refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed)) - - await load(messages.loadingPlugins, '') -} - -void main() diff --git a/apps/desktop/renderer/policy-login-loading.html b/apps/desktop/renderer/policy-login-loading.html new file mode 100644 index 0000000000..5e7658b826 --- /dev/null +++ b/apps/desktop/renderer/policy-login-loading.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + +

    + + + diff --git a/apps/desktop/renderer/update-close.svg b/apps/desktop/renderer/update-close.svg new file mode 100644 index 0000000000..23baa97ffc --- /dev/null +++ b/apps/desktop/renderer/update-close.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css new file mode 100644 index 0000000000..98183989a5 --- /dev/null +++ b/apps/desktop/renderer/update-dialog.css @@ -0,0 +1,31 @@ +:root { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; + color: #0f1115; + background: transparent; +} +* { box-sizing: border-box; } +body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; } +body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); } +main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); } +header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; } +main:focus { outline: none; } +h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; } +p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; } +#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; } +button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; } +.primary { background: #0f1115; color: #fff; } +.secondary { background: #f5f5f5; color: inherit; } +.close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; } +.close img { width: 14px; height: 14px; } +button:hover { filter: brightness(0.95); } +button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; } +details { margin-top: 12px; font-size: 13px; line-height: 20px; } +summary { cursor: pointer; color: #5b6472; } +pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; } +button:disabled { opacity: 0.5; cursor: default; } +[hidden] { display: none !important; } +body.update-dialog { opacity: 0; transition: opacity 150ms ease; } +body.update-dialog.visible { opacity: 1; } +@starting-style { body.update-dialog.visible { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } } diff --git a/apps/desktop/renderer/update-dialog.html b/apps/desktop/renderer/update-dialog.html new file mode 100644 index 0000000000..85a0f20abd --- /dev/null +++ b/apps/desktop/renderer/update-dialog.html @@ -0,0 +1,22 @@ + + + + + + + + + + +
    +

    +

    + +
    +
    + + + diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js new file mode 100644 index 0000000000..d03eadadf7 --- /dev/null +++ b/apps/desktop/renderer/update-dialog.js @@ -0,0 +1,57 @@ +/** Main-owned copy and responses; Escape is cancellation, never acceptance. */ +const api = window.dshUpdateDialog +let view +let responding = false +function respond(index) { + if (responding || view === undefined) return + responding = true + void api.respond(view.revision, index).catch(() => { responding = false }) +} +document.getElementById('close').addEventListener('click', () => { respond(view.cancelId) }) +document.addEventListener('keydown', event => { + if (event.key === 'Escape' && view !== undefined) { event.preventDefault(); respond(view.cancelId) } + if (event.key !== 'Tab') return + const controls = [...document.querySelectorAll('button, details:not([hidden]) > summary, details[open]:not([hidden]) > pre')] + const current = controls.indexOf(document.activeElement) + const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) + : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length + event.preventDefault() + controls[next].focus() +}) +function render(state) { + if (state === null) { + responding = true + document.body.classList.remove('visible') + return + } + if (view !== undefined && state.revision <= view.revision) return + responding = false + view = state + document.documentElement.lang = state.locale + document.title = state.title + document.getElementById('title').textContent = state.message + document.getElementById('detail').textContent = state.detail + document.getElementById('detail').hidden = state.detail === '' + document.getElementById('close').setAttribute('aria-label', state.closeLabel) + document.getElementById('technical-details').hidden = state.technicalDetails === '' + document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel + document.getElementById('technical-details-content').textContent = state.technicalDetails + document.getElementById('technical-details').open = false + document.getElementById('actions').replaceChildren() + for (const [index, label] of state.buttons.entries()) { + const button = document.createElement('button') + button.type = 'button' + button.textContent = label + button.className = index === 0 ? 'primary' : 'secondary' + button.addEventListener('click', () => { respond(index) }) + document.getElementById('actions').append(button) + } + document.querySelector('main').hidden = false + document.getElementById('dialog').scrollTop = 0 + document.body.classList.add('visible') + document.getElementById('dialog').focus() +} +let received = false +const unsubscribe = api.subscribe(state => { received = true; render(state) }) +window.addEventListener('pagehide', unsubscribe, { once: true }) +void api.status().then(state => { if (!received) render(state) }) diff --git a/apps/desktop/scripts/build-installed-update-worker.mjs b/apps/desktop/scripts/build-installed-update-worker.mjs new file mode 100644 index 0000000000..b79925f07a --- /dev/null +++ b/apps/desktop/scripts/build-installed-update-worker.mjs @@ -0,0 +1,29 @@ +/** Internal builder child; requires its live parent's version-scoped packaging record and never publishes. */ +import { readFile, realpath } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' +import { assertInstalledUpdateSigningClear } from './installed-update-packaging.ts' +import { createInstalledUpdateBuilderConfig } from './installed-update-builder.ts' +import { packagingOutputRedactor } from './packaging-run.mjs' + +try { + const [manifest, version, ...extra] = process.argv.slice(2) + if (!manifest || !version || extra.length !== 0) throw new Error('invalid worker arguments') + const run = await readInstalledUpdateRun(manifest) + const record = await realpath(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR ?? '') + if (dirname(record) !== await realpath(join(run.root, version, 'packaging'))) throw new Error('wrong packaging record directory') + const metadata = JSON.parse(await readFile(join(record, 'run.json'), 'utf8')) + if (metadata.mode !== 'operator-authorized-single-version' || metadata.id !== run.id + || metadata.version !== version || metadata.pid !== process.ppid) throw new Error('worker is not owned by the authorized parent') + await assertInstalledUpdateSigningClear() + const config = await createInstalledUpdateBuilderConfig(manifest, version, process.env) + const { build, Platform, Arch } = await import('electron-builder') + await build({ projectDir: join(import.meta.dirname, '..'), config, + targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never' }) +} catch (error) { + const redactor = packagingOutputRedactor(Object.entries(process.env) + .filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD/iu.test(name)).map(([, value]) => value ?? ''), text => process.stderr.write(text)) + redactor.write(Buffer.from(error instanceof Error ? `${error.stack ?? error.message}\n` : 'installed update: builder worker failed\n')) + redactor.end() + process.exitCode = 1 +} diff --git a/apps/desktop/scripts/cos-operation.ts b/apps/desktop/scripts/cos-operation.ts new file mode 100644 index 0000000000..0db3aa6f26 --- /dev/null +++ b/apps/desktop/scripts/cos-operation.ts @@ -0,0 +1,44 @@ +/** Total deadlines and request cleanup for one qualification COS operation. */ +import * as http from 'node:http' +import * as https from 'node:https' +import type COS from 'cos-nodejs-sdk-v5' + +/** + * Run one SDK operation with a shared deadline across its HTTP attempts. + * @param cos Client dedicated to this operation, with no concurrent users. + * @param timeoutMs Total network-operation budget in milliseconds. + * @param operation SDK call whose result is returned after its requests close. + * @returns SDK result; expiration rejects with TimeoutError after aborting the transport. + */ +export async function cosOperation(cos: COS, timeoutMs: number, operation: () => Promise): Promise { + const controller = new AbortController() + const timeout = new Error('installed update: COS operation exceeded its deadline') + timeout.name = 'TimeoutError' + const closed: Promise[] = [] + const wrap = (transport: typeof http | typeof https) => ({ + ...transport, + request(options: http.RequestOptions) { + const request = transport.request({ ...options, signal: controller.signal }) + closed.push(new Promise((resolve) => { request.once('close', resolve) })) + return request + }, + }) + const modules = { 'http:': wrap(http), 'https:': wrap(https) } + // cos-request forwards httpModules to its native request layer, including every SDK retry. + const configure = (options: { httpModules?: typeof modules }): void => { options.httpModules = modules } + cos.on('before-send', configure) + const timer = setTimeout(() => { controller.abort(timeout) }, timeoutMs) + try { + const result = await operation() + if (controller.signal.aborted) throw timeout + return result + } catch (error) { + if (controller.signal.aborted) throw timeout + throw error + } finally { + clearTimeout(timer) + controller.abort() + await Promise.all(closed) + cos.off('before-send', configure) + } +} diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index da3c36ccb5..eebd20156a 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -1,6 +1,6 @@ /** Resolve the Desktop auto-update channel and its Tencent COS destination. */ -import { prerelease, valid } from 'semver' +import { valid } from 'semver' /** Environment variable that selects the Desktop update deployment. */ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' @@ -77,9 +77,7 @@ export function desktopUpdateMetadataFilename(version, platform) { 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` + return `nightly${platform === 'darwin' ? '-mac' : ''}.yml` } /** @@ -139,7 +137,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { 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}` + const keyPrefix = `dsh-desk/feeds/${target}` return { environment, target, diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 0f253ef88f..375e61b542 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -19,7 +19,8 @@ export function resolveDesktopBuildTarget( 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 arch = env.DSH_DESKTOP_TARGET_ARCH ?? env.npm_config_arch + ?? (platform === 'win32' || platform === 'win' ? 'x64' : hostArch) const os = platform === 'darwin' ? 'mac' : platform === 'win32' || platform === 'win' ? 'win' : platform const target = `${os}-${arch}` if (!SUPPORTED_TARGETS.has(target)) { diff --git a/apps/desktop/scripts/desktop-cos.ts b/apps/desktop/scripts/desktop-cos.ts new file mode 100644 index 0000000000..68306ffc64 --- /dev/null +++ b/apps/desktop/scripts/desktop-cos.ts @@ -0,0 +1,53 @@ +/** Shared Tencent COS client construction for Desktop update objects. */ + +import COS from 'cos-nodejs-sdk-v5' + +/** Region of every Desktop update bucket, present or future deployment. */ +export const DESKTOP_COS_REGION = 'ap-beijing' + +/** Request-level inactivity deadline for COS transfers, in milliseconds. */ +const TRANSFER_TIMEOUT_MS = 900_000 + +/** Credentials for one COS client; values are never written to retained records. */ +export interface DesktopCosCredentials { + readonly secretId: string + readonly secretKey: string +} + +/** Request options the SDK exposes to `before-send` listeners. */ +interface CosRequestOptions { + headers: Record +} + +/** + * Create a COS client whose writes cannot be repeated by the SDK. + * + * The SDK retries a failed request only while the request body is not a stream, so every caller + * supplies a stream with an explicit `ContentLength` and a precomputed `Content-MD5`. Host + * switching, redirect following, and clock-offset correction stay off so an ambiguous write is + * never sent to another endpoint. The SDK also injects an empty `Cache-Control` header when the + * caller names none; that header is removed here so uploading leaves cache policy to deployment + * infrastructure. + * @param credentials SecretId and SecretKey for the selected deployment. + * @returns A COS client that sends HTTPS requests to the region named by each call. + */ +export function createDesktopCos(credentials: DesktopCosCredentials): COS { + const cos = new COS({ + SecretId: credentials.secretId, + SecretKey: credentials.secretKey, + Protocol: 'https:', + KeepAlive: false, + FollowRedirect: false, + AutoSwitchHost: false, + CorrectClockSkew: false, + ChunkRetryTimes: 0, + Timeout: TRANSFER_TIMEOUT_MS, + UploadCheckContentMd5: false, + }) + cos.on('before-send', (options: CosRequestOptions) => { + for (const name of Object.keys(options.headers)) { + if (name.toLowerCase() === 'cache-control' && options.headers[name] === '') delete options.headers[name] + } + }) + return cos +} diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index fc2c26b37e..ee4f31b2bb 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -7,13 +7,15 @@ import { parseEnv } from 'node:util' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } 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' +import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV)|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)$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|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)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|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|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu -const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN'] +const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu +const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] /** * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. @@ -74,6 +76,7 @@ function requireReadableFile(environment, name) { */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { resolveDesktopAppId(environment) + resolveDesktopPolicyEnvironment(environment) if (options.unsigned) return if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) if (target.platform === 'win32') { @@ -83,6 +86,7 @@ export function validateDesktopPackageEnvironment(environment, target, options = tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN, keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, }) + if (!options.prepareOnly) resolveWindowsSignatureCacheDirectory(environment) } else { resolveMacOSSigningEnvironment(environment) const strategies = [ @@ -96,5 +100,9 @@ export function validateDesktopPackageEnvironment(environment, target, options = const credentials = resolveMacOSNotarizationEnvironment(environment) if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY') if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN') + requireReadableFile(environment, 'CSC_LINK') + if (environment.CSC_KEY_PASSWORD === undefined) { + throw new Error('desktop package: CSC_KEY_PASSWORD must be set to the p12 export password (use an explicit empty value for an unencrypted p12)') + } } } diff --git a/apps/desktop/scripts/desktop-policy-environment.d.mts b/apps/desktop/scripts/desktop-policy-environment.d.mts new file mode 100644 index 0000000000..a9cd2995fe --- /dev/null +++ b/apps/desktop/scripts/desktop-policy-environment.d.mts @@ -0,0 +1,14 @@ +/** Required deployment-selected metadata for mandatory-update policy requests. */ +export interface DesktopPolicyEnvironment { + origin: string + allowedPageOrigins: string[] + authentication: 'anonymous' | 'feishu-test' + [key: string]: unknown +} + +/** + * Resolve policy settings before artifact preparation or signing. + * @param environment File-owned release settings; only the selected origin is required. + * @returns Policy metadata with deployment-selected origin and authentication. + */ +export function resolveDesktopPolicyEnvironment(environment: NodeJS.ProcessEnv): DesktopPolicyEnvironment diff --git a/apps/desktop/scripts/desktop-policy-environment.mjs b/apps/desktop/scripts/desktop-policy-environment.mjs new file mode 100644 index 0000000000..a582e37772 --- /dev/null +++ b/apps/desktop/scripts/desktop-policy-environment.mjs @@ -0,0 +1,36 @@ +/** Resolve the required policy service from the same deployment as updater publication. */ +import { resolveDesktopAutoUpdateEnvironment } from './desktop-auto-update-environment.mjs' + +function origin(value, name) { + let url + try { url = new URL(value) } catch { throw new Error(`desktop package: ${name} requires an HTTPS origin`) } + if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error(`desktop package: ${name} requires an HTTPS origin without credentials, path, query, or fragment`) + } + return url.origin +} + +/** + * Resolve mandatory policy metadata before preparing artifacts or accessing signing hardware. + * @param {NodeJS.ProcessEnv} environment File-owned release settings; the unselected origin is not required. + * @returns {{ origin: string, allowedPageOrigins: string[], authentication: 'anonymous' | 'feishu-test', [key: string]: unknown }} Selected policy. + */ +export function resolveDesktopPolicyEnvironment(environment) { + const deployment = resolveDesktopAutoUpdateEnvironment(environment) + const name = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN' + const selected = origin(environment[name], name) + let settings = {} + if (environment.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG !== undefined) { + try { settings = JSON.parse(environment.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG) } + catch { throw new Error('desktop package: DSH_DESKTOP_MANDATORY_UPDATE_CONFIG must be valid JSON') } + } + if (typeof settings !== 'object' || settings === null || Array.isArray(settings) + || 'origin' in settings || 'authentication' in settings) { + throw new Error('desktop package: policy options must be an object without origin or authentication; use the deployment origin settings') + } + const pages = settings.allowedPageOrigins ?? [selected] + if (!Array.isArray(pages) || pages.length === 0) throw new Error('desktop package: allowedPageOrigins must be a nonempty array') + return { ...settings, origin: selected, + allowedPageOrigins: pages.map(value => origin(value, 'allowedPageOrigins')), + authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' } +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index ff8a2f4868..0ec76dba3a 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -4,7 +4,8 @@ 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 { dump, load } from 'js-yaml' +import { prerelease } from 'semver' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopBuildRecordFilename, @@ -31,8 +32,9 @@ export interface DesktopUploadArtifact { readonly filename: string readonly key: string readonly contentType: string - readonly cacheControl: string readonly channelMetadata: boolean + /** Published YAML with normalized artifact URLs; binary bytes remain file-backed. */ + readonly contents?: string } /** A fully validated upload operation with channel metadata ordered last. */ @@ -156,9 +158,6 @@ function uploadArtifact( filename, key: `${keyPrefix}/${filename}`, contentType, - cacheControl: channelMetadata - ? 'no-cache' - : 'public, max-age=31536000, immutable', channelMetadata, } } @@ -223,27 +222,42 @@ export async function createDesktopUploadPlan( const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) const artifacts: DesktopUploadArtifact[] = [] + const binaryPrefix = `dsh-desk/bin/${targetName}` 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'), + uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage'), + uploadArtifact(updaterPath, binaryPrefix, 'application/zip'), + uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'), ) } else { - const blockMapSize = object(metadata.files[0], `${metadataFilename}.files[0]`).blockMapSize - numberField(blockMapSize, `${metadataFilename}.files[0].blockMapSize`) + const blockmapPath = await requireArtifact(artifactsRoot, `${base}.exe.blockmap`) artifacts.push(uploadArtifact( updaterPath, - update.keyPrefix, + binaryPrefix, 'application/vnd.microsoft.portable-executable', )) + artifacts.push(uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream')) } - artifacts.push(uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true)) + const payloadUrl = `${update.origin}/${binaryPrefix}/${updaterInfo.filename}` + const published = { + ...metadata, + files: [{ ...object(metadata.files[0], `${metadataFilename}.files[0]`), url: payloadUrl }], + ...(metadata.path === undefined ? {} : { path: payloadUrl }), + } + const channelArtifact = { + ...uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true), + contents: dump(published), + } + artifacts.push(channelArtifact) + if (prerelease(dshVersion) === null) { + const stableFilename = metadataFilename.replace('nightly', 'latest') + artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) + } return { environment: update.environment, target: targetName, diff --git a/apps/desktop/scripts/desktop-upload-run.ts b/apps/desktop/scripts/desktop-upload-run.ts new file mode 100644 index 0000000000..d8361c9224 --- /dev/null +++ b/apps/desktop/scripts/desktop-upload-run.ts @@ -0,0 +1,126 @@ +/** Durable, credential-free evidence for test and production release uploads. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import type COS from 'cos-nodejs-sdk-v5' +import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-plan.ts' +import { DESKTOP_COS_REGION } from './desktop-cos.ts' +import { recordPackagingEvent } from './packaging-run.mjs' + +const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch', + 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) + +/** Object keys fingerprinted into every upload record to bind evidence to this uploader. */ +const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts'] + +async function fingerprint(artifact: DesktopUploadArtifact) { + const sha512 = createHash('sha512') + const md5 = createHash('md5') + let size = 0 + const source = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)] + for await (const bytes of source) { + size += bytes.length + sha512.update(bytes) + md5.update(bytes) + } + return { size, sha512: sha512.digest('base64'), md5: md5.digest('base64') } +} + +function receipt(value: unknown): object { + if (typeof value !== 'object' || value === null) return {} + const response = value as { statusCode?: unknown; RequestId?: unknown } + return { + ...(typeof response.statusCode === 'number' ? { httpStatus: response.statusCode } : {}), + ...(typeof response.RequestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(response.RequestId) + ? { requestId: response.RequestId } : {}), + } +} + +function failureReceipt(error: unknown): object { + const codes = typeof error === 'object' && error !== null + ? ['code' in error ? error.code : undefined, 'name' in error ? error.name : undefined] : [] + const errorCode = codes.find(code => typeof code === 'string' && FAILURE_CODES.has(code)) ?? 'UNCLASSIFIED' + return { errorCode, ...receipt(error) } +} + +function streamedBody(artifact: DesktopUploadArtifact): Readable { + return artifact.contents === undefined + ? createReadStream(artifact.path) + : Readable.from([Buffer.from(artifact.contents)]) +} + +/** + * Upload an already validated release, flushing intent and response evidence around every PUT. + * + * Each object is sent as one streamed PUT with an explicit length and Content-MD5, which is also + * what keeps the COS SDK's internal retry path unreachable: it repeats a request only when the + * body is not a stream. This function never retries either, so every confirmed PUT is the only + * write for its key. + * @param plan Validated release metadata; credential values must not be included. + * @param cos Caller-owned client from the Desktop COS factory in `desktop-cos.ts`. + * @param recordsRoot Local retained evidence parent, outside disposable artifact directories. + * @returns Fresh record directory after all PUTs succeed; errors retain partial evidence and stop later PUTs. + */ +export async function uploadDesktopRelease(plan: DesktopUploadPlan, cos: COS, recordsRoot: string): Promise { + await mkdir(recordsRoot, { recursive: true }) + const directory = await mkdtemp(join(recordsRoot, `${plan.environment}-${plan.target}-`)) + process.stdout.write(`desktop upload: record ${directory}\n`) + const startedAt = new Date().toISOString() + let stage = 'prepare' + let key: string | undefined + let confirmedPuts = 0 + let success = false + let failure: object | undefined + try { + await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) + recordPackagingEvent(directory, { type: 'upload-start', environment: plan.environment, target: plan.target, version: plan.version }) + const artifacts = [] + for (const artifact of plan.artifacts) { + stage = 'hash-input' + key = artifact.key + artifacts.push({ ...artifact, ...await fingerprint(artifact) }) + } + const sourceSha256: Record = {} + for (const filename of UPLOADER_SOURCES) { + sourceSha256[filename] = createHash('sha256').update(await readFile(join(import.meta.dirname, filename))).digest('hex') + } + await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1, + environment: plan.environment, target: plan.target, version: plan.version, bucket: plan.bucket, + publicUrl: plan.publicUrl, maxAttempts: 1, sourceSha256, artifacts }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + for (const artifact of artifacts) { + key = artifact.key + stage = 'verify-input' + const current = await fingerprint(artifact) + if (current.sha512 !== artifact.sha512 || current.size !== artifact.size) throw new Error('desktop upload: input changed') + stage = 'put' + recordPackagingEvent(directory, { type: 'put-intent', key, size: artifact.size, sha512: artifact.sha512, + channelMetadata: artifact.channelMetadata }) + const body = streamedBody(artifact) + try { + const response = await cos.putObject({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION, Key: key, + Body: body, ContentLength: artifact.size, ContentType: artifact.contentType, + Headers: { 'Content-MD5': artifact.md5 } }) + confirmedPuts++ + stage = 'record-response' + recordPackagingEvent(directory, { type: 'put-confirmed', key, attempts: 1, ...receipt(response) }) + } finally { + body.destroy() + } + process.stdout.write(`desktop upload: uploaded ${key}\n`) + } + stage = 'complete' + recordPackagingEvent(directory, { type: 'upload-complete', confirmedPuts }) + success = true + return directory + } catch (error) { + failure = failureReceipt(error) + throw new Error(`desktop upload: stopped at ${stage}; inspect ${directory} before another upload`) + } finally { + await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, startedAt, + finishedAt: new Date().toISOString(), environment: plan.environment, target: plan.target, version: plan.version, + success, stage, key, confirmedPuts, failure, publicReadback: 'not-performed' }, null, 2)}\n`, + { flag: 'wx', mode: 0o600, flush: true }) + } +} diff --git a/apps/desktop/scripts/electron-builder-config.d.mts b/apps/desktop/scripts/electron-builder-config.d.mts new file mode 100644 index 0000000000..655996a234 --- /dev/null +++ b/apps/desktop/scripts/electron-builder-config.d.mts @@ -0,0 +1,2 @@ +/** Import the factory without evaluating an ordinary release configuration. */ +export { createElectronBuilderConfig } from '../electron-builder.config.mjs' diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs new file mode 100644 index 0000000000..cf2f76768f --- /dev/null +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -0,0 +1,216 @@ +import { X509Certificate } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + resolveDesktopAppId, + resolveMacOSNotarizationEnvironment, + resolveMacOSSigningEnvironment, +} from './desktop-release-environment.mjs' +import { notarizeMacOSDiskImageArtifact } from './notarize-macos-disk-images.mjs' +import { verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs' +import { + createWindowsTokenSigner, + installWindowsNsisBootstrapSigner, + resolveWindowsUpdatePublisher, + scrubWindowsSigningEnvironment, +} from './windows-sign.mjs' +import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' +import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' +import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' +import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' +import { preserveWindowsRuntimeSignature, signWindowsCode } from './windows-runtime-signature.mjs' +import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from './windows-asar-unpack.mjs' +import { recordPackagingEvent } from './packaging-run.mjs' +import { + resolveMacOSAppUpdateFeed, + verifyMacOSAppUpdateConfig, + writeMacOSAppUpdateConfig, +} from './macos-app-update-config.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. + * @param {string | undefined} preparedRuntime - Verified private dsh tree for installed-update qualification; ordinary releases use the target tree. + * @returns {object} electron-builder configuration. + */ +export function createElectronBuilderConfig( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, + preparedRuntime = undefined, +) { + const appId = resolveDesktopAppId(env) + const policy = resolveDesktopPolicyEnvironment(env) + const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM + const resolvedPlatform = targetPlatform ?? hostPlatform + const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch + if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) { + throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1') + } + const unsigned = env.DSH_DESKTOP_UNSIGNED === '1' + if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows') + const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') + const packagesWindows = resolvedPlatform === 'win32' + if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller() + const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined + if (packagesMacOS) resolveMacOSNotarizationEnvironment(env) + const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + let primaryRuntimeDestination + let dshDestination + let windowsCode = [] + const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg'] + const windowsSigner = packagesWindows && !unsigned + ? 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, + preserveSignature: async path => { + for (const [sourceRoot, destinationRoot] of [[join(buildPaths.runtime, 'primary-runtime'), primaryRuntimeDestination], [buildPaths.dsh, dshDestination]]) { + if (destinationRoot !== undefined && await preserveWindowsRuntimeSignature(path, { + sourceRoot, destinationRoot, runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR, + })) return true + } + return false + }, + }) + : undefined + if (windowsSigner !== undefined) { + installWindowsNsisBootstrapSigner({ sign: windowsSigner }) + } + const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) + if (preparedRuntime !== undefined) buildPaths.dsh = preparedRuntime + return { + appId, + extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy }, + productName: 'DeepSeek Harness', + artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', + directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, + asar: true, + electronDist: buildPaths.electron, + electronFuses: { runAsNode: true }, + beforeBuild: async () => { + if (resolvedPlatform !== 'win32') return true + await promisify(execFile)('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + fileURLToPath(new URL('./prepare-windows-installer.ps1', import.meta.url)), + '-OutputDirectory', join(buildPaths.root, 'installer-ui')], { + env: scrubWindowsSigningEnvironment(env), windowsHide: true, + }) + if (windowsSigner !== undefined) { + await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false }) + } + // A falsy result tells electron-builder to omit its production node_modules collection. + return true + }, + files: [ + 'lib/main.js', + 'lib/preload-app.cjs', + 'lib/preload-mandatory.cjs', + 'lib/preload-update-dialog.cjs', + 'renderer/**/*', + 'package.json', + { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] }, + // electron-builder excludes a source directory's root node_modules. + { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, + ], + asarUnpack: unpack, + extraResources: [ + { from: buildPaths.runtime, to: 'runtime' }, + { from: fileURLToPath(new URL('../resources/icon-windows.png', import.meta.url)), to: 'icon.png' }, + ], + mac: { + icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)), + category: 'public.app-category.developer-tools', + identity: macOSSigning?.signingIdentity, + forceCodeSigning: true, + hardenedRuntime: true, + // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. + signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], + notarize: true, + target: ['dmg', 'zip'], + }, + dmg: { + sign: true, + writeUpdateInfo: false, + }, + beforePack: async context => { + if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh) + if (windowsSigner !== undefined) { + primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime') + dshDestination = join(context.appOutDir, 'resources', 'app.asar.unpacked', 'dsh') + } + if (policy === undefined) return + const { resolveDesktopPolicyConfig } = await import('../lib/types/mandatory-update-policy.js') + resolveDesktopPolicyConfig(policy) + }, + afterPack: async context => { + const { verifyDesktopRuntime } = await import('../lib/types/runtime-tree.js') + const resourcesDir = context.packager.getResourcesDir(context.appOutDir) + if (resolvedPlatform === 'darwin' && update !== undefined) { + await writeMacOSAppUpdateConfig(resourcesDir, resolveMacOSAppUpdateFeed(context.packager.config.publish), + context.packager.appInfo.updaterCacheDirName) + } + await verifyDesktopRuntime(buildPaths.dsh, + context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + // Unsigned Windows builds skip electron-builder's afterSign hook. + if (packagesWindows && unsigned) await verifyWindowsAsarUnpack(buildPaths.dsh, resourcesDir, windowsCode) + }, + afterSign: async context => { + if (windowsSigner !== undefined) { + await signWindowsCode(context.appOutDir, { + thumbprint: new X509Certificate(await readFile(env.DSH_DESKTOP_WINDOWS_CER_FILE)).fingerprint.replaceAll(':', ''), + sign: windowsSigner, + record: event => recordPackagingEvent(env.DSH_DESKTOP_PACKAGING_RUN_DIR, event), + }) + await verifyWindowsAsarUnpack(buildPaths.dsh, context.packager.getResourcesDir(context.appOutDir), windowsCode) + } + if (context.electronPlatformName !== 'darwin') return + const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + if (update !== undefined) { + await verifyMacOSAppUpdateConfig(appPath, resolveMacOSAppUpdateFeed(context.packager.config.publish), + context.packager.appInfo.updaterCacheDirName) + } + verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) + }, + artifactBuildCompleted: artifact => { + if (!artifact.file.endsWith('.dmg')) return + return notarizeMacOSDiskImageArtifact( + artifact, + env, + macOSSigning ?? resolveMacOSSigningEnvironment(env), + ) + }, + win: { + icon: fileURLToPath(new URL('../resources/icon-windows.png', import.meta.url)), + forceCodeSigning: !unsigned, + signtoolOptions: { + sign: windowsSigner, + publisherName: windowsSigner === undefined ? undefined : resolveWindowsUpdatePublisher(env.DSH_DESKTOP_WINDOWS_CER_FILE), + signingHashAlgorithms: ['sha256'], + }, + target: ['nsis'], + }, + linux: { + category: 'Development', + target: ['AppImage'], + }, + nsis: { + installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), + uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), + include: fileURLToPath(new URL('./installer.nsh', import.meta.url)), + oneClick: false, + perMachine: false, + allowElevation: false, + allowToChangeInstallationDirectory: false, + installerLanguages: ['en_US', 'zh_CN'], + differentialPackage: true, + }, + detectUpdateChannel: false, + publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }], + } +} diff --git a/apps/desktop/scripts/installed-update-builder.ts b/apps/desktop/scripts/installed-update-builder.ts new file mode 100644 index 0000000000..c42ae2347b --- /dev/null +++ b/apps/desktop/scripts/installed-update-builder.ts @@ -0,0 +1,39 @@ +/** Select isolated qualification inputs without weakening the ordinary Windows installer or signing hooks. */ +import { join } from 'node:path' +import { createElectronBuilderConfig } from './electron-builder-config.mjs' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' +import { verifyInstalledUpdateApplication } from './prepare-installed-update-application.ts' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' + +/** + * Validate one version and construct its signed-only, test-only builder configuration. + * @param manifest Original run.json with prepared runtime and application files. + * @param version One of the run's two versions. + * @param environment File-owned .env.windows settings loaded by a supervised caller; never logged here. + * @returns Configuration only. Calling builder hooks requires separate hardware authorization and supervision. + */ +export async function createInstalledUpdateBuilderConfig(manifest: string, version: string, environment: NodeJS.ProcessEnv) { + const run = await readInstalledUpdateRun(manifest) + if (!run.versions.includes(version)) throw new Error('installed update: package version is outside the qualification run') + if (environment.DSH_DESKTOP_AUTO_UPDATE_ENV !== 'test' || environment.DSH_DESKTOP_UNSIGNED === '1' + || environment.DOWNLOAD_TEST_ORIGIN !== run.origin || environment.DOWNLOAD_TEST_COS_BUCKET !== run.bucket) { + throw new Error('installed update: signed ordinary-update qualification requires matching test deployment settings') + } + const application = await verifyInstalledUpdateApplication(run.root) + const dsh = join(run.root, version, 'dsh') + await verifyDesktopRuntime(dsh, version, { platform: 'win32', arch: 'x64' }) + const config = createElectronBuilderConfig({ ...environment, DSH_DESKTOP_APP_ID: run.appId, + DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh) + return { ...config, + productName: run.productName, + directories: { ...config.directories, output: join(run.root, version, 'installer') }, + extraMetadata: { ...config.extraMetadata, name: `dsh-update-test-${run.id}`, version, main: 'qualification-bootstrap.mjs' }, + files: [ + { from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] }, + 'package.json', + { from: dsh, to: 'dsh', filter: ['**/*'] }, + { from: join(dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, + ], + publish: [{ provider: 'generic' as const, url: `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`, channel: 'nightly' }], + } +} diff --git a/apps/desktop/scripts/installed-update-cos.ts b/apps/desktop/scripts/installed-update-cos.ts new file mode 100644 index 0000000000..7dcff940b4 --- /dev/null +++ b/apps/desktop/scripts/installed-update-cos.ts @@ -0,0 +1,112 @@ +/** Fixed test-COS transport; callers authorize writes separately from local planning. */ +import { createReadStream } from 'node:fs' +import { createHash } from 'node:crypto' +import { Readable, Writable } from 'node:stream' +import { cosOperation } from './cos-operation.ts' +import { createDesktopCos, DESKTOP_COS_REGION } from './desktop-cos.ts' +import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import type { InstalledUpdatePublicationStore, InstalledUpdateRemoteObject } from './installed-update-publication.ts' + +const BUCKET = 'bj-toc-download-test-1320056602' +const ORIGIN = 'https://download-test.deepseek.com' + +/** COS reports a missing key through this error code; no other status means absence. */ +function isMissingObject(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey' +} + +async function hashStream(stream: AsyncIterable): Promise { + const hash = createHash('sha512') + let size = 0 + for await (const bytes of stream) { hash.update(bytes); size += bytes.length } + return { sha512: hash.digest('base64'), size } +} + +/** + * Create a fixed test transport from .env.windows, passing only test upload credentials to the SDK. + * Version queries have a 30-second total deadline; object reads and PUTs have 15 minutes. + * Expiration aborts HTTP requests and waits for closure before releasing the publication operation. + * @returns Store whose writes are streamed and therefore cannot be repeated by the SDK. + */ +export function createInstalledUpdateCos(): InstalledUpdatePublicationStore { + const environment = loadDesktopPackageEnvironment('win32') + if (environment.DSH_DESKTOP_AUTO_UPDATE_ENV !== 'test' || environment.DOWNLOAD_TEST_ORIGIN !== ORIGIN + || environment.DOWNLOAD_TEST_COS_BUCKET !== BUCKET || !environment.DOWNLOAD_TEST_COS_SECRET_ID?.trim() + || !environment.DOWNLOAD_TEST_COS_SECRET_KEY?.trim()) throw new Error('installed update: complete test upload settings are required') + const credentials = { + secretId: environment.DOWNLOAD_TEST_COS_SECRET_ID, + secretKey: environment.DOWNLOAD_TEST_COS_SECRET_KEY, + } + const client = () => createDesktopCos(credentials) + const keyAllowed = (key: string): void => { + if (!/^dsh-desk\/(?:bin|feeds)\/qualification\/[a-f0-9]{24}\/win-x64\/[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(key)) { + throw new Error('installed update: COS key must stay in the Windows qualification namespace') + } + } + return { + async versioningDisabled() { + const cos = client() + const response = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: BUCKET, Region: DESKTOP_COS_REGION })) + const status: 'Enabled' | 'Suspended' | undefined = response.VersioningConfiguration.Status + return response.statusCode === 200 && status === undefined + }, + async read(key) { + keyAllowed(key) + const hash = createHash('sha512') + let size = 0 + // Output keeps the object out of memory and makes the SDK wait for the write to finish. + const output = new Writable({ + write(bytes: Buffer, _encoding, done) { hash.update(bytes); size += bytes.length; done() }, + }) + try { + const cos = client() + await cosOperation(cos, 900_000, () => cos.getObject({ Bucket: BUCKET, Region: DESKTOP_COS_REGION, Key: key, Output: output })) + } catch (error) { + if (isMissingObject(error)) return null + throw error + } finally { output.destroy() } + return { sha512: hash.digest('base64'), size } + }, + async publicRead(url) { + const parsed = new URL(url) + if (parsed.origin !== ORIGIN || parsed.search || parsed.hash) throw new Error('installed update: exact test public URL is required') + keyAllowed(parsed.pathname.slice(1)) + const response = await fetch(url, { redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(900_000) }) + if (response.status === 404) { await response.body?.cancel(); return null } + if (!response.ok || !response.body) { await response.body?.cancel(); throw new Error('installed update: public object read failed') } + const reader = response.body.getReader() + async function* bytes() { + try { + for (;;) { const next = await reader.read(); if (next.done) return; yield next.value } + } finally { try { await reader.cancel() } finally { reader.releaseLock() } } + } + return hashStream(bytes()) + }, + async put(key, object) { + keyAllowed(key) + const md5 = createHash('md5') + const sha512 = createHash('sha512') + let size = 0 + const add = (bytes: Buffer): void => { md5.update(bytes); sha512.update(bytes); size += bytes.length } + if ('path' in object.source) { + for await (const bytes of createReadStream(object.source.path)) add(bytes as Buffer) + } else add(Buffer.from(object.source.contents)) + if (size !== object.size || sha512.digest('base64') !== object.sha512) { + throw new Error('installed update: upload input bytes changed') + } + const headers: Record = { 'Content-MD5': md5.digest('base64') } + if (object.forbidOverwrite) headers['x-cos-forbid-overwrite'] = 'true' + const body = 'path' in object.source + ? createReadStream(object.source.path) + : Readable.from([Buffer.from(object.source.contents)]) + try { + const cos = client() + const response = await cosOperation(cos, 900_000, () => cos.putObject({ + Bucket: BUCKET, Region: DESKTOP_COS_REGION, Key: key, Body: body, + ContentLength: object.size, ContentType: key.endsWith('.yml') ? 'application/yaml' : 'application/octet-stream', + CacheControl: 'no-store', Headers: headers })) + return response.RequestId === undefined ? {} : { requestId: response.RequestId } + } finally { body.destroy() } + }, + } +} diff --git a/apps/desktop/scripts/installed-update-distribution.ts b/apps/desktop/scripts/installed-update-distribution.ts new file mode 100644 index 0000000000..3738fda13f --- /dev/null +++ b/apps/desktop/scripts/installed-update-distribution.ts @@ -0,0 +1,74 @@ +/** Validate local qualification payload bytes and prepare separate binary and fixed-feed operations. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { dump, load } from 'js-yaml' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' + +/** A local immutable object, with its final test-only destination and digest. */ +export interface InstalledUpdateBinary { + readonly path: string + readonly key: string + readonly size: number + readonly sha512: string +} + +/** File integrity does not establish signature, application identity, or authority to publish. */ +export interface InstalledUpdateDistribution { + readonly version: string + readonly bucket: string + readonly binaries: readonly InstalledUpdateBinary[] + readonly feed: { readonly key: string; readonly url: string; readonly contents: string; readonly sha512: string } + readonly verified: 'file-integrity-only' + readonly publicationAuthorized: false +} + +async function binary(path: string, key: string): Promise { + const file = await stat(path) + if (!file.isFile() || file.size === 0) throw new Error('installed update: missing or empty binary material') + const hash = createHash('sha512') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return { path, key, size: file.size, sha512: hash.digest('base64') } +} + +function record(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid feed metadata') + return value as Record +} + +/** + * Check generated YAML against installer bytes and prepare one independent fixed-feed body per version. + * @param manifest Original qualification run.json; test identity and destinations are revalidated. + * @param version One of the run's two versions, never a version inferred from YAML. + * @returns Read-only binary and feed plans; no credentials, network, signing, or installation are involved. + */ +export async function planInstalledUpdateDistribution(manifest: string, version: string): Promise { + const run = await readInstalledUpdateRun(manifest) + if (!run.versions.includes(version)) throw new Error('installed update: version is outside the qualification run') + const directory = join(run.root, version, 'installer') + const metadata = record(load(await readFile(join(directory, 'nightly.yml'), 'utf8'))) + if (metadata.version !== version || !Array.isArray(metadata.files) || metadata.files.length !== 1) { + throw new Error('installed update: feed must describe exactly the selected version and installer') + } + const info = record(metadata.files[0]) + const filename = `deepseek-harness-${version}-win-x64.exe` + if (info.url !== filename || (metadata.path !== undefined && metadata.path !== filename)) { + throw new Error('installed update: feed filename must identify the selected local Windows installer') + } + const installer = await binary(join(directory, filename), `${run.binPrefix}/${filename}`) + if (info.size !== installer.size || info.sha512 !== installer.sha512 + || (metadata.sha512 !== undefined && metadata.sha512 !== installer.sha512)) { + throw new Error('installed update: installer size or SHA-512 differs from the generated feed') + } + const blockmap = await binary(join(directory, `${filename}.blockmap`), `${run.binPrefix}/${filename}.blockmap`) + if (metadata.releaseDate !== undefined && (typeof metadata.releaseDate !== 'string' + || !Number.isFinite(Date.parse(metadata.releaseDate)))) throw new Error('installed update: invalid feed release date') + const url = `${run.origin}/${installer.key}` + const contents = dump({ version, files: [{ url, size: installer.size, sha512: installer.sha512 }], + path: url, sha512: installer.sha512, ...(metadata.releaseDate === undefined ? {} : { releaseDate: metadata.releaseDate }) }) + return { version, bucket: run.bucket, binaries: [installer, blockmap], + feed: { key: run.feedKey, url: `${run.origin}/${run.feedKey}`, contents, + sha512: createHash('sha512').update(contents).digest('base64') }, + verified: 'file-integrity-only', publicationAuthorized: false } +} diff --git a/apps/desktop/scripts/installed-update-identity.d.mts b/apps/desktop/scripts/installed-update-identity.d.mts new file mode 100644 index 0000000000..dc1876f2b1 --- /dev/null +++ b/apps/desktop/scripts/installed-update-identity.d.mts @@ -0,0 +1,7 @@ +/** Configure per-run Electron data and journal paths before production imports. */ +export function configureInstalledUpdateIdentity( + app: { getPath(name: string): string; setPath(name: string, path: string): void }, + run: { id: string; versions: readonly string[] }, + metadata: { version?: string; dshDesktopAppId?: string }, + environment: NodeJS.ProcessEnv, +): { root: string; userData: string; harnessHome: string; journals: string } diff --git a/apps/desktop/scripts/installed-update-identity.mjs b/apps/desktop/scripts/installed-update-identity.mjs new file mode 100644 index 0000000000..ac8bbe7320 --- /dev/null +++ b/apps/desktop/scripts/installed-update-identity.mjs @@ -0,0 +1,27 @@ +/** Configure only a qualification package's identity before the production main entry loads. */ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Keep test application data and journals outside the replaceable installation tree. + * @param {{ getPath(name: string): string, setPath(name: string, path: string): void }} app Electron path API before readiness. + * @param {{ id: string, versions: readonly string[] }} run Embedded qualification identity and version pair. + * @param {{ version?: string, dshDesktopAppId?: string }} metadata Installed package metadata. + * @param {NodeJS.ProcessEnv} environment Main-process environment modified before production imports. + * @returns {{ root: string, userData: string, harnessHome: string, journals: string }} Shared paths for both versions. + */ +export function configureInstalledUpdateIdentity(app, run, metadata, environment) { + if (!/^[a-f0-9]{24}$/u.test(run.id) || run.versions.length !== 2 + || metadata.dshDesktopAppId !== `com.deepseek.dsh.qualification.q${run.id}` + || !run.versions.includes(metadata.version)) { + throw new Error('installed update: qualification package identity does not match its bootstrap') + } + const root = join(app.getPath('appData'), 'dsh-update-qualification', run.id) + const paths = { root, userData: join(root, 'user-data'), harnessHome: join(root, 'dsh-home'), journals: join(root, 'journals') } + for (const directory of [paths.userData, paths.harnessHome, paths.journals]) mkdirSync(directory, { recursive: true }) + app.setPath('userData', paths.userData) + app.setPath('sessionData', paths.userData) + environment.DSH_HOME = paths.harnessHome + environment.DSH_DESKTOP_UPDATE_JOURNAL_DIR = paths.journals + return paths +} diff --git a/apps/desktop/scripts/installed-update-network.ps1 b/apps/desktop/scripts/installed-update-network.ps1 new file mode 100644 index 0000000000..1990f8e253 --- /dev/null +++ b/apps/desktop/scripts/installed-update-network.ps1 @@ -0,0 +1,89 @@ +# Operator-owned test-application fault. Status is read-only; Block and Restore require typed confirmation. +param( + [Parameter(Mandatory = $true)][string]$Plan, + [ValidateSet('Status', 'Block', 'Restore')][string]$Action = 'Status' +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$planPath = (Resolve-Path -LiteralPath $Plan).Path +$spec = Get-Content -LiteralPath $planPath -Raw -Encoding UTF8 | ConvertFrom-Json +if ($spec.schemaVersion -ne 1 -or $spec.runId -cnotmatch '^[a-f0-9]{24}$' -or + $spec.ruleName -cne "DSH-Update-Qualification-$($spec.runId)" -or + $spec.sha512Hex -cnotmatch '^[A-F0-9]{128}$' -or + $spec.executable -notmatch '^[A-Za-z]:\\' -or + [IO.Path]::GetFileName($spec.executable) -cne "DSH Update Test $($spec.runId).exe" -or + [IO.Path]::GetFullPath($spec.executable) -cne $spec.executable) { + throw 'Invalid test-only network plan; no network changes were made.' +} +$owner = "dsh-update-qualification:$($spec.runId):$($spec.sha512Hex)" +$recordParent = Join-Path (Split-Path -Parent $planPath) 'records' +[IO.Directory]::CreateDirectory($recordParent) | Out-Null +$record = Join-Path $recordParent ([Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $record | Out-Null +$events = Join-Path $record 'events.jsonl' +function Save-Event([string]$Stage, [object]$Data) { + $line = @{ time = [DateTime]::UtcNow.ToString('o'); action = $Action; stage = $Stage; data = $Data } | ConvertTo-Json -Depth 6 -Compress + $bytes = [Text.Encoding]::UTF8.GetBytes($line + "`n") + $stream = [IO.File]::Open($events, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } +} +function Find-OwnedRule { + $rules = @(Get-NetFirewallRule -PolicyStore PersistentStore | Where-Object { $_.Name -ceq $spec.ruleName }) + if ($rules.Count -gt 1) { throw 'Multiple rules match this run; manual inspection is required.' } + if ($rules.Count -eq 0) { return $null } + $rule = $rules[0] + $filters = @($rule | Get-NetFirewallApplicationFilter) + if ($rule.Description -cne $owner -or [string]$rule.Direction -ne 'Outbound' -or [string]$rule.Action -ne 'Block' -or + $filters.Count -ne 1 -or $filters[0].Program -ine $spec.executable) { + throw 'The existing rule is not owned by this exact test plan; it was not modified.' + } + return $rule +} +$success = $false +Write-Output "NETWORK_FAULT_RECORD $record" +try { + Save-Event 'started' @{ runId = $spec.runId; executable = $spec.executable; ruleName = $spec.ruleName } + $existing = Find-OwnedRule + Save-Event 'before' @{ present = ($null -ne $existing) } + if ($Action -eq 'Block') { + if ($null -ne $existing) { throw 'A test rule already exists. Restore it before starting another fault.' } + if ((Get-FileHash -LiteralPath $spec.executable -Algorithm SHA512).Hash -cne $spec.sha512Hex) { + throw 'The test executable changed. No rule was created.' + } + Write-Output "Only this executable will be blocked: $($spec.executable)" + Write-Output 'Keep a second administrator terminal ready to run Restore. Do not disable the adapter, VPN, or proxy.' + if ((Read-Host "Type BLOCK $($spec.runId) after download progress begins") -cne "BLOCK $($spec.runId)") { + throw 'Confirmation declined; no rule was created.' + } + if ($null -ne (Find-OwnedRule)) { throw 'A rule appeared during confirmation. No rule was created.' } + if ((Get-FileHash -LiteralPath $spec.executable -Algorithm SHA512).Hash -cne $spec.sha512Hex) { + throw 'The test executable changed during confirmation. No rule was created.' + } + Save-Event 'creating-rule' @{ name = $spec.ruleName } + New-NetFirewallRule -Name $spec.ruleName -DisplayName $spec.ruleName -Description $owner ` + -Direction Outbound -Program $spec.executable -Action Block -Profile Any -Enabled True -PolicyStore PersistentStore | Out-Null + $created = Find-OwnedRule + if ($null -eq $created -or [string]$created.Enabled -ne 'True') { throw 'The rule was not confirmed enabled. Use Restore.' } + Save-Event 'blocked-rule-present' @{ trafficInterruptionVerified = $false } + } elseif ($Action -eq 'Restore') { + if ($null -ne $existing) { + if ((Read-Host "Type RESTORE $($spec.runId)") -cne "RESTORE $($spec.runId)") { throw 'Restoration declined.' } + $existing = Find-OwnedRule + if ($null -ne $existing) { + Save-Event 'removing-rule' @{ name = $spec.ruleName } + $existing | Remove-NetFirewallRule + } + } + if ($null -ne (Find-OwnedRule)) { throw 'The test rule still exists; restoration is incomplete.' } + Save-Event 'rule-absent' @{ downloadRecoveryVerified = $false } + } else { + Save-Event 'status' @{ present = ($null -ne $existing); enabled = $(if ($null -ne $existing) { [string]$existing.Enabled } else { $null }) } + Write-Output "Test rule present: $($null -ne $existing)" + } + $success = $true +} catch { + Save-Event 'failed' @{ message = $_.Exception.Message; automaticRetry = $false } + throw +} finally { + Save-Event 'finished' @{ success = $success; rulePresenceIsNotTrafficEvidence = $true } +} diff --git a/apps/desktop/scripts/installed-update-package-content.ts b/apps/desktop/scripts/installed-update-package-content.ts new file mode 100644 index 0000000000..a8b914f3e2 --- /dev/null +++ b/apps/desktop/scripts/installed-update-package-content.ts @@ -0,0 +1,125 @@ +/** Verify identity and update configuration from an extracted installer payload, not a neighboring unpacked build. */ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { load } from 'js-yaml' +import { readAsar, type Node as AsarNode } from 'app-builder-lib/out/asar/asar.js' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' +import { verifyInstalledUpdateApplication } from './prepare-installed-update-application.ts' +import { inventoryDesktopRuntime, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { resolveDesktopPolicyConfig } from '../src/mandatory-update-policy.ts' + +const require = createRequire(import.meta.url) +const builderRequire = createRequire(require.resolve('app-builder-lib/package.json')) +const { extractAll } = builderRequire('@electron/asar') as { extractAll: (archive: string, destination: string) => void } +const { createTransformer } = builderRequire('app-builder-lib/out/fileTransformer.js') as { + createTransformer: (source: string, configuration: object, metadata: null) => + (file: string) => string | null | Promise +} + +function object(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid package metadata') + return value as Record +} + +/** + * Check the actual payload's ASAR, bundled runtime, private identity, feed, cache, and publisher metadata. + * @param manifest Retained qualification manifest. + * @param version Exact selected version. + * @param payload Extracted installer payload in a private verification directory. + * @param publisher Expected DN derived from the public release certificate. + * @returns Content observations only; signatures, installation, and restart are separate checks. + */ +export async function verifyInstalledUpdatePackageContent(manifest: string, version: string, payload: string, publisher: string) { + const run = await readInstalledUpdateRun(manifest) + if (!run.versions.includes(version)) throw new Error('installed update: payload version is outside the run') + await verifyInstalledUpdateApplication(run.root) + const archive = await readAsar(join(payload, 'resources/app.asar')) + const metadata = object(await archive.readJson('package.json')) + const policy = resolveDesktopPolicyConfig(metadata.dshMandatoryUpdatePolicy) + if (metadata.name !== `dsh-update-test-${run.id}` || metadata.version !== version + || metadata.dshDesktopAppId !== run.appId || metadata.main !== 'qualification-bootstrap.mjs' + || metadata.type !== 'module' || policy?.authentication !== 'feishu-test') { + throw new Error('installed update: packaged application identity, version, entry, or policy differs') + } + const inventory = JSON.parse(await readFile(join(run.root, 'application/result.json'), 'utf8')) as { + files: { path: string; sha256: string }[] + } + const expectedPaths = new Set(inventory.files.map(file => file.path)) + const inspect = (node: AsarNode, path = ''): void => { + if (path === 'node_modules' || path === 'package.json') return + if (node.link !== undefined || (node.unpacked === true && !path.startsWith('dsh/'))) { + throw new Error('installed update: application archive contains external entries') + } + if (node.files !== undefined) { + for (const [name, child] of Object.entries(node.files)) { + if (name === '.' || name === '..' || /[\\/:*?"<>|\x00-\x1f]/u.test(name)) { + throw new Error('installed update: application archive contains an unsafe path') + } + inspect(child, path === '' ? name : `${path}/${name}`) + } + } else if (!path.startsWith('dsh/') && !expectedPaths.has(path)) { + throw new Error('installed update: application archive contains additional files') + } + } + inspect(archive.header) + if (archive.header.files?.dsh?.files === undefined) throw new Error('installed update: application archive lacks the dsh runtime') + for (const file of inventory.files) { + const path = file.path.split('/').join(process.platform === 'win32' ? '\\' : '/') + const node = archive.getFile(path, false) + if (node.link !== undefined || node.unpacked === true + || createHash('sha256').update(await archive.readFile(path)).digest('hex') !== file.sha256) { + throw new Error('installed update: packaged application file differs from frozen inputs') + } + } + const dependencies = [] + for (const name of ['electron-updater', 'semver']) { + const actual = object(await archive.readJson(join('node_modules', name, 'package.json'))) + const expected = object(JSON.parse(await readFile(require.resolve(`${name}/package.json`), 'utf8'))) + if (actual.name !== name || actual.version !== expected.version) throw new Error('installed update: packaged updater dependency differs from verification tools') + dependencies.push({ name, version: actual.version }) + } + const update = object(load(await readFile(join(payload, 'resources/app-update.yml'), 'utf8'))) + const url = `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}` + if (update.provider !== 'generic' || update.url !== url || update.channel !== 'nightly' + || update.updaterCacheDirName !== `dsh-update-test-${run.id}-updater` + || !Array.isArray(update.publisherName) || update.publisherName.length !== 1 || update.publisherName[0] !== publisher) { + throw new Error('installed update: packaged feed, cache identity, channel, or publisher differs') + } + const extracted = await mkdtemp(join(dirname(payload), 'asar-')) + extractAll(join(payload, 'resources/app.asar'), extracted) + const prepared = await verifyDesktopRuntime(join(run.root, version, 'dsh'), version, { platform: 'win32', arch: 'x64' }) + const runtime = readDesktopRuntime(join(extracted, 'dsh')) + if (JSON.stringify(runtime.release) !== JSON.stringify(prepared.release) + || JSON.stringify(runtime.sharedPackages) !== JSON.stringify(prepared.sharedPackages) + || JSON.stringify(runtime.files.map(file => file.path)) !== JSON.stringify(prepared.files.map(file => file.path))) { + throw new Error('installed update: packaged runtime does not describe the prepared release') + } + const actualFiles = inventoryDesktopRuntime(join(extracted, 'dsh')) + if (JSON.stringify(actualFiles.map(file => file.path)) !== JSON.stringify(prepared.files.map(file => file.path))) { + throw new Error('installed update: packaged runtime file list differs from prepared inputs') + } + const transform = createTransformer('', {}, null) + for (const [index, file] of prepared.files.entries()) { + if (file.path.endsWith('.exe')) continue + if (runtime.files[index]!.sha256 !== file.sha256 || runtime.files[index]!.bytes !== file.bytes) { + throw new Error('installed update: non-executable runtime descriptor differs from prepared inputs') + } + const transformed = file.path.startsWith('node_modules/') && file.path.endsWith('/package.json') + ? await transform(runtimePath(join(run.root, version, 'dsh'), file.path)) : null + const bytes = transformed === null ? file.bytes : Buffer.byteLength(transformed) + const hash = transformed === null ? file.sha256 : createHash('sha256').update(transformed).digest('hex') + if (actualFiles[index]!.sha256 !== hash || actualFiles[index]!.bytes !== bytes) { + throw new Error('installed update: non-executable runtime bytes differ from prepared inputs') + } + } + return { version, appId: run.appId, applicationFiles: inventory.files.length, dependencies, dependenciesFrozen: false, + runtimeFiles: runtime.files.length, feedUrl: `${run.origin}/${run.feedKey}`, installed: false, + resignedExecutables: runtime.files.filter(file => file.path.endsWith('.exe')).map((file) => { + if (archive.getFile(join('dsh', file.path), false).unpacked !== true) { + throw new Error('installed update: executable runtime file must be outside ASAR') + } + return join(payload, 'resources/app.asar.unpacked/dsh', file.path) + }) } +} diff --git a/apps/desktop/scripts/installed-update-packaging.ts b/apps/desktop/scripts/installed-update-packaging.ts new file mode 100644 index 0000000000..f0660e6f96 --- /dev/null +++ b/apps/desktop/scripts/installed-update-packaging.ts @@ -0,0 +1,122 @@ +/** Supervise one explicitly confirmed qualification build; checks never launch a child or clear a signing interlock. */ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' +import { createInstalledUpdateBuilderConfig } from './installed-update-builder.ts' +import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { desktopElectronBuilderEnvironment } from './package-target.ts' +import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs' +import { planInstalledUpdateDistribution } from './installed-update-distribution.ts' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY = resolve(APP_ROOT, '../..') +const SIGNING_FIELDS = new Set(['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', + 'DSH_DESKTOP_WINDOWS_KEY_CONTAINER', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN']) + +/** Public refusal without credential values or contents of the incident record. */ +export class InstalledUpdateSigningHoldError extends Error { + constructor() { + super('installed update: signing interlock exists; administrator-reviewed recovery is required; do not clear it or retry automatically') + } +} + +async function absent(path: string): Promise { + try { await lstat(path); return false } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true + throw error + } +} + +/** + * Reject a retained or active hardware attempt without reading its contents or changing it. + * @param stateFile Per-user signing interlock; an isolated file may be supplied by tests, not the CLI. + * @returns Nothing when absent; an inaccessible or existing file rejects before credentials are loaded. + */ +export async function assertInstalledUpdateSigningClear(stateFile = join(homedir(), '.dsh-desktop-signing', 'attempt.json')): Promise { + if (!await absent(stateFile)) throw new InstalledUpdateSigningHoldError() +} + +/** + * Restrict builder children to ordinary tool settings and the four file-owned signing inputs. + * @param environment Loaded .env.windows settings, never raw log data. + * @returns A new environment without upload/LLM credentials or Node preload overrides. + */ +export function installedUpdatePackagingEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return desktopElectronBuilderEnvironment(Object.fromEntries(Object.entries(environment) + .filter(([name]) => SIGNING_FIELDS.has(name) || !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name))), false) +} + +async function inputHashes(manifest: string, version: string, environment: NodeJS.ProcessEnv): Promise { + const run = await readInstalledUpdateRun(manifest) + const paths = [manifest, join(run.root, 'application/result.json'), join(run.root, version, 'dsh/desktop-runtime.json'), + join(REPOSITORY, 'pnpm-lock.yaml'), join(APP_ROOT, 'package.json'), + ...['electron-builder-config.mjs', 'installed-update-builder.ts', 'build-installed-update-worker.mjs', + 'windows-sign.mjs', 'windows-sign.cmd', 'windows-signing-state.mjs', 'windows-timestamp.mjs', 'windows-directory-installer.mjs', + 'installer.nsh', 'prepare-windows-installer.ps1'].map(path => join(import.meta.dirname, path)), + environment.DSH_DESKTOP_WINDOWS_CER_FILE!, environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!] + const files = [] + for (const path of paths) { + files.push({ path, sha256: createHash('sha256').update(await readFile(path)).digest('hex') }) + } + const git = (args: string[]): string => execFileSync('git', args, { cwd: REPOSITORY, windowsHide: true, encoding: 'utf8' }).trim() + return { files, sourceCommit: git(['rev-parse', 'HEAD']), + dirtyFiles: git(['status', '--porcelain=v1', '--untracked-files=normal']).split('\n').filter(Boolean), + dependenciesByteFrozen: false } +} + +/** + * Check, or after explicit confirmation build, exactly one version with retained fail-stop records. + * @param manifest Existing test-only run manifest. + * @param version One exact run version; an existing packaging attempt or installer refuses reuse. + * @param options Execute selection and operator confirmation; the CLI has no noninteractive approval shortcut. + * @returns Check/build observations only. A successful builder is not package verification or installation acceptance. + */ +export async function packageInstalledUpdate( + manifest: string, version: string, options: { execute: boolean; confirm: (id: string, version: string) => Promise }, +): Promise { + const run = await readInstalledUpdateRun(manifest) + if (!run.versions.includes(version)) throw new Error('installed update: package version is outside the run') + await assertInstalledUpdateSigningClear() + const preparation = join(run.root, version, 'packaging') + const output = join(run.root, version, 'installer') + if (!await absent(preparation) || !await absent(output)) throw new Error('installed update: existing packaging attempt or output requires a new run') + const environment = installedUpdatePackagingEnvironment({ ...loadDesktopPackageEnvironment('win32'), + DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64' }) + await createInstalledUpdateBuilderConfig(manifest, version, environment) + const before = await inputHashes(manifest, version, environment) + if (!options.execute) return { mode: 'check', version, childLaunched: false, signed: false, publicationAuthorized: false } + if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('installed update: execution requires Windows x64') + if (!await options.confirm(run.id, version)) throw new Error('installed update: operator did not confirm this version') + await assertInstalledUpdateSigningClear() + await mkdir(preparation) + const record = createPackagingRun(preparation, { mode: 'operator-authorized-single-version', id: run.id, version }) + console.log(`INSTALLED_UPDATE_PACKAGING_RECORD ${record.directory}`) + let success = false + try { + await mkdir(output) + await writeFile(join(record.directory, 'inputs.json'), `${JSON.stringify(before, null, 2)}\n`, { flag: 'wx', flush: true }) + if (JSON.stringify(await inputHashes(manifest, version, environment)) !== JSON.stringify(before)) { + throw new Error('installed update: recorded source or tool inputs changed before packaging') + } + await record.run('signed-installer', process.execPath, + ['--import', 'tsx', join(import.meta.dirname, 'build-installed-update-worker.mjs'), resolve(manifest), version], + { cwd: REPOSITORY, env: environment, timeoutMs: 15 * 60_000 }) + await createInstalledUpdateBuilderConfig(manifest, version, environment) + if (JSON.stringify(await inputHashes(manifest, version, environment)) !== JSON.stringify(before)) { + throw new Error('installed update: recorded source or tool inputs changed during packaging') + } + const files = await planInstalledUpdateDistribution(manifest, version) + await writeFile(join(record.directory, 'artifact-files.json'), `${JSON.stringify(files, null, 2)}\n`, { flag: 'wx', flush: true }) + const result = { version, builderCompleted: true, packageVerification: 'pending', installerExecuted: false, published: false } + await writeFile(join(record.directory, 'builder-result.json'), `${JSON.stringify(result)}\n`, { flag: 'wx', flush: true }) + success = true + return result + } catch (error) { + recordPackagingEvent(record.directory, { type: 'qualification-failed', retryAllowed: false }) + throw error + } finally { record.finish(success) } +} diff --git a/apps/desktop/scripts/installed-update-publication.ts b/apps/desktop/scripts/installed-update-publication.ts new file mode 100644 index 0000000000..d4b7014928 --- /dev/null +++ b/apps/desktop/scripts/installed-update-publication.ts @@ -0,0 +1,217 @@ +/** Separate immutable qualification uploads from explicitly authorized fixed-feed publication. */ +import { mkdir, mkdtemp, readFile, readdir, rmdir, writeFile } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' +import { inspectInstalledUpdateJournals, readInstalledUpdateRun } from './installed-update-qualification.ts' +import { planInstalledUpdateDistribution } from './installed-update-distribution.ts' +import { installedUpdateFileHash } from './installed-update-signature.mjs' +import { recordPackagingEvent } from './packaging-run.mjs' + +/** One fully read object; digest and byte count describe actual received bytes. */ +export interface InstalledUpdateRemoteObject { + readonly sha512: string + readonly size: number +} + +/** Transport-owned authentication is never included in publication records. */ +export interface InstalledUpdatePublicationStore { + /** @returns True only after authoritative bucket configuration confirms versioning is disabled. */ + versioningDisabled(): Promise + /** @param key Exact run-owned object key. @returns Actual object digest, or null only for a confirmed missing object. */ + read(key: string): Promise + /** @param url Exact public URL, without cache-busting parameters. @returns Actual public bytes, or confirmed absence. */ + publicRead(url: string): Promise + /** + * Write once without automatic retries; forbidOverwrite must reach the server for immutable objects. + * @param key Exact run-owned destination. + * @param object Local binary or retained feed, with declared integrity and overwrite policy. + * @returns Public server receipt, never authorization headers or credentials. + */ + put(key: string, object: { + readonly source: { readonly path: string } | { readonly contents: string } + readonly size: number + readonly sha512: string + readonly forbidOverwrite: boolean + }): Promise<{ readonly requestId?: string }> +} + +/** Selected operator action; upload never advertises either version to clients. */ +export type InstalledUpdatePublicationAction = 'upload-binaries' | 'publish-feed' + +/** + * Revalidate a successful local package check against current bytes, without credentials or network access. + * @param manifest Original test manifest. + * @param version Exact selected version. + * @param receipt That version's successful package-verification result. + * @returns Current distribution plan bound to the retained verification evidence. + */ +export async function verifiedInstalledUpdateDistribution(manifest: string, version: string, receipt: string) { + const run = await readInstalledUpdateRun(manifest) + const path = relative(join(run.root, version, 'verification'), resolve(receipt)).replaceAll('\\', '/') + if (!/^check-[^/]+\/result\.json$/u.test(path)) throw new Error('installed update: matching package verification receipt is required') + const result = JSON.parse(await readFile(receipt, 'utf8')) as { + schemaVersion?: unknown + runId?: unknown + version?: unknown + stage?: unknown + passed?: unknown + installerSignature?: { valid?: unknown; timestamped?: unknown; sha512?: unknown; updaterVerificationInvoked?: unknown } + contents?: { appId?: unknown; version?: unknown } + } + const distribution = await planInstalledUpdateDistribution(manifest, version) + const inputs = JSON.parse(await readFile(join(dirname(receipt), 'inputs.json'), 'utf8')) as { + manifestSha512?: unknown + distribution?: unknown + } + if (result.schemaVersion !== 1 || result.runId !== run.id || result.version !== version || result.stage !== 'complete' || result.passed !== true + || result.contents?.appId !== run.appId || result.contents.version !== version + || result.installerSignature?.valid !== true || result.installerSignature.timestamped !== true + || result.installerSignature.updaterVerificationInvoked !== true + || result.installerSignature.sha512 !== distribution.binaries[0]!.sha512 + || inputs.manifestSha512 !== await installedUpdateFileHash(manifest) + || JSON.stringify(inputs.distribution) !== JSON.stringify(distribution)) { + throw new Error('installed update: current files do not match successful package verification') + } + return { run, distribution, receiptSha512: await installedUpdateFileHash(receipt) } +} + +function matches(actual: InstalledUpdateRemoteObject | null, expected: InstalledUpdateRemoteObject): boolean { + return actual?.sha512 === expected.sha512 && actual.size === expected.size +} + +async function verifiedUploadReceipt(prepared: Awaited>) { + const parent = join(prepared.run.root, 'publication-records') + for (const entry of await readdir(parent, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('operation-')) continue + const directory = join(parent, entry.name) + if (!(await readdir(directory)).includes('result.json')) continue + const path = join(directory, 'result.json') + const result: unknown = JSON.parse(await readFile(path, 'utf8')) + if (typeof result !== 'object' || result === null) continue + const fields = result as Record + if (fields.schemaVersion !== 1 || fields.success !== true || fields.stage !== 'complete' + || fields.action !== 'upload-binaries' || fields.runId !== prepared.run.id || fields.version !== prepared.distribution.version) continue + const planPath = join(directory, 'plan.json') + const plan: unknown = JSON.parse(await readFile(planPath, 'utf8')) + if (JSON.stringify(plan) !== JSON.stringify(prepared)) continue + return { path, sha512: await installedUpdateFileHash(path), planSha512: await installedUpdateFileHash(planPath) } + } + throw new Error('matching successful binary upload receipt is required') +} + +async function startupEvidence(manifest: string, directory: string | undefined) { + const run = await readInstalledUpdateRun(manifest) + if (!directory || !resolve(directory).replaceAll('\\', '/').endsWith(`/dsh-update-qualification/${run.id}/journals`)) { + throw new Error('installed update: original installed application journal directory is required before successor publication') + } + const report = await inspectInstalledUpdateJournals(directory, run.versions) + const ready = report.milestones['original-workspace'] + if (!ready || Date.parse(ready.time) > Date.now()) throw new Error('installed update: original workspace startup is not recorded') + return ready +} + +/** + * Execute one separately authorized upload or feed publication with local exclusion and retained evidence. + * @param manifest Original qualification manifest. + * @param version Version to upload or advertise. + * @param receipt Matching successful package verification. + * @param action Upload objects without a feed, or publish only after public object verification. + * @param store Explicit transport, supplied only after operator authorization; writes must not retry. + * @param journalDirectory Original installed-app journal directory, required for successor publication. + * @returns Retained operation result path; any error stops subsequent writes and preserves partial evidence. + */ +export async function executeInstalledUpdatePublication( + manifest: string, version: string, receipt: string, action: InstalledUpdatePublicationAction, + store: InstalledUpdatePublicationStore, journalDirectory?: string, +): Promise { + const prepared = await verifiedInstalledUpdateDistribution(manifest, version, receipt) + const { run, distribution } = prepared + const successor = action === 'publish-feed' && version === run.versions[1] + const startup = successor ? await startupEvidence(manifest, journalDirectory) : undefined + const lock = join(run.root, 'publication.lock') + await mkdir(lock) + let record: string | undefined + const result: Record = { schemaVersion: 1, runId: run.id, version, action, success: false, startup, + startedAt: new Date().toISOString(), singlePublisherRequired: true } + const stage = (name: string, data: object = {}): void => { + result.stage = name + recordPackagingEvent(record!, { type: 'publication-stage', stage: name, ...data }) + console.log(`INSTALLED_UPDATE_PUBLICATION_STAGE ${name}`) + } + try { + const parent = join(run.root, 'publication-records') + await mkdir(parent, { recursive: true }) + record = await mkdtemp(join(parent, 'operation-')) + console.log(`INSTALLED_UPDATE_PUBLICATION_RECORD ${record}`) + await writeFile(join(record, 'plan.json'), `${JSON.stringify(prepared, null, 2)}\n`, { flag: 'wx', flush: true }) + if (action === 'publish-feed') { + stage('binary-upload-receipt') + result.binaryUploadReceipt = await verifiedUploadReceipt(prepared) + stage('binary-upload-receipt-verified', result.binaryUploadReceipt as object) + } else { + stage('bucket-versioning') + if (!await store.versioningDisabled()) throw new Error('bucket versioning is not confirmed disabled') + } + for (const binary of action === 'upload-binaries' ? distribution.binaries : []) { + stage('binary-origin-read', { key: binary.key }) + const existing = await store.read(binary.key) + if (existing !== null && !matches(existing, binary)) throw new Error('existing binary differs') + if (existing === null) { + if (action !== 'upload-binaries') throw new Error('binary upload must precede feed publication') + if (JSON.stringify(await verifiedInstalledUpdateDistribution(manifest, version, receipt)) !== JSON.stringify(prepared)) { + throw new Error('local inputs changed') + } + stage('binary-put', { key: binary.key }) + const response = await store.put(binary.key, { source: { path: binary.path }, size: binary.size, + sha512: binary.sha512, forbidOverwrite: true }) + stage('binary-put-response', { key: binary.key, ...response }) + } + stage('binary-public-read', { key: binary.key }) + if (!matches(await store.publicRead(`${run.origin}/${binary.key}`), binary)) throw new Error('public binary differs') + } + if (action === 'publish-feed') { + stage('feed-origin-read') + const previous = await store.read(distribution.feed.key) + const previousPlan = successor ? await planInstalledUpdateDistribution(manifest, run.versions[0]) : undefined + const expectedPrevious = previousPlan === undefined ? null + : { sha512: previousPlan.feed.sha512, size: Buffer.byteLength(previousPlan.feed.contents) } + const alreadyPublished = matches(previous, { sha512: distribution.feed.sha512, size: Buffer.byteLength(distribution.feed.contents) }) + if (!alreadyPublished && (expectedPrevious === null ? previous !== null : !matches(previous, expectedPrevious))) { + throw new Error('unexpected previous feed') + } + if (successor) { + const evidence = await startupEvidence(manifest, journalDirectory) + if (JSON.stringify(evidence) !== JSON.stringify(startup)) throw new Error('startup evidence changed') + } + if (JSON.stringify(await verifiedInstalledUpdateDistribution(manifest, version, receipt)) !== JSON.stringify(prepared)) { + throw new Error('local inputs changed') + } + await writeFile(join(record, 'feed.yml'), distribution.feed.contents, { flag: 'wx', flush: true }) + result.alreadyPublished = alreadyPublished + if (!alreadyPublished) { + stage('feed-put', { key: distribution.feed.key, previous, startedAfterOriginal: successor }) + result.putResponse = await store.put(distribution.feed.key, { source: { contents: distribution.feed.contents }, + size: Buffer.byteLength(distribution.feed.contents), sha512: distribution.feed.sha512, forbidOverwrite: !successor }) + } + stage('feed-public-read') + if (!matches(await store.publicRead(distribution.feed.url), { sha512: distribution.feed.sha512, + size: Buffer.byteLength(distribution.feed.contents) })) throw new Error('public feed differs') + } + stage('complete') + result.success = true + return join(record, 'result.json') + } catch (error) { + result.failure = 'operation stopped; inspect the retained stage and remote state before any further publication' + if (typeof error === 'object' && error !== null && 'statusCode' in error) { + const statusCode = error.statusCode + if (typeof statusCode === 'number' && statusCode >= 100 && statusCode <= 599) { + result.httpStatus = statusCode + } + } + throw new Error(`installed update: publication stopped; record: ${record ?? 'not allocated'}`) + } finally { + try { + if (record !== undefined) await writeFile(join(record, 'result.json'), `${JSON.stringify({ ...result, + finishedAt: new Date().toISOString() }, null, 2)}\n`, { flag: 'wx', flush: true }) + } finally { await rmdir(lock) } + } +} diff --git a/apps/desktop/scripts/installed-update-qualification.ts b/apps/desktop/scripts/installed-update-qualification.ts new file mode 100644 index 0000000000..40877759d7 --- /dev/null +++ b/apps/desktop/scripts/installed-update-qualification.ts @@ -0,0 +1,278 @@ +/** Local-only material allocation and journal inspection for operator-driven installed updates. */ +import { createHash, randomBytes } from 'node:crypto' +import { mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { gt, valid } from 'semver' + +/** Source identifiers exclude file contents, configuration values, and credentials. */ +export interface InstalledUpdateSource { + readonly version: string + readonly commit: string + readonly dirtyFiles: readonly string[] +} + +/** A private test namespace; creating it performs no signing, installation, or remote operation. */ +export interface InstalledUpdateRun { + readonly schemaVersion: 1 + readonly id: string + readonly root: string + readonly createdAt: string + readonly source: InstalledUpdateSource + readonly versions: readonly [string, string] + readonly appId: string + readonly productName: string + readonly environment: 'test' + readonly origin: string + readonly bucket: string + readonly feedKey: string + readonly binPrefix: string +} + +/** + * Allocate a new local run and retain its manifest without reading release credentials. + * @param parent Ignored material directory; each invocation acquires a separate child atomically. + * @param versions Explicit original and successor test versions, in increasing order. + * @param source Source version, Git commit, and dirty-file list captured before material preparation. + * @returns The retained run manifest; no package or publication is implied by its presence. + */ +export async function createInstalledUpdateRun( + parent: string, versions: readonly [string, string], source: InstalledUpdateSource, +): Promise { + validateVersions(versions) + if (valid(source.version) === null || !/^[a-f0-9]{40,64}$/u.test(source.commit)) { + throw new Error('installed update: valid source version and Git commit are required') + } + await mkdir(parent, { recursive: true }) + const root = await mkdtemp(join(resolve(parent), 'installed-update-')) + const id = randomBytes(12).toString('hex') + const run: InstalledUpdateRun = { + schemaVersion: 1, id, root, createdAt: new Date().toISOString(), source, versions, + appId: `com.deepseek.dsh.qualification.q${id}`, productName: `DSH Update Test ${id}`, + environment: 'test', origin: 'https://download-test.deepseek.com', bucket: 'bj-toc-download-test-1320056602', + feedKey: `dsh-desk/feeds/qualification/${id}/win-x64/nightly.yml`, + binPrefix: `dsh-desk/bin/qualification/${id}/win-x64`, + } + await writeFile(join(root, 'run.json'), `${JSON.stringify(run, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + return run +} + +function validateVersions(versions: readonly [string, string]): void { + const pattern = /^\d+\.\d+\.\d+-(?:nightly\.[0-9.]+|[0-9A-Za-z.-]+\.\d{8}\.[1-9]\d*)$/u + if (versions.some(version => valid(version) !== version || !pattern.test(version)) + || !gt(versions[1], versions[0])) { + throw new Error('installed update: two increasing dated test versions are required') + } +} + +/** + * Load a retained manifest and reject altered destinations or application identities. + * @param path Local run.json produced by the allocator. + * @returns Validated test-only run; the directory must still be the original manifest location. + */ +export async function readInstalledUpdateRun(path: string): Promise { + const value: unknown = JSON.parse(await readFile(path, 'utf8')) + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid run manifest') + const row = value as Record + if (row.schemaVersion !== 1 || typeof row.id !== 'string' || !/^[a-f0-9]{24}$/u.test(row.id) + || row.root !== resolve(dirname(path)) || row.environment !== 'test' + || row.origin !== 'https://download-test.deepseek.com' || row.bucket !== 'bj-toc-download-test-1320056602' + || row.appId !== `com.deepseek.dsh.qualification.q${row.id}` || row.productName !== `DSH Update Test ${row.id}` + || row.feedKey !== `dsh-desk/feeds/qualification/${row.id}/win-x64/nightly.yml` + || row.binPrefix !== `dsh-desk/bin/qualification/${row.id}/win-x64` + || !Array.isArray(row.versions) || row.versions.length !== 2 || row.versions.some(version => typeof version !== 'string')) { + throw new Error('installed update: manifest identity, location, versions, or test destination changed') + } + validateVersions(row.versions as [string, string]) + if (typeof row.source !== 'object' || row.source === null || Array.isArray(row.source)) { + throw new Error('installed update: missing source version and commit') + } + const source = row.source as Record + if (typeof source.version !== 'string' || valid(source.version) === null + || typeof source.commit !== 'string' || !/^[a-f0-9]{40,64}$/u.test(source.commit) + || !Array.isArray(source.dirtyFiles) || source.dirtyFiles.some(file => typeof file !== 'string') + || typeof row.createdAt !== 'string' || !Number.isFinite(Date.parse(row.createdAt))) { + throw new Error('installed update: invalid source version, commit, file list, or creation time') + } + return row as unknown as InstalledUpdateRun +} + +interface JournalRecord { + readonly pid: number + readonly sequence: number + readonly time: string + readonly version: string + readonly event: string + readonly phase?: string + readonly targetVersion?: string + readonly failedOperation?: string + readonly percent?: number +} + +/** A location in retained evidence, never a copy of raw diagnostics. */ +export interface InstalledUpdateObservation { + readonly file: string + readonly sequence: number + readonly time: string +} + +/** Observed journal sequence is deliberately separate from operator acceptance. */ +export interface InstalledUpdateEvidence { + readonly schemaVersion: 1 + readonly versions: readonly [string, string] + readonly filesRead: number + readonly recordedFlow: 'complete' | 'incomplete' + readonly milestones: Readonly> + readonly missing: readonly string[] + readonly operatorVerificationRequired: readonly string[] +} + +const FIELDS = new Set(['schemaVersion', 'sequence', 'time', 'pid', 'version', 'event', 'phase', + 'targetVersion', 'percent', 'failedOperation', 'errorCode']) +const ACTIONS = new Set(['started', 'workspace-ready', 'workspace-failed', 'check-requested', + 'download-requested', 'install-confirmed', 'quit-requested', 'state']) +const PHASES = new Set(['idle', 'checking', 'available', 'downloading', 'verifying', 'installing', 'ready', 'error']) +const OPERATIONS = new Set(['check', 'download', 'install']) +const ERROR_CODES = new Set(['ETIMEDOUT', 'ENOSPC', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_RESET', + 'ERR_CONNECTION_CLOSED', 'ERR_NAME_NOT_RESOLVED', 'ERR_UPDATER_INVALID_SIGNATURE', 'ERR_UPDATER_CHECKSUM_MISMATCH', 'UNCLASSIFIED']) +const STAGES = ['original-workspace', 'first-download', 'transfer-started', 'download-failed', + 'manual-retry', 'download-ready', 'install-confirmed', 'original-quit', 'successor-started', 'successor-workspace'] as const + +function parseRecord(line: string, sequence: number): JournalRecord { + let value: unknown + try { value = JSON.parse(line) } + catch { throw new Error('installed update: invalid journal JSON; raw input is withheld') } + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('installed update: invalid journal record') + const row = value as Record + if (Object.keys(row).some(key => !FIELDS.has(key)) || row.schemaVersion !== 1 || row.sequence !== sequence + || !Number.isSafeInteger(row.pid) || (row.pid as number) <= 0 + || typeof row.time !== 'string' || !Number.isFinite(Date.parse(row.time)) + || new Date(row.time).toISOString() !== row.time || typeof row.version !== 'string' || valid(row.version) !== row.version + || typeof row.event !== 'string' || !ACTIONS.has(row.event) + || (sequence === 0 && row.event !== 'started') || (sequence > 0 && row.event === 'started') + || (row.event === 'state' && (typeof row.phase !== 'string' || !PHASES.has(row.phase))) + || (row.phase !== undefined && (typeof row.phase !== 'string' || !PHASES.has(row.phase))) + || (row.failedOperation !== undefined && (typeof row.failedOperation !== 'string' || !OPERATIONS.has(row.failedOperation))) + || (row.errorCode !== undefined && (typeof row.errorCode !== 'string' || !ERROR_CODES.has(row.errorCode))) + || (row.percent !== undefined && (typeof row.percent !== 'number' || !Number.isInteger(row.percent) + || row.percent < 0 || row.percent > 100)) + || (row.targetVersion !== undefined && (typeof row.targetVersion !== 'string' || valid(row.targetVersion) !== row.targetVersion))) { + throw new Error('installed update: unsupported or inconsistent journal record; raw input is withheld') + } + return row as unknown as JournalRecord +} + +/** + * Inspect a private evidence directory without changing files or declaring the installation successful. + * @param directory Directory containing only the qualification run's per-process JSONL journals. + * @param versions Expected installed original and successor versions. + * @returns Ordered failure/retry/restart observations plus required independent operator verification. + */ +export async function inspectInstalledUpdateJournals( + directory: string, versions: readonly [string, string], +): Promise { + return inspectJournalRuns(await readJournalRuns(directory, versions), versions) +} + +interface JournalRun { readonly file: string; readonly text: string; readonly records: JournalRecord[] } + +async function readJournalRuns(directory: string, versions: readonly [string, string]): Promise { + validateVersions(versions) + const files = (await readdir(directory)).filter(file => file.endsWith('.jsonl')).sort() + const runs: JournalRun[] = [] + let totalBytes = 0 + for (const file of files) { + if (!/^\d+-[a-f0-9-]{36}\.jsonl$/u.test(file)) throw new Error('installed update: unexpected journal filename') + if ((await stat(join(directory, file))).size > 10 * 1024 * 1024) throw new Error('installed update: journal exceeds 10 MiB inspection limit') + const text = await readFile(join(directory, file), 'utf8') + const bytes = Buffer.byteLength(text) + totalBytes += bytes + if (bytes > 10 * 1024 * 1024 || totalBytes > 50 * 1024 * 1024) throw new Error('installed update: journal snapshot exceeds inspection limit') + if (!text.endsWith('\n')) throw new Error('installed update: incomplete journal tail') + const records = text.slice(0, -1).split('\n').map(parseRecord) + if (records.some(row => row.version !== records[0]!.version || row.pid !== records[0]!.pid || !versions.includes(row.version))) { + throw new Error('installed update: mixed or unexpected installed versions') + } + runs.push({ file, text, records }) + } + return runs +} + +function inspectJournalRuns(runs: readonly JournalRun[], versions: readonly [string, string]): InstalledUpdateEvidence { + let milestones: Record = {} + // A retry and installation authorization must belong to the same original process. + for (const run of runs.filter(run => run.records[0]!.version === versions[0])) { + const candidate: Record = {} + let stage = 0 + for (const record of run.records) { + if (stage >= 6 && (record.event === 'download-requested' || (record.event === 'state' && record.phase === 'error'))) { + for (const key of STAGES.slice(4)) delete candidate[key] + stage = 4 + } + const matches = [record.event === 'workspace-ready', record.event === 'download-requested', + record.event === 'state' && record.phase === 'downloading' && record.targetVersion === versions[1] + && record.percent !== undefined && record.percent > 0, + record.event === 'state' && record.phase === 'error' && record.failedOperation === 'download' + && record.targetVersion === versions[1], + record.event === 'download-requested', + record.event === 'state' && record.phase === 'ready' && record.targetVersion === versions[1], + record.event === 'install-confirmed', record.event === 'quit-requested'] + if (stage < 8 && matches[stage]) { + candidate[STAGES[stage]!] = { file: run.file, sequence: record.sequence, time: record.time } + stage++ + } + } + if (Object.keys(candidate).length > Object.keys(milestones).length) milestones = candidate + } + const quit = milestones['original-quit'] + if (quit !== undefined) { + for (const run of runs.filter(run => run.records[0]!.version === versions[1])) { + const started = run.records[0]! + if (Date.parse(started.time) < Date.parse(quit.time)) continue + const ready = run.records.find(record => record.event === 'workspace-ready') + if (ready === undefined) continue + milestones['successor-started'] = { file: run.file, sequence: started.sequence, time: started.time } + milestones['successor-workspace'] = { file: run.file, sequence: ready.sequence, time: ready.time } + break + } + } + const missing = STAGES.filter(stage => milestones[stage] === undefined) + return { schemaVersion: 1, versions, filesRead: runs.length, + recordedFlow: missing.length === 0 ? 'complete' : 'incomplete', milestones, missing, + operatorVerificationRequired: ['feed-publication-after-original-startup', 'network-fault-and-recovery', + 'installer-completion-and-installed-path', 'test-data-preserved', 'screenshots-and-user-confirmations'] } +} + +/** + * Retain validated journal bytes and a report from that same snapshot, without copying application data. + * @param manifest Original test run manifest. + * @param directory The run's installed-app journals directory; collection never changes its files. + * @returns Independent local collection directory; incomplete flow is retained as incomplete, not acceptance. + */ +export async function collectInstalledUpdateJournals(manifest: string, directory: string): Promise { + const run = await readInstalledUpdateRun(manifest) + if (!resolve(directory).replaceAll('\\', '/').endsWith(`/dsh-update-qualification/${run.id}/journals`)) { + throw new Error('installed update: matching installed-app journal directory is required') + } + const snapshots = await readJournalRuns(directory, run.versions) + const evidence = inspectJournalRuns(snapshots, run.versions) + const parent = join(run.root, 'evidence') + await mkdir(parent, { recursive: true }) + const collection = await mkdtemp(join(parent, 'collection-')) + try { + await writeFile(join(collection, 'started.json'), `${JSON.stringify({ time: new Date().toISOString(), runId: run.id })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) + await mkdir(join(collection, 'journals')) + for (const snapshot of snapshots) { + await writeFile(join(collection, 'journals', snapshot.file), snapshot.text, { flag: 'wx', mode: 0o600, flush: true }) + } + const result = { schemaVersion: 1, runId: run.id, collectedAt: new Date().toISOString(), sourceDirectory: resolve(directory), + evidence, operatorAcceptance: 'pending', files: snapshots.map(snapshot => ({ path: `journals/${snapshot.file}`, + bytes: Buffer.byteLength(snapshot.text), sha256: createHash('sha256').update(snapshot.text).digest('hex') })) } + await writeFile(join(collection, 'report.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + return collection + } catch { + await writeFile(join(collection, 'failed.json'), `${JSON.stringify({ failed: true, time: new Date().toISOString() })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) + throw new Error(`installed update: journal collection failed; partial evidence retained at ${collection}`) + } +} diff --git a/apps/desktop/scripts/installed-update-signature.d.mts b/apps/desktop/scripts/installed-update-signature.d.mts new file mode 100644 index 0000000000..a669ea1a27 --- /dev/null +++ b/apps/desktop/scripts/installed-update-signature.d.mts @@ -0,0 +1,10 @@ +/** Hash a local file without executing it. @param path File path. @returns Base64 SHA-512. */ +export function installedUpdateFileHash(path: string): Promise +/** + * Verify with the updater and Authenticode, including timestamp presence, without private-key access. + * @param file Executable inspected as data. + * @param publisher Trusted public certificate DN. + * @param directory New private evidence directory. + * @returns Public signature attributes; rejects skipped, invalid, untimestamped, or changed inputs. + */ +export function verifyInstalledUpdateSignature(file: string, publisher: string, directory: string): Promise diff --git a/apps/desktop/scripts/installed-update-signature.mjs b/apps/desktop/scripts/installed-update-signature.mjs new file mode 100644 index 0000000000..4630c3c0e7 --- /dev/null +++ b/apps/desktop/scripts/installed-update-signature.mjs @@ -0,0 +1,77 @@ +/** Verify supplied executable files without invoking SignTool, private keys, or installer entry points. */ +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' + +const require = createRequire(import.meta.url) + +/** + * Hash a file without loading its complete contents into memory. + * @param {string} path Local file. + * @returns {Promise} Base64 SHA-512. + */ +export async function installedUpdateFileHash(path) { + const hash = createHash('sha512') + for await (const bytes of createReadStream(path)) hash.update(bytes) + return hash.digest('base64') +} + +/** + * Require the real updater verifier, a valid Authenticode signature, and a timestamp. + * @param {string} file Executable inspected as data, never executed. + * @param {string} publisher Expected DN from the trusted public release certificate, not downloaded YAML. + * @param {string} directory New private evidence directory owned by this signature check. + * @returns {Promise} Public signature attributes and unchanged-file SHA-512; no installation claim. + */ +export async function verifyInstalledUpdateSignature(file, publisher, directory) { + if (process.platform !== 'win32') throw new Error('installed update: signature verification requires Windows') + const before = await installedUpdateFileHash(file) + const config = join(directory, 'signature-config.json') + await writeFile(config, `${JSON.stringify({ publisherName: [publisher] })}\n`, { flag: 'wx', flush: true }) + const environment = Object.fromEntries(Object.entries(process.env) + .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$|^PSModulePath$/iu.test(name))) + const options = { env: environment, encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024 } + const verification = await promisify(execFile)(process.execPath, [import.meta.filename, '--updater', config, file], options) + if (JSON.parse(verification.stdout).updaterVerificationInvoked !== true) { + throw new Error('installed update: updater signature verification was not confirmed') + } + const { stdout, stderr } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + '$ErrorActionPreference="Stop"; $s=Get-AuthenticodeSignature -LiteralPath $env:DSH_VERIFY_FILE; [pscustomobject]@{valid=($s.Status -eq "Valid");timestamped=($null -ne $s.TimeStamperCertificate);signer=$s.SignerCertificate.Thumbprint;timestamp=$s.TimeStamperCertificate.Thumbprint}|ConvertTo-Json -Compress'], + { ...options, env: { ...environment, DSH_VERIFY_FILE: file } }) + const details = JSON.parse(stdout) + if (stderr || details.valid !== true || details.timestamped !== true || !/^[A-Fa-f0-9]{40}$/u.test(details.signer) + || !/^[A-Fa-f0-9]{40}$/u.test(details.timestamp)) throw new Error('installed update: valid timestamped signature is required') + if (await installedUpdateFileHash(file) !== before) throw new Error('installed update: file changed during signature verification') + return { sha512: before, valid: true, timestamped: true, signerThumbprint: details.signer, + timestampThumbprint: details.timestamp, updaterVerificationInvoked: true } +} + +async function verifyWithUpdater(config, file) { + const { NsisUpdater } = require('electron-updater') + const updater = new NsisUpdater(null, { version: '0.0.0', isPackaged: true }) + updater.autoInstallOnAppQuit = false + updater.updateConfigPath = config + const logs = [] + updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))])) + try { + const result = await updater.verifySignature(file) + if (result !== null || !logs.some(line => line.startsWith('Verifying signature ')) + || logs.some(line => line.includes('Ignoring signature validation'))) { + throw new Error('installed update: updater signature verification rejected the file or was skipped') + } + return { updaterVerificationInvoked: true } + } finally { updater.removeAllListeners() } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const [mode, config, file, ...extra] = process.argv.slice(2) + if (mode !== '--updater' || !config || !file || extra.length) process.exitCode = 1 + else verifyWithUpdater(config, file).then(result => console.log(JSON.stringify(result))).catch(() => { + console.error('installed update: updater signature verification failed') + process.exitCode = 1 + }) +} diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index a7a8c41046..fd3f1c4c36 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -8,7 +8,8 @@ !endif !macro customHeader - !include "${INSTALLER_SOURCE_DIR}\strings.nsh" + !define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_SOURCE_DIR}\strings.nsh" + !include "${INSTALLER_STRINGS_FILE}" !ifndef BUILD_UNINSTALLER !include "${INSTALLER_SOURCE_DIR}\theme.nsh" !include "${INSTALLER_SOURCE_DIR}\pages.nsh" diff --git a/apps/desktop/scripts/macos-app-update-config.d.mts b/apps/desktop/scripts/macos-app-update-config.d.mts new file mode 100644 index 0000000000..624ab0d341 --- /dev/null +++ b/apps/desktop/scripts/macos-app-update-config.d.mts @@ -0,0 +1,35 @@ +/** Resolved fields required to embed a macOS updater feed. */ +export interface MacOSAppUpdateFeed { + readonly publicUrl: string +} + +/** Packaged electron-updater configuration for macOS. */ +export interface MacOSAppUpdateConfig { + readonly provider: 'generic' + readonly url: string + readonly channel: 'nightly' + readonly updaterCacheDirName: string +} + +/** Resolve the one generic macOS feed from the final electron-builder configuration. */ +export function resolveMacOSAppUpdateFeed(publish: unknown): MacOSAppUpdateFeed + +/** Create the electron-updater configuration embedded before code signing. */ +export function createMacOSAppUpdateConfig( + update: MacOSAppUpdateFeed, + updaterCacheDirName: string, +): MacOSAppUpdateConfig + +/** Write the updater configuration into an assembled App before signing. */ +export function writeMacOSAppUpdateConfig( + resourcesDir: string, + update: MacOSAppUpdateFeed, + updaterCacheDirName: string, +): Promise + +/** Verify the updater configuration inside an assembled macOS App. */ +export function verifyMacOSAppUpdateConfig( + appPath: string, + update: MacOSAppUpdateFeed, + updaterCacheDirName?: string, +): Promise diff --git a/apps/desktop/scripts/macos-app-update-config.mjs b/apps/desktop/scripts/macos-app-update-config.mjs new file mode 100644 index 0000000000..3b9e0019c6 --- /dev/null +++ b/apps/desktop/scripts/macos-app-update-config.mjs @@ -0,0 +1,91 @@ +/** Write and verify the updater configuration sealed into a macOS application. */ + +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { dump, load } from 'js-yaml' + +const CONFIG_FILENAME = 'app-update.yml' +const CHANNEL = 'nightly' + +function object(value, label) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`desktop macOS update config: ${label} must be an object`) + } + return value +} + +function nonEmptyString(value, label) { + if (typeof value !== 'string' || value === '') { + throw new Error(`desktop macOS update config: ${label} must be a non-empty string`) + } + return value +} + +/** + * Resolve the one generic macOS feed from the final electron-builder configuration. + * @param {unknown} publish - Final electron-builder publish setting. + * @returns {{ publicUrl: string }} Resolved feed used by the packaged App. + */ +export function resolveMacOSAppUpdateFeed(publish) { + if (!Array.isArray(publish) || publish.length !== 1) { + throw new Error('desktop macOS update config: publish must contain exactly one provider') + } + const provider = object(publish[0], 'publish provider') + if (provider.provider !== 'generic' || provider.channel !== CHANNEL) { + throw new Error('desktop macOS update config: publish provider must be generic Nightly') + } + return { publicUrl: nonEmptyString(provider.url, 'publish provider URL') } +} + +/** + * Create the electron-updater configuration embedded before code signing. + * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {string} updaterCacheDirName - electron-builder application cache directory. + * @returns {{ provider: 'generic', url: string, channel: 'nightly', updaterCacheDirName: string }} Packaged updater fields. + */ +export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { + return { + provider: 'generic', + url: nonEmptyString(update.publicUrl, 'public URL'), + channel: CHANNEL, + updaterCacheDirName: nonEmptyString(updaterCacheDirName, 'updater cache directory'), + } +} + +/** + * Write the updater configuration into an assembled App before signing. + * @param {string} resourcesDir - App Contents/Resources directory. + * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {string} updaterCacheDirName - electron-builder application cache directory. + * @returns {Promise} Resolves after the configuration is durable. + */ +export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCacheDirName) { + const config = createMacOSAppUpdateConfig(update, updaterCacheDirName) + await writeFile(join(resourcesDir, CONFIG_FILENAME), dump(config, { lineWidth: -1, noRefs: true })) +} + +/** + * Verify the updater configuration inside an assembled macOS App. + * @param {string} appPath - Application bundle path. + * @param {{ publicUrl: string }} update - Expected update feed. + * @param {string | undefined} updaterCacheDirName - Exact cache directory when known. + * @returns {Promise} Resolves when the packaged configuration matches the release destination. + */ +export async function verifyMacOSAppUpdateConfig(appPath, update, updaterCacheDirName = undefined) { + const path = join(appPath, 'Contents', 'Resources', CONFIG_FILENAME) + let parsed + try { + parsed = load(await readFile(path, 'utf8')) + } + catch (error) { + throw new Error(`desktop macOS update config: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`) + } + const config = object(parsed, CONFIG_FILENAME) + if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== CHANNEL) { + throw new Error(`desktop macOS update config: ${path} does not match ${update.publicUrl}`) + } + const actualCacheDirName = nonEmptyString(config.updaterCacheDirName, `${CONFIG_FILENAME}.updaterCacheDirName`) + if (updaterCacheDirName !== undefined && actualCacheDirName !== updaterCacheDirName) { + throw new Error(`desktop macOS update config: ${path} has updater cache directory ${actualCacheDirName}; expected ${updaterCacheDirName}`) + } +} diff --git a/apps/desktop/scripts/macos-signing-keychain.d.mts b/apps/desktop/scripts/macos-signing-keychain.d.mts new file mode 100644 index 0000000000..452878aad1 --- /dev/null +++ b/apps/desktop/scripts/macos-signing-keychain.d.mts @@ -0,0 +1,16 @@ +/** Own a temporary PKCS#12 signing identity for one macOS packaging invocation. */ + +/** + * 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. + * 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. + * @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, +): Promise diff --git a/apps/desktop/scripts/macos-signing-keychain.mjs b/apps/desktop/scripts/macos-signing-keychain.mjs new file mode 100644 index 0000000000..5e4d0f2fcb --- /dev/null +++ b/apps/desktop/scripts/macos-signing-keychain.mjs @@ -0,0 +1,60 @@ +/** Own a temporary PKCS#12 signing identity for one macOS packaging invocation. */ +import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +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} + */ +function execute(command, args) { + try { execFileSync(command, args, { stdio: 'pipe', timeout: 120_000 }) } + 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`) + } +} + +/** + * 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. + * 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. + * @returns {Promise} Resolves after work and cleanup; rejects on setup, work, or cleanup failure. + */ +export async function withMacOSSigningKeychain(environment, action, run = execute) { + const certificate = environment.CSC_LINK + const exportPassword = environment.CSC_KEY_PASSWORD + if (!certificate || exportPassword === undefined) throw new Error('desktop macOS signing: CSC_LINK and CSC_KEY_PASSWORD are required') + const directory = mkdtempSync(join(tmpdir(), 'dsh-macos-signing-')) + const keychain = join(directory, 'signing.keychain-db') + const password = randomBytes(32).toString('base64') + /** @param {string[]} args Security command arguments. */ + const security = args => run('/usr/bin/security', args) + let created = false + try { + security(['create-keychain', '-p', password, keychain]) + created = true + security(['unlock-keychain', '-p', password, keychain]) + 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]) + 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]) + run('/usr/bin/codesign', ['--verify', '--strict', probe]) + const childEnvironment = { ...environment, CSC_KEYCHAIN: keychain } + delete childEnvironment.CSC_LINK + delete childEnvironment.CSC_KEY_PASSWORD + await action(childEnvironment) + } finally { + try { if (created) security(['delete-keychain', keychain]) } + finally { rmSync(directory, { recursive: true, force: true }) } + } +} diff --git a/apps/desktop/scripts/manage-windows-signature-cache.mjs b/apps/desktop/scripts/manage-windows-signature-cache.mjs new file mode 100644 index 0000000000..de44540c70 --- /dev/null +++ b/apps/desktop/scripts/manage-windows-signature-cache.mjs @@ -0,0 +1,28 @@ +/** Explicit current-account migration and storage inspection under the signing-stage lock. */ +import { parseArgs } from 'node:util' +import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' +import { maintainSignatureCache, migrateSignatureCache } from './windows-signature-cache.mjs' +import { withWindowsSigningStage } from './windows-signing-stage.mjs' + +const { values } = parseArgs({ options: { + from: { type: 'string' }, usage: { type: 'boolean' }, clear: { type: 'boolean' }, directory: { type: 'string' }, +} }) +if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) { + throw new Error('Specify exactly one of --from , --usage or --clear') +} +const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32') + : { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory } +const root = resolveWindowsSignatureCacheDirectory(environment) +const record = event => process.stdout.write(`${JSON.stringify(event)}\n`) +await withWindowsSigningStage({ stage: values.from !== undefined ? 'cache-migration' : 'cache-maintenance', record }, async () => { + await prepareWindowsSignatureCacheDirectory(root) + if (values.from !== undefined) { + const source = resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.from }) + await prepareWindowsSignatureCacheDirectory(source, true) + record({ type: 'signature-cache-migration-summary', ...await migrateSignatureCache({ source, root, record }) }) + return + } + record({ type: values.clear ? 'signature-cache-cleared' : 'signature-cache-usage', root, + ...await maintainSignatureCache(root, values.clear) }) +}) diff --git a/apps/desktop/scripts/package-installed-update.ts b/apps/desktop/scripts/package-installed-update.ts new file mode 100644 index 0000000000..e2659f0a4f --- /dev/null +++ b/apps/desktop/scripts/package-installed-update.ts @@ -0,0 +1,30 @@ +/** Operator-only signed packaging; default checks do not launch children, sign, install, or publish. */ +import { createInterface } from 'node:readline/promises' +import { stdin, stdout } from 'node:process' +import { InstalledUpdateSigningHoldError, packageInstalledUpdate } from './installed-update-packaging.ts' + +async function main(): Promise { + const [manifest, version, mode, ...extra] = process.argv.slice(2) + if (!manifest || !version || extra.length !== 0 || (mode !== undefined && mode !== '--check' && mode !== '--execute')) { + throw new Error('invalid invocation') + } + const result = await packageInstalledUpdate(manifest, version, { execute: mode === '--execute', + confirm: async (id, selectedVersion) => { + if (!stdin.isTTY || !stdout.isTTY) return false + const terminal = createInterface({ input: stdin, output: stdout }) + try { + console.log('仅在管理员已审核并恢复签名保护后继续。确认已注销令牌、剩余 5/5、PIN 正确且无其他签名任务。') + console.log('本次打包会有多次签名;不重试失败操作,驱动内部认证次数无法保证。出现密码弹窗请取消,不要补输。') + console.log('PIN 只从 .env.windows 读取;签名接口要求它短暂出现在 SignTool 参数中。不会安装或上传。') + const expected = `PACKAGE ${selectedVersion} ${id}` + return (await terminal.question(`仅授权这个版本,请输入 ${expected}:`)) === expected + } finally { terminal.close() } + } }) + console.log(JSON.stringify(result, null, 2)) +} + +main().catch((error: unknown) => { + console.error(error instanceof InstalledUpdateSigningHoldError ? error.message + : 'installed update packaging stopped. Check prepared files and the retained record; do not retry or remove protection automatically.') + process.exitCode = 1 +}) diff --git a/apps/desktop/scripts/package-macos.ts b/apps/desktop/scripts/package-macos.ts index f1378d55ef..96fb3f7be4 100644 --- a/apps/desktop/scripts/package-macos.ts +++ b/apps/desktop/scripts/package-macos.ts @@ -4,13 +4,16 @@ import { execFile } from 'node:child_process' import { mkdtemp, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { promisify } from 'node:util' -import { Arch, getArchSuffix } from 'electron-builder' import { notarize } from '@electron/notarize' import { resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, } from './desktop-release-environment.mjs' -import { desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs' +import { + desktopUpdateMetadataFilename, + resolveDesktopAutoUpdateConfig, +} from './desktop-auto-update-environment.mjs' +import { verifyMacOSAppUpdateConfig } from './macos-app-update-config.mjs' import { verifyMacOSNotarizedApplication, verifyMacOSSignature } from './verify-macos-signature.mjs' const execute = promisify(execFile) @@ -72,15 +75,19 @@ export async function packageMacOSArtifacts( const { arch, version, artifactsRoot, environment } = request const expected = resolveMacOSSigningEnvironment(environment) const credentials = resolveMacOSNotarizationEnvironment(environment) - const appPath = join(artifactsRoot, `mac${getArchSuffix(Arch[arch])}`, 'DeepSeek Harness.app') + const update = resolveDesktopAutoUpdateConfig(environment, 'darwin', arch) + const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-')) const zipApp = join(root, 'zip', basename(appPath)) const dmgApp = join(root, 'dmg', basename(appPath)) const zipOutput = join(root, 'zip-artifacts') const dmgOutput = join(root, 'dmg-artifacts') try { + await verifyMacOSAppUpdateConfig(appPath, update) await apple.copyApp(appPath, zipApp) await apple.copyApp(appPath, dmgApp) + await verifyMacOSAppUpdateConfig(zipApp, update) + await verifyMacOSAppUpdateConfig(dmgApp, update) apple.verifySignature(zipApp, expected) apple.verifySignature(dmgApp, expected) const results = await Promise.allSettled([ @@ -97,6 +104,10 @@ export async function packageMacOSArtifacts( if (failures.length > 0) { throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed') } + await verifyMacOSAppUpdateConfig(zipApp, update) + await verifyMacOSAppUpdateConfig(dmgApp, update) + apple.verifySignature(zipApp, expected) + apple.verifySignature(dmgApp, expected) const base = `deepseek-harness-${version}-mac-${arch}` const artifacts = [ [dmgOutput, `${base}.dmg`], diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 5cd2c81b1c..6988a4edac 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -11,6 +11,10 @@ import { import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs' +import { withWindowsSigningStage } from './windows-signing-stage.mjs' +import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' +import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs' const APP_ROOT = resolve(import.meta.dirname, '..') const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') @@ -20,6 +24,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [ 'DSH_DESKTOP_WINDOWS_KEY_CONTAINER', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', + 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR', ] as const const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_TEST_COS_SECRET_ID', @@ -253,11 +258,13 @@ function runPnpm( args: readonly string[], env: NodeJS.ProcessEnv = process.env, cwd: string = APP_ROOT, + run?: ReturnType, ): Promise { const pnpmEntry = process.env.npm_execpath if (pnpmEntry === undefined || pnpmEntry === '') { throw new Error('desktop package: invoke this script through a pnpm package command') } + if (run !== undefined) return run.run(args.join(' '), process.execPath, [pnpmEntry, ...args], { cwd, env }) return new Promise((resolvePromise, reject) => { const child = spawn(process.execPath, [pnpmEntry, ...args], { cwd, @@ -281,6 +288,36 @@ async function main(): Promise { process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`) return } + const run = target.platform === 'win32' + ? createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), { + target: target.name, unsigned: invocation.unsigned, version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), + }) : undefined + if (run !== undefined) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`) + let success = false + try { + if (target.platform === 'darwin') { + await withMacOSSigningKeychain(environment, signingEnvironment => packageTarget(invocation, signingEnvironment, run)) + } else { + await packageTarget(invocation, environment, run) + } + success = true + } finally { run?.finish(success) } +} + +/** + * Prepare one release only after its signing preflight, without publishing from the builder. + * @param invocation Validated host, target and packaging mode. + * @param environment File-owned release configuration. + * @param run Windows stage supervisor; required for signed Windows packaging. + * @returns Resolves after preparation or complete packaging; any failed stage prevents a release record. + */ +export async function packageTarget( + invocation: DesktopPackageInvocation, + environment: NodeJS.ProcessEnv, + run: ReturnType | undefined, +): Promise { + const { target } = invocation + const execute = (args: readonly string[], env: NodeJS.ProcessEnv, cwd: string = APP_ROOT) => runPnpm(args, env, cwd, run) const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) if (!invocation.prepareOnly && !invocation.unsigned) { @@ -297,32 +334,63 @@ async function main(): Promise { for (const name of WINDOWS_SIGNING_ENV_NAMES) { if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name] } - await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) - await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT) - await runPnpm([ + const signPrimaryRuntime = target.platform === 'win32' && !invocation.unsigned && !invocation.prepareOnly + const signedStage = async (stage: string, operation: () => Promise): Promise => { + if (!signPrimaryRuntime) return operation() + if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run') + const controller = new AbortController() + const interrupted = (): void => controller.abort() + const detach = (): void => { + process.removeListener('SIGINT', interrupted) + process.removeListener('SIGTERM', interrupted) + } + process.once('SIGINT', interrupted) + process.once('SIGTERM', interrupted) + try { + const options = { stage, signal: controller.signal, record: (event: object): void => recordPackagingEvent(run.directory, event) } + await withWindowsSigningStage(options, async () => { + detach() + await operation() + }) + } finally { detach() } + } + if (signPrimaryRuntime) { + if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run') + await signedStage('preflight', async () => { + await prepareWindowsSignatureCacheDirectory(resolveWindowsSignatureCacheDirectory(environment)) + await run.run('preflight:windows-signing', process.execPath, + ['--import', 'tsx/esm', join(APP_ROOT, 'scripts/windows-signing-preflight.ts')], + { cwd: APP_ROOT, env: electronBuilderEnv, timeoutMs: 60_000 }) + }) + } + await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) + await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT) + await execute([ '--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) + await execute(['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/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) - await runPnpm([ + await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) + await execute([ '--dir', 'native/system/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:dsh'], targetEnv) + await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], targetEnv) + if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv) + await execute(['run', 'prepare:packages'], targetEnv) + await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], targetEnv) + if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv) if (invocation.prepareOnly) return if (target.platform === 'darwin' && !invocation.directory) { - await runPnpm([ + await execute([ ...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false', ], electronBuilderEnv) @@ -331,10 +399,11 @@ async function main(): Promise { version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, - }, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)) + }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)) } else { - await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) + await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) } + if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) } diff --git a/apps/desktop/scripts/packaging-run.d.mts b/apps/desktop/scripts/packaging-run.d.mts new file mode 100644 index 0000000000..862436b0c3 --- /dev/null +++ b/apps/desktop/scripts/packaging-run.d.mts @@ -0,0 +1,40 @@ +/** + * Persist one credential-free packaging event before its operation. + * @param directory Private run directory. + * @param event Whitelisted fields, excluding arguments and environments. + * @returns Nothing. + */ +export function recordPackagingEvent(directory: string, event: object): void +/** + * Publish a fatal marker that stops the supervised stage tree. + * @param directory Private run directory. + * @param reason Credential-free failure category. + * @returns Nothing. + */ +export function failPackagingRun(directory: string, reason: string): void +/** + * Redact inherited secret values across arbitrary output chunk boundaries. + * @param secrets Exact credential values to remove. + * @param emit Redacted output sink. + * @returns A UTF-8 stream consumer that flushes its suffix on end. + */ +export function packagingOutputRedactor(secrets: readonly string[], emit: (text: string) => void): { + write(chunk: Buffer): void + end(): void +} +/** + * Allocate retained evidence and supervise sequential child-process stages. + * @param root Parent directory for retained records. + * @param metadata Public target/version metadata only. + * @returns A run that blocks later stages after failure and awaits owned process termination. + */ +export function createPackagingRun(root: string, metadata: object): { + directory: string + run(stage: string, executable: string, args: readonly string[], options: { + cwd: string + env: NodeJS.ProcessEnv + /** Optional stage deadline; timeout fails the run even if the child reports exit zero. */ + timeoutMs?: number + }): Promise + finish(success: boolean): void +} diff --git a/apps/desktop/scripts/packaging-run.mjs b/apps/desktop/scripts/packaging-run.mjs new file mode 100644 index 0000000000..6071b17952 --- /dev/null +++ b/apps/desktop/scripts/packaging-run.mjs @@ -0,0 +1,171 @@ +/** Persist redacted packaging evidence and terminate the owned stage tree on fatal signing failures. */ +import { spawn } from 'node:child_process' +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { StringDecoder } from 'node:string_decoder' + +const FATAL_NOTIFICATION = 'DSH_DESKTOP_PACKAGING_FATAL' + +/** + * Append one credential-free event before its corresponding operation starts. + * @param {string} directory Private run directory. + * @param {object} event Whitelisted event fields; never pass command arguments or environments. + * @returns {void} + */ +export function recordPackagingEvent(directory, event) { + appendFileSync(join(directory, 'events.jsonl'), `${JSON.stringify({ time: new Date().toISOString(), pid: process.pid, ...event })}\n`, { flush: true }) +} + +/** + * Publish the fatal marker before rejecting a signing operation. + * @param {string} directory Private run directory. + * @param {string} reason Credential-free failure category. + * @returns {void} + */ +export function failPackagingRun(directory, reason) { + try { + writeFileSync(join(directory, 'fatal.json'), `${JSON.stringify({ time: new Date().toISOString(), pid: process.pid, reason })}\n`, { flush: true }) + } finally { process.stderr.write(`\n${FATAL_NOTIFICATION}\n`) } +} + +/** + * Redact complete secrets even when process output splits them across chunks. + * @param {readonly string[]} secrets Exact inherited credential values. + * @param {(text: string) => void} emit Redacted output sink. + * @returns {{write: (chunk: Buffer) => void, end: () => void}} Bounded streaming redactor. + */ +export function packagingOutputRedactor(secrets, emit) { + const values = [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length) + const decoder = new StringDecoder('utf8') + let buffer = '' + function drain(final) { + let output = '' + while (buffer.length > 0) { + const match = values.find(value => buffer.startsWith(value)) + if (match !== undefined) { output += '[REDACTED]'; buffer = buffer.slice(match.length); continue } + if (!final && values.some(value => value.startsWith(buffer))) break + output += buffer[0] + buffer = buffer.slice(1) + } + if (output !== '') emit(output) + } + return { + write(chunk) { buffer += decoder.write(chunk); drain(false) }, + end() { buffer += decoder.end(); drain(true) }, + } +} + +/** + * Allocate a run whose failures never become release completion records. + * @param {string} root Parent for retained packaging records. + * @param {object} metadata Public target/version metadata only. + * @returns {{directory: string, run: (stage: string, executable: string, args: readonly string[], options: {cwd: string, env: NodeJS.ProcessEnv, timeoutMs?: number}) => Promise, finish: (success: boolean) => void}} Owned run supervisor; an optional stage deadline records timeout independently of exit status and awaits termination. + */ +export function createPackagingRun(root, metadata) { + mkdirSync(root, { recursive: true }) + const directory = realpathSync(mkdtempSync(join(resolve(root), `${new Date().toISOString().replaceAll(':', '-')}-`))) + for (const name of ['events.jsonl', 'stdout.log', 'stderr.log']) { + writeFileSync(join(directory, name), '', { flag: 'wx', mode: 0o600 }) + } + writeFileSync(join(directory, 'run.json'), `${JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...metadata })}\n`, { flag: 'wx', flush: true }) + let failed = false + let active = false + const fatal = join(directory, 'fatal.json') + async function run(stage, executable, args, options) { + if (failed || existsSync(fatal)) throw new Error(`desktop package: run is blocked; see ${directory}`) + if (active) throw new Error('desktop package: supervised stages must run sequentially') + active = true + let child + let fatalObserved = false + let launchError = false + let outputError = false + let termination + let terminationCode + let terminationError = false + let stageClosed = false + let timedOut = false + let deadline + let closed + const safeEnvironment = Object.fromEntries(Object.entries(options.env).filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$/iu.test(name))) + function stop() { + fatalObserved = true + failed = true + if (termination !== undefined || child?.pid === undefined || stageClosed) return + if (process.platform === 'win32') { + const killer = spawn(join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { + env: safeEnvironment, windowsHide: true, stdio: 'ignore', + }) + termination = new Promise(resolveTermination => { + killer.once('error', () => { terminationError = true }) + killer.once('close', code => { + terminationCode = code + if (code !== 0) { terminationError = true; child.kill() } + resolveTermination() + }) + }) + } else { + try { process.kill(-child.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') terminationError = true } + termination = Promise.resolve() + } + } + const interrupted = () => stop() + process.once('SIGINT', interrupted) + process.once('SIGTERM', interrupted) + try { + recordPackagingEvent(directory, { type: 'stage-start', stage }) + child = spawn(executable, [...args], { cwd: options.cwd, env: { ...options.env, DSH_DESKTOP_PACKAGING_RUN_DIR: directory }, + windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }) + closed = new Promise(resolveClose => { + child.once('error', () => { launchError = true }) + child.once('close', (code, signal) => { stageClosed = true; resolveClose({ code, signal }) }) + }) + recordPackagingEvent(directory, { type: 'stage-spawn', stage, childPid: child.pid }) + if (options.timeoutMs !== undefined) deadline = setTimeout(() => { timedOut = true; stop() }, options.timeoutMs) + const secrets = Object.entries(options.env).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD/iu.test(name)).map(([, value]) => value ?? '') + const streams = [['stdout', child.stdout, process.stdout], ['stderr', child.stderr, process.stderr]] + for (const [name, stream, consoleStream] of streams) { + let notification = '' + const redactor = packagingOutputRedactor(secrets, text => { + try { + appendFileSync(join(directory, `${name}.log`), text, { flush: true }) + consoleStream.write(text) + } catch { outputError = true; stop() } + notification += text + if (notification.includes(FATAL_NOTIFICATION)) stop() + notification = notification.slice(-FATAL_NOTIFICATION.length) + }) + stream.on('data', chunk => redactor.write(chunk)) + stream.once('end', () => redactor.end()) + stream.once('error', () => { outputError = true; stop() }) + } + if (existsSync(fatal)) stop() + const result = await closed + await termination + fatalObserved ||= existsSync(fatal) + recordPackagingEvent(directory, { type: 'stage-end', stage, ...result, timedOut, fatalObserved, launchError, outputError, terminationCode, terminationError }) + if (result.code !== 0 || result.signal !== null || fatalObserved || launchError || outputError || terminationError) { + failed = true + throw new Error(`desktop package: ${stage} failed; evidence: ${directory}`) + } + } catch (error) { + failed = true + stop() + await closed + await termination + throw error + } finally { + clearTimeout(deadline) + process.removeListener('SIGINT', interrupted) + process.removeListener('SIGTERM', interrupted) + active = false + } + } + return { + directory, + run, + finish(success) { + if (active) throw new Error('desktop package: cannot finish an active run') + writeFileSync(join(directory, 'result.json'), `${JSON.stringify({ completedAt: new Date().toISOString(), success: success && !failed && !existsSync(fatal) })}\n`, { flag: 'wx', flush: true }) + }, + } +} diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 12c8f4bc55..d1124618d6 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -1,6 +1,6 @@ /** Materialize the complete production runtime before publishing Desktop resources. */ -import { spawn, execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join, relative, resolve } from 'node:path' @@ -17,7 +17,8 @@ import { verifyDesktopCoreLockfile, } from '../src/core-package-set.ts' import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' -import { smokeDesktopRuntime } from './smoke-runtime.ts' +import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' +import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts' import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { resolveDesktopAppId, @@ -28,6 +29,7 @@ import { } 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' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() @@ -123,10 +125,12 @@ async function main(): Promise { const targetName = resolveDesktopBuildTarget() const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } const modules = join(BUILD_ROOT, 'node_modules') + const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8')) + const officeEngine = selectOfficeEngine(officeManifest, target) mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), { recursive: true, dereference: true, - filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined, + filter: source => desktopRuntimeFileExclusion(relative(modules, source), target, officeEngine) === undefined, }) writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', @@ -137,22 +141,21 @@ async function main(): Promise { throw new Error(`desktop runtime: missing private Host file ${file}`) } } + if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', '@deepseek-ai', `libreoffice-kit-${officeEngine}`, 'prebuilds.json'))) { + throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) + } if (process.platform === 'darwin') { await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) } + await prepareRuntimeManifests(DSH_OUTPUT_ROOT) smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')) writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) - await new Promise((accept, reject) => { - execFile(NODE, ['--expose-internals', join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT], - { timeout: 120_000, env: desktopNodeEnvironment(NODE, join(RUNTIME_ROOT, 'bin'), { ...process.env, NODE_OPTIONS: '' }) }, (error, stdout, stderr) => { - if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error })) - else { process.stdout.write(stdout); accept() } - }) - }) - await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) - await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + if (!process.argv.includes('--defer-runtime-smoke')) { + await smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor) + await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + } } catch (error) { rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) throw error diff --git a/apps/desktop/scripts/prepare-installed-update-application.ts b/apps/desktop/scripts/prepare-installed-update-application.ts new file mode 100644 index 0000000000..b2c4b2a47f --- /dev/null +++ b/apps/desktop/scripts/prepare-installed-update-application.ts @@ -0,0 +1,94 @@ +/** Freeze the built main, preload, renderer, and bootstrap files shared by a qualification version pair. */ +import { createHash } from 'node:crypto' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' + +interface ApplicationFile { readonly path: string; readonly sha256: string } + +async function applicationFiles(root: string, relative = ''): Promise { + const files: string[] = [] + for (const entry of await readdir(join(root, relative), { withFileTypes: true })) { + const path = relative === '' ? entry.name : `${relative}/${entry.name}` + if (entry.isDirectory()) files.push(...await applicationFiles(root, path)) + else if (entry.isFile()) files.push(path) + else throw new Error('installed update: application links and special files cannot be frozen') + } + return files +} + +/** + * Copy only shipped application files and record their hashes; existing output refuses reuse. + * @param manifest Original run manifest with completed bootstrap preparation. + * @param source Desktop app directory containing freshly built lib and renderer files. + * @returns Hash record, not dependency, signed-package, or boot qualification evidence. + */ +export async function prepareInstalledUpdateApplication(manifest: string, source: string): Promise { + const run = await readInstalledUpdateRun(manifest) + const directory = join(run.root, 'application') + await mkdir(directory) + await writeFile(join(directory, 'started.json'), `${JSON.stringify({ time: new Date().toISOString() })}\n`, { flag: 'wx', flush: true }) + try { + const modules = (await readdir(join(source, 'lib'), { withFileTypes: true })) + .filter(entry => /\.(?:js|cjs)$/u.test(entry.name)) + if (modules.some(entry => !entry.isFile())) throw new Error('installed update: application modules must be regular files') + const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer')].sort() + for (const required of ['lib/main.js', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', 'lib/preload-update-dialog.cjs']) { + if (!paths.includes(required)) throw new Error('installed update: rebuild Desktop before freezing application files') + } + const files: ApplicationFile[] = [] + const inputs = [...paths.map(path => ({ path, source: join(source, path) })), + ...['qualification-bootstrap.mjs', 'installed-update-identity.mjs'].map(path => ({ path, source: join(run.root, 'bootstrap', path) }))] + for (const input of inputs) { + const bytes = await readFile(input.source) + const target = join(directory, 'files', input.path) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, bytes, { flag: 'wx', flush: true }) + files.push({ path: input.path, sha256: createHash('sha256').update(bytes).digest('hex') }) + } + for (const [index, input] of inputs.entries()) { + if (createHash('sha256').update(await readFile(input.source)).digest('hex') !== files[index]!.sha256) { + throw new Error('installed update: application input changed while freezing files') + } + } + const result = { schemaVersion: 1, files, dependenciesFrozen: false, signed: false, bootTested: false } + await writeFile(join(directory, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', flush: true }) + return result + } catch (error) { + await writeFile(join(directory, 'failed.json'), `${JSON.stringify({ failed: true, retryAllowed: false })}\n`, { flag: 'wx', flush: true }) + throw error + } +} + +/** + * Verify frozen application bytes before selecting them for a package. + * @param root Validated run directory. + * @returns The application file directory; node_modules and build hooks remain separate inputs. + */ +export async function verifyInstalledUpdateApplication(root: string): Promise { + const directory = join(root, 'application') + const record = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8')) as { schemaVersion?: unknown; files?: unknown } + if (record.schemaVersion !== 1 || !Array.isArray(record.files) || record.files.length === 0) { + throw new Error('installed update: missing application file inventory') + } + const paths = new Set() + for (const entry of record.files as unknown[]) { + if (typeof entry !== 'object' || entry === null || !('path' in entry) || !('sha256' in entry) + || typeof entry.path !== 'string' || typeof entry.sha256 !== 'string' + || entry.path.includes('\\') || entry.path.includes(':') || entry.path.split('/').some(part => ['', '.', '..'].includes(part)) + || !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path) + || !/^[a-f0-9]{64}$/u.test(entry.sha256) || paths.has(entry.path)) { + throw new Error('installed update: invalid application file inventory') + } + paths.add(entry.path) + if (createHash('sha256').update(await readFile(join(directory, 'files', entry.path))).digest('hex') !== entry.sha256) { + throw new Error('installed update: frozen application file changed') + } + } + const actual = await applicationFiles(join(directory, 'files')) + if (actual.length !== paths.size || actual.some(path => !paths.has(path)) + || ['lib/main.js', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) { + throw new Error('installed update: frozen application inventory has missing or additional files') + } + return join(directory, 'files') +} diff --git a/apps/desktop/scripts/prepare-installed-update-bootstrap.ts b/apps/desktop/scripts/prepare-installed-update-bootstrap.ts new file mode 100644 index 0000000000..ed144864ca --- /dev/null +++ b/apps/desktop/scripts/prepare-installed-update-bootstrap.ts @@ -0,0 +1,29 @@ +/** Prepare immutable per-run bootstrap files without launching the application or loading credentials. */ +import { copyFile, constants, mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' + +/** + * Write the shared qualification entry for both versions; refuse to overwrite an existing preparation. + * @param manifest Original run.json in its allocated directory. + * @returns The private directory whose two modules must be included in each package's files. + */ +export async function prepareInstalledUpdateBootstrap(manifest: string): Promise { + const run = await readInstalledUpdateRun(manifest) + const directory = join(run.root, 'bootstrap') + await mkdir(directory) + await copyFile(join(import.meta.dirname, 'installed-update-identity.mjs'), + join(directory, 'installed-update-identity.mjs'), constants.COPYFILE_EXCL) + const entry = `import { app } from 'electron' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { configureInstalledUpdateIdentity } from './installed-update-identity.mjs' + +const run = ${JSON.stringify({ id: run.id, versions: run.versions })} +const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8')) +configureInstalledUpdateIdentity(app, run, metadata, process.env) +await import('./lib/main.js') +` + await writeFile(join(directory, 'qualification-bootstrap.mjs'), entry, { flag: 'wx', mode: 0o600, flush: true }) + return directory +} diff --git a/apps/desktop/scripts/prepare-installed-update-network.ts b/apps/desktop/scripts/prepare-installed-update-network.ts new file mode 100644 index 0000000000..5bde34103c --- /dev/null +++ b/apps/desktop/scripts/prepare-installed-update-network.ts @@ -0,0 +1,62 @@ +/** Prepare one operator-owned firewall fault for an installed, independently verified qualification executable. */ +import { lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' +import { installedUpdateFileHash } from './installed-update-signature.mjs' + +/** + * Bind a local recovery plan to the version 1 executable hash from retained package verification. + * @param manifest Original qualification run.json. + * @param executable Absolute installed test application path, not the extracted verification payload. + * @param receipt Successful version 1 package verification result.json. + * @returns Exclusively created plan path; no credentials, subprocess, or firewall operation is involved. + */ +export async function prepareInstalledUpdateNetwork(manifest: string, executable: string, receipt: string): Promise { + const run = await readInstalledUpdateRun(manifest) + const receiptRelative = relative(join(run.root, run.versions[0], 'verification'), resolve(receipt)).replaceAll('\\', '/') + if (!/^check-[^/]+\/result\.json$/u.test(receiptRelative)) throw new Error('installed update: version 1 verification receipt is required') + const result = JSON.parse(await readFile(receipt, 'utf8')) as { + schemaVersion?: unknown + runId?: unknown + version?: unknown + passed?: unknown + applicationSignature?: { sha512?: unknown; valid?: unknown; timestamped?: unknown; updaterVerificationInvoked?: unknown } + contents?: { appId?: unknown; version?: unknown } + } + const signature = result.applicationSignature + if (result.schemaVersion !== 1 || result.runId !== run.id || result.version !== run.versions[0] || result.passed !== true + || result.contents?.appId !== run.appId || result.contents.version !== run.versions[0] + || signature?.valid !== true || signature.timestamped !== true || signature.updaterVerificationInvoked !== true + || typeof signature.sha512 !== 'string' || !/^[A-Za-z0-9+/]{86}==$/u.test(signature.sha512)) { + throw new Error('installed update: successful identity and signature verification is required') + } + if (!isAbsolute(executable) || basename(executable) !== `${run.productName}.exe` + || !(await lstat(executable)).isFile()) { + throw new Error('installed update: installed test executable path or bytes do not match verification') + } + const installed = await realpath(executable) + const insideRun = relative(await realpath(run.root), installed) + if ((!insideRun.startsWith(`..${sep}`) && !isAbsolute(insideRun)) + || await installedUpdateFileHash(installed) !== signature.sha512) { + throw new Error('installed update: installed test executable path or bytes do not match verification') + } + const directory = join(run.root, 'network-fault') + await mkdir(directory) + const path = join(directory, 'plan.json') + await writeFile(path, `${JSON.stringify({ schemaVersion: 1, runId: run.id, executable: installed, + sha512Hex: Buffer.from(signature.sha512, 'base64').toString('hex').toUpperCase(), + ruleName: `DSH-Update-Qualification-${run.id}`, manifest: resolve(manifest), receipt: resolve(receipt), + receiptSha512: await installedUpdateFileHash(receipt), networkChanged: false }, null, 2)}\n`, { flag: 'wx', flush: true }) + return path +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const [manifest, executable, receipt, ...extra] = process.argv.slice(2) + if (!manifest || !executable || !receipt || extra.length) { + console.error('usage: prepare-installed-update-network.ts ') + process.exitCode = 1 + } else prepareInstalledUpdateNetwork(manifest, executable, receipt).then(path => console.log(path)).catch(() => { + console.error('installed update: network plan preparation failed; no network changes were made') + process.exitCode = 1 + }) +} diff --git a/apps/desktop/scripts/prepare-installed-update-runtime.ts b/apps/desktop/scripts/prepare-installed-update-runtime.ts new file mode 100644 index 0000000000..8ef2715d92 --- /dev/null +++ b/apps/desktop/scripts/prepare-installed-update-runtime.ts @@ -0,0 +1,71 @@ +/** Copy a verified runtime into isolated, version-bound qualification resources without signing. */ +import { createHash } from 'node:crypto' +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { runtimePath, verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' + +interface PackageMetadata { + name?: string + version?: string + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record +} + +/** + * Clone one verified source tree into two synthetic release versions, retaining source bytes unchanged. + * @param manifest The existing test run manifest; both version directories must be absent. + * @param sourceRoot Fresh prepared dsh runtime, never the user's installed application. + * @returns Completion record with descriptor hashes; this is not signed or boot-tested artifact evidence. + */ +export async function prepareInstalledUpdateRuntime(manifest: string, sourceRoot: string): Promise { + const run = await readInstalledUpdateRun(manifest) + const receipt = join(run.root, 'runtime-preparation') + await mkdir(receipt) + await writeFile(join(receipt, 'started.json'), `${JSON.stringify({ sourceRoot, time: new Date().toISOString() })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) + const results: object[] = [] + try { + const source = await verifyDesktopRuntime(sourceRoot, run.source.version) + const sourceHash = createHash('sha256').update(await readFile(join(sourceRoot, 'desktop-runtime.json'))).digest('hex') + await writeFile(join(receipt, 'source.json'), `${JSON.stringify({ sourceHash, version: run.source.version })}\n`, + { flag: 'wx', mode: 0o600, flush: true }) + const releaseNames = new Set(source.sharedPackages.filter(entry => entry.version === run.source.version + && (entry.name === '@deepseek-ai/dsh' || entry.name.startsWith('@deepseek-ai/dsh-'))).map(entry => entry.name)) + for (const version of run.versions) { + const directory = join(run.root, version) + await mkdir(directory) + const runtime = join(directory, 'dsh') + await cp(sourceRoot, runtime, { recursive: true, force: false, errorOnExist: true }) + const paths = [join(runtime, 'package.json'), ...source.sharedPackages.filter(entry => releaseNames.has(entry.name)) + .map(entry => join(runtimePath(runtime, entry.path), 'package.json'))] + for (const path of paths) { + const metadata = JSON.parse(await readFile(path, 'utf8')) as PackageMetadata + metadata.version = version + for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const) { + for (const [name, value] of Object.entries(metadata[field] ?? {})) { + if (releaseNames.has(name) && value === run.source.version) metadata[field]![name] = version + } + } + await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`) + } + writeDesktopRuntime(runtime, { ...source.release, version }, source.sharedPackages.map(entry => entry.name), source) + const verified = await verifyDesktopRuntime(runtime, version, source) + results.push({ version, runtime, files: verified.files.length, sharedPackages: verified.sharedPackages.length, + descriptorSha256: createHash('sha256').update(await readFile(join(runtime, 'desktop-runtime.json'))).digest('hex') }) + } + await verifyDesktopRuntime(sourceRoot, run.source.version, source) + if (createHash('sha256').update(await readFile(join(sourceRoot, 'desktop-runtime.json'))).digest('hex') !== sourceHash) { + throw new Error('installed update: source runtime changed during qualification preparation') + } + const result = { schemaVersion: 1, sourceHash, versions: results, signed: false, bootTested: false } + await writeFile(join(receipt, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + return result + } catch (error) { + await writeFile(join(receipt, 'failed.json'), `${JSON.stringify({ failed: true, time: new Date().toISOString(), + completedVersions: results.length, retryAllowed: false })}\n`, { flag: 'wx', mode: 0o600, flush: true }) + throw error + } +} diff --git a/apps/desktop/scripts/prepare-installed-update.ts b/apps/desktop/scripts/prepare-installed-update.ts new file mode 100644 index 0000000000..68ca146fb6 --- /dev/null +++ b/apps/desktop/scripts/prepare-installed-update.ts @@ -0,0 +1,60 @@ +/** Allocate operator qualification materials or inspect journals; never signs, uploads, or installs. */ +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { collectInstalledUpdateJournals, createInstalledUpdateRun, inspectInstalledUpdateJournals } from './installed-update-qualification.ts' +import { prepareInstalledUpdateBootstrap } from './prepare-installed-update-bootstrap.ts' +import { prepareInstalledUpdateRuntime } from './prepare-installed-update-runtime.ts' +import { planInstalledUpdateDistribution } from './installed-update-distribution.ts' +import { prepareInstalledUpdateApplication } from './prepare-installed-update-application.ts' + +const repository = resolve(import.meta.dirname, '../../..') + +async function main(): Promise { + const [command, original, successor, directory, ...extra] = process.argv.slice(2) + if (command === 'collect' && original !== undefined && successor !== undefined && directory === undefined) { + console.log(JSON.stringify({ collection: await collectInstalledUpdateJournals(original, successor), operatorAcceptance: 'pending' }, null, 2)) + return + } + if (command === 'application' && original !== undefined && successor === undefined) { + console.log(JSON.stringify(await prepareInstalledUpdateApplication(original, resolve(repository, 'apps/desktop')), null, 2)) + return + } + if (command === 'files' && original !== undefined && successor !== undefined && directory === undefined) { + console.log(JSON.stringify(await planInstalledUpdateDistribution(original, successor), null, 2)) + return + } + if (command === 'bootstrap' && original !== undefined && successor === undefined) { + console.log(JSON.stringify({ bootstrap: await prepareInstalledUpdateBootstrap(original), launched: false }, null, 2)) + return + } + if (command === 'runtime' && original !== undefined && successor === undefined) { + console.log(JSON.stringify(await prepareInstalledUpdateRuntime(original, + resolve(repository, 'apps/desktop/.desktop-build/targets/win-x64/dsh')), null, 2)) + return + } + if ((command !== 'init' && command !== 'inspect') || original === undefined || successor === undefined + || extra.length !== 0 || (command === 'init' && directory !== undefined) || (command === 'inspect' && directory === undefined)) { + throw new Error('usage: prepare-installed-update.ts init | bootstrap | runtime | application | files | collect | inspect ') + } + if (command === 'inspect') { + const evidence = await inspectInstalledUpdateJournals(directory!, [original, successor]) + console.log(JSON.stringify(evidence, null, 2)) + if (evidence.recordedFlow === 'incomplete') process.exitCode = 2 + return + } + const metadata = JSON.parse(await readFile(resolve(repository, 'package.json'), 'utf8')) as { version: string } + const git = (args: string[]): string => execFileSync('git', args, { cwd: repository, encoding: 'utf8', windowsHide: true }).trim() + const run = await createInstalledUpdateRun(resolve(repository, 'apps/desktop/.desktop-build/qualification'), [original, successor], { + version: metadata.version, commit: git(['rev-parse', 'HEAD']), + dirtyFiles: git(['status', '--porcelain=v1', '--untracked-files=normal']).split('\n').filter(Boolean), + }) + console.log(JSON.stringify({ manifest: resolve(run.root, 'run.json'), appId: run.appId, + versions: run.versions, feedUrl: `${run.origin}/${run.feedKey}`, artifactsPrepared: false, published: false }, null, 2)) +} + +main().catch(() => { + // Inputs may be logs or local configuration; do not echo arbitrary exception details. + console.error('installed update preparation failed; verify arguments, Git checkout, versions, and local evidence files. No remote operation was attempted.') + process.exitCode = 1 +}) diff --git a/apps/desktop/scripts/prepare-package-set.ts b/apps/desktop/scripts/prepare-package-set.ts index f67d767e81..7e7d62eea2 100644 --- a/apps/desktop/scripts/prepare-package-set.ts +++ b/apps/desktop/scripts/prepare-package-set.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto' import { constants, copyFileSync, + globSync, mkdirSync, readFileSync, readdirSync, @@ -13,6 +14,7 @@ import { } from 'node:fs' import { basename, join, resolve } from 'node:path' import { parseArgs } from 'node:util' +import * as yaml from 'js-yaml' import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES, @@ -49,13 +51,17 @@ function dependencyNames(manifest: Readonly>, section: s } /** - * Select the complete available first-party dependency closures rooted at dsh and its private Host. + * Select workspace dependencies rooted at dsh and its private Host; npm resolves external packages. + * Reads the repository workspace manifest and package manifests to distinguish required local packages from npm-resolved externals. * @param available - Packed packages indexed by package name. * @returns Selected packages sorted by name. */ export function selectDesktopPackageClosure( available: ReadonlyMap, ): PackedDesktopPackage[] { + const workspace = yaml.load(readFileSync(join(REPOSITORY_ROOT, 'pnpm-workspace.yaml'), 'utf8')) as { packages: string[] } + const workspaceNames = new Set(globSync(workspace.packages.map(pattern => `${pattern}/package.json`), { cwd: REPOSITORY_ROOT }) + .map(path => (JSON.parse(readFileSync(join(REPOSITORY_ROOT, path), 'utf8')) as { name: string }).name)) const selected = new Map() const visit = (name: string): void => { if (selected.has(name)) return @@ -65,8 +71,8 @@ export function selectDesktopPackageClosure( 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}`) + else if (workspaceNames.has(dependency)) { + throw new Error(`desktop package set: ${name} requires unpacked package ${dependency}`) } } } diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index 1c57d168ea..9163db1234 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -11,6 +11,7 @@ import extractZip from 'extract-zip' import { x as extractTar } from 'tar' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import lock from './primary-runtime-lock.json' with { type: 'json' } /** @@ -88,9 +89,10 @@ export async function prepareOfficeSkillAssets(source: string, destination: stri /** * Materialize the selected Desktop target's primary runtime in its build resources. - * @returns Resolves after dependency installation and native-target execution checks. + * @param options - Signed Windows packaging defers execution until its supervised signing stage. + * @returns Resolves after materialization and, unless deferred, native-target execution checks. */ -export async function preparePrimaryRuntime(): Promise { +export async function preparePrimaryRuntime(options: { deferSmoke?: boolean } = {}): Promise { const target = resolveDesktopBuildTarget() const paths = resolveDesktopTargetBuildPaths() const artifact = lock.targets[target] @@ -145,7 +147,7 @@ export async function preparePrimaryRuntime(): Promise { const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json')) await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), join(paths.runtime, 'office-skills')) - smokePrimaryRuntime(join(paths.runtime, 'primary-runtime')) + if (!options.deferSmoke) smokePrimaryRuntime(join(paths.runtime, 'primary-runtime')) } /** @@ -157,7 +159,8 @@ export function smokePrimaryRuntime(root: string): void { if (manifest.platform !== process.platform || manifest.arch !== process.arch) return if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) - const options = { stdio: 'inherit', timeout: 120_000 } as const + const options = { stdio: 'inherit', timeout: 120_000, env: scrubWindowsSigningEnvironment(process.env) } as const + execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options) execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options) diff --git a/apps/desktop/scripts/prepare-runtime-manifests.ts b/apps/desktop/scripts/prepare-runtime-manifests.ts new file mode 100644 index 0000000000..7f919b24fd --- /dev/null +++ b/apps/desktop/scripts/prepare-runtime-manifests.ts @@ -0,0 +1,26 @@ +/** Apply electron-builder's dependency metadata cleanup before sealing runtime bytes. */ +import { readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createRequire } from 'node:module' + +// electron-builder omits declarations for its internal manifest transformer. +const { createTransformer } = createRequire(import.meta.url)('app-builder-lib/out/fileTransformer.js') as { + createTransformer: (root: string, config: object, extraMetadata: undefined) => + (path: string) => Promise | null +} + +/** + * Materialize the same manifest bytes electron-builder will put in the archive. + * @param root Prepared application dependency tree. + * @returns Resolves after dependency manifests are ready for integrity inventory. + */ +export async function prepareRuntimeManifests(root: string): Promise { + const transform = createTransformer(root, {}, undefined) + const modules = join(root, 'node_modules') + for (const entry of await readdir(modules, { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || entry.name !== 'package.json') continue + const path = join(entry.parentPath, entry.name) + const transformed = await transform(path) + if (transformed != null) await writeFile(path, transformed) + } +} diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 1bf2f85ee7..ee31e032ee 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process' import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' +import { parseArgs } from 'node:util' import { downloadArtifact } from '@electron/get' import extractZip from 'extract-zip' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' @@ -25,6 +26,7 @@ function preparePnpm(): string { } async function main(): Promise { + const { values } = parseArgs({ options: { 'defer-primary-runtime-smoke': { type: 'boolean', default: false } } }) const target = resolveDesktopBuildTarget() const platform = target.startsWith('mac-') ? 'darwin' : 'win32' const arch = target.endsWith('arm64') ? 'arm64' : 'x64' @@ -47,7 +49,7 @@ async function main(): Promise { node: nodeVersion, pnpm: pnpmVersion, }, undefined, 2)}\n`) - await preparePrimaryRuntime() + await preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }) } await main() diff --git a/apps/desktop/scripts/prepare-windows-installer.ps1 b/apps/desktop/scripts/prepare-windows-installer.ps1 index 6977f98b9c..faaff19d17 100644 --- a/apps/desktop/scripts/prepare-windows-installer.ps1 +++ b/apps/desktop/scripts/prepare-windows-installer.ps1 @@ -29,6 +29,16 @@ if ($TestProgress -or $CompileProgressOnly) { & $testExecutable if ($LASTEXITCODE -ne 0) { throw 'Progress timeline regression failed.' } } + $presentationSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-presentation.cpp')) + $presentationExecutable = Join-Path $output 'presentation-test.exe' + $presentationScript = Join-Path $output 'compile-presentation-test.cmd' + [IO.File]::WriteAllLines($presentationScript, @('@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' -f $presentationSource, (Join-Path $output 'presentation-test.obj'), $presentationExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default) + & $env:ComSpec /d /c $presentationScript + if ($LASTEXITCODE -ne 0) { throw 'Installer presentation test compilation failed.' } + if ($TestProgress) { + & $presentationExecutable + if ($LASTEXITCODE -ne 0) { throw 'Installer presentation 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/publish-installed-update.ts b/apps/desktop/scripts/publish-installed-update.ts new file mode 100644 index 0000000000..39418067b9 --- /dev/null +++ b/apps/desktop/scripts/publish-installed-update.ts @@ -0,0 +1,40 @@ +/** Operator entry for separate test binary uploads and fixed-feed publication; default mode is local-only. */ +import { parseArgs } from 'node:util' +import { createInterface } from 'node:readline/promises' +import { stdin, stdout } from 'node:process' +import { verifiedInstalledUpdateDistribution, executeInstalledUpdatePublication } from './installed-update-publication.ts' +import { createInstalledUpdateCos } from './installed-update-cos.ts' + +async function main(): Promise { + const { values, positionals } = parseArgs({ allowPositionals: true, options: { + execute: { type: 'boolean', default: false }, journals: { type: 'string' }, + } }) + const [action, manifest, version, receipt, ...extra] = positionals + if ((action !== 'upload-binaries' && action !== 'publish-feed') || !manifest || !version || !receipt || extra.length) { + throw new Error('invalid invocation') + } + const prepared = await verifiedInstalledUpdateDistribution(manifest, version, receipt) + console.log(JSON.stringify({ action, version, runId: prepared.run.id, destination: prepared.distribution, + mode: values.execute ? 'awaiting-operator' : 'local-check', networkStarted: false }, null, 2)) + if (!values.execute) return + if (!stdin.isTTY || !stdout.isTTY) throw new Error('operator terminal required') + const expected = `${action === 'upload-binaries' ? 'UPLOAD' : 'PUBLISH'} ${version} ${prepared.run.id}` + const terminal = createInterface({ input: stdin, output: stdout }) + let confirmed = false + try { + console.log(action === 'upload-binaries' + ? '仅授权此批次 test 二进制上传。确认没有其他发布者;会完整回读安装包,可能产生较大下载流量。' + : '仅授权此批次 test 清单发布。确认没有其他发布者;复用成功上传回执,仅回读清单,不重复下载安装包。') + if (action === 'publish-feed' && version === prepared.run.versions[1]) { + console.log('确认版本 1 已通过安装后的入口启动并仍在运行;必须提供该批次 journals 目录。') + } + confirmed = (await terminal.question(`请输入 ${expected}:`)) === expected + } finally { terminal.close() } + if (!confirmed) throw new Error('operator declined') + console.log(await executeInstalledUpdatePublication(manifest, version, receipt, action, createInstalledUpdateCos(), values.journals)) +} + +main().catch(() => { + console.error('installed update: upload/publication stopped. Review local prerequisites and any retained operation record; no automatic retry.') + process.exitCode = 1 +}) diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts index 7e5e7d6649..1bc16b2a1c 100644 --- a/apps/desktop/scripts/runtime-file-policy.ts +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -6,10 +6,11 @@ * node_modules tree, including nested package containers. * @param path - Path relative to the production node_modules directory. * @param target - Platform and architecture of the Electron Node runtime. + * @param officeEngine - Engine selected from the installed kit manifest. * @returns Omission reason, or undefined when the entry must be copied. */ export function desktopRuntimeFileExclusion( - path: string, target: { platform: NodeJS.Platform; arch: string }, + path: string, target: { platform: NodeJS.Platform; arch: string }, officeEngine: string, ): string | undefined { const parts = path.split(/[\\/]/u) if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { @@ -23,6 +24,9 @@ export function desktopRuntimeFileExclusion( const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1 const name = packageParts.slice(0, nameParts).join('/') const entry = packageParts.slice(nameParts).join('/') + if (name.startsWith('@deepseek-ai/libreoffice-kit-')) { + if (name !== `@deepseek-ai/libreoffice-kit-${officeEngine}`) return 'LibreOffice other platform' + } if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { return 'fs-ext compiler output' } diff --git a/apps/desktop/scripts/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts new file mode 100644 index 0000000000..d48711b4a0 --- /dev/null +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -0,0 +1,93 @@ +/** Sign Windows runtime code before executing it, retaining vendor signatures and fail-stop hardware protection. */ +import { X509Certificate } from 'node:crypto' +import { readFile, realpath } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { createWindowsTokenSigner } from './windows-sign.mjs' +import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs' +import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs' +import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' +import { withWindowsSigningStage } from './windows-signing-stage.mjs' +import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' +import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' +import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' + +/** + * Sign the bundled interpreters before their smoke checks. + * @param root Materialized primary runtime. + * @param options Supervised signer, certificate identity, audit sink and runtime check. + * @returns Resolves after verified signatures and a successful smoke. + */ +export async function signWindowsPrimaryRuntime( + root: string, options: WindowsCodeSigningOptions & { smoke: (root: string) => void }, +): Promise { + await signWindowsCode(root, options) + options.smoke(root) + options.record({ type: 'primary-runtime-smoke-success' }) +} + +/** + * Seal application dependency hashes only after all Windows code is signed. + * @param root Materialized production dependency directory. + * @param version Expected release version. + * @param options Supervised signing dependencies and a smoke receiving the signed descriptor. + * @returns Resolves after final inventory verification and smoke; any failure stops the stage. + */ +export async function signWindowsDesktopRuntime(root: string, version: string, + options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise }): Promise { + const descriptor = await verifyDesktopRuntime(root, version) + await signWindowsCode(root, options) + writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name)) + const signed = await verifyDesktopRuntime(root, version) + await options.smoke(signed) + await verifyDesktopRuntime(root, version) +} + +async function main(): Promise { + if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64') + const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + if (!runDirectory) throw new Error('primary runtime signing requires a supervised packaging run') + await readFile(join(runDirectory, 'run.json')) + const certificateFile = process.env.DSH_DESKTOP_WINDOWS_CER_FILE + if (!certificateFile) throw new Error('primary runtime signing requires the configured certificate') + const thumbprint = new X509Certificate(await readFile(certificateFile)).fingerprint.replaceAll(':', '') + const record = (event: object): void => { recordPackagingEvent(runDirectory, event) } + try { + await withWindowsSigningStage({ stage: process.argv.includes('--dsh') ? 'dsh-runtime' : 'primary-runtime', record }, async () => { + const paths = resolveDesktopTargetBuildPaths() + const sign = createWindowsTokenSigner({ certificateFile, signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL, + keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN }) + const identity = await signatureCacheIdentity([ + await realpath(certificateFile), await realpath(process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL!), + join(import.meta.dirname, 'windows-sign.cmd'), join(import.meta.dirname, 'windows-sign.mjs'), + join(import.meta.dirname, 'windows-timestamp.mjs'), + ]) + const cacheRoot = resolveWindowsSignatureCacheDirectory(process.env) + await prepareWindowsSignatureCacheDirectory(cacheRoot) + const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint, + sign, inspect: inspectWindowsRuntimeSignature, record }) + const options = { thumbprint, sign: cachedSign, record } + record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity }) + try { + if (process.argv.includes('--dsh')) { + const version = JSON.parse(await readFile(join(paths.dsh, 'package.json'), 'utf8')).version as string + await signWindowsDesktopRuntime(paths.dsh, version, { ...options, + smoke: descriptor => smokePreparedRuntime(paths.dsh, join(paths.electron, 'electron.exe'), paths.runtime, descriptor), + }) + } else { + await signWindowsPrimaryRuntime(join(paths.runtime, 'primary-runtime'), { ...options, smoke: smokePrimaryRuntime }) + } + } finally { + const summary = cachedSign.summary() + record({ type: 'signature-cache-summary', ...summary }) + process.stdout.write(`SIGNATURE_CACHE_SUMMARY ${JSON.stringify(summary)}\n`) + } + }) + } catch (error) { + failPackagingRun(runDirectory, 'primary-runtime-signing-or-smoke-failed') + throw error + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await main() diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts new file mode 100644 index 0000000000..5fc993f25d --- /dev/null +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -0,0 +1,13 @@ +/** Validate the assembled Windows application before recording release completion. */ +import { join } from 'node:path' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { verifyWindowsCode } from './windows-runtime-signature.mjs' +import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' + +const paths = resolveDesktopTargetBuildPaths() +const application = join(paths.artifacts, 'win-unpacked') +const descriptor = await verifyDesktopRuntime(paths.dsh, readDesktopRuntime(paths.dsh).release.version) +await verifyWindowsCode(application) +await smokePreparedRuntime(join(application, 'resources', 'app.asar', 'dsh'), + join(application, 'DeepSeek Harness.exe'), join(application, 'resources', 'runtime'), descriptor) diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts new file mode 100644 index 0000000000..94f0c1e55e --- /dev/null +++ b/apps/desktop/scripts/smoke-prepared-runtime.ts @@ -0,0 +1,36 @@ +/** Check payloads and Host boot with private native-cache and Harness directories. */ +import { execFile } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { desktopNodeEnvironment } from '../src/node-environment.ts' +import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' +import { smokeDesktopRuntime } from './smoke-runtime.ts' + +/** + * Exercise real runtime files and Host composition, including an ASAR root when packaged. + * @param root Prepared or archived dsh directory. + * @param node Target Electron executable. + * @param resourcesRuntime External runtime directory beside the archive. + * @param descriptor Runtime descriptor already verified against the selected target, which may differ from the build host. + * @returns Resolves after payload checks, Host readiness and teardown. + */ +export async function smokePreparedRuntime( + root: string, node: string, resourcesRuntime: string, descriptor: DesktopRuntimeDescriptor, +): Promise { + const cache = await mkdtemp(join(tmpdir(), 'desktop-native-smoke-')) + const environment = { ...scrubWindowsSigningEnvironment(process.env), NODE_OPTIONS: '', + NARB_NATIVE_CACHE_DIR: cache, NARB_DISABLE_NATIVE_CACHE: '0' } + try { + const { stdout } = await promisify(execFile)(node, [ + '--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime, + ], { timeout: 120_000, windowsHide: true, + env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) }) + process.stdout.write(stdout) + await smokeDesktopRuntime(root, node, descriptor, environment, resourcesRuntime) + } finally { + await rm(cache, { recursive: true, force: true }) + } +} diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index cf3d848d7f..f3697cf521 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -12,11 +12,18 @@ import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. + * @param environment - Credential-scrubbed build environment and private native cache. + * @param resourcesRuntime - Bundled interpreters outside the application archive. */ -export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise { +export async function smokeDesktopRuntime( + root: string, node: string, runtime: DesktopRuntimeDescriptor, environment: NodeJS.ProcessEnv, resourcesRuntime: string, +): Promise { const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) const profile = join(home, 'profiles', 'desktop') - const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home }) + const host = new DesktopHostProcess(node, root, profile, undefined, { ...environment, DSH_HOME: home }, + undefined, join(resourcesRuntime, 'primary-runtime'), 'runtime', + { pnpm: join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.cjs'), nodeBin: join(resourcesRuntime, 'bin') }) + let timer: ReturnType | undefined try { createPluginProfile(profile) const pluginName = 'desktop-runtime-smoke-plugin' @@ -45,7 +52,10 @@ export function apply(ctx) { manifest.dsh.profile.bundles.push(pluginName) writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') - const ready = await host.start() + const ready = await Promise.race([host.start(), new Promise((_, reject) => { + timer = setTimeout(() => { reject(new Error('desktop runtime: Host readiness exceeded 120 seconds')) }, 120_000) + })]) + clearTimeout(timer) const login = await fetch(ready.url, { redirect: 'manual' }) const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; ') const response = await fetch(new URL('/', ready.url), { headers: { cookie } }) @@ -55,6 +65,7 @@ export function apply(ctx) { const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } }) if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed') } finally { + clearTimeout(timer) await host.stop() rmSync(home, { recursive: true, force: true }) } diff --git a/apps/desktop/scripts/test-host-updates.ts b/apps/desktop/scripts/test-host-updates.ts new file mode 100644 index 0000000000..c9b7a991e1 --- /dev/null +++ b/apps/desktop/scripts/test-host-updates.ts @@ -0,0 +1,54 @@ +/** Run built Desktop Host task protection in a private development profile without Electron or installation. */ +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createDevelopmentProjectMetadata } from '../src/project-manager.ts' +import { removeOwnedDirectory } from '../src/owned-directory.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' + +const repo = resolve(import.meta.dirname, '../../..') +const evidenceRoot = join(repo, 'apps/desktop/.desktop-build/qualification') +await mkdir(evidenceRoot, { recursive: true }) +const root = await mkdtemp(join(evidenceRoot, 'host-updates-')) +const project = join(root, 'project') +const manifest = JSON.parse(await readFile(join(repo, 'apps/desktop/package.json'), 'utf8')) as { version: string } +const pnpm = JSON.parse(await readFile(join(repo, 'apps/desktop/node_modules/pnpm/package.json'), 'utf8')) as { version: string } +try { + createDevelopmentProjectMetadata(project, { + schemaVersion: 1, version: manifest.version, nodeVersion: process.versions.node, + pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + }) + // This suite tests the Host, not dev.ts's dependency projection. Preserve pnpm's existing graph verbatim. + await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(project, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir') + await writeFile(join(project, 'cordis.patch.yml'), JSON.stringify([ + { id: 'webserver', config: { host: '127.0.0.1', port: 0 } }, + { id: 'llm-deepseek', disabled: true }, + { id: 'session-title-llm', disabled: true }, + { id: 'session-telemetry-otel', disabled: true }, + { id: 'agent-instructions', disabled: true }, + { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { insert: [{ id: 'update-qualification', name: new URL('../tests/fixtures/host-update-control.mjs', import.meta.url).href }] }, + ])) + const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => + /^(?:path|systemroot|windir|comspec|pathext)$/iu.test(name))) + const child = spawn(process.execPath, [fileURLToPath(new URL('../tests/fixtures/host-update-qualification.mjs', import.meta.url)), root], { + cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root, + TEMP: root, TMP: root, TMPDIR: root }, stdio: 'inherit', windowsHide: true, + }) + let timedOut = false + const timeout = setTimeout(() => { timedOut = true; child.kill() }, 120_000) + try { + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => { done({ code, signal }) }) + }) + if (timedOut || result.signal !== null || result.code !== 0) { + throw new Error(`Host update qualification failed: timeout=${String(timedOut)}, signal=${String(result.signal)}, code=${String(result.code)}`) + } + console.log(`Host update evidence: ${root}`) + } finally { clearTimeout(timeout) } +} finally { + removeOwnedDirectory(project) +} diff --git a/apps/desktop/scripts/test-local-updater.mjs b/apps/desktop/scripts/test-local-updater.mjs new file mode 100644 index 0000000000..76ff543216 --- /dev/null +++ b/apps/desktop/scripts/test-local-updater.mjs @@ -0,0 +1,38 @@ +/** Run the built Desktop coordinator against real Electron downloads without installing software. */ +import { spawn } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import electron from 'electron' + +if (process.platform !== 'win32') throw new Error('Local NSIS updater qualification requires Windows') +const root = await mkdtemp(join(tmpdir(), 'dsh-local-updater-')) +let timedOut = false +try { + const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => + !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^ELECTRON_RUN_AS_NODE$/iu.test(name))) + const child = spawn(electron, [fileURLToPath(new URL('../tests/fixtures/local-updater.mjs', import.meta.url))], { + env: { ...environment, DSH_LOCAL_UPDATE_TEST_ROOT: root }, stdio: 'inherit', windowsHide: true, + }) + const timeout = setTimeout(() => { timedOut = true; child.kill() }, 120_000) + try { + const result = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => resolve({ code, signal })) + }) + if (timedOut) throw new Error('Local updater qualification exceeded its 120-second deadline') + if (result.signal !== null) throw new Error(`Electron exited on ${result.signal}`) + if (result.code !== 0) throw new Error(`Local updater qualification failed: exit=${result.code}`) + const evidenceRoot = fileURLToPath(new URL('../.desktop-build/qualification/', import.meta.url)) + await mkdir(evidenceRoot, { recursive: true }) + const evidence = await mkdtemp(join(evidenceRoot, 'local-updater-')) + const report = JSON.parse(await readFile(join(root, 'result.json'), 'utf8')) + await copyFile(join(root, 'result.json'), join(evidence, 'result.json')) + if (report.screenshot.captured) await copyFile(join(root, 'mandatory-update.png'), join(evidence, 'mandatory-update.png')) + for (const name of report.dialogScreenshots) await copyFile(join(root, name), join(evidence, name)) + console.log(`Local updater evidence: ${evidence}`) + } finally { clearTimeout(timeout) } +} finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) +} diff --git a/apps/desktop/scripts/test-signed-updates.mjs b/apps/desktop/scripts/test-signed-updates.mjs new file mode 100644 index 0000000000..484db21300 --- /dev/null +++ b/apps/desktop/scripts/test-signed-updates.mjs @@ -0,0 +1,72 @@ +/** Run real Electron downloads from local executable inputs without installing or publishing them. */ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import electron from 'electron' + +assert.equal(process.platform, 'win32', 'Signed NSIS download qualification requires Windows') +const [certificate, signed, unsigned, old] = process.argv.slice(2) +assert.ok(certificate && signed && unsigned, + 'Usage: node apps/desktop/scripts/test-signed-updates.mjs [old.exe]') +const paths = await Promise.all([certificate, signed, unsigned, ...(old ? [old, `${signed}.blockmap`, `${old}.blockmap`] : [])] + .map(file => realpath(file))) +const evidence = fileURLToPath(new URL('../.desktop-build/qualification/', import.meta.url)) +await mkdir(evidence, { recursive: true }) +const root = await mkdtemp(join(evidence, 'signed-downloads-')) +await mkdir(join(root, 'runtime')) +const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => + !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^ELECTRON_RUN_AS_NODE$/iu.test(name))) +let timedOut = false +const timeoutMs = Number(process.env.DSH_SIGNED_UPDATE_TEST_TIMEOUT_MS ?? 180_000) +assert.ok(Number.isSafeInteger(timeoutMs) && timeoutMs >= 1000 && timeoutMs <= 180_000, + 'DSH_SIGNED_UPDATE_TEST_TIMEOUT_MS must be an integer from 1000 to 180000') +let termination = Promise.resolve() +let terminationError +let launchError +const startedAt = new Date().toISOString() +const child = spawn(electron, [fileURLToPath(new URL('../tests/fixtures/signed-updates.mjs', import.meta.url))], { + env: { ...environment, DSH_SIGNED_UPDATE_TEST_ROOT: root, + DSH_SIGNED_UPDATE_CERTIFICATE: paths[0], DSH_SIGNED_UPDATE_SIGNED: paths[1], DSH_SIGNED_UPDATE_UNSIGNED: paths[2], + DSH_SIGNED_UPDATE_OLD: paths[3] ?? '' }, + stdio: 'inherit', windowsHide: true, +}) +child.once('error', error => { launchError = error.message }) +const deadline = setTimeout(() => { + timedOut = true + // Authenticode starts PowerShell; stop the complete owned process tree before deleting downloads. + termination = new Promise((resolve) => { + const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + env: environment, stdio: 'ignore', windowsHide: true, + }) + killer.once('error', error => { terminationError = error.message }) + killer.once('close', (code) => { + if (code !== 0) terminationError ??= `taskkill exited ${code}` + resolve() + }) + }) +}, timeoutMs) +try { + const result = await new Promise((resolve) => { + child.once('close', (code, signal) => resolve({ code, signal })) + }) + clearTimeout(deadline) + await termination + await writeFile(join(root, 'process.json'), `${JSON.stringify({ ...result, timedOut, pid: child.pid, + startedAt, completedAt: new Date().toISOString(), launchError, terminationError }, null, 2)}\n`) + assert.equal(launchError, undefined) + assert.equal(terminationError, undefined) + assert.equal(timedOut, false, `Signed download qualification exceeded ${timeoutMs} ms`) + assert.equal(result.signal, null, `Electron exited on ${result.signal}`) + assert.equal(result.code, 0, `Signed download qualification failed; see ${root}`) + const report = JSON.parse(await readFile(join(root, 'result.json'), 'utf8')) + assert.equal(report.passed, true) +} +finally { + clearTimeout(deadline) + await termination + // Only this invocation's runtime directory is removed after process exit; reports remain. + await rm(join(root, 'runtime'), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + console.log(`SIGNED_UPDATE_DOWNLOAD_RESULT ${root}`) +} diff --git a/apps/desktop/scripts/test-windows-installer.mjs b/apps/desktop/scripts/test-windows-installer.mjs index fe978e094f..95e4db8b70 100644 --- a/apps/desktop/scripts/test-windows-installer.mjs +++ b/apps/desktop/scripts/test-windows-installer.mjs @@ -1,13 +1,14 @@ /** Builds an isolated native payload through the production NSIS configuration and exercises its UI. */ import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { createWindowsTokenSigner, installWindowsNsisBootstrapSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { createPackagingRun } from './packaging-run.mjs' if (process.platform !== 'win32' || process.arch !== 'x64') { throw new Error('Installer UI checks require an interactive Windows x64 desktop') @@ -27,29 +28,39 @@ const payload = join(output, 'payload') await mkdir(join(payload, 'resources'), { recursive: true }) const previousEnvironment = { ...process.env } const signingEnvironment = process.argv.includes('--signed') ? loadDesktopPackageEnvironment('win32') : {} +const signingRun = process.argv.includes('--signed') + ? createPackagingRun(join(output, 'packaging-runs'), { target: 'installer-test' }) : undefined const sign = process.argv.includes('--signed') ? createWindowsTokenSigner({ certificateFile: signingEnvironment.DSH_DESKTOP_WINDOWS_CER_FILE, signTool: signingEnvironment.DSH_DESKTOP_WINDOWS_SIGNTOOL, tokenPin: signingEnvironment.DSH_DESKTOP_WINDOWS_TOKEN_PIN, keyContainer: signingEnvironment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, + runDirectory: signingRun.directory, }) : undefined +let succeeded = false try { Object.assign(process.env, { DSH_DESKTOP_APP_ID: `com.deepseek.harness.installertest.n${id}`, DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '1', CSC_IDENTITY_AUTO_DISCOVERY: 'false', ELECTRON_BUILDER_7Z_FILTER: 'BCJ', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: signingEnvironment.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN + ?? 'https://harness-test.deepseek.com', + ...signingRun ? { DSH_DESKTOP_PACKAGING_RUN_DIR: signingRun.directory } : {}, }) const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const childOptions = { env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, maxBuffer: 8 * 1024 * 1024 } await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions) const progressTest = join(output, 'ui', 'progress-test.exe') + const presentationTest = join(output, 'ui', 'presentation-test.exe') if (sign) { await sign({ path: progressTest, hash: 'sha256', isNest: false }) + await sign({ path: presentationTest, hash: 'sha256', isNest: false }) await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false }) installWindowsNsisBootstrapSigner({ sign }) } await execute(progressTest, [], childOptions) + await execute(presentationTest, [], childOptions) const payloadSource = join(output, 'payload.nsi') await writeFile(payloadSource, `Unicode true RequestExecutionLevel user @@ -67,8 +78,7 @@ SectionEnd const compiler = await getMakeNsisPath() await execute(compiler.path, ['/V2', payloadSource], { ...childOptions, env: { ...childOptions.env, ...compiler.env } }) if (sign) await sign({ path: join(payload, `${productName}.exe`), hash: 'sha256', isNest: false }) - const include = join(output, 'include.nsh') - await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`) + const sourceStrings = await readFile(join(appRoot, 'installer', 'strings.nsh'), 'utf8') const config = createElectronBuilderConfig() if (sign) { config.win.forceCodeSigning = true @@ -77,6 +87,12 @@ SectionEnd for (const language of ['en_US', 'zh_CN']) { const languageOutput = join(output, language) await mkdir(languageOutput) + const strings = join(languageOutput, 'strings.nsh') + const languageId = language === 'en_US' ? 'ENGLISH' : 'SIMPCHINESE' + await writeFile(strings, sourceStrings.split('\n').filter((line) => + !line.startsWith('LangString ') || line.includes(`\${LANG_${languageId}}`)).join('\n')) + const include = join(languageOutput, 'include.nsh') + await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!define INSTALLER_STRINGS_FILE "${strings}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`) await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never', config: { ...config, productName, artifactName: 'installer-test.exe', directories: { output: languageOutput }, nsis: { ...config.nsis, guid, include, installerLanguages: [language] }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined }, @@ -86,7 +102,9 @@ SectionEnd '-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput], childOptions) process.stdout.write(`${language}\n${result.stdout}`) } + succeeded = true } finally { + signingRun?.finish(succeeded) for (const name of Object.keys(process.env)) if (!(name in previousEnvironment)) delete process.env[name] Object.assign(process.env, previousEnvironment) process.stdout.write(`Installer test artifacts: ${output}\n`) diff --git a/apps/desktop/scripts/test-windows-update-signature.mjs b/apps/desktop/scripts/test-windows-update-signature.mjs new file mode 100644 index 0000000000..b8a276c716 --- /dev/null +++ b/apps/desktop/scripts/test-windows-update-signature.mjs @@ -0,0 +1,80 @@ +/** Real NSIS publisher verification against supplied files; never signs or executes an installer. */ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { resolveWindowsUpdatePublisher } from './windows-sign.mjs' + +assert.equal(process.platform, 'win32', 'Authenticode qualification requires Windows') +const [certificateFile, signedFile, unsignedFile] = process.argv.slice(2) +assert.ok(certificateFile && signedFile && unsignedFile, + 'Usage: node apps/desktop/scripts/test-windows-update-signature.mjs ') +const require = createRequire(import.meta.url) +const { NsisUpdater } = require('electron-updater') +const { WindowsSignToolManager } = require('app-builder-lib/out/codeSign/windowsSignToolManager.js') +const { getAppUpdatePublishConfiguration } = require('app-builder-lib/out/publish/PublishManager.js') +const { Platform } = require('app-builder-lib') +const publisherName = resolveWindowsUpdatePublisher(certificateFile) +const signed = await realpath(signedFile) +const unsigned = await realpath(unsignedFile) +assert.notEqual(signed, unsigned, 'Signed and unsigned controls must be distinct files') +const qualification = fileURLToPath(new URL('../.desktop-build/qualification/', import.meta.url)) +await mkdir(qualification, { recursive: true }) +const root = await mkdtemp(join(qualification, 'windows-update-signature-')) +const report = { publisherName, installerExecuted: false, cases: [], passed: false } + +async function digest(file) { + const hash = createHash('sha512') + for await (const chunk of createReadStream(file)) hash.update(chunk) + return hash.digest('base64') +} + +try { + const before = { signed: await digest(signed), unsigned: await digest(unsigned) } + const platformSpecificBuildOptions = { signtoolOptions: { publisherName } } + const manager = new WindowsSignToolManager({ platformSpecificBuildOptions }) + // Keep builder/updater metadata code real; no packaging, key lookup, HTTP, or installer entry runs. + const metadata = await getAppUpdatePublishConfiguration({ + platform: Platform.WINDOWS, + platformSpecificBuildOptions, + appInfo: { updaterCacheDirName: 'private-signature-qualification' }, + signingManager: { value: Promise.resolve(manager) }, + isForceCodeSigningVerification: true, + info: {}, + config: { publish: [{ provider: 'generic', url: 'https://unused.invalid/', channel: 'nightly' }] }, + expandMacro: value => value, + }, null, 1, true) + assert.deepEqual(metadata.publisherName, [publisherName]) + for (const [name, file, config, accepted] of [ + ['missing-publisher-negative-control', signed, { ...metadata, publisherName: undefined }, true], + ['matching-publisher', signed, metadata, true], + ['wrong-publisher', signed, { ...metadata, publisherName: ['CN=Not the release publisher'] }, false], + ['unsigned-file', unsigned, metadata, false], + ]) { + const configFile = join(root, `${name}.yml`) + await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx' }) + const updater = new NsisUpdater(null, { version: '0.1.0', isPackaged: true }) + updater.autoInstallOnAppQuit = false + updater.updateConfigPath = configFile + const logs = [] + updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))])) + const result = await updater.verifySignature(file) + assert.equal(result === null, accepted, `${name}: unexpected signature result`) + assert.ok(!logs.some(line => line.includes('Ignoring signature validation')), 'PowerShell verification must not be skipped') + const verificationInvoked = logs.some(line => line.startsWith('Verifying signature ')) + assert.equal(verificationInvoked, name !== 'missing-publisher-negative-control') + if (name === 'wrong-publisher') assert.match(result, /"Status": 0/u, 'Wrong-publisher control must have a valid signature') + report.cases.push({ name, file, accepted, verificationInvoked, sha512: file === signed ? before.signed : before.unsigned }) + updater.removeAllListeners() + } + assert.equal(await digest(signed), before.signed, 'Signed input changed during verification') + assert.equal(await digest(unsigned), before.unsigned, 'Unsigned input changed during verification') + report.passed = true +} +finally { + await writeFile(join(root, 'result.json'), `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx' }) + console.log(`WINDOWS_UPDATE_SIGNATURE_RESULT ${join(root, 'result.json')}`) +} diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts new file mode 100644 index 0000000000..53fbd5e43a --- /dev/null +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -0,0 +1,75 @@ +/** Run the compiled Electron main entry in a private app with local update delivery and real Host IPC. */ +import { execFileSync, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { cp, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +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' + +const repo = resolve(import.meta.dirname, '../../..') +const interactive = process.argv.includes('--interactive') +if (process.argv.slice(2).some(argument => argument !== '--interactive')) throw new Error('Expected only --interactive') +const evidence = join(repo, 'apps/desktop/.desktop-build/qualification') +await mkdir(evidence, { recursive: true }) +const root = await mkdtemp(join(evidence, 'electron-workspace-updates-')) +const application = join(root, 'app') +const project = join(application, '.desktop-build/development/project') +const profile = join(root, 'home/profiles/desktop') +const manifest = JSON.parse(await readFile(join(repo, 'apps/desktop/package.json'), 'utf8')) as { version: string } +const pnpm = JSON.parse(await readFile(join(repo, 'apps/desktop/node_modules/pnpm/package.json'), 'utf8')) as { version: string } +try { + const require = createRequire(import.meta.url) + const electron = require('electron') as string + const release = { schemaVersion: 1 as const, version: manifest.version, + 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 } + createDevelopmentProjectMetadata(project, release) + createPluginProfile(profile) + await writeFile(join(project, 'desktop-runtime.json'), JSON.stringify({ + schemaVersion: 1, release, platform: process.platform, arch: process.arch, files: [], + sharedPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host'] + .map(name => ({ name, version: manifest.version, path: `node_modules/${name}` })), + })) + await cp(join(repo, 'apps/desktop/lib/types'), join(application, 'lib'), { recursive: true }) + await cp(join(repo, 'apps/desktop/renderer'), join(application, 'renderer'), { recursive: true }) + for (const name of ['preload-app', 'preload', 'preload-mandatory', 'preload-update-dialog']) { + await cp(join(repo, `apps/desktop/lib/${name}.cjs`), join(application, `lib/${name}.cjs`)) + } + await writeFile(join(application, 'package.json'), JSON.stringify({ name: 'desktop-update-qualification', version: manifest.version, type: 'module' })) + for (const owner of [application, project]) { + await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') + } + await writeFile(join(profile, 'cordis.patch.yml'), JSON.stringify([ + { id: 'webserver', config: { host: '127.0.0.1', port: 0 } }, + { id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true }, + { id: 'session-telemetry-otel', disabled: true }, + { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { insert: [{ id: 'update-control', name: new URL('../tests/fixtures/workspace-update-host.mjs', import.meta.url).href }] }, + ])) + const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => + /^(?:path|systemroot|windir|comspec|pathext)$/iu.test(name))) + const child = spawn(electron, [fileURLToPath(new URL('../tests/fixtures/workspace-updates.mjs', import.meta.url)), '--lang=zh-CN', + ...(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' }, + // Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks. + stdio: 'inherit', windowsHide: false, + }) + let timedOut = false + const timer = interactive ? undefined : setTimeout(() => { timedOut = true; child.kill() }, 120_000) + try { + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => { done({ code, signal }) }) + }) + if (timedOut || result.signal !== null || result.code !== 0) { + throw new Error(`Workspace qualification failed: timeout=${String(timedOut)}, signal=${String(result.signal)}, exit=${String(result.code)}; evidence=${root}`) + } + console.log(`Electron workspace evidence: ${root}`) + } finally { clearTimeout(timer) } +} finally { removeOwnedDirectory(application) } diff --git a/apps/desktop/scripts/upload-target.ts b/apps/desktop/scripts/upload-target.ts index 3e3e69635b..f587d3b977 100644 --- a/apps/desktop/scripts/upload-target.ts +++ b/apps/desktop/scripts/upload-target.ts @@ -1,16 +1,13 @@ /** 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 { createDesktopCos } from './desktop-cos.ts' +import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' -import { - createDesktopUploadPlan, - type DesktopUploadArtifact, -} from './desktop-upload-plan.ts' +import { createDesktopUploadPlan } from './desktop-upload-plan.ts' +import { uploadDesktopRelease } from './desktop-upload-run.ts' const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) @@ -29,58 +26,70 @@ function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): return value } -async function putArtifact( - client: S3Client, - bucket: string, - artifact: DesktopUploadArtifact, -): Promise { - 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, - })) +/** + * Use the credential launcher's selected deployment only when it matches the packaged release destination. + * @param fileEnvironment Target dotenv settings that own the release destination. + * @param injectedEnvironment Child environment containing the DPAPI-decrypted credential pair. + * @param selected Deployment authorized by the launcher operator. + * @param bucket Bucket authorized by the launcher operator. + * @param target Packaged target being uploaded. + * @returns Release settings with only the selected credential pair replaced. + */ +export function resolveCredentialUploadEnvironment( + fileEnvironment: NodeJS.ProcessEnv, injectedEnvironment: NodeJS.ProcessEnv, + selected: 'test' | 'production', bucket: string, + target: DesktopPackageTargetName, +): NodeJS.ProcessEnv { + const platform = target === 'win-x64' ? 'win32' : 'darwin' + const arch = target === 'mac-arm64' ? 'arm64' : 'x64' + const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch) + if (destination.environment !== selected || destination.bucket !== bucket) { + throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination') } - finally { - body.destroy() + if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected + || injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) { + throw new Error('desktop upload: credential launcher environment differs from its explicit arguments') + } + return { + ...fileEnvironment, + [destination.secretIdEnvName]: requiredEnvironmentValue(injectedEnvironment, destination.secretIdEnvName), + [destination.secretKeyEnvName]: requiredEnvironmentValue(injectedEnvironment, destination.secretKeyEnvName), } - process.stdout.write(`desktop upload: uploaded ${artifact.key}\n`) } async function main(): Promise { - const { positionals } = parseArgs({ args: process.argv.slice(2), allowPositionals: true }) + const { positionals, values } = parseArgs({ args: process.argv.slice(2), allowPositionals: true, options: { + 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, + } }) const target = positionals[0] if (target === undefined || positionals.length !== 1) { throw new Error('desktop upload: expected exactly one target') } const name = targetName(target) - const environment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin') + const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin') + const launcher = values['credential-launcher'] === true + if (launcher ? values.environment === undefined || values.bucket === undefined + : values.environment !== undefined || values.bucket !== undefined) { + throw new Error('desktop upload: credential launcher requires an explicit environment and bucket') + } + if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') { + throw new Error('desktop upload: credential launcher environment must be test or production') + } + const environment = launcher + ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name) + : fileEnvironment const plan = await createDesktopUploadPlan(name, { environment }) - const client = new S3Client({ - region: 'Auto', - endpoint: 'https://cos.ap-beijing.myqcloud.com', - credentials: { - accessKeyId: requiredEnvironmentValue(environment, plan.secretIdEnvName), - secretAccessKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName), - }, + const cos = createDesktopCos({ + secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName), + secretKey: requiredEnvironmentValue(environment, 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() - } + await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records')) } 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`) + main().catch(() => { + process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n') process.exitCode = 1 }) } diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 new file mode 100644 index 0000000000..178880ba47 --- /dev/null +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -0,0 +1,133 @@ +#requires -Version 5.1 +<# +.SYNOPSIS +Checks Windows DPAPI credentials, or explicitly uploads one completed Desktop target. +.DESCRIPTION +Imports encrypted SecretId and SecretKey fields from an external CLIXML file. +Only the Node child receives plaintext COS credentials. The default check starts +a keyless-code probe without contacting COS; it does not verify cloud permissions. +.PARAMETER CredentialFile +Path to the CLIXML file created by the current Windows user on this machine. +.PARAMETER Environment +Deployment that owns the credential pair; never inferred from the filename. +.PARAMETER Target +Completed Desktop target to upload when Upload is explicitly selected. +.PARAMETER Bucket +COS bucket for an explicit upload. Not needed for the local credential check. +.PARAMETER Upload +Authorize the existing target upload entry. Omit to check credentials locally. +#> +[CmdletBinding(DefaultParameterSetName = 'Check')] +param( + [Parameter(Mandatory = $true)] + [string]$CredentialFile, + [Parameter(Mandatory = $true)] + [ValidateSet('test', 'production')] + [string]$Environment, + [Parameter(ParameterSetName = 'Publish', Mandatory = $true)] + [ValidateSet('win-x64', 'mac-x64', 'mac-arm64')] + [string]$Target, + [Parameter(ParameterSetName = 'Publish', Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [ValidatePattern('^[a-z0-9-]+$')] + [string]$Bucket, + [Parameter(ParameterSetName = 'Publish', Mandatory = $true)] + [switch]$Upload +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$child = $null +$started = $false +$secretId = $null +$secretKey = $null +$credentials = $null +$startInfo = $null +$stage = 'decrypt-file' + +try { + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw 'Windows DPAPI is required.' + } + try { + $credentials = Import-Clixml -LiteralPath $CredentialFile + foreach ($field in @('SecretId', 'SecretKey')) { + if ($credentials.$field -isnot [Security.SecureString] -or $credentials.$field.Length -eq 0) { + throw 'Expected non-empty encrypted fields.' + } + } + } catch { + throw 'Cannot read encrypted COS credentials. Check the file and use its original Windows user and machine.' + } + + $stage = 'prepare-node' + $startInfo = New-Object Diagnostics.ProcessStartInfo + $startInfo.FileName = (Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source + $startInfo.WorkingDirectory = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../..')) + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($name in @($startInfo.EnvironmentVariables.Keys)) { + # Node preload hooks and unrelated release secrets must not reach this credential-bearing process. + if ($name -match 'KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_DESKTOP_WINDOWS_|^APPLE_|^CSC_') { + $startInfo.EnvironmentVariables.Remove($name) + } + } + $stage = 'prepare-credentials' + $prefix = if ($Environment -eq 'production') { 'DOWNLOAD_PROD_COS' } else { 'DOWNLOAD_TEST_COS' } + $secretId = [Net.NetworkCredential]::new('', $credentials.SecretId).Password + $secretKey = [Net.NetworkCredential]::new('', $credentials.SecretKey).Password + if ([string]::IsNullOrWhiteSpace($secretId) -or [string]::IsNullOrWhiteSpace($secretKey)) { + throw 'COS credential fields must not be blank.' + } + $startInfo.EnvironmentVariables["${prefix}_SECRET_ID"] = $secretId + $startInfo.EnvironmentVariables["${prefix}_SECRET_KEY"] = $secretKey + $startInfo.EnvironmentVariables['DSH_DESKTOP_AUTO_UPDATE_ENV'] = $Environment + if ($Upload) { + $startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket + $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket" + Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." + } else { + $probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)" + $startInfo.Arguments = "-e `"$probe`"" + } + + $stage = 'run-node' + $child = New-Object Diagnostics.Process + $child.StartInfo = $startInfo + $started = $child.Start() + $stderr = $child.StandardError.ReadToEndAsync() + while ($null -ne ($line = $child.StandardOutput.ReadLine())) { + Write-Output $line.Replace($secretId, '[REDACTED]').Replace($secretKey, '[REDACTED]') + } + $child.WaitForExit() + # SDK exception objects can include signed request details; do not forward raw stderr. + $null = $stderr.GetAwaiter().GetResult() + if ($child.ExitCode -ne 0) { + throw "Node upload/check failed (exit $($child.ExitCode)); private diagnostics suppressed." + } + if (-not $Upload) { + Write-Output 'desktop credentials: encrypted fields loaded; child environment verified; no network request made.' + } +} catch { + # Import and process exceptions are not safe credential diagnostics. + Write-Output "desktop credentials: failed; stage=$stage; line=$($_.InvocationInfo.ScriptLineNumber). Verify the encrypted file, Windows account, Node, and release inputs. No secrets printed." + exit 1 +} finally { + if ($started -and -not $child.HasExited) { + $child.Kill() + $child.WaitForExit() + } + if ($null -ne $child) { $child.Dispose() } + if ($null -ne $startInfo) { $startInfo.EnvironmentVariables.Clear() } + if ($null -ne $credentials) { + foreach ($field in @('SecretId', 'SecretKey')) { + if ($credentials.PSObject.Properties[$field] -and $credentials.$field -is [Security.SecureString]) { + $credentials.$field.Dispose() + } + } + } + $secretId = $null + $secretKey = $null +} diff --git a/apps/desktop/scripts/verify-installed-update-package.ts b/apps/desktop/scripts/verify-installed-update-package.ts new file mode 100644 index 0000000000..76d3f2e8cb --- /dev/null +++ b/apps/desktop/scripts/verify-installed-update-package.ts @@ -0,0 +1,125 @@ +/** Verify signed installer bytes and extract their payload without running any installer or signing operation. */ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { readInstalledUpdateRun } from './installed-update-qualification.ts' +import { planInstalledUpdateDistribution } from './installed-update-distribution.ts' +import { installedUpdateFileHash, verifyInstalledUpdateSignature } from './installed-update-signature.mjs' +import { resolveWindowsUpdatePublisher } from './windows-sign.mjs' +import { verifyInstalledUpdatePackageContent } from './installed-update-package-content.ts' +import { recordPackagingEvent } from './packaging-run.mjs' + +/** + * Reject nonrelative archive names and link entries before extraction into a new directory. + * @param listing The pinned 7-Zip's UTF-8 technical listing, with archive headers suppressed. + * @returns Number of relative archive entries; rejects empty or unsafe listings. + */ +export function validateInstalledUpdateArchivePaths(listing: string): number { + const paths = [...listing.matchAll(/^Path = (.+)\r?$/gmu)].map(match => match[1]!.replace(/\r$/u, '').replaceAll('\\', '/')) + if (paths.length === 0 || /^(?:Symbolic Link|Hard Link|Reparse Point) = .+/mu.test(listing) + || /^Attributes = .*\blrwx/mu.test(listing)) throw new Error('installed update: archive links or missing entries are not accepted') + const seen = new Set() + for (const path of paths) { + if (seen.has(path.toLowerCase())) throw new Error('installed update: archive contains duplicate Windows paths') + seen.add(path.toLowerCase()) + if (/[\x00-\x1f:*?"<>|]/u.test(path) || path.split('/').some(part => part === '' || part === '.' || part === '..' + || /[. ]$/u.test(part) || /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/iu.test(part))) { + throw new Error('installed update: archive contains an unsafe path') + } + } + return paths.length +} + +/** + * Produce retained signature, extraction, and content evidence for one final installer. + * @param manifest Original test run. + * @param version One exact run version. + * @param certificate Trusted public certificate; no .env or PIN is read. + * @param archiveTool Reviewed local 7-Zip executable, never an executable extracted from this installer. + * @returns Result path; failure retains partial records and never reports a passed package. + */ +export async function verifyInstalledUpdatePackage( + manifest: string, version: string, certificate: string, archiveTool: string, +): Promise { + const run = await readInstalledUpdateRun(manifest) + if (!run.versions.includes(version)) throw new Error('installed update: verification version is outside the run') + const parent = join(run.root, version, 'verification') + await mkdir(parent, { recursive: true }) + const record = await mkdtemp(join(parent, 'check-')) + const result: Record = { schemaVersion: 1, runId: run.id, version, passed: false, + installerExecuted: false, published: false, manualChecks: ['installer-registration', 'startup', 'upgrade', 'data-retention'] } + const stage = (name: string): void => { result.stage = name; recordPackagingEvent(record, { type: 'verification-stage', stage: name }) } + console.log(`INSTALLED_UPDATE_VERIFICATION_RECORD ${record}`) + try { + stage('local-file-plan') + const plan = await planInstalledUpdateDistribution(manifest, version) + const installer = plan.binaries[0]!.path + const publisher = resolveWindowsUpdatePublisher(certificate) + const toolHash = await installedUpdateFileHash(archiveTool) + const certificateSha512 = await installedUpdateFileHash(certificate) + const manifestSha512 = await installedUpdateFileHash(manifest) + await writeFile(join(record, 'inputs.json'), `${JSON.stringify({ manifestSha512, distribution: plan, + certificate, certificateSha512, archiveTool, toolHash })}\n`, { flag: 'wx', flush: true }) + await mkdir(join(record, 'installer-signature')) + stage('installer-signature') + result.installerSignature = await verifyInstalledUpdateSignature(installer, publisher, join(record, 'installer-signature')) + const environment = Object.fromEntries(Object.entries(process.env) + .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(name))) + const execute = (args: string[]) => promisify(execFile)(archiveTool, args, { + env: environment, cwd: record, windowsHide: true, encoding: 'utf8' as const, timeout: 120_000, maxBuffer: 16 * 1024 * 1024, + }) + stage('archive-paths') + const listing = await execute(['l', '-slt', '-ba', '-sccUTF-8', '--', installer]) + await writeFile(join(record, 'archive-list.txt'), listing.stdout, { flag: 'wx', flush: true }) + result.archiveEntries = validateInstalledUpdateArchivePaths(listing.stdout) + const payload = join(record, 'payload') + await mkdir(payload) + stage('extraction') + const extraction = await execute(['x', '-y', '-bd', '-bso0', '-bsp0', `-o${payload}`, '--', installer]) + await writeFile(join(record, 'extraction.log'), `${extraction.stdout}\n${extraction.stderr}`, { flag: 'wx', flush: true }) + stage('payload-content') + const contents = await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher) + result.contents = contents + await mkdir(join(record, 'application-signature')) + stage('application-signature') + result.applicationSignature = await verifyInstalledUpdateSignature(join(payload, `${run.productName}.exe`), + publisher, join(record, 'application-signature')) + const runtimeSignatures: object[] = [] + result.runtimeSignatures = runtimeSignatures + for (const [index, path] of contents.resignedExecutables.entries()) { + stage(`runtime-signature-${index}`) + const directory = join(record, `runtime-signature-${index}`) + await mkdir(directory) + runtimeSignatures.push({ path, ...await verifyInstalledUpdateSignature(path, publisher, directory) }) + } + stage('unchanged-inputs') + if (await installedUpdateFileHash(installer) !== plan.binaries[0]!.sha512 + || await installedUpdateFileHash(archiveTool) !== toolHash || await installedUpdateFileHash(certificate) !== certificateSha512 + || await installedUpdateFileHash(manifest) !== manifestSha512 + || JSON.stringify(await planInstalledUpdateDistribution(manifest, version)) !== JSON.stringify(plan)) { + throw new Error('installed update: verification input changed') + } + stage('complete') + result.passed = true + return join(record, 'result.json') + } catch (error) { + result.failure = error instanceof Error ? error.message : 'verification failed' + throw error + } finally { + await writeFile(join(record, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx', flush: true }) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const [manifest, version, certificate, archiveTool, ...extra] = process.argv.slice(2) + if (!manifest || !version || !certificate || !archiveTool || extra.length !== 0) { + console.error('usage: verify-installed-update-package.ts ') + process.exitCode = 1 + } else { + verifyInstalledUpdatePackage(manifest, version, certificate, archiveTool).then(path => console.log(path)).catch(() => { + console.error('installed update: package verification failed; inspect the retained record. No installer was executed.') + process.exitCode = 1 + }) + } +} diff --git a/apps/desktop/scripts/verify-macos-signature.d.mts b/apps/desktop/scripts/verify-macos-signature.d.mts index 9bf7926ce8..a33c3f4213 100644 --- a/apps/desktop/scripts/verify-macos-signature.d.mts +++ b/apps/desktop/scripts/verify-macos-signature.d.mts @@ -15,7 +15,7 @@ export function assertMacOSSignatureDetails(details: string, expected: MacOSSign export function assertMacOSRuntimeSignatureDetails(details: string, expected: MacOSSigningEnvironment): void /** - * Sign one Mach-O file embedded in the runtime tree. + * Sign one Mach-O file using the packaging-owned CSC_KEYCHAIN; missing setup rejects before signing. * @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. diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index 529f8fb8a2..6817f0962f 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -107,7 +107,7 @@ function runCodeSign(args) { } /** - * Sign one Mach-O file embedded in the runtime tree. + * Sign one Mach-O file using the packaging-owned CSC_KEYCHAIN; missing setup rejects before signing. * @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. @@ -115,9 +115,12 @@ function runCodeSign(args) { * @returns {Promise} Resolves after codesign exits successfully. */ export async function signMacOSRuntimeCode(path, identifier, expected, entitlements) { + const keychain = process.env.CSC_KEYCHAIN + if (!keychain) throw new Error('desktop macOS signing: run through the package command to prepare the signing keychain') await runAppleCommandAsync('/usr/bin/codesign', [ '--force', '--sign', expected.signingIdentity, + '--keychain', keychain, '--identifier', identifier, '--timestamp', '--options', 'runtime', diff --git a/apps/desktop/scripts/windows-asar-unpack.d.mts b/apps/desktop/scripts/windows-asar-unpack.d.mts new file mode 100644 index 0000000000..984c055737 --- /dev/null +++ b/apps/desktop/scripts/windows-asar-unpack.d.mts @@ -0,0 +1,18 @@ +import type { BeforePackContext } from 'app-builder-lib' + +/** + * Install source-relative PE patterns in the active builder configuration. + * @param context Active builder configuration and cleanup owner. + * @param sourceRoot Verified prepared dsh directory; external sources receive a build-owned staging copy. + * @returns PE paths relative to the original prepared directory. + */ +export function prepareWindowsAsarUnpack(context: BeforePackContext, sourceRoot: string): Promise + +/** + * Reject inline, absent, linked, or changed PE files in the assembled application. + * @param sourceRoot Original signed and sealed dsh directory. + * @param resourcesDir Assembled application resources directory. + * @param files PE paths returned by prepareWindowsAsarUnpack. + * @returns Resolves after every PE has an unpacked ASAR entry and identical bytes. + */ +export function verifyWindowsAsarUnpack(sourceRoot: string, resourcesDir: string, files: string[]): Promise diff --git a/apps/desktop/scripts/windows-asar-unpack.mjs b/apps/desktop/scripts/windows-asar-unpack.mjs new file mode 100644 index 0000000000..d406c27235 --- /dev/null +++ b/apps/desktop/scripts/windows-asar-unpack.mjs @@ -0,0 +1,70 @@ +/** Keep prepared Windows PE files outside ASAR without changing their sealed bytes. */ +import { cp, lstat, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { readAsar } from 'app-builder-lib/out/asar/asar.js' +import { windowsRuntimeCode } from './windows-runtime-signature.mjs' + +function inside(root, file) { + const suffix = relative(root, file) + return suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix) +} + +function unpackPattern(path) { + // Builder removes backslash escapes; brace expansion also ignores character classes. + // A brace matches one character, so similarly named neighbors may also be unpacked. + return path.split(sep).join('/').replace(/[\[\]{}()*?+@#]/gu, + character => character === '{' || character === '}' ? '?' : `[${character}]`).replace(/^!/u, '@(!)') +} + +/** + * Install source-relative PE patterns in the configuration consumed by electron-builder. + * @param {import('app-builder-lib').BeforePackContext} context Active builder configuration and cleanup owner. + * @param {string} sourceRoot Verified prepared dsh directory; its files remain unchanged. + * @returns {Promise} PE paths relative to the original prepared directory. + */ +export async function prepareWindowsAsarUnpack(context, sourceRoot) { + const files = (await windowsRuntimeCode(sourceRoot)).map(file => relative(sourceRoot, file)) + const { config, info } = context.packager + const { appDir } = info + let copyRoot = sourceRoot + if (!inside(appDir, sourceRoot)) { + // Builder's source matcher slices the appDir prefix even for external FileSets. + const parent = join(appDir, '.desktop-build') + await mkdir(parent, { recursive: true }) + const stage = await mkdtemp(join(parent, 'asar-source-')) + copyRoot = join(stage, 'dsh') + info.disposeOnBuildFinish(() => rm(stage, { recursive: true, force: true })) + await cp(sourceRoot, copyRoot, { recursive: true, force: false, errorOnExist: true }) + config.files = config.files.map(file => { + if (typeof file === 'string' || file.from === undefined) return file + const from = resolve(appDir, file.from) + return inside(sourceRoot, from) ? { ...file, from: join(copyRoot, relative(sourceRoot, from)) } : file + }) + } + const existing = config.asarUnpack ?? [] + config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing), + ...files.map(file => unpackPattern(relative(appDir, join(copyRoot, file))))] + return files +} + +/** + * Reject inline, absent, linked, or changed PE files in the assembled application. + * @param {string} sourceRoot Original signed and sealed dsh directory. + * @param {string} resourcesDir Assembled application resources directory. + * @param {string[]} files PE paths returned by prepareWindowsAsarUnpack. + * @returns {Promise} Resolves after every PE has an unpacked ASAR entry and identical bytes. + */ +export async function verifyWindowsAsarUnpack(sourceRoot, resourcesDir, files) { + const archive = await readAsar(join(resourcesDir, 'app.asar')) + for (const file of files) { + const entry = archive.getFile(join('dsh', file), false) + if (entry.unpacked !== true || entry.link !== undefined) { + throw new Error(`Windows ASAR: PE must be unpacked: ${file}`) + } + const copied = join(resourcesDir, 'app.asar.unpacked', 'dsh', file) + const stat = await lstat(copied) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Windows ASAR: expected a real PE file: ${file}`) + const [prepared, packaged] = await Promise.all([readFile(join(sourceRoot, file)), readFile(copied)]) + if (!prepared.equals(packaged)) throw new Error(`Windows ASAR: PE bytes changed: ${file}`) + } +} diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts new file mode 100644 index 0000000000..9c6e207a17 --- /dev/null +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -0,0 +1,57 @@ +/** Public-key verification result; no hardware authentication is performed. */ +export interface WindowsRuntimeSignature { + status: string + timestamped: boolean + thumbprint: string | null +} + +/** + * Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key. + * @param path File to inspect. + * @returns Authenticode verification result. + */ +export function inspectWindowsRuntimeSignature(path: string): Promise + +/** + * Preserve a copied primary-runtime executable only after signature and exact-byte verification. + * @param path Signing-hook target. + * @param options Prepared and copied runtime roots with retained audit directory. + * @returns True for a verified runtime copy; false for targets outside that directory. + */ +export function preserveWindowsRuntimeSignature(path: string, options: { + sourceRoot: string + destinationRoot: string + runDirectory: string + inspect?: typeof inspectWindowsRuntimeSignature +}): Promise + +/** Supervised signing dependencies shared by all Windows release trees. */ +export interface WindowsCodeSigningOptions { + thumbprint: string + sign: ReturnType + inspect?: typeof inspectWindowsRuntimeSignature + record: (event: object) => void +} + +/** + * Enumerate PE files by content without following links. + * @param root Materialized directory to scan. + * @returns Sorted PE paths; rejects malformed Windows executables and links. + */ +export function windowsRuntimeCode(root: string): Promise + +/** + * Preserve valid signatures and sign unsigned PE files with a supervised signer. + * @param root Materialized directory to sign. + * @param options Signer, certificate identity and audit sink. + * @returns Resolves after verified sequential signing; rejects without retries. + */ +export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise + +/** + * Reject unsigned or invalid PE files in a completed directory. + * @param root Materialized artifact directory. + * @param inspect Public-key verifier. + * @returns Resolves after all PE signatures are valid. + */ +export function verifyWindowsCode(root: string, inspect?: typeof inspectWindowsRuntimeSignature): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs new file mode 100644 index 0000000000..ee70330fd7 --- /dev/null +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -0,0 +1,143 @@ +/** Inspect runtime signatures and preserve byte-identical copies made by electron-builder. */ +import { execFile } from 'node:child_process' +import { lstat, open, readdir, readFile, realpath } from 'node:fs/promises' +import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { promisify } from 'node:util' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' +import { recordPackagingEvent } from './packaging-run.mjs' +import { inspectSignaturesBatched } from './windows-signature-batch.mjs' + +/** + * Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key. + * @param {string} path File to inspect. + * @returns {Promise<{status: string, timestamped: boolean, thumbprint: string | null}>} Authenticode verification result. + */ +export async function inspectWindowsRuntimeSignature(path) { + // Node can inherit PowerShell 7's module search path while launching Windows PowerShell 5. + const { stdout, stderr } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + '$ErrorActionPreference="Stop"; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new(); Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1" -ErrorAction Stop; Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1" -ErrorAction Stop; $s=Get-AuthenticodeSignature -LiteralPath $env:DSH_RUNTIME_VERIFY_FILE; [pscustomobject]@{status=[string]$s.Status;timestamped=($null -ne $s.TimeStamperCertificate);thumbprint=$s.SignerCertificate.Thumbprint}|ConvertTo-Json -Compress'], { + env: { ...scrubWindowsSigningEnvironment(process.env), DSH_RUNTIME_VERIFY_FILE: path }, + encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024, + }) + const value = JSON.parse(stdout) + if (stderr || typeof value !== 'object' || value === null || typeof value.status !== 'string' + || typeof value.timestamped !== 'boolean' + || !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) { + throw new Error(`Windows code: invalid signature inspection: ${path}`) + } + return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint } +} + +/** + * Preserve a copied runtime executable only after signature and exact-byte verification. + * @param {string} path Signing-hook target. + * @param {{sourceRoot: string, destinationRoot: string, runDirectory: string, inspect?: typeof inspectWindowsRuntimeSignature}} options Prepared and copied runtime roots with retained audit directory. + * @returns {Promise} True for a verified runtime copy; false for targets outside that directory. + */ +export async function preserveWindowsRuntimeSignature(path, options) { + const suffix = relative(options.destinationRoot, path) + if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) return false + const source = join(options.sourceRoot, suffix) + for (const file of [source, path]) { + if (await realpath(file) !== resolve(file)) throw new Error(`Windows code: linked copy is not signable: ${file}`) + } + const [prepared, copied] = await Promise.all([readFile(source), readFile(path)]) + if (!prepared.equals(copied)) throw new Error(`Windows code: copied executable changed: ${path}`) + const signature = await (options.inspect ?? inspectWindowsRuntimeSignature)(path) + if (signature.status !== 'Valid') throw new Error(`Windows code: copied signature is ${signature.status}: ${path}`) + recordPackagingEvent(options.runDirectory, { type: 'primary-runtime-copy-verified', path, ...signature }) + return true +} + +/** + * Enumerate Windows code without following links or treating foreign .node files as PE binaries. + * @param {string} root - Owned, materialized runtime directory. + * @returns {Promise} Sorted real PE files; rejects links and malformed Windows executable files. + */ +export async function windowsRuntimeCode(root) { + const rootStat = await lstat(root) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory') + const files = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name) + if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`) + if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue } + if (!entry.isFile()) continue + const file = await open(path, 'r') + let portableExecutable = false + let windowsCandidate = false + try { + const header = Buffer.alloc(64) + const { bytesRead } = await file.read(header, 0, header.length, 0) + windowsCandidate = bytesRead >= 2 && header.readUInt16LE(0) === 0x5a4d + if (bytesRead === 64 && windowsCandidate) { + const signature = Buffer.alloc(4) + const offset = header.readUInt32LE(0x3c) + const read = await file.read(signature, 0, 4, offset) + portableExecutable = offset >= 64 && read.bytesRead === 4 && signature.readUInt32LE(0) === 0x4550 + } + } finally { await file.close() } + if (portableExecutable) files.push(path) + else if (windowsCandidate || ['.exe', '.dll', '.pyd'].includes(extname(path).toLowerCase())) throw new Error(`Windows code: invalid PE file: ${path}`) + } + return files.sort() +} + +/** Drain each public-key verification batch before signing or reporting a failure. */ +async function inspectWindowsCode(files, inspect) { + if (inspect === inspectWindowsRuntimeSignature) return inspectSignaturesBatched(files) + const signatures = [] + for (let offset = 0; offset < files.length; offset += 4) { + const results = await Promise.allSettled(files.slice(offset, offset + 4).map(path => inspect(path))) + for (const result of results) { + if (result.status === 'rejected') throw result.reason + signatures.push(result.value) + } + } + return signatures +} + +/** + * Preserve valid signatures and sign unsigned PE files with a supervised signer. + * @param {string} root - Owned final runtime directory. + * @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink. + * @returns {Promise} Resolves only after sequential signatures and verification; no retries. + */ +export async function signWindowsCode(root, options) { + const inspect = options.inspect ?? inspectWindowsRuntimeSignature + const files = await windowsRuntimeCode(root) + if (files.length === 0) throw new Error('Windows code: no Windows code found') + const unsigned = [] + const signatures = await inspectWindowsCode(files, inspect) + for (const [index, path] of files.entries()) { + const signature = signatures[index] + options.record({ type: 'windows-code-signature', path, ...signature }) + if (signature.status === 'NotSigned') unsigned.push(path) + else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) + } + options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length }) + for (const path of unsigned) { + await options.sign({ path, hash: 'sha256', isNest: false }) + const signature = await inspect(path) + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { + throw new Error(`Windows code: signing verification failed: ${path}`) + } + options.record({ type: 'windows-code-signature-verified', path, ...signature }) + } +} + +/** + * Reject any unsigned or invalid PE remaining in a completed directory. + * @param {string} root Materialized artifact directory. + * @param {typeof inspectWindowsRuntimeSignature} inspect Public-key verifier. + * @returns {Promise} Resolves after every discovered PE has a valid signature. + */ +export async function verifyWindowsCode(root, inspect = inspectWindowsRuntimeSignature) { + const files = await windowsRuntimeCode(root) + if (files.length === 0) throw new Error('Windows code: no Windows code found') + const signatures = await inspectWindowsCode(files, inspect) + for (const [index, path] of files.entries()) { + const signature = signatures[index] + if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) + } +} diff --git a/apps/desktop/scripts/windows-sign.cmd b/apps/desktop/scripts/windows-sign.cmd index 366f787f57..8afed8833e 100644 --- a/apps/desktop/scripts/windows-sign.cmd +++ b/apps/desktop/scripts/windows-sign.cmd @@ -13,5 +13,5 @@ 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%" +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% "%targetFile%" exit /b %errorlevel% diff --git a/apps/desktop/scripts/windows-sign.d.mts b/apps/desktop/scripts/windows-sign.d.mts index e56534ce75..c49c6f6993 100644 --- a/apps/desktop/scripts/windows-sign.d.mts +++ b/apps/desktop/scripts/windows-sign.d.mts @@ -14,6 +14,13 @@ export function buildWindowsSigningEnvironment(environment: NodeJS.ProcessEnv, i keyContainer: string }): NodeJS.ProcessEnv +/** + * Pin updater verification to the release certificate's organization, country, and common name. + * @param certificateFile Public Windows Code Signing certificate file. + * @returns Distinguished-name attributes consumed by electron-updater. + */ +export function resolveWindowsUpdatePublisher(certificateFile: string | undefined): string + /** * Serialize hardware-token signing and stop all queued tasks after the first failure. * @@ -26,6 +33,9 @@ export function createWindowsTokenSigner(options: { tokenPin?: string | undefined keyContainer?: string | undefined commandInterpreter?: string | undefined + runDirectory?: string | undefined + stateDirectory?: string | undefined + preserveSignature?: (path: string) => Promise }): ( configuration: { path: string diff --git a/apps/desktop/scripts/windows-sign.mjs b/apps/desktop/scripts/windows-sign.mjs index b9b2780dca..3ebd199615 100644 --- a/apps/desktop/scripts/windows-sign.mjs +++ b/apps/desktop/scripts/windows-sign.mjs @@ -6,6 +6,9 @@ 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' +import { beginWindowsSigningAttempt } from './windows-signing-state.mjs' +import { completeWindowsSignature, normalizeWindowsSignature } from './windows-timestamp.mjs' +import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' const execFileAsync = promisify(execFile) const { WineVmManager } = wineVmModule @@ -67,7 +70,28 @@ function resolveCertificateFile(value) { 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 + return { path, certificate } +} + +/** + * Pin updater verification to the release certificate's organization, country, and common name. + * + * @param {string | undefined} certificateFile Public Windows Code Signing certificate file. + * @returns {string} Distinguished-name attributes consumed by electron-updater. + */ +export function resolveWindowsUpdatePublisher(certificateFile) { + const { certificate } = resolveCertificateFile(certificateFile) + const subject = certificate.toLegacyObject().subject + // These attributes have identical names in OpenSSL and Windows certificate subjects. + return ['CN', 'O', 'C'].map((key) => { + const value = subject[key] + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Windows update publisher requires one nonempty ${key} certificate attribute`) + } + const escaped = value.replace(/[\\",;+\x00-\x20]/gu, + character => `\\${character.charCodeAt(0).toString(16).padStart(2, '0')}`) + return `${key}=${escaped}` + }).join(',') } function resolveSignTool(value) { @@ -136,11 +160,11 @@ export function buildWindowsSigningEnvironment(environment, input) { /** * Serialize SafeNet signing and stop all queued tasks after the first failure. * - * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration. + * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string, runDirectory?: string, stateDirectory?: string, preserveSignature?: (path: string) => Promise }} options Release identity, supervised run, verified-copy preservation, and test-only isolated interlock directory. * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise} The signing hook. */ export function createWindowsTokenSigner(options) { - const certificateFile = resolveCertificateFile(options.certificateFile) + const { path: certificateFile, certificate } = resolveCertificateFile(options.certificateFile) const signTool = resolveSignTool(options.signTool) const { keyContainer, tokenPin } = resolveTokenIdentity(options) const commandInterpreter = options.commandInterpreter @@ -152,35 +176,67 @@ export function createWindowsTokenSigner(options) { if (configuration.hash !== 'sha256') { throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`) } - await repairDanglingAuthenticodeDirectory(configuration.path) + if (configuration.isNest) throw new Error('Windows release signing does not support appended signatures') + if (await options.preserveSignature?.(configuration.path)) return const secrets = [tokenPin] - let result + const runDirectory = options.runDirectory ?? process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + if (!runDirectory) throw new Error('Windows hardware signing requires a supervised packaging run') + const { inspectWindowsRuntimeSignature: inspect } = await import('./windows-runtime-signature.mjs') + const thumbprint = certificate.fingerprint.replaceAll(':', '') 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: true, + await completeWindowsSignature(configuration.path, { + thumbprint, inspect, evidenceDirectory: runDirectory, + record: event => recordPackagingEvent(runDirectory, event), + normalize: path => normalizeWindowsSignature(path, signTool, scrubWindowsSigningEnvironment(process.env)), + timestamp: async path => { await execFileAsync(signTool, ['timestamp', '/v', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', path], { + env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, timeout: 60_000, + }) }, + sign: async (path) => { + await repairDanglingAuthenticodeDirectory(path) + const attempt = beginWindowsSigningAttempt({ runDirectory, + stateDirectory: options.stateDirectory, target: path }) + let result + try { + const operation = execFileAsync(commandInterpreter, [ + '/d', + '/v:off', + '/c', + WINDOWS_SIGN_SCRIPT, + ], { + cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY, + env: buildWindowsSigningEnvironment(process.env, { + certificateFile, + signTool, + path, + isNest: false, + tokenPin, + keyContainer, + }), + windowsHide: true, + }) + attempt.started(operation.child?.pid ?? null) + result = await operation + const signature = await inspect(path) + if (signature.status !== 'Valid' || signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { + throw new Error('Windows hardware signing: primary signature verification failed') + } + } + catch (error) { + const failure = createRedactedWindowsSigningError(error, path, secrets) + attempt.failure(typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, failure.message) + throw failure + } + attempt.success() + const stdout = redactedSigningOutput(result.stdout, secrets) + const stderr = redactedSigningOutput(result.stderr, secrets) + if (stdout !== '') process.stdout.write(stdout) + if (stderr !== '') process.stderr.write(stderr) + }, }) + } catch (error) { + failPackagingRun(runDirectory, 'signature-completion-failed') + throw error } - 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) }) return pending } diff --git a/apps/desktop/scripts/windows-signature-batch.d.mts b/apps/desktop/scripts/windows-signature-batch.d.mts new file mode 100644 index 0000000000..a58c652d42 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-batch.d.mts @@ -0,0 +1,17 @@ +import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs' + +/** + * Validate complete, ordered, per-file inspection results. + * @param stdout Newline-delimited signature records. + * @param stderr Process diagnostics, which must be empty. + * @param files Exact requested file order. + * @returns Validated signatures; rejects missing, duplicate, reordered or malformed rows. + */ +export function parseSignatureRows(stdout: string, stderr: string, files: readonly string[]): WindowsRuntimeSignature[] + +/** + * Inspect every file with at most four processes and 32 files per process. + * @param files Materialized PE paths in stable order. + * @returns One signature per file, after all processes in each batch have exited, including on failure. + */ +export function inspectSignaturesBatched(files: readonly string[]): Promise diff --git a/apps/desktop/scripts/windows-signature-batch.mjs b/apps/desktop/scripts/windows-signature-batch.mjs new file mode 100644 index 0000000000..cbc37b9d30 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-batch.mjs @@ -0,0 +1,57 @@ +/** Bounded public-key verification batches; every output row must match its requested file. */ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' + +const command = '$ErrorActionPreference="Stop"; [Console]::InputEncoding=[System.Text.UTF8Encoding]::new(); [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new(); Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1" -ErrorAction Stop; Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1" -ErrorAction Stop; $files=[Console]::In.ReadToEnd()|ConvertFrom-Json; foreach($file in $files){ $s=Get-AuthenticodeSignature -LiteralPath $file; [pscustomobject]@{path=$file;status=[string]$s.Status;timestamped=($null -ne $s.TimeStamperCertificate);thumbprint=$s.SignerCertificate.Thumbprint}|ConvertTo-Json -Compress }' + +/** + * Reject missing, duplicated, reordered or malformed file results. + * @param {string} stdout Newline-delimited signature records. + * @param {string} stderr Process diagnostics; any content rejects the batch. + * @param {readonly string[]} files Exact requested order. + * @returns {Array<{status: string, timestamped: boolean, thumbprint: string|null}>} Complete signature results. + */ +export function parseSignatureRows(stdout, stderr, files) { + const rows = stdout.trim().split(/\r?\n/u) + if (stderr || rows.length !== files.length) throw new Error('Windows signature batch: incomplete inspection output') + return rows.map((row, index) => { + const value = JSON.parse(row) + if (value === null || typeof value !== 'object' || value.path !== files[index] + || typeof value.status !== 'string' || typeof value.timestamped !== 'boolean' + || !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) { + throw new Error(`Windows signature batch: invalid inspection output: ${files[index]}`) + } + return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint } + }) +} + +async function inspectBatch(files) { + const pending = promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { + env: scrubWindowsSigningEnvironment(process.env), encoding: 'utf8', windowsHide: true, + timeout: 60_000, maxBuffer: 64 * 1024 * files.length, + }) + pending.child.stdin.on('error', () => { /* Process failure is reported by the awaited execFile result. */ }) + pending.child.stdin.end(JSON.stringify(files)) + const { stdout, stderr } = await pending + return parseSignatureRows(stdout, stderr, files) +} + +/** + * Inspect every file through at most four processes, draining failures before returning. + * @param {readonly string[]} files Materialized PE paths in stable order. + * @returns {Promise>} One result per input. + */ +export async function inspectSignaturesBatched(files) { + const signatures = [] + for (let offset = 0; offset < files.length; offset += 128) { + const groups = [] + for (let i = offset; i < Math.min(files.length, offset + 128); i += 32) groups.push(files.slice(i, i + 32)) + const settled = await Promise.allSettled(groups.map(inspectBatch)) + for (const result of settled) { + if (result.status === 'rejected') throw result.reason + signatures.push(...result.value) + } + } + return signatures +} diff --git a/apps/desktop/scripts/windows-signature-cache-directory.d.mts b/apps/desktop/scripts/windows-signature-cache-directory.d.mts new file mode 100644 index 0000000000..411767c61f --- /dev/null +++ b/apps/desktop/scripts/windows-signature-cache-directory.d.mts @@ -0,0 +1,14 @@ +/** + * Resolve the current account's cache or a file-owned absolute local override. + * @param environment Packaging configuration; an absent override uses the current account's signing-state directory. + * @returns Normalized drive path; rejects relative, network, device and alternate-stream paths. + */ +export function resolveWindowsSignatureCacheDirectory(environment: NodeJS.ProcessEnv): string + +/** + * Validate directory ownership, links, drive type and destination access permissions. + * @param path Absolute directory to create or inspect. + * @param existingSource Validate an existing migration source without changing its permissions. + * @returns Resolves after validation; never reads cache entries from another account's directory. + */ +export function prepareWindowsSignatureCacheDirectory(path: string, existingSource?: boolean): Promise diff --git a/apps/desktop/scripts/windows-signature-cache-directory.mjs b/apps/desktop/scripts/windows-signature-cache-directory.mjs new file mode 100644 index 0000000000..69b2064672 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-cache-directory.mjs @@ -0,0 +1,38 @@ +/** Resolve and validate the current account's shared runtime-signature cache. */ +import { execFile } from 'node:child_process' +import { homedir } from 'node:os' +import { realpath } from 'node:fs/promises' +import { join, resolve, win32 } from 'node:path' +import { promisify } from 'node:util' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' + +/** + * Resolve the file-owned override or the current account's signing-state cache without reading its contents. + * @param {NodeJS.ProcessEnv} environment File-owned packaging configuration. + * @returns {string} Absolute local cache path; relative, network, device and alternate-stream paths are rejected. + */ +export function resolveWindowsSignatureCacheDirectory(environment) { + const path = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR + ?? win32.join(homedir(), '.dsh-desktop-signing', 'signature-cache', 'v1') + if (!/^[a-z]:[\\/]/iu.test(path) || path.slice(2).includes(':') || /[\0\r\n]/u.test(path)) { + throw new Error('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR must be an absolute local drive directory') + } + return win32.resolve(path) +} + +/** + * Create private current-account storage or validate an explicitly selected migration source. + * @param {string} path Absolute local directory. + * @param {boolean} existingSource Require an existing, current-account-owned migration source without changing its permissions. + * @returns {Promise} Resolves after checking drive type, ancestor links, ownership and destination permissions. + */ +export async function prepareWindowsSignatureCacheDirectory(path, existingSource = false) { + if (process.platform !== 'win32') throw new Error('Windows signature cache directory validation requires Windows') + await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', + join(import.meta.dirname, 'windows-signature-cache-directory.ps1'), '-CachePath', resolve(path), + ...(existingSource ? ['-ExistingSource'] : []), + ], { env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, timeout: 60_000 }) + if ((await realpath(path)).toLowerCase() !== resolve(path).toLowerCase()) { + throw new Error('Signature cache directory is redirected; configure DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR outside redirected storage') + } +} diff --git a/apps/desktop/scripts/windows-signature-cache-directory.ps1 b/apps/desktop/scripts/windows-signature-cache-directory.ps1 new file mode 100644 index 0000000000..4ea88dd487 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-cache-directory.ps1 @@ -0,0 +1,40 @@ +# Validate account ownership before accessing cache contents; new caches receive a private DACL. +param([Parameter(Mandatory=$true)][string]$CachePath, [switch]$ExistingSource) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() +Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1" -ErrorAction Stop +$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$fullPath = [System.IO.Path]::GetFullPath($CachePath) +if ($fullPath -notmatch '^[A-Za-z]:\\' -or $CachePath -notmatch '^[A-Za-z]:[\\/]') { throw 'Signature cache requires an absolute local drive path' } +$drive = [System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($fullPath)) +if ($drive.DriveType -ne [System.IO.DriveType]::Fixed) { throw 'Signature cache requires a fixed local drive' } +for ($ancestor = [System.IO.DirectoryInfo]::new($fullPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) { + if ($ancestor.Exists -and ($ancestor.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw 'Signature cache cannot use reparse points in its path' + } +} +if ([System.IO.File]::Exists($fullPath)) { throw 'Signature cache path must be a directory' } +if (-not [System.IO.Directory]::Exists($fullPath)) { + if ($ExistingSource) { throw 'Signature cache migration source does not exist' } + $null = [System.IO.Directory]::CreateDirectory($fullPath) + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($currentSid) + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544')) { + $identity = [System.Security.Principal.SecurityIdentifier]::new($sid) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $fullPath -AclObject $acl +} +$acl = Get-Acl -LiteralPath $fullPath +if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $currentSid.Value) { + throw 'Signature cache directory must belong to the current Windows account' +} +if (-not $ExistingSource) { + foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544')) { + throw 'Signature cache permissions must exclude other ordinary Windows accounts' + } + } +} diff --git a/apps/desktop/scripts/windows-signature-cache.d.mts b/apps/desktop/scripts/windows-signature-cache.d.mts new file mode 100644 index 0000000000..e68abdf345 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-cache.d.mts @@ -0,0 +1,70 @@ +import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs' +import type { createWindowsTokenSigner } from './windows-sign.mjs' + +/** File-owned cache policy and existing supervised signing operations. */ +export interface WindowsSignatureCacheOptions { + root: string + identity: string + thumbprint: string + sign: ReturnType + inspect: (path: string) => Promise + record: (event: object) => void +} + +/** + * Hash the public certificate and files controlling signing output in fixed order. + * @param files Certificate and signing-toolchain paths, excluding credentials. + * @returns Content identity independent of installation paths and PIN values. + */ +export function signatureCacheIdentity(files: readonly string[]): Promise + +/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */ +export interface WindowsSignatureCacheSummary { + root: string + identity: string + hits: number + misses: number + published: number + retained: number + signingCalls: number + avoidedSigningCalls: number + validationFailures: number + verificationMs: number + restoreMs: number + signingMs: number +} + +/** Serial cache callback with a snapshot of this instance's completed and failed operations. */ +export type WindowsCachedSigner = ReturnType & { + /** @returns Current counters and elapsed times; does not wait for queued operations. */ + summary(): WindowsSignatureCacheSummary +} + +/** + * Reuse complete signed files only after input, integrity, identity and timestamp checks. + * @param options Cache policy, supervised signer, public-key verifier and audit sink. + * @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries. + */ +export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner + +/** The caller validates ownership and holds the user-wide signing-stage lock throughout migration. */ +export interface SignatureCacheMigrationOptions { + source: string + root: string + record: (event: object) => void +} + +/** + * Copy complete entries without changing the source or replacing valid destination entries. + * @param options Validated directories and credential-free audit sink. + * @returns Counts of published, retained and skipped entries; corruption rejects without repair. Actual use rechecks trust. + */ +export function migrateSignatureCache(options: SignatureCacheMigrationOptions): Promise<{ published: number, retained: number, skipped: number }> + +/** + * Inspect or explicitly clear complete entries under the caller's signing-stage lock. + * @param root Validated current-account cache directory. + * @param clear Remove complete entries after atomic retirement; incomplete staging directories remain untouched. + * @returns Count and bytes of complete entries, plus the number of skipped incomplete entries. + */ +export function maintainSignatureCache(root: string, clear?: boolean): Promise<{ entries: number, bytes: number, incomplete: number }> diff --git a/apps/desktop/scripts/windows-signature-cache.mjs b/apps/desktop/scripts/windows-signature-cache.mjs new file mode 100644 index 0000000000..1df9cd955f --- /dev/null +++ b/apps/desktop/scripts/windows-signature-cache.mjs @@ -0,0 +1,218 @@ +/** Cache complete signed files; corrupt entries stop the caller before hardware access. */ +import { createHash, randomUUID } from 'node:crypto' +import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve, toNamespacedPath } from 'node:path' + +const digest = bytes => createHash('sha256').update(bytes).digest('hex') + +/** + * Include public certificate and every file that controls signing output in the policy identity. + * @param {readonly string[]} files Certificate, SignTool, signing script and signer implementation paths, in fixed order. + * @returns {Promise} Content identity independent of installation paths and PIN values. + */ +export async function signatureCacheIdentity(files) { + return digest(JSON.stringify(await Promise.all(files.map(async path => digest(await regularFile(path)))))) +} + +async function regularFile(path) { + const stat = await lstat(path) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`signature cache: expected regular file: ${path}`) + return readFile(path) +} + +async function directory(path) { + const stat = await lstat(path) + const normalize = value => process.platform === 'win32' ? toNamespacedPath(resolve(value)).toLowerCase() : resolve(value) + if (!stat.isDirectory() || stat.isSymbolicLink() || normalize(await realpath(path)) !== normalize(path)) { + throw new Error(`signature cache: expected unlinked directory: ${path}`) + } +} + +function validSignature(signature, thumbprint) { + return signature.status === 'Valid' && signature.timestamped + && signature.thumbprint?.toUpperCase() === thumbprint.toUpperCase() +} + +async function readCacheEntry(entry, key) { + await directory(entry) + const record = JSON.parse((await regularFile(join(entry, 'record.json'))).toString('utf8')) + if (record === null || typeof record !== 'object' || record.version !== 1 || record.key !== key + || typeof record.inputDigest !== 'string' || !/^[a-f\d]{64}$/u.test(record.inputDigest) + || typeof record.identity !== 'string' || typeof record.signedDigest !== 'string' + || !/^[a-f\d]{64}$/u.test(record.signedDigest) + || digest(JSON.stringify({ version: 1, inputDigest: record.inputDigest, identity: record.identity })) !== key) { + throw new Error('signature cache: invalid input record') + } + if (digest(await regularFile(join(entry, 'payload'))) !== record.signedDigest) throw new Error('signature cache: corrupt signed payload') + return record +} + +/** + * Import complete integrity-checked entries while the caller holds the signing-stage lock. + * @param {import('./windows-signature-cache.mjs').SignatureCacheMigrationOptions} options Validated source, private destination and audit sink. + * @returns {Promise<{published: number, retained: number, skipped: number}>} Counts; existing valid entries win even when timestamp bytes differ. Trust is rechecked at actual use. + */ +export async function migrateSignatureCache(options) { + if (resolve(options.source).toLowerCase() === resolve(options.root).toLowerCase()) throw new Error('signature cache: migration source equals destination') + await directory(options.source) + await mkdir(options.root, { recursive: true }) + await directory(options.root) + const counts = { published: 0, retained: 0, skipped: 0 } + for (const key of await readdir(options.source)) { + if (!/^[a-f\d]{64}$/u.test(key)) { + counts.skipped++ + options.record({ type: 'signature-cache-migration-skipped', reason: 'not-complete-entry-name' }) + continue + } + const source = join(options.source, key) + await readCacheEntry(source, key) + const target = join(options.root, key) + let exists = false + try { await lstat(target); exists = true } + catch (error) { if (error.code !== 'ENOENT') throw error } + if (exists) { + await readCacheEntry(target, key) + counts.retained++ + continue + } + const staging = await mkdtemp(toNamespacedPath(join(options.root, '.publish-'))) + try { + await copyFile(join(source, 'record.json'), join(staging, 'record.json')) + await copyFile(join(source, 'payload'), join(staging, 'payload')) + await readCacheEntry(staging, key) + await rename(staging, target) + counts.published++ + } finally { await rm(staging, { recursive: true, force: true }) } + } + return counts +} + +/** + * Count or explicitly clear complete entries while the caller holds the signing-stage lock. + * @param {string} root Validated, current-account cache directory. + * @param {boolean} clear Retire each complete entry atomically before removing its two regular files. + * @returns {Promise<{entries: number, bytes: number, incomplete: number}>} Complete-entry size and count; incomplete entries are left untouched. + */ +export async function maintainSignatureCache(root, clear = false) { + await directory(root) + let entries = 0 + let bytes = 0 + let incomplete = 0 + for (const name of await readdir(root)) { + if (!/^[a-f\d]{64}$/u.test(name)) { incomplete++; continue } + const entry = join(root, name) + await directory(entry) + const files = await readdir(entry) + if (files.length !== 2 || !files.includes('record.json') || !files.includes('payload')) { + throw new Error('signature cache: unexpected files prevent cache maintenance') + } + for (const file of files) { + const stat = await lstat(join(entry, file)) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('signature cache: expected a regular entry file') + bytes += stat.size + } + if (clear) { + const retired = join(root, `.clear-${randomUUID()}`) + await rename(entry, retired) + for (const file of files) await unlink(join(retired, file)) + await rmdir(retired) + } + entries++ + } + return { entries, bytes, incomplete } +} + +/** + * Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy. + * @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations. + * @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp. + */ +export function createCachedSigner(options) { + let pending = Promise.resolve() + const statistics = { hits: 0, misses: 0, published: 0, retained: 0, signingCalls: 0, validationFailures: 0, + verificationMs: 0, restoreMs: 0, signingMs: 0 } + async function verify(path) { + const start = performance.now() + try { + if (!validSignature(await options.inspect(path), options.thumbprint)) throw new Error('signature cache: invalid signature') + } finally { statistics.verificationMs += performance.now() - start } + } + async function readEntry(entry, key, inputDigest) { + const record = await readCacheEntry(entry, key) + if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record') + return record + } + async function sign(configuration) { + if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported') + const inputDigest = digest(await regularFile(configuration.path)) + const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity })) + await mkdir(options.root, { recursive: true }) + await directory(options.root) + const entry = join(options.root, key) + let hit = false + try { await lstat(entry); hit = true } + catch (error) { if (error.code !== 'ENOENT') throw error } + if (hit) { + const start = performance.now() + try { + const record = await readEntry(entry, key, inputDigest) + const staging = await mkdtemp(toNamespacedPath(join(dirname(configuration.path), '.signature-restore-'))) + try { + const candidate = join(staging, basename(configuration.path)) + await copyFile(join(entry, 'payload'), candidate) + if (digest(await regularFile(candidate)) !== record.signedDigest) throw new Error('signature cache: restore changed signed bytes') + try { await verify(candidate) } + catch (error) { throw new Error('signature cache: invalid cached signature', { cause: error }) } + if (digest(await regularFile(configuration.path)) !== inputDigest) throw new Error('signature cache: target changed before restore') + await rename(candidate, configuration.path) + } finally { await rm(staging, { recursive: true, force: true }) } + } catch (error) { + statistics.validationFailures++ + options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path }) + throw error + } finally { statistics.restoreMs += performance.now() - start } + statistics.hits++ + options.record({ type: 'signature-cache-hit', key, path: configuration.path }) + return + } + statistics.misses++ + options.record({ type: 'signature-cache-miss', key, path: configuration.path }) + const start = performance.now() + statistics.signingCalls++ + try { await options.sign(configuration) } + finally { statistics.signingMs += performance.now() - start } + try { await verify(configuration.path) } + catch (error) { + statistics.validationFailures++ + options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path }) + throw new Error('signature cache: new signature verification failed', { cause: error }) + } + const staging = await mkdtemp(toNamespacedPath(join(options.root, '.publish-'))) + try { + await copyFile(configuration.path, join(staging, 'payload')) + const signedDigest = digest(await regularFile(join(staging, 'payload'))) + if (signedDigest !== digest(await regularFile(configuration.path))) throw new Error('signature cache: signed file changed during publication') + await writeFile(join(staging, 'record.json'), JSON.stringify({ version: 1, key, inputDigest, identity: options.identity, signedDigest }), { flag: 'wx', flush: true }) + let published = false + try { await rename(staging, entry); published = true } + catch (error) { + if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error + try { await readEntry(entry, key, inputDigest) } + catch (validationError) { + statistics.validationFailures++ + options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path }) + throw validationError + } + } + if (published) statistics.published++ + else statistics.retained++ + options.record({ type: published ? 'signature-cache-published' : 'signature-cache-retained', key, path: configuration.path }) + } finally { await rm(staging, { recursive: true, force: true }) } + } + const signer = configuration => { + pending = pending.then(() => sign(configuration)) + return pending + } + signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits }) + return signer +} diff --git a/apps/desktop/scripts/windows-signing-preflight.ts b/apps/desktop/scripts/windows-signing-preflight.ts new file mode 100644 index 0000000000..6076e4cd4e --- /dev/null +++ b/apps/desktop/scripts/windows-signing-preflight.ts @@ -0,0 +1,98 @@ +/** Check the production signing path with one private probe before preparing release artifacts. */ +import { execFile } from 'node:child_process' +import { X509Certificate, createHash } from 'node:crypto' +import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs' +import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs' +import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' + +const PROBE_SOURCE = 'internal static class SigningProbe { private static int Main() { return 0; } }\n' + +interface SigningPreflightOptions { + runDirectory: string + environment: NodeJS.ProcessEnv + stateDirectory?: string + compile?: (compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv) => Promise + sign?: ReturnType + inspect?: (path: string) => Promise +} + +async function compileProbe(compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv): Promise { + const { NODE_OPTIONS: _options, NODE_PATH: _path, ...safeEnvironment } = scrubWindowsSigningEnvironment(environment) + await promisify(execFile)(compiler, ['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, source], { + env: safeEnvironment, windowsHide: true, timeout: 30_000, + }) +} + +/** + * Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate. + * @param options Supervised evidence directory, file-owned settings and isolated test adapters. + * @returns Resolves after one signature and verification; failures retain evidence; timestamp attempts never repeat hardware signing. + */ +export async function preflightWindowsSigning(options: SigningPreflightOptions): Promise { + const { environment, runDirectory } = options + const record = (event: object): void => { recordPackagingEvent(runDirectory, event) } + const signer = createWindowsTokenSigner({ + certificateFile: environment.DSH_DESKTOP_WINDOWS_CER_FILE, + signTool: environment.DSH_DESKTOP_WINDOWS_SIGNTOOL, + keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, + tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN, + runDirectory, + stateDirectory: options.stateDirectory, + }) + const state = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing') + const lock = join(state, 'attempt.json') + const compiler = join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe') + await access(join(runDirectory, 'run.json')) + for (const path of [join(runDirectory, 'fatal.json'), lock]) { + try { await lstat(path) } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + throw new Error(`Windows signing preflight refused: inspect retained failure or interlock at ${path}; no automatic recovery`) + } + if (!(await lstat(compiler)).isFile()) throw new Error('Windows signing preflight requires the local .NET Framework C# compiler') + const certificate = await readFile(environment.DSH_DESKTOP_WINDOWS_CER_FILE!) + const leaf = new X509Certificate(certificate) + if (Date.now() < Date.parse(leaf.validFrom) || Date.now() > Date.parse(leaf.validTo)) { + throw new Error('Windows signing preflight requires a currently valid public certificate') + } + const thumbprint = leaf.fingerprint.replaceAll(':', '') + const directory = join(runDirectory, 'signing-preflight') + await mkdir(directory) + const source = join(directory, 'probe.cs') + const output = join(directory, 'probe.exe') + await writeFile(source, PROBE_SOURCE, { flag: 'wx', flush: true }) + record({ type: 'signing-preflight-static', compiler, + compilerSha256: createHash('sha256').update(await readFile(compiler)).digest('hex'), + signToolSha256: createHash('sha256').update(await readFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!)).digest('hex'), + certificateSha256: createHash('sha256').update(certificate).digest('hex') }) + await (options.compile ?? compileProbe)(compiler, source, output, environment) + const inspect = options.inspect ?? inspectWindowsRuntimeSignature + if ((await inspect(output)).status !== 'NotSigned') throw new Error('Windows signing preflight requires a new unsigned probe') + record({ type: 'signing-preflight-probe', path: output, sha256: createHash('sha256').update(await readFile(output)).digest('hex') }) + await (options.sign ?? signer)({ path: output, hash: 'sha256', isNest: false }) + const signature = await inspect(output) + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { + throw new Error('Windows signing preflight verification failed: expected the configured certificate and a valid timestamp') + } + record({ type: 'signing-preflight-success', ...signature, + sha256: createHash('sha256').update(await readFile(output)).digest('hex'), probeExecuted: false }) +} + +async function main(): Promise { + if (process.platform !== 'win32') throw new Error('Windows signing preflight requires Windows') + const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + if (!runDirectory) throw new Error('Windows signing preflight requires a supervised packaging run') + try { await preflightWindowsSigning({ runDirectory, environment: process.env }) } + catch (error) { + failPackagingRun(runDirectory, 'windows-signing-preflight-failed') + throw error + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await main() diff --git a/apps/desktop/scripts/windows-signing-stage.d.mts b/apps/desktop/scripts/windows-signing-stage.d.mts new file mode 100644 index 0000000000..12f8e49101 --- /dev/null +++ b/apps/desktop/scripts/windows-signing-stage.d.mts @@ -0,0 +1,16 @@ +/** Stage-wide ownership is independent of the retained hardware-attempt interlock. */ +export interface WindowsSigningStageOptions { + stage: string + record: (event: object) => void + signal?: AbortSignal + /** Test-only storage isolates independent processes from the real account's signing state. */ + stateDirectory?: string +} + +/** + * Queue one complete signing or cache-maintenance stage under the account's exclusive file handle. + * @param options Audit sink, optional wait cancellation and isolated test storage. + * @param operation Stage that awaits all owned children before settling. + * @returns The stage result, after releasing the handle; acquisition errors never run the stage. + */ +export function withWindowsSigningStage(options: WindowsSigningStageOptions, operation: () => Promise): Promise diff --git a/apps/desktop/scripts/windows-signing-stage.mjs b/apps/desktop/scripts/windows-signing-stage.mjs new file mode 100644 index 0000000000..d81ef8bdc3 --- /dev/null +++ b/apps/desktop/scripts/windows-signing-stage.mjs @@ -0,0 +1,62 @@ +/** Serialize complete signing and cache-maintenance stages without clearing hardware failure evidence. */ +import { lstat, mkdir, realpath } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, resolve, toNamespacedPath } from 'node:path' +import { setTimeout } from 'node:timers/promises' + +let bindings +async function windows() { + if (process.platform !== 'win32') throw new Error('Windows signing stages require Windows') + if (bindings !== undefined) return bindings + const koffi = (await import('koffi')).default + const kernel = koffi.load('kernel32.dll') + bindings = { + open: kernel.func('__stdcall', 'CreateFileW', 'intptr', ['str16', 'uint', 'uint', 'void*', 'uint', 'uint', 'intptr']), + close: kernel.func('__stdcall', 'CloseHandle', 'int', ['intptr']), + error: kernel.func('__stdcall', 'GetLastError', 'uint', []), + } + return bindings +} + +/** + * Hold one account-wide exclusive file handle until a stage and its children have settled. + * @template T + * @param {import('./windows-signing-stage.mjs').WindowsSigningStageOptions} options Audit sink, cancellation and isolated test storage. + * @param {() => Promise} operation Entire stage, including verification and cache publication. + * @returns {Promise} Stage result; contention waits, all other acquisition errors stop before the operation. + */ +export async function withWindowsSigningStage(options, operation) { + const api = await windows() + const root = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing') + await mkdir(root, { recursive: true }) + const stat = await lstat(root) + const normalize = value => toNamespacedPath(resolve(value)).toLowerCase() + if (!stat.isDirectory() || stat.isSymbolicLink() || normalize(await realpath(root)) !== normalize(root)) { + throw new Error('Windows signing stage requires an unlinked local state directory') + } + const path = join(root, 'stage.lock') + try { + const file = await lstat(path) + if (!file.isFile() || file.isSymbolicLink()) throw new Error('Windows signing stage requires a regular lock file') + } catch (error) { if (error.code !== 'ENOENT') throw error } + const start = performance.now() + let handle + let waiting = false + for (;;) { + options.signal?.throwIfAborted() + // OPEN_ALWAYS retains the same file; denying sharing prevents concurrent holders and deletion. + handle = api.open(toNamespacedPath(path), 0xc0000000, 0, null, 4, 0x80, 0) + if (handle !== -1) break + const code = api.error() + if (code !== 32) throw new Error(`Windows signing stage cannot open its lock (Win32 ${code})`) + if (!waiting) { options.record({ type: 'signing-stage-wait', stage: options.stage }); waiting = true } + await setTimeout(100, undefined, { signal: options.signal }) + } + try { + options.record({ type: 'signing-stage-acquired', stage: options.stage, waitMs: performance.now() - start }) + return await operation() + } finally { + if (api.close(handle) === 0) throw new Error(`Windows signing stage cannot release its lock (Win32 ${api.error()})`) + options.record({ type: 'signing-stage-released', stage: options.stage }) + } +} diff --git a/apps/desktop/scripts/windows-signing-state.d.mts b/apps/desktop/scripts/windows-signing-state.d.mts new file mode 100644 index 0000000000..b9f3b0841f --- /dev/null +++ b/apps/desktop/scripts/windows-signing-state.d.mts @@ -0,0 +1,14 @@ +/** + * Acquire durable evidence and the shared signing interlock before any hardware operation. + * @param options Supervised run, target artifact, and test-only isolated state directory. + * @returns Process evidence and completion callbacks; only success releases the interlock. + */ +export function beginWindowsSigningAttempt(options: { + runDirectory?: string | undefined + stateDirectory?: string | undefined + target: string +}): { + started(pid: number | null): void + success(): void + failure(code: number | string | null, diagnostic: string): void +} diff --git a/apps/desktop/scripts/windows-signing-state.mjs b/apps/desktop/scripts/windows-signing-state.mjs new file mode 100644 index 0000000000..d1d99ec488 --- /dev/null +++ b/apps/desktop/scripts/windows-signing-state.mjs @@ -0,0 +1,49 @@ +/** Persist a per-user hardware-signing interlock; failures and interrupted attempts never unlock automatically. */ +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, isAbsolute } from 'node:path' +import { randomUUID } from 'node:crypto' +import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' + +/** + * Acquire the one hardware-signing attempt slot before launching SignTool. + * @param {{runDirectory?: string, stateDirectory?: string, target: string}} options Run evidence and test-only isolated state directory. + * @returns {{started: (pid: number|null) => void, success: () => void, failure: (code: number|string|null, diagnostic: string) => void}} Process evidence and completion callbacks; failure retains the interlock. + */ +export function beginWindowsSigningAttempt(options) { + const runDirectory = options.runDirectory ?? process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + if (!runDirectory || !isAbsolute(runDirectory) || !existsSync(join(runDirectory, 'run.json'))) { + throw new Error('Windows hardware signing requires a supervised packaging run with retained records') + } + if (existsSync(join(runDirectory, 'fatal.json'))) throw new Error('Windows signing refused: packaging run already failed') + const root = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing') + const lock = join(root, 'attempt.json') + const attemptId = randomUUID() + mkdirSync(root, { recursive: true }) + let descriptor + try { descriptor = openSync(lock, 'wx', 0o600) } + catch (error) { + failPackagingRun(runDirectory, 'hardware-signing-interlock-unavailable') + throw new Error(`Windows signing refused: interlock unavailable at ${lock}; inspect the previous attempt before administrator-approved recovery (${error.code})`) + } + try { + writeFileSync(descriptor, `${JSON.stringify({ attemptId, runDirectory, pid: process.pid, startedAt: new Date().toISOString(), target: options.target })}\n`, { flush: true }) + } finally { closeSync(descriptor) } + try { recordPackagingEvent(runDirectory, { type: 'sign-start', attemptId, target: options.target }) } + catch (error) { failPackagingRun(runDirectory, 'signing-audit-write-failed'); throw error } + return { + started(pid) { + recordPackagingEvent(runDirectory, { type: 'sign-command-start', attemptId, commandPid: pid }) + }, + success() { + recordPackagingEvent(runDirectory, { type: 'sign-success', attemptId, target: options.target }) + if (JSON.parse(readFileSync(lock, 'utf8')).attemptId !== attemptId) throw new Error('Windows signing interlock ownership changed') + unlinkSync(lock) + }, + failure(code, diagnostic) { + // The attempt file stays in place even if recording the failure or notifying the parent fails. + try { recordPackagingEvent(runDirectory, { type: 'sign-failure', attemptId, target: options.target, code, diagnostic }) } + finally { failPackagingRun(runDirectory, 'hardware-signing-failed') } + }, + } +} diff --git a/apps/desktop/scripts/windows-timestamp.d.mts b/apps/desktop/scripts/windows-timestamp.d.mts new file mode 100644 index 0000000000..bb1dd617c0 --- /dev/null +++ b/apps/desktop/scripts/windows-timestamp.d.mts @@ -0,0 +1,30 @@ +import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs' + +/** + * Remove unsigned signature attributes on a previously verified private copy. + * @param path Single-signature private copy. + * @param signTool Configured SignTool executable. + * @param environment Credential-free subprocess environment. + * @returns Resolves after removal or an unchanged-file warning; other failures reject. + */ +export function normalizeWindowsSignature(path: string, signTool: string, environment: NodeJS.ProcessEnv): Promise + +/** Public operations on isolated files; sign resolves only after verified hardware success. */ +export interface WindowsTimestampOptions { + sign: (path: string) => Promise + timestamp: (path: string) => Promise + normalize: (path: string) => Promise + inspect: (path: string) => Promise + thumbprint: string + evidenceDirectory: string + record: (event: object) => void + wait?: (milliseconds: number) => Promise +} + +/** + * Complete a single verified hardware signature using bounded, isolated timestamp attempts. + * @param path Exclusively owned build file in trusted storage. + * @param options Verified signer, public operations, certificate and retained audit storage. + * @returns Resolves after a valid timestamp and exact normalized-content verification; failures preserve evidence. + */ +export function completeWindowsSignature(path: string, options: WindowsTimestampOptions): Promise diff --git a/apps/desktop/scripts/windows-timestamp.mjs b/apps/desktop/scripts/windows-timestamp.mjs new file mode 100644 index 0000000000..bb25e2d80f --- /dev/null +++ b/apps/desktop/scripts/windows-timestamp.mjs @@ -0,0 +1,107 @@ +/** Finish timestamps on isolated copies without repeating a verified private-key operation. */ +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { copyFile, cp, lstat, mkdtemp, readFile, rename, rm } from 'node:fs/promises' +import { dirname, extname, join, toNamespacedPath } from 'node:path' +import { tmpdir } from 'node:os' +import { setTimeout } from 'node:timers/promises' + +async function digest(path) { + const stat = await lstat(path) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Windows timestamp: expected a regular file') + return createHash('sha256').update(await readFile(path)).digest('hex') +} + +/** + * Remove unsigned signature attributes on a private copy using the configured SignTool. + * @param {string} path Previously verified, single-signature private copy. + * @param {string} signTool Configured SignTool executable. + * @param {NodeJS.ProcessEnv} environment Credential-free subprocess environment. + * @returns {Promise} Resolves after removal, or a warning that left all bytes unchanged. + */ +export async function normalizeWindowsSignature(path, signTool, environment) { + const before = await digest(path) + try { + await promisify(execFile)(signTool, ['remove', '/u', path], { env: environment, windowsHide: true, timeout: 60_000 }) + } catch (error) { + // SignTool returns warning status when a verified signature has no unsigned attributes. + if (error.code !== 2 || error.signal || error.killed || await digest(path) !== before) throw error + } +} + +/** + * Sign once, timestamp fresh copies at most three times, then atomically replace the unchanged input. + * @param {string} path Exclusively owned build file; storage must be trusted. + * @param {import('./windows-timestamp.mjs').WindowsTimestampOptions} options Verified hardware signer and credential-free public operations. + * @returns {Promise} Resolves only after complete signature, exact normalized bytes and committed-byte verification. + */ +export async function completeWindowsSignature(path, options) { + const original = await digest(path) + const staging = await mkdtemp(join(tmpdir(), 'dsh-sign-')) + const extension = extname(path) + let publication + let completed = false + let retained = false + try { + const raw = join(staging, `raw${extension}`) + const normalized = join(staging, `normalized${extension}`) + // SignTool does not consistently support extended-length Windows paths. + if (normalized.length >= 260) throw new Error('Windows timestamp: temporary SignTool path exceeds MAX_PATH') + await copyFile(path, raw) + if (await digest(raw) !== original) throw new Error('Windows timestamp: input changed during staging') + options.record({ type: 'signature-staging', path, stagedPath: raw }) + await options.sign(raw) + const frozen = await digest(raw) + await copyFile(raw, normalized) + await options.normalize(normalized) + const baseline = await digest(normalized) + for (let attempt = 1; attempt <= 3; attempt++) { + if (await digest(raw) !== frozen) throw new Error('Windows timestamp: signed input changed') + const candidate = join(staging, `attempt-${attempt}${extension}`) + await copyFile(raw, candidate) + if (await digest(candidate) !== frozen) throw new Error('Windows timestamp: copy changed') + options.record({ type: 'timestamp-start', path, attempt }) + try { await options.timestamp(candidate) } + catch (error) { + options.record({ type: 'timestamp-failure', path, attempt, code: typeof error.code === 'number' ? error.code : null }) + if (await digest(raw) !== frozen) throw new Error('Windows timestamp: signed input changed after failure') + if (attempt === 3 || ![1, 2].includes(error.code) || error.signal || error.killed) throw error + await (options.wait ?? setTimeout)(attempt * 1000) + continue + } + const signature = await options.inspect(candidate) + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { + throw new Error('Windows timestamp: completed signature verification failed') + } + await copyFile(candidate, normalized) + await options.normalize(normalized) + if (await digest(normalized) !== baseline) throw new Error('Windows timestamp: signing content changed') + if (await digest(raw) !== frozen || await digest(path) !== original) throw new Error('Windows timestamp: input changed before publication') + const expected = await digest(candidate) + publication = await mkdtemp(toNamespacedPath(join(dirname(path), '.complete-signature-'))) + const ready = join(publication, `ready${extension}`) + await copyFile(candidate, ready) + if (await digest(ready) !== expected || await digest(path) !== original) throw new Error('Windows timestamp: input changed before publication') + await rename(ready, path) + if (await digest(path) !== expected) throw new Error('Windows timestamp: committed bytes changed') + options.record({ type: 'timestamp-success', path, attempt }) + completed = true + return + } + } catch (error) { + try { + const evidence = await mkdtemp(join(options.evidenceDirectory, 'signature-failure-')) + await cp(staging, join(evidence, 'files'), { recursive: true, errorOnExist: true, force: false }) + options.record({ type: 'signature-failure-evidence', path, directory: evidence }) + retained = true + } catch (evidenceError) { + throw new AggregateError([error, evidenceError], 'Windows signature failed and its evidence could not be retained') + } + throw error + } finally { + if (publication) await rm(publication, { recursive: true, force: true }) + // Failure staging remains available if evidence publication or its audit record fails. + if (completed || retained) await rm(staging, { recursive: true, force: true }) + } +} diff --git a/apps/desktop/src/fatal-recovery.ts b/apps/desktop/src/fatal-recovery.ts index 5460dd3a11..d2b9dc452b 100644 --- a/apps/desktop/src/fatal-recovery.ts +++ b/apps/desktop/src/fatal-recovery.ts @@ -43,12 +43,15 @@ export class DesktopFatalRecovery { let detail = desktopErrorState(error).message let message = messages.fatalSummary for (;;) { + const addressInUse = /\blisten EADDRINUSE\b/u.test(detail) const { response } = await this.operations.show({ type: 'error', title: messages.startupFailed, message, - detail: dialogDetail(detail, messages), - buttons: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins], + detail: addressInUse ? messages.startupAddressInUse : dialogDetail(detail, messages), + buttons: addressInUse + ? [messages.exitApplication, messages.restartApplication] + : [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins], defaultId: 1, cancelId: 0, noLink: true, diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index c5b035b07c..e4be343c99 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -15,7 +15,12 @@ interface FatalEvent { readonly message: string } -type DesktopHostEvent = ReadyEvent | FatalEvent +type DesktopHostEvent = ReadyEvent | FatalEvent | { readonly type: 'shutdown-complete' } | { + readonly type: 'update-tasks' + readonly requestId: number + readonly active: boolean + readonly error?: string +} const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024 @@ -23,10 +28,15 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { if (typeof message !== 'object' || message === null || !('type' in message)) return false const candidate = message as Record switch (candidate.type) { + case 'shutdown-complete': + return true case 'ready': return typeof candidate.url === 'string' case 'fatal': return typeof candidate.message === 'string' + case 'update-tasks': + return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean' + && (candidate.error === undefined || typeof candidate.error === 'string') default: return false } @@ -51,6 +61,9 @@ export interface DesktopHostReady { readonly injections?: readonly unknown[] | undefined } +/** The child has exited, but task teardown did not finish successfully. */ +export class DesktopHostUncleanExitError extends Error {} + /** One Web backend running under the Electron executable in Node mode. */ export class DesktopHostProcess { private child: ChildProcess | undefined @@ -64,6 +77,9 @@ export class DesktopHostProcess { private stderr = '' private failureReported = false private stopping = false + private shutdownCompleted = false + private nextControlId = 1 + private readonly taskQueries = new Map void; reject: (error: Error) => void }>() /** * @param node - Absolute Electron executable in Node mode. @@ -121,7 +137,16 @@ export class DesktopHostProcess { return } if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections }) - else this.fail(new Error(message.message)) + else if (message.type === 'shutdown-complete') { + if (this.stopping) this.shutdownCompleted = true + else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown')) + } + else if (message.type === 'fatal') this.fail(new Error(message.message)) + else { + const query = this.taskQueries.get(message.requestId) + if (message.error === undefined) query?.resolve(message.active) + else query?.reject(new Error(message.error)) + } }) child.once('error', (error) => { this.fail(error) }) this.exitPromise = new Promise((resolve) => { @@ -135,14 +160,45 @@ export class DesktopHostProcess { return this.readyPromise } - /** Request graceful teardown and await exit, escalating termination when needed. */ - async stop(): Promise { + /** + * Inspect active work or lock request admission for update handoff. + * @param action - Read-only inspection, admission lock, or recovery unlock. + * @returns Whether live tasks would be affected. Locking drains admitted API requests before inspecting tasks; + * an unanswered drain fails at the control-request deadline without authorizing installation. + */ + async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise { + const child = this.child + if (child === undefined || !child.connected || this.failureReported || this.stopping) { + throw new Error('desktop update: Host is unavailable') + } + const requestId = this.nextControlId++ + let timer: ReturnType | undefined + try { + return await new Promise((resolve, reject) => { + this.taskQueries.set(requestId, { resolve, reject }) + timer = setTimeout(() => { reject(new Error('desktop update: task inspection timed out')) }, 10_000) + child.send({ type: 'update-tasks', requestId, action }, (error) => { if (error !== null) reject(error) }) + }) + } finally { + clearTimeout(timer) + this.taskQueries.delete(requestId) + } + } + + /** + * Request teardown and await child exit, escalating termination when needed. + * @param requireGraceful - Reject update handoff after forced termination or unsuccessful child exit. + * @returns Completion of owned process teardown. DesktopHostUncleanExitError confirms exit but refuses installation; + * other failures do not confirm exit. + */ + async stop(requireGraceful = false): Promise { const child = this.child if (child === undefined) return this.stopping = true if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) }) const exited = this.exitPromise ?? Promise.resolve() - if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM') + const graceful = await exitsWithin(exited, 10_000) + if (!graceful) child.kill('SIGTERM') if (!await exitsWithin(exited, 5_000)) { child.kill('SIGKILL') if (!await exitsWithin(exited, 5_000)) { @@ -150,10 +206,16 @@ export class DesktopHostProcess { } } this.child = undefined + if (requireGraceful && (!graceful || child.exitCode !== 0 || !this.shutdownCompleted)) { + // This diagnostic reaches expandable UI; arbitrary plugin stderr can contain credentials. + throw new DesktopHostUncleanExitError(`desktop update: Host did not complete graceful task teardown (exit ${String(child.exitCode)}, signal ${String(child.signalCode)}, shutdown acknowledged ${String(this.shutdownCompleted)}, graceful deadline exceeded ${String(!graceful)})`) + } } private fail(error: Error): void { this.readyReject(error) + for (const query of this.taskQueries.values()) query.reject(error) + this.taskQueries.clear() if (!this.failureReported && !this.stopping) { this.failureReported = true try { this.onFailure?.(error) } catch (listenerError) { diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 9c7329d0d4..e78db93fee 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,52 +1,66 @@ /** Typed preload operations exposed only by the Electron shell. */ import type { IpcMainInvokeEvent } from 'electron' -import type { DesktopPluginRecord } from './project-manager.ts' -import type { DesktopLocale } from './locale.ts' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { - localeGet: 'dsh-desktop:locale-get', boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', directoryPick: 'dsh-desktop:directory-pick', - pluginsList: 'dsh-desktop:plugins-list', - pluginsAdd: 'dsh-desktop:plugins-add', - pluginsRemove: 'dsh-desktop:plugins-remove', - pluginsUpdate: 'dsh-desktop:plugins-update', - pluginsToggle: 'dsh-desktop:plugins-toggle', - updatesCheck: 'dsh-desktop:updates-check', - updatesInstall: 'dsh-desktop:updates-install', - updatesState: 'dsh-desktop:updates-state', + updatesStatus: 'dsh-desktop:updates-status', + updatesOpen: 'dsh-desktop:updates-open', + updatesPresentation: 'dsh-desktop:updates-presentation', nativeThemeSet: 'dsh-desktop:native-theme-set', + windowsAppearance: 'dsh-desktop:windows-appearance', + windowsMenu: 'dsh-desktop:windows-menu', } as const /** Desktop release update state rendered by desktop-owned UI. */ +export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' + export interface DesktopUpdateState { - readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error' + readonly phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' | 'ready' | 'error' readonly version?: string readonly message?: string + /** Main-owned diagnostics without subprocess output or credentials; hidden until expanded. */ + readonly technicalDetails?: string + readonly percent?: number + readonly failedOperation?: 'check' | 'download' | 'install' + /** Main-owned preparation cause; UI wording is selected by the active locale. */ + readonly preparationFailure?: DesktopUpdatePreparationFailureKind } -/** Narrow bridge exposed through context isolation. */ -export interface DshDesktopApi { +/** Classified failure copy selected by the Web locale without exposing raw updater diagnostics. */ +export type DesktopUpdateFailureKind = + | 'check' + | 'check-network' + | 'download' + | 'download-network' + | 'install' + | 'install-network' + | 'stop-failed' + | 'tasks-changed' + | 'tasks-unavailable' + +/** Semantic status content; actions open main-process confirmation dialogs only. */ +export interface DesktopUpdatePresentation { + readonly phase: DesktopUpdateState['phase'] + readonly version?: string + readonly percent?: number + readonly failure?: DesktopUpdateFailureKind +} + +/** Product documents cannot supply update versions, package URLs, or installation authorization. */ +export interface DshDesktopProductApi { readonly protocolVersion: 1 - locale(): Promise - readonly plugins: { - list(): Promise - add(spec: string): Promise - remove(name: string): Promise - update(name: string, version: string): Promise - toggle(name: string, enabled: boolean): Promise - } readonly updates: { - check(): Promise - install(): Promise - subscribe(listener: (state: DesktopUpdateState) => void): () => void + status(): Promise + open(): Promise + subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void } } -/** Scheme of Desktop-owned application and shell documents. */ +/** Scheme of Desktop-owned application documents. */ export const SCHEME = 'dsh-app' /** diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index e055c7710e..03e6f29a57 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -2,49 +2,90 @@ export const en = { application: 'Application', + aboutMenu: 'About DeepSeek Harness', + edit: 'Edit', + menuBar: 'Application menu', + delete: 'Delete', + undo: 'Undo', + redo: 'Redo', + cut: 'Cut', + copy: 'Copy', + paste: 'Paste', + selectAll: 'Select All', startupFailed: 'DeepSeek Harness is unavailable', fatalSummary: 'The application could not start or stopped unexpectedly.', + startupAddressInUse: 'Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart.', diagnosticTruncated: '… Error details shortened. The full diagnostic was written to the Electron console.', startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.', exitApplication: 'Exit', restartApplication: 'Restart', recoveryOperationFailed: 'The recovery operation failed', - disableThirdPartyPlugins: 'Disable all third-party plugins and restart', - pluginsMenu: 'Desktop Plugins…', + disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart', checkUpdatesMenu: 'Check for Updates…', updateCheckFailedTitle: 'Update Check Failed', + updateCheckFailed: 'Could not check for updates. Please try again later.', + updateDownloadFailed: 'Could not download the update. Please try again.', + updateInstallFailed: 'Could not install the update. Please try again later.', + updateCheckNetworkFailed: 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.', + updateDownloadNetworkFailed: 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.', + updateInstallNetworkFailed: 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.', unknownError: 'Unknown error', updateCheckTitle: 'Check for Updates', - updateCurrent: 'You already have the latest version.', + updateCurrent: 'No updates available. Current version: V{version}', + updateChecking: 'Checking for updates…', + updateDownload: 'Download update', + updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded', + updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.', + updateClose: 'Close', + updateAcknowledge: 'OK', + updateLater: 'Update later', + updateDownloading: 'Downloading {percent}%…', + updateVerifying: 'Verifying update files…', + updateInstalling: 'Preparing to restart…', + updateRetry: 'Retry update', + updateActiveTasks: 'Tasks are still in progress', + updateActiveTasksDetail: 'Restarting to update may interrupt these tasks. Continue updating?', + updateStopTasks: 'Stop tasks and update', + updateTasksChanged: 'New tasks started. Review the update confirmation again.', + updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.', + updateStopFailed: 'Tasks could not be stopped safely. The update was not installed. Please try again later.', + updateTechnicalDetails: 'View technical details', 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', - enable: 'Enable', - disable: 'Disable', - disabled: 'Disabled', - changingActivation: 'Changing plugin activation…', - npmPackage: 'npm package', - install: 'Install', - installed: 'Installed', - noPlugins: 'No Desktop plugins are installed.', - remove: 'Remove', - update: 'Update', - cancel: 'Cancel', - targetVersion: 'Enter the target version for {name}', - removing: 'Removing {name}…', - updating: 'Updating {name}…', - installing: 'Installing {spec}…', - operationComplete: 'Done. The Desktop backend has restarted.', - refreshing: 'Refreshing…', - refreshed: 'Plugin list refreshed.', - loadingPlugins: 'Reading Desktop plugins…', + mandatoryTitle: 'Update required', + mandatoryDetail: 'This version is no longer supported. Update to continue. Existing tasks can keep running until you approve a restart.', + mandatoryUnavailable: 'The update requirement could not be checked. Retry when the connection is available.', + policyLoginTitle: 'Sign in to the test environment', + policyLoginRequired: 'This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.', + policyLogin: 'Sign in with Feishu', + policyLoginFailed: 'Test environment sign-in did not complete. Please check your connection and try again.', + policyLoginLoading: 'Loading sign-in page…', + mandatoryNoRelease: 'No applicable update is available. Check again or contact support.', + mandatoryRefresh: 'Check again', + mandatoryPage: 'Open download page', + mandatoryCopy: 'Copy download address', + mandatoryPageFailed: 'The download page could not be opened. Copy the address below and open it in your browser.', + mandatoryActionFailed: 'The update action failed. Retry; the update requirement remains active.', + mandatoryReady: 'Update ready', + mandatoryVersion: 'V{version}', + mandatoryReadyDetail: 'Installing the update will restart the application.', + mandatoryDeferred: 'Existing tasks can keep running. Update to continue using the application.', + mandatoryContinue: 'Continue installing update', + mandatoryInspecting: 'Checking tasks…', + mandatoryStopping: 'Safely stopping tasks in the application.', + mandatoryRestarting: 'The application will restart shortly. Please wait.', + mandatoryDownloadFailed: 'The update files could not be downloaded or prepared. Please retry.', + mandatoryInstallFailed: 'The update was not installed. Check tasks again and retry.', + mandatoryOpenHelp: 'If the page did not open, you can', + mandatoryReopen: 'Open download page again', + mandatoryCopied: 'Link copied', + mandatoryCopyFailed: 'Copy failed. Select and copy the address below manually.', + mandatoryAddress: 'Download address', + mandatoryNotification: 'Return to the application to confirm installation and restart.', } as const /** Every Desktop locale supplies the complete English key set. */ @@ -52,49 +93,90 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', + aboutMenu: '关于 DeepSeek Harness', + edit: '编辑', + menuBar: '应用菜单', + delete: '删除', + undo: '撤销', + redo: '重做', + cut: '剪切', + copy: '复制', + paste: '粘贴', + selectAll: '全选', startupFailed: 'DeepSeek Harness 无法使用', fatalSummary: '应用无法启动或已意外停止。', + startupAddressInUse: '有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。', diagnosticTruncated: '… 错误详情已截短,完整诊断已写入 Electron 控制台。', startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。', exitApplication: '退出', restartApplication: '重启', recoveryOperationFailed: '恢复操作失败', - disableThirdPartyPlugins: '禁用全部第三方插件并重启', - pluginsMenu: '桌面插件…', + disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启', checkUpdatesMenu: '检查更新…', updateCheckFailedTitle: '更新检查失败', + updateCheckFailed: '检查更新失败,请稍后重试。', + updateDownloadFailed: '下载更新失败,请重试。', + updateInstallFailed: '安装更新失败,请稍后重试。', + updateCheckNetworkFailed: '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。', + updateDownloadNetworkFailed: '下载更新失败,请重试。网络连接异常,请检查网络后重试。', + updateInstallNetworkFailed: '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。', unknownError: '未知错误', updateCheckTitle: '检查更新', - updateCurrent: '当前已是最新版本。', + updateCurrent: '当前暂无可用更新。当前版本:V{version}', + updateChecking: '正在检查更新…', + updateDownload: '下载更新', + updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成', + updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。', + updateClose: '关闭', + updateAcknowledge: '确定', + updateLater: '稍后更新', + updateDownloading: '正在下载 {percent}%…', + updateVerifying: '正在校验更新文件…', + updateInstalling: '正在准备重启…', + updateRetry: '重试更新', + updateActiveTasks: '仍有进行中的任务', + updateActiveTasksDetail: '重启更新可能中断这些任务,是否要继续更新?', + updateStopTasks: '停止任务并更新', + updateTasksChanged: '有新任务开始,请重新确认更新。', + updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。', + updateStopFailed: '未能安全停止任务,更新未安装。请稍后重试。', + updateTechnicalDetails: '查看技术详情', updateTitle: 'DeepSeek Harness 更新', updateAvailable: '发现可用更新', updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。', installAndRestart: '安装并重启', later: '稍后', updateFailedTitle: '更新失败', - pluginManagerTitle: '桌面插件', - pluginWindowTitle: 'DeepSeek Harness — 桌面插件', - pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。', - refresh: '刷新', - enable: '启用', - disable: '禁用', - disabled: '已禁用', - changingActivation: '正在更改插件启用状态…', - npmPackage: 'npm 包', - install: '安装', - installed: '已安装', - noPlugins: '还没有安装桌面插件。', - remove: '移除', - update: '更新', - cancel: '取消', - targetVersion: '输入 {name} 的目标版本', - removing: '正在移除 {name}…', - updating: '正在更新 {name}…', - installing: '正在安装 {spec}…', - operationComplete: '操作完成,桌面后端已重新启动。', - refreshing: '正在刷新…', - refreshed: '插件列表已刷新。', - loadingPlugins: '正在读取桌面插件…', + mandatoryTitle: '需要更新', + mandatoryDetail: '当前版本已停止支持,请更新后继续使用。在您确认重启之前,现有任务可以继续运行。', + mandatoryUnavailable: '暂时无法检查更新要求,请在网络恢复后重试。', + policyLoginTitle: '登录测试环境', + policyLoginRequired: '这是测试版应用,检查更新要求需要先通过飞书登录。登录不会下载或安装更新。', + policyLogin: '通过飞书登录', + policyLoginFailed: '测试环境登录未完成,请检查网络后重试。', + policyLoginLoading: '正在加载登录页面…', + mandatoryNoRelease: '暂时没有可用的更新,请重新检查或联系支持人员。', + mandatoryRefresh: '重新检查', + mandatoryPage: '前往官网下载', + mandatoryCopy: '复制下载链接', + mandatoryPageFailed: '无法打开浏览器,请复制下载链接后手动打开。', + mandatoryActionFailed: '更新操作失败,请重试;应用仍需更新后才能继续使用。', + mandatoryReady: '更新已准备就绪', + mandatoryVersion: 'V{version}', + mandatoryReadyDetail: '安装后将重新启动应用。', + mandatoryDeferred: '现有任务可以继续运行。完成更新后才能继续操作应用。', + mandatoryContinue: '继续安装更新', + mandatoryInspecting: '正在检查任务状态…', + mandatoryStopping: '正在安全结束应用中的任务。', + mandatoryRestarting: '应用即将重启,请稍候。', + mandatoryDownloadFailed: '更新文件下载或准备失败,请重试。', + mandatoryInstallFailed: '更新尚未安装,请重新检查任务后重试。', + mandatoryOpenHelp: '若页面未打开,可', + mandatoryReopen: '重新打开官网', + mandatoryCopied: '已复制链接', + mandatoryCopyFailed: '复制失败,请手动选择下方地址复制。', + mandatoryAddress: '下载地址', + mandatoryNotification: '返回应用确认安装并重启。', } as const satisfies DesktopMessages /** Locale payload exposed to the Desktop-owned renderer. */ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index bc5a801212..f9bfb78409 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,8 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** 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 { join } from 'node:path' import { fileURLToPath } from 'node:url' import { app, @@ -9,6 +10,7 @@ import { dialog, ipcMain, Menu, + powerMonitor, nativeTheme, protocol, session, @@ -17,8 +19,8 @@ import { type MenuItemConstructorOptions, } from 'electron' import { resolveDesktopPaths } from './paths.ts' -import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' -import { DesktopHostProcess } from './host-process.ts' +import { DesktopProjectManager } from './project-manager.ts' +import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' @@ -27,17 +29,33 @@ import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' +import { DesktopUpdateJournal } from './update-journal.ts' +import { DesktopUpdatePreparationError } from './update-error.ts' +import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts' +import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts' +import { desktopErrorState } from './startup-error.ts' +import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts' +import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts' +import { DesktopPolicyTestAuth } from './policy-test-auth.ts' +import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' +import { readDesktopRuntime } from './runtime-tree.ts' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} let shuttingDown = false +let windowsLanguage: string | undefined + +function currentDesktopLocale(): ReturnType { + return resolveDesktopLocale(windowsLanguage ?? app.getLocale()) +} const recovery = new DesktopFatalRecovery({ - messages: () => resolveDesktopLocale(app.getLocale()).messages, + messages: () => currentDesktopLocale().messages, show: options => dialog.showMessageBox(options), stop: () => { shuttingDown = true; return stopForRecovery() }, disablePlugins: async () => { const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources()) - await manager.disableAllPlugins() + const backupPath = await manager.disableAllPlugins() + console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' }) }, exit: () => { app.quit() }, restart: () => { app.relaunch(); app.quit() }, @@ -61,13 +79,6 @@ protocol.registerSchemesAsPrivileged([{ }, }]) -const MIME: Readonly> = { - '.css': 'text/css; charset=utf-8', - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.svg': 'image/svg+xml', -} - interface RuntimeResources { readonly nodeBin: string readonly node: string @@ -97,13 +108,18 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } -function createWindow(preload: string, show = false): BrowserWindow { +function createWindow(preload: string, show = false, primary = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, height: 840, minWidth: 880, minHeight: 600, show, + ...(process.platform === 'win32' && primary ? { + titleBarStyle: 'hidden' as const, + titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb', + symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' }, + } : {}), // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy // needs a transparent window background to show through the page. ...(process.platform === 'darwin' ? { @@ -144,7 +160,15 @@ function createWindow(preload: string, show = false): BrowserWindow { items.push({ role: 'copy', enabled: editFlags.canCopy }) } // Empty accelerators suppress Electron's default shortcut labels for native roles. - if (items.length > 0) Menu.buildFromTemplate(items.map(item => ({ ...item, accelerator: '' }))).popup({ window }) + if (items.length > 0) { + const messages = currentDesktopLocale().messages + Menu.buildFromTemplate(items.map(item => ({ + ...item, + ...(process.platform === 'win32' && item.role !== undefined && item.role in messages + ? { label: messages[item.role as keyof typeof messages] } : {}), + accelerator: '', + }))).popup({ window }) + } }) window.webContents.on('will-navigate', (event, url) => { const destination = new URL(url) @@ -158,27 +182,9 @@ function createWindow(preload: string, show = false): BrowserWindow { return window } -async function serveShellAsset(request: Request): Promise { - 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 { + const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR + const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion()) const resources = runtimeResources() const paths = resolveDesktopPaths() const development = !app.isPackaged @@ -186,18 +192,44 @@ async function main(): Promise { const manager = new DesktopProjectManager(paths, resources) let quitting = false let startup: Promise | undefined + let workspaceRecovery: Promise | undefined let mainWindow: BrowserWindow | undefined - let pluginWindow: BrowserWindow | undefined let shellInstallerOwnsQuit = false + let requireCleanStop = false + let updateStoppedHost = false + let updateStopFailure: DesktopHostUncleanExitError | undefined let updateState: DesktopUpdateState = { phase: 'idle' } + let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined + let mandatoryUI: DesktopMandatoryUpdateWindow | undefined + let policyAuth: DesktopPolicyTestAuth | undefined + const isQuitting = (): boolean => quitting + const currentMainWindow = (): BrowserWindow | undefined => mainWindow + const ordinaryDialogs = new Set() const locale = resolveDesktopLocale(app.getLocale()) const messages = locale.messages + const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), locale) + const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true + const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise => { + const controller = new AbortController() + ordinaryDialogs.add(controller) + try { + if (mainWindow === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false } + return await updateDialog.show(mainWindow, { ...options, signal: controller.signal }) + } + finally { ordinaryDialogs.delete(controller) } + } const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url)) - const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url)) const applicationUrl = `${SCHEME}://app/` let hostUrl: string | undefined let hostCookie: string | undefined let injections: readonly unknown[] = [] + const assertProductSender = (event: IpcMainInvokeEvent): void => { + assertDesktopSender(event, ['app']) + if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents + || event.senderFrame === null || event.senderFrame !== mainWindow.webContents.mainFrame) { + throw new Error('dsh desktop: rejected IPC from an unowned renderer') + } + } let navigation: { window: BrowserWindow; url: string; promise: Promise } | undefined const navigateMain = (url: string): Promise => { const window = mainWindow @@ -228,25 +260,66 @@ async function main(): Promise { if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections') injections = ready.injections }, - stop: () => host.stop(), + stop: async () => { + try { await host.stop(requireCleanStop) } + catch (error) { + if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error + // Backend cleanup succeeded; installation still rejects the unsuccessful task teardown. + updateStopFailure = error + } + }, + updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action), } }, (state) => { if (state.phase === 'error') reportFatal(new Error(state.message)) }) + const updateErrors = new WeakMap>() + const showUpdateFailure = (state: DesktopUpdateState): Promise => { + if (state.phase !== 'error') return Promise.resolve() + if (isMandatory()) { mandatoryUI?.sync(); return Promise.resolve() } + let shown = updateErrors.get(state) + if (shown === undefined) { + shown = ordinaryMessageBox({ type: 'error', title: messages.updateFailedTitle, + message: desktopUpdateErrorSummary(state, messages), + technicalDetails: state.technicalDetails ?? state.message ?? '' }).then(() => {}) + updateErrors.set(state, shown) + } + return shown + } const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => { + updateJournal?.state(state) updateState = state + mandatoryUI?.sync() for (const window of BrowserWindow.getAllWindows()) { - window.webContents.send(DESKTOP_IPC.updatesState, state) + window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state)) + } + if (state.phase === 'error' && state.failedOperation !== 'check') { + const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting + shellInstallerOwnsQuit = false + updateStoppedHost = false + if (restoreHost) { + // Only confirmed process exit permits replacement before another installation confirmation. + const hostReady = backend.start(async () => {}) + startup = hostReady + const recovery = hostReady.then(async () => { + if (quitting) return + // A replacement Host can have a new port, cookie, or boot injections even at the same URL. + navigation = undefined + await navigateMain(applicationUrl) + if (backend.host !== undefined) updateJournal?.action('workspace-ready') + }) + workspaceRecovery = recovery + void recovery.catch(reportFatal).finally(() => { + if (startup === hostReady) startup = undefined + if (workspaceRecovery === recovery) workspaceRecovery = undefined + }) + } + void showUpdateFailure(state).catch((error: unknown) => { console.error(error) }) } return state } - const hooks: DesktopProjectHooks = { - beforeChange: () => backend.stop(), - afterChange: () => backend.start(async () => {}), - } - stopForRecovery = () => backend.close() const reconcileBackend = (): Promise => { @@ -255,8 +328,10 @@ async function main(): Promise { await backend.start(async () => { await manager.applyRelease(app.isPackaged) }) + if (backend.host !== undefined) updateJournal?.action('workspace-ready') // The existing Web document resumes through the boot IPC response. })().catch((error: unknown) => { + updateJournal?.action('workspace-failed') reportFatal(error) throw error }).finally(() => { startup = undefined }) @@ -266,13 +341,65 @@ async function main(): Promise { const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { - shellInstallerOwnsQuit = true - await backend.stop() + await workspaceRecovery + await startup?.catch(() => undefined) + const host = backend.host + if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', messages.updateTasksUnavailable) + const active = await host.updateTasks('inspect') + const confirmation: Electron.MessageBoxOptions = { + type: active ? 'warning' : 'info', title: messages.updateTitle, + message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: updates.state.version ?? '' }), + detail: active ? messages.updateActiveTasksDetail + : messages.updateDownloadedDetail, + buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], + defaultId: 1, cancelId: 1, + } + if (isMandatory()) { + if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false + } else { + if (mainWindow === undefined) return false + const result = await updateDialog.show(mainWindow, confirmation) + if (result.response !== 0 || isMandatory()) return false + } + if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', messages.updateTasksUnavailable) + try { + const stillActive = await host.updateTasks('lock') + if (stillActive && !active) throw new DesktopUpdatePreparationError('tasks-changed', messages.updateTasksChanged) + mandatoryUI?.preparingRestart(stillActive) + requireCleanStop = true + updateStopFailure = undefined + await backend.stop() + updateStoppedHost = true + // The backend's async cleanup callback can assign this after the reset above. + const stopFailure = updateStopFailure as DesktopHostUncleanExitError | undefined + if (stopFailure !== undefined) throw new DesktopUpdatePreparationError('stop-failed', messages.updateStopFailed, stopFailure.message) + updateJournal?.action('install-confirmed') + shellInstallerOwnsQuit = true + } catch (error) { + if (!updateStoppedHost) await host.updateTasks('unlock').catch((unlockError: unknown) => { console.error(unlockError) }) + throw error + } finally { + requireCleanStop = false + } + return true }, ) + const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) + + const downloadUpdate = async (version: string): Promise => { + updateJournal?.action('download-requested') + const state = await updates.download(version) + if (state.phase !== 'ready' || quitting) return state + // Only a completed user-driven download opens this prompt; cancelling installation does not reopen it. + return updates.install(version) + } + protocol.handle(SCHEME, (request) => { const url = new URL(request.url) + // Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets. + // Removing this route leaves those modal windows empty while their parent remains blocked. + if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer')) if (url.hostname === 'app') { if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/') || ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) { @@ -283,7 +410,6 @@ async function main(): Promise { } return forwardWebRequest(request, hostUrl, hostCookie) } - if (url.hostname === 'shell') return serveShellAsset(request) return Promise.resolve(new Response(null, { status: 404 })) }) @@ -318,132 +444,231 @@ async function main(): Promise { callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } }) }) - const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters[0]): Promise => { - assertDesktopSender(event, ['shell']) - await startup?.catch(() => undefined) - if (recovery.active) throw new Error(messages.fatalSummary) - try { - await manager.mutate(mutation, hooks) - } finally { - if (backend.state.phase === 'ready') { - navigation = undefined - await navigateMain(applicationUrl).catch(reportFatal) - } - } - } - - ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { - assertDesktopSender(event, ['shell']) - return locale - }) // Only the main window may synchronize its palette with the native material. ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => { if (mainWindow === undefined || event.sender !== mainWindow.webContents) return if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source }) - ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => { - assertDesktopSender(event, ['shell']) - return manager.listPlugins() + ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { + assertProductSender(event) + return presentDesktopUpdate(updates.state) }) - ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => { - if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string') - return mutate(event, { type: 'plugin-add', spec }) - }) - ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => { - if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string') - return mutate(event, { type: 'plugin-remove', name }) - }) - ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => { - if (typeof name !== 'string' || typeof version !== 'string') { - throw new Error('dsh desktop: plugin name and version must be strings') - } - return mutate(event, { type: 'plugin-update', name, version }) - }) - ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => { - if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request') - return mutate(event, { type: 'plugin-toggle', name, enabled }) - }) - ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { - assertDesktopSender(event, ['shell']) - return updates.check() - }) - ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => { - assertDesktopSender(event, ['shell']) - await updates.install() + ipcMain.handle(DESKTOP_IPC.updatesOpen, async (event) => { + assertProductSender(event) + await openUpdatePrompt() }) - const checkAndPrompt = async (manual: boolean): Promise => { - const state = await updates.check() - if (state.phase === 'error') { - if (manual) { - await dialog.showMessageBox({ - type: 'error', - title: messages.updateCheckFailedTitle, - message: state.message ?? messages.unknownError, - }) + let promptOperation: Promise | undefined + let policyAuthenticationQueued = false + const openUpdatePrompt = (manual = false): Promise => { + if (authenticationOperation !== undefined) { + policyAuth?.focus(); updateDialog.focus() + } + let failedOperation: 'check' | 'download' | 'install' = 'check' + promptOperation ??= Promise.resolve().then(async () => { + if (manual) updateJournal?.action('check-requested') + const joinedPolicyAuthentication = authenticationOperation !== undefined + if (joinedPolicyAuthentication) await authenticatePolicy() + if (isMandatory()) { + mandatoryUI?.focus() + if (manual) await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) + return } + let controller: AbortController | undefined + let progress: Promise | undefined + try { + let state = updates.state + if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { + controller = new AbortController() + ordinaryDialogs.add(controller) + progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle, + message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal }) + if (!joinedPolicyAuthentication) { + void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) }) + } + state = await updateSchedule.check(true) + } + if (isMandatory()) { mandatoryUI?.focus(); return } + if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return } + if (state.phase === 'idle') { + await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle, + message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) }) + return + } + if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) { + if (state.version !== undefined) { + failedOperation = 'install' + await showUpdateFailure(await updates.install(state.version)) + } + return + } + if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return + if (manual) { + const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable, + detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }), + buttons: [messages.updateDownload], cancelId: 1 }) + if (result.response !== 0) return + } + if (!isMandatory() && state.version !== undefined) { + controller?.abort() + failedOperation = 'download' + await showUpdateFailure(await downloadUpdate(state.version)) + } + } finally { + controller?.abort() + if (controller !== undefined) ordinaryDialogs.delete(controller) + await progress + } + }).catch((error: unknown) => showUpdateFailure({ phase: 'error', failedOperation, + message: desktopErrorState(error).message })) + .finally(() => { promptOperation = undefined; flushQueuedPolicyAuthentication() }) + return promptOperation + } + + let authenticationOperation: Promise | undefined + const authenticatePolicy = () => { + if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() } + authenticationOperation ??= runPolicyAuthentication().finally(() => { authenticationOperation = undefined }) + return authenticationOperation + } + const flushQueuedPolicyAuthentication = (): void => { + if (!policyAuthenticationQueued || promptOperation !== undefined || authenticationOperation !== undefined + || isMandatory() || quitting) return + policyAuthenticationQueued = false + void authenticatePolicy().catch((error: unknown) => { console.error(error) }) + } + const queuePolicyAuthentication = (): void => { + if (authenticationOperation !== undefined) { + policyAuth?.focus(); updateDialog.focus() return } - if (state.phase !== 'available') { - if (manual) { - await dialog.showMessageBox({ - type: 'info', - title: messages.updateCheckTitle, - message: state.message ?? messages.updateCurrent, - }) - } - return + policyAuthenticationQueued = true + flushQueuedPolicyAuthentication() + } + const runPolicyAuthentication = async () => { + if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined + const parent = mandatoryUI?.confirmationWindow ?? mainWindow + if (parent === undefined) return undefined + const consent = await updateDialog.show(parent, { type: 'info', title: messages.policyLoginTitle, + message: messages.policyLoginRequired, buttons: [messages.policyLogin, messages.later], cancelId: 1 }) + if (consent.response !== 0 || isQuitting()) return undefined + const outcome = await policyAuth.login() + if (isQuitting() || outcome === 'cancelled') return undefined + if (outcome === 'failed') { + await updateDialog.show(parent, { type: 'error', title: messages.policyLoginTitle, + message: messages.policyLoginFailed, buttons: [messages.updateAcknowledge], cancelId: 0 }) + return undefined } - 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, + // Drain a pre-login request before asking the server to evaluate the new cookies. + await mandatoryPolicy.check('login-return') + if (isQuitting()) return undefined + return mandatoryPolicy.check('login-return', true) + } + + const checkPolicyManually = async (authentication: 'immediate' | 'deferred' = 'immediate') => { + if (authenticationOperation !== undefined) return authenticatePolicy() + const policy = await mandatoryPolicy?.check('manual', true) + if (policy?.error !== 'authentication-required') return policy + if (authentication === 'immediate') return authenticatePolicy() + queuePolicyAuthentication() + return policy + } + + const automaticCheck = (): void => { + if (!quitting) void mandatoryPolicy?.check('foreground-or-resume').catch((error: unknown) => { console.error(error) }) + if (!quitting) void updateSchedule.check().catch((error: unknown) => { console.error(error) }) + } + powerMonitor.on('resume', automaticCheck) + app.on('will-quit', () => { + updateSchedule.dispose() + powerMonitor.off('resume', automaticCheck) + updates.dispose() + }) + + app.setAboutPanelOptions({ + applicationName: 'DeepSeek Harness', + applicationVersion: app.getVersion(), + // The release has no separate build number; omit Electron's bundle version. + version: '', + copyright: '', + iconPath: development ? join(app.getAppPath(), 'resources', 'icon-windows.png') + : join(process.resourcesPath, 'icon.png'), + }) + // A custom application menu replaces Electron's default menu, so macOS needs + // its standard menus and application hide commands declared explicitly. + const darwin = process.platform === 'darwin' + const platformMenus: MenuItemConstructorOptions[] = darwin + ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] + : [{ role: 'editMenu' }] + const hideCommands: MenuItemConstructorOptions[] = darwin + ? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] + : [] + const applicationItems = (): MenuItemConstructorOptions[] => [ + { label: currentDesktopLocale().messages.aboutMenu, role: 'about' }, + { type: 'separator' }, + { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } }, + { type: 'separator' }, + ...hideCommands, + { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, + ] + Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ + label: darwin ? app.name : currentDesktopLocale().messages.application, + submenu: applicationItems(), + }, ...platformMenus])) + + if (process.platform === 'win32') { + ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => { + assertDesktopSender(event, ['app']) + if (mainWindow === undefined || event.sender !== mainWindow.webContents + || event.senderFrame !== mainWindow.webContents.mainFrame) throw new Error('desktop menu: rejected sender') + if ((name !== 'application' && name !== 'edit') + || typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y) + || x < 0 || y < 0 || x > 100_000 || y > 100_000) throw new Error('desktop menu: invalid popup request') + const window = mainWindow + // Editor-owned history listens to key events rather than Chromium's native undo stack. + const editItem = (label: string, keyCode: string, modifiers: Array<'control'>, accelerator?: string): MenuItemConstructorOptions => ({ + label, + ...(accelerator === undefined ? {} : { accelerator }), + click: () => { + window.webContents.focus() + window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }) + window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }) + }, }) - } + const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [ + editItem(currentDesktopLocale().messages.undo, 'Z', ['control'], 'Ctrl+Z'), + editItem(currentDesktopLocale().messages.redo, 'Y', ['control'], 'Ctrl+Y'), + { type: 'separator' }, + editItem(currentDesktopLocale().messages.cut, 'X', ['control'], 'Ctrl+X'), + editItem(currentDesktopLocale().messages.copy, 'C', ['control'], 'Ctrl+C'), + editItem(currentDesktopLocale().messages.paste, 'V', ['control'], 'Ctrl+V'), + editItem(currentDesktopLocale().messages.delete, 'Delete', []), + { type: 'separator' }, + editItem(currentDesktopLocale().messages.selectAll, 'A', ['control'], 'Ctrl+A'), + ] + const zoom = mainWindow.webContents.getZoomFactor() + return new Promise((resolve) => { + Menu.buildFromTemplate(items).popup({ window, x: Math.round(x * zoom), y: Math.round(y * zoom), callback: resolve }) + }) + }) + ipcMain.on(DESKTOP_IPC.windowsAppearance, (event, language: unknown, color: unknown, symbolColor: unknown) => { + if (mainWindow === undefined || event.sender !== mainWindow.webContents + || event.senderFrame !== mainWindow.webContents.mainFrame) return + if (!event.senderFrame.url.startsWith(`${SCHEME}://app/`)) return + if (typeof language === 'string' && /^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/u.test(language)) { + windowsLanguage = language + } + // Empty colors precede client stylesheet installation; only CSS color values cross IPC. + const validColor = (value: unknown): value is string => typeof value === 'string' + && /^(?:#[\da-f]{3,8}|rgba?\([\d.,%\s]+\))$/iu.test(value) + if (validColor(color) && validColor(symbolColor)) mainWindow.setTitleBarOverlay({ color, symbolColor }) + }) } - 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: messages.pluginsMenu, - accelerator: 'CmdOrCtrl+,', - click: openPluginWindow, - }, - { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } }, - { type: 'separator' }, - { role: 'quit' }, - ], - }, { role: 'editMenu' }])) - const createMainWindow = (): BrowserWindow => { - const window = createWindow(appPreload, true) + const window = createWindow(appPreload, true, true) mainWindow = window + window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => { if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) { @@ -462,6 +687,8 @@ async function main(): Promise { return window } focusPrimaryWindow = () => { + if (quitting) return + if (isMandatory()) { mandatoryUI?.focus(); return } const window = mainWindow if (window === undefined || window.isDestroyed()) { try { createMainWindow() } catch (error) { reportFatal(error); return } @@ -481,23 +708,77 @@ async function main(): Promise { }) app.on('before-quit', (event) => { shuttingDown = true - if (shellInstallerOwnsQuit || quitting) return + updateJournal?.action('quit-requested') + if (shellInstallerOwnsQuit) { + updateDialog.dispose() + mandatoryUI?.dispose() + return + } + if (quitting) return event.preventDefault() quitting = true - void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) + if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide() + updateSchedule.dispose() + updateDialog.dispose() + mandatoryUI?.dispose() + void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close()]) + .catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) }) mainWindow = createMainWindow() + const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) + if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest') + const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG + const policyInput: unknown = app.isPackaged + ? ('dshMandatoryUpdatePolicy' in manifest ? manifest.dshMandatoryUpdatePolicy : undefined) + : developmentPolicy === undefined ? undefined : JSON.parse(developmentPolicy) as unknown + const policyConfig = resolveDesktopPolicyConfig(policyInput, !app.isPackaged) + if (policyConfig !== undefined) { + if (policyConfig.authentication === 'feishu-test') { + policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, locale, () => mandatoryUI?.confirmationWindow ?? mainWindow, + (event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) }) + } + const bundleId = app.isPackaged + ? ('dshDesktopAppId' in manifest ? manifest.dshDesktopAppId : undefined) + : process.env.DSH_DESKTOP_APP_ID + if (typeof bundleId !== 'string' || bundleId.trim() === '') throw new Error('desktop policy: missing application bundle ID') + if (!['win32', 'darwin'].includes(process.platform) || !['x64', 'arm64'].includes(process.arch)) throw new Error('desktop policy: unsupported platform') + let wasBlocking = false + mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { + platform: process.platform === 'win32' ? 'desktop-win' : 'desktop-mac', arch: process.arch as 'x64' | 'arm64', + version: app.getVersion(), bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), + bundleId, locale: locale.id, + }, (state) => { + if (state.error !== 'authentication-required') policyAuthenticationQueued = false + if (state.blocking) { + for (const controller of ordinaryDialogs) controller.abort() + if (!wasBlocking) updateDialog.cancel() + } + mandatoryUI?.sync() + if (state.blocking && !wasBlocking) void updateSchedule.check(false, true).catch((error: unknown) => { console.error(error) }) + wasBlocking = state.blocking + }, policyAuth?.request) + const policy = mandatoryPolicy + mandatoryUI = new DesktopMandatoryUpdateWindow({ + preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale, + allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow, + policy: () => policy.state, update: () => updates.state, + refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) }, + download: downloadUpdate, install: version => updates.install(version), + }) + void mandatoryPolicy.check('launch').then((state) => { + if (app.isPackaged && state.error === 'authentication-required' && !isQuitting()) queuePolicyAuthentication() + }).catch((error: unknown) => { console.error(error) }) + } + automaticCheck() await reconcileBackend().catch(() => undefined) // Window lifecycle callbacks run while backend startup is pending. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (quitting) return - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (mainWindow !== undefined && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { - mainWindow.webContents.openDevTools({ mode: 'detach' }) + if (isQuitting()) return + const window = currentMainWindow() + if (window !== undefined && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + window.webContents.openDevTools({ mode: 'detach' }) } publishUpdate(updateState) - setTimeout(() => { void checkAndPrompt(false) }, 10_000) } const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() }) diff --git a/apps/desktop/src/mandatory-update-ipc.ts b/apps/desktop/src/mandatory-update-ipc.ts new file mode 100644 index 0000000000..f09f77a8fa --- /dev/null +++ b/apps/desktop/src/mandatory-update-ipc.ts @@ -0,0 +1,4 @@ +/** Dependency-free IPC names shared with the sandboxed mandatory-update preload. */ +export const MANDATORY_IPC = { + status: 'dsh-desktop:mandatory-status', state: 'dsh-desktop:mandatory-state', action: 'dsh-desktop:mandatory-action', +} as const diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts new file mode 100644 index 0000000000..1ccc068969 --- /dev/null +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -0,0 +1,223 @@ +/** Mandatory-update policy, independent of local business traffic and updater artifacts. */ + +import { valid } from 'semver' + +/** Installed release identity; no field is supplied by a renderer. */ +export interface DesktopPolicyIdentity { + readonly platform: 'desktop-win' | 'desktop-mac' + readonly version: string + readonly bundledDshVersion: string + readonly bundleId: string + readonly locale: string + readonly arch: 'x64' | 'arm64' +} + +/** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */ +export interface DesktopPolicyConfig { + readonly origin: string + readonly allowedPageOrigins: readonly string[] + readonly intervalMs: number + readonly timeoutMs: number + readonly maxBackoffMs: number + readonly jitter: number + readonly authentication: 'anonymous' | 'feishu-test' +} + +/** A known block survives transport and parsing failures, but not a fresh no-force success. */ +export interface DesktopPolicyState { + readonly blocking: boolean + readonly checking: boolean + readonly title?: string + readonly detail?: string + readonly page?: string + readonly error?: 'unavailable' | 'authentication-required' +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : undefined +} + +function origin(value: unknown, local: boolean): string { + if (typeof value !== 'string') throw new Error('desktop policy: origin must be a URL') + const url = new URL(value) + if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '' + || (url.protocol !== 'https:' && !(local && url.protocol === 'http:' && url.hostname === '127.0.0.1'))) { + throw new Error('desktop policy: expected an HTTPS origin without credentials, path, query, or fragment') + } + return url.origin +} + +/** + * Resolve deployment JSON without guessing a production service or download destination. + * @param input - Parsed configuration with origin and allowedPageOrigins; absent configuration disables policy queries. + * @param allowLoopback - Explicit unpackaged/test permission for an HTTP 127.0.0.1 policy origin only. + * @returns Validated polling options, or undefined when unconfigured. + */ +export function resolveDesktopPolicyConfig(input: unknown, allowLoopback = false): DesktopPolicyConfig | undefined { + if (input === undefined) return undefined + const value = record(input) + if (value === undefined || !Array.isArray(value.allowedPageOrigins) || value.allowedPageOrigins.length === 0) { + throw new Error('desktop policy: configure origin and a nonempty allowedPageOrigins list') + } + const fields = value + function duration(key: string, fallback: number): number { + const duration = fields[key] ?? fallback + if (typeof duration !== 'number' || !Number.isSafeInteger(duration) || duration < 1_000 || duration > 2_147_483_647) { + throw new Error(`desktop policy: ${key} must be an integer from 1000 through 2147483647`) + } + return duration + } + const intervalMs = duration('intervalMs', 600_000) + const maxBackoffMs = duration('maxBackoffMs', 3_600_000) + const jitter = value.jitter ?? 0.2 + const authentication = value.authentication ?? 'anonymous' + if (authentication !== 'anonymous' && authentication !== 'feishu-test') { + throw new Error('desktop policy: authentication must be anonymous or feishu-test') + } + if (typeof jitter !== 'number' || !Number.isFinite(jitter) || jitter < 0 || jitter > 1 || maxBackoffMs < intervalMs) { + throw new Error('desktop policy: jitter must be in [0, 1] and maxBackoffMs must cover intervalMs') + } + return { + origin: origin(value.origin, authentication === 'anonymous' && allowLoopback), + allowedPageOrigins: value.allowedPageOrigins.map(item => origin(item, false)), + intervalMs, timeoutMs: duration('timeoutMs', 15_000), maxBackoffMs, jitter, authentication, + } +} + +/** + * Validate the fallback page immediately before browser or clipboard use. + * @param value - Policy-provided page, never an updater feed or shell command. + * @param allowedOrigins - Exact HTTPS origins from deployment configuration. + * @returns Normalized allowed URL, or undefined for a missing/disallowed destination. + */ +export function desktopPolicyPage(value: unknown, allowedOrigins: readonly string[]): string | undefined { + if (typeof value !== 'string' || value.length > 2048) return undefined + let url: URL + try { url = new URL(value) } catch { return undefined } + return url.protocol === 'https:' && url.username === '' && url.password === '' && allowedOrigins.includes(url.origin) + ? url.href : undefined +} + +function text(value: unknown, limit: number): string | undefined { + return typeof value === 'string' && value.trim() !== '' && value.length <= limit ? value : undefined +} + +function parsePolicy(body: unknown, ok: boolean, config: DesktopPolicyConfig): DesktopPolicyState { + const root = record(body) + const data = record(root?.data) + if (root?.code === 40005) { + const content = record(data?.show_content) + const title = text(content?.title, 256) + const detail = text(content?.detail, 16_384) + const page = desktopPolicyPage(data?.desktop_app_link, config.allowedPageOrigins) + return { blocking: true, checking: false, + ...(title === undefined ? {} : { title }), ...(detail === undefined ? {} : { detail }), ...(page === undefined ? {} : { page }) } + } + if (ok && root?.code === 0 && data?.biz_code === 0 && data.biz_data === null) return { blocking: false, checking: false } + throw new Error('desktop policy: response does not contain a valid mandatory or no-force decision') +} + +/** Owns one immutable installed-client context, its in-flight request, and polling schedule. */ +export class DesktopMandatoryUpdatePolicy { + private current: DesktopPolicyState = { blocking: false, checking: false } + private pending: Promise | undefined + private controller: AbortController | undefined + private timer: ReturnType | undefined + private disposed = false + private failures = 0 + private nextCheck = -Infinity + private readonly headers: Readonly> + + /** + * @param config - Resolved deployment settings. + * @param identity - Installed software identity, copied once for this lifetime. + * @param publish - Receives policy changes without controlling downloads or existing tasks. + * @param request - Anonymous Fetch or the dedicated test-authentication Session transport. + * @param random - Jitter source, replaceable for clock-driven tests. + */ + constructor( + private readonly config: DesktopPolicyConfig, + identity: DesktopPolicyIdentity, + private readonly publish: (state: DesktopPolicyState) => void, + private readonly request: typeof fetch = fetch, + private readonly random: () => number = Math.random, + ) { + if (valid(identity.version) === null || valid(identity.bundledDshVersion) === null || identity.bundleId.trim() === '' + || (identity.platform === 'desktop-win' && identity.arch !== 'x64')) throw new Error('desktop policy: invalid installed client identity') + this.headers = Object.freeze({ + 'x-client-platform': identity.platform, 'x-client-version': identity.version, + 'x-client-bundle-id': identity.bundleId, 'x-client-locale': identity.locale, + 'x-client-arch': identity.arch, 'x-client-update-channel': 'nightly', + 'x-client-bundled-dsh-version': identity.bundledDshVersion, + }) + } + + /** Latest policy; failures never erase a known mandatory decision. */ + get state(): DesktopPolicyState { return this.current } + + /** + * Check immediately when manual, otherwise only when due; matching concurrent callers share one request. + * @param scenario - Trigger recorded in the query, independent of backend matching. + * @param manual - Bypass interval/backoff without bypassing request coalescing. + * @returns Current decision or retained decision with an error; disposed instances reject. + */ + check(scenario: string, manual = false): Promise { + if (this.disposed) return Promise.reject(new Error('desktop policy: disposed')) + if (this.pending !== undefined) return this.pending + if (!manual && Date.now() < this.nextCheck) return Promise.resolve(this.current) + clearTimeout(this.timer) + this.pending = Promise.resolve().then(async () => { + if (this.disposed) return this.current + const controller = new AbortController() + this.controller = controller + const timeout = setTimeout(() => { controller.abort() }, this.config.timeoutMs) + this.setState({ ...this.current, checking: true }) + try { + const url = new URL('/api/v0/check_client_update', this.config.origin) + url.searchParams.set('scenario', scenario) + const response = await this.request(url, { headers: this.headers, signal: controller.signal, + credentials: this.config.authentication === 'feishu-test' ? 'include' : 'omit', cache: 'no-store', redirect: 'error' }) + const body: unknown = await response.json() + if (this.config.authentication === 'feishu-test' && response.status === 401 + && record(record(body)?.error)?.code === 'UNAUTHENTICATED') { + this.failures++ + this.setState({ ...this.current, checking: false, error: 'authentication-required' }) + return this.current + } + const state = parsePolicy(body, response.ok, this.config) + this.failures = 0 + this.setState(state) + } catch { + // Transport, JSON, and protocol failures retain any previously established block. + this.failures++ + this.setState({ ...this.current, checking: false, error: 'unavailable' }) + } finally { + clearTimeout(timeout) + this.controller = undefined + } + return this.current + }).finally(() => { + this.pending = undefined + if (this.disposed) return + const base = Math.min(this.config.maxBackoffMs, this.config.intervalMs * 2 ** Math.min(this.failures, 20)) + const delay = Math.min(this.config.maxBackoffMs, Math.round(base * (1 + this.random() * this.config.jitter))) + this.nextCheck = Date.now() + delay + this.timer = setTimeout(() => { void this.check('periodic') }, delay) + }) + return this.pending + } + + /** Abort the owned request and await settlement; late responses cannot publish or schedule work. */ + async dispose(): Promise { + this.disposed = true + clearTimeout(this.timer) + this.controller?.abort() + await this.pending + } + + private setState(state: DesktopPolicyState): void { + if (this.disposed) return + this.current = state + this.publish(state) + } +} diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts new file mode 100644 index 0000000000..17438ab15e --- /dev/null +++ b/apps/desktop/src/mandatory-update-window.ts @@ -0,0 +1,264 @@ +/** Shell-owned modal policy UI; only explicit actions authorize downloads or browser navigation. */ + +import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' +import type { DesktopLocale } from './locale.ts' +import type { DesktopUpdateState } from './ipc.ts' +import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts' +import { MANDATORY_IPC } from './mandatory-update-ipc.ts' +import { createMandatoryUpdateWindow } from './update-overlay.ts' +import { DesktopUpdateAttention } from './update-attention.ts' + +/** A renderer action never carries a URL or authorizes a different version. */ +export type MandatoryUpdateAction = 'refresh' | 'download' | 'install' | 'later' | 'page' | 'copy' + +/** Combined view rendered as text by the shell-owned page. */ +export interface MandatoryUpdateView { + readonly locale: DesktopLocale + readonly policy: DesktopPolicyState + readonly update: DesktopUpdateState + readonly error?: string + readonly confirmation?: { readonly version: string; readonly active: boolean; readonly revision: number } + readonly deferred: boolean + readonly restart?: 'stopping-tasks' | 'preparing' + readonly navigation?: { readonly page: 'requested' | 'failed'; readonly copy?: 'copied' | 'failed' } +} + +/** Narrow isolated bridge, absent from product and plugin documents. */ +export interface MandatoryUpdateApi { + status(): Promise + action(action: MandatoryUpdateAction, version?: string, confirmationRevision?: number): Promise + subscribe(listener: (state: MandatoryUpdateView) => void): () => void +} + +/** Main-process operations owned by the policy client, updater, and application lifecycle. */ +export interface MandatoryUpdateWindowOptions { + readonly preload: string + readonly locale: DesktopLocale + readonly allowedPageOrigins: readonly string[] + readonly parent: () => BrowserWindow | undefined + readonly policy: () => DesktopPolicyState + readonly update: () => DesktopUpdateState + readonly refresh: () => Promise + readonly download: (version: string) => Promise + readonly install: (version: string) => Promise +} + +// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. +const page = 'dsh-app://shell/mandatory-update.html' + +/** A modal child blocks the product window without cancelling work in the Host. */ +export class DesktopMandatoryUpdateWindow { + private window: BrowserWindow | undefined + private closing: ReturnType | undefined + private disposed = false + private error: string | undefined + private action: Promise | undefined + private confirmation: { version: string; active: boolean; revision: number; resolve: (approved: boolean) => void } | undefined + private confirmationRevision = 0 + private deferred = false + private restart: MandatoryUpdateView['restart'] + private navigation: MandatoryUpdateView['navigation'] + private navigationUrl: string | undefined + private navigationEpoch = 0 + private readonly attention: DesktopUpdateAttention + + /** @param options - Main-process actions and immutable deployment/navigation settings. */ + constructor(private readonly options: MandatoryUpdateWindowOptions) { + this.attention = new DesktopUpdateAttention(options.locale) + ipcMain.handle(MANDATORY_IPC.status, (event) => { this.assertSender(event); return this.view() }) + ipcMain.handle(MANDATORY_IPC.action, (event, action: unknown, version: unknown, confirmationRevision: unknown) => { + this.assertSender(event) + if (!this.options.policy().blocking) throw new Error('desktop policy: no mandatory decision is active') + if (typeof action !== 'string' || !['refresh', 'download', 'install', 'later', 'page', 'copy'].includes(action)) throw new Error('desktop policy: invalid action') + if (['download', 'install', 'later'].includes(action) && typeof version !== 'string') throw new Error('desktop policy: missing confirmed version') + if (action === 'page' || action === 'copy') return this.navigate(action) + if (this.confirmation !== undefined && (action === 'install' || action === 'later')) { + if (version !== this.confirmation.version || confirmationRevision !== this.confirmation.revision) { + throw new Error('desktop policy: stale installation confirmation') + } + if (action === 'later' && !this.confirmation.active) throw new Error('desktop policy: no task deferral is offered') + this.deferred = action === 'later' + this.finishConfirmation(action === 'install') + this.sync() + return Promise.resolve() + } + if (action === 'later') throw new Error('desktop policy: no installation confirmation') + this.action ??= Promise.resolve().then(async () => { + this.error = undefined + this.restart = undefined + this.deferred = false + this.clearNavigation() + if (action === 'download') this.attention.reset() + this.sync() + switch (action) { + case 'refresh': await this.options.refresh(); break + case 'download': await this.options.download(version as string); break + case 'install': await this.options.install(version as string); break + } + }).catch(() => { + this.error = this.options.locale.messages.mandatoryActionFailed + }).finally(() => { this.action = undefined; this.sync() }) + return this.action + }) + } + + /** Active modal used as the owner of shell installation-confirmation dialogs. */ + get confirmationWindow(): BrowserWindow | undefined { return this.window } + + /** + * @param version - Updater-owned target, already downloaded and verified. + * @param active - Fresh Host task inspection; unknown state must fail before calling. + * @returns Explicit approval from this same modal, or false on deferral, policy clearance, or disposal. + */ + confirm(version: string, active: boolean): Promise { + if (this.disposed || !this.options.policy().blocking) return Promise.resolve(false) + this.finishConfirmation(false) + this.deferred = false + this.restart = undefined + return new Promise((resolve) => { + this.confirmation = { version, active, revision: ++this.confirmationRevision, resolve } + this.sync() + const parent = this.options.parent() + if (parent !== undefined && this.window !== undefined) { + this.attention.ready(version, parent, this.window, () => { + if (!this.disposed && this.options.policy().blocking && this.confirmation !== undefined) this.focus() + }) + } else this.finishConfirmation(false) + }) + } + + /** @param active - Whether admitted tasks are actually being stopped after installation approval. */ + preparingRestart(active: boolean): void { + this.restart = active ? 'stopping-tasks' : 'preparing' + this.attention.clear() + this.sync() + } + + /** Publish current status, create the block immediately, or close it only after policy clearance. */ + sync(): void { + if (this.disposed) return + if (!this.options.policy().blocking) { + this.finishConfirmation(false) + this.attention.reset() + this.clearNavigation() + this.restart = undefined + this.deferred = false + if (this.window !== undefined && this.closing === undefined) { + const window = this.window + window.webContents.send(MANDATORY_IPC.state, this.view()) + this.closing = setTimeout(() => { + this.closing = undefined + if (this.window === window) this.window = undefined + window.destroy() + }, 150) + } + this.error = undefined + return + } + clearTimeout(this.closing) + this.closing = undefined + if (this.navigationUrl !== this.options.policy().page) this.clearNavigation() + if (this.options.update().phase === 'error') this.restart = undefined + if (this.window === undefined) { + const parent = this.options.parent() + if (parent === undefined) return + const window = createMandatoryUpdateWindow(parent, this.options.preload, this.options.locale.messages.mandatoryTitle) + this.window = window + window.setMenu(null) + window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } }) + window.on('closed', () => { if (this.window === window) this.window = undefined }) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() }) + window.webContents.on('render-process-gone', () => { + if (!this.disposed) void window.loadURL(page).catch(() => { + // A failed recovery keeps the parent blocked and leaves application exit available. + if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed) + }) + }) + void window.loadURL(page).catch(() => { + // The parent stays modal-blocked if its dedicated recovery document cannot load. + if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed) + }) + } + this.window.webContents.send(MANDATORY_IPC.state, this.view()) + } + + /** Focus the block instead of opening ordinary product or plugin interactions. */ + focus(): void { + this.sync() + const parent = this.options.parent() + if (parent?.isMinimized()) parent.restore() + parent?.show() + this.window?.show() + this.window?.focus() + } + + /** Detach IPC and release the modal during application shutdown. */ + dispose(): void { + this.disposed = true + clearTimeout(this.closing) + this.closing = undefined + this.finishConfirmation(false) + this.attention.reset() + this.clearNavigation() + ipcMain.removeHandler(MANDATORY_IPC.status) + ipcMain.removeHandler(MANDATORY_IPC.action) + this.window?.destroy() + this.window = undefined + } + + private view(): MandatoryUpdateView { + return { locale: this.options.locale, policy: this.options.policy(), update: this.options.update(), + deferred: this.deferred, + ...(this.confirmation === undefined ? {} + : { confirmation: { version: this.confirmation.version, active: this.confirmation.active, revision: this.confirmation.revision } }), + ...(this.restart === undefined ? {} : { restart: this.restart }), + ...(this.navigation === undefined ? {} : { navigation: this.navigation }), + ...(this.error === undefined ? {} : { error: this.error }) } + } + + private finishConfirmation(approved: boolean): void { + const confirmation = this.confirmation + this.confirmation = undefined + this.attention.clear() + confirmation?.resolve(approved) + } + + private clearNavigation(): void { + this.navigation = undefined + this.navigationUrl = undefined + this.navigationEpoch++ + } + + private async navigate(action: 'page' | 'copy'): Promise { + const url = desktopPolicyPage(this.options.policy().page, this.options.allowedPageOrigins) + if (url === undefined) throw new Error('desktop policy: no allowed download page') + if (this.navigationUrl !== url) this.clearNavigation() + this.navigationUrl = url + if (action === 'page') { + this.navigation = { page: 'requested' } + this.navigationEpoch++ + } + const epoch = this.navigationEpoch + this.sync() + try { + if (action === 'copy') { + await clipboard.writeText(url) + if (await clipboard.readText() !== url) throw new Error('desktop policy: clipboard did not retain download address') + } + else await shell.openExternal(url) + if (epoch !== this.navigationEpoch || this.disposed) return + if (action === 'copy') this.navigation = { page: this.navigation?.page ?? 'requested', copy: 'copied' } + } catch { + if (epoch !== this.navigationEpoch || this.disposed) return + this.navigation = action === 'page' ? { ...this.navigation, page: 'failed' } + : { page: this.navigation?.page ?? 'requested', copy: 'failed' } + } + this.sync() + } + + private assertSender(event: IpcMainInvokeEvent): void { + if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame + || event.senderFrame.url !== page) throw new Error('desktop policy: rejected unowned renderer') + } +} diff --git a/apps/desktop/src/owned-directory.ts b/apps/desktop/src/owned-directory.ts new file mode 100644 index 0000000000..0caad07ca8 --- /dev/null +++ b/apps/desktop/src/owned-directory.ts @@ -0,0 +1,26 @@ +/** Desktop transaction cleanup that unlinks directory links without visiting their targets. */ + +import { lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Remove an owned directory and its contents, unlinking root and nested links. + * Missing roots are ignored; existing roots must be directories or links. + * @param path - Owned directory or link to remove; link targets are preserved. + */ +export function removeOwnedDirectory(path: string): void { + const stat = lstatSync(path, { throwIfNoEntry: false }) + if (stat === undefined) return + if (stat.isSymbolicLink()) { + unlinkSync(path) + return + } + if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) + // Electron's recursive rm follows nested Windows junctions into installed resources. + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name) + if (entry.isDirectory()) removeOwnedDirectory(child) + else unlinkSync(child) + } + rmdirSync(path) +} diff --git a/apps/desktop/src/policy-test-auth.ts b/apps/desktop/src/policy-test-auth.ts new file mode 100644 index 0000000000..b7d5d65208 --- /dev/null +++ b/apps/desktop/src/policy-test-auth.ts @@ -0,0 +1,160 @@ +/** Isolated, process-lifetime Feishu cookies for explicitly configured test policy requests. */ +import { randomUUID } from 'node:crypto' +import { BrowserWindow, session } from 'electron' +import type { DesktopLocale } from './locale.ts' + +const FEISHU_ORIGINS = ['https://open.feishu.cn', 'https://accounts.feishu.cn', + 'https://passport.feishu.cn', 'https://login.feishu.cn'] + +/** Packaged placeholder document; it is the window's first document and needs no network. */ +const LOGIN_LOADING_PAGE = 'renderer/policy-login-loading.html' + +/** File name of that placeholder, for recognizing its own load and load failures. */ +const LOGIN_LOADING_FILE = 'policy-login-loading.html' + +/** Navigation completion is not proof of authentication or a valid update policy. */ +export type DesktopPolicyLoginResult = 'returned' | 'cancelled' | 'failed' + +/** Owns the login window and its nonpersistent Session; no product window shares its cookies or privileges. */ +export class DesktopPolicyTestAuth { + private readonly browserSession = session.fromPartition(`dsh-policy-auth-${randomUUID()}`, { cache: false }) + private window: BrowserWindow | undefined + private pending: Promise | undefined + private disposed = false + private rejectLogin: (() => void) | undefined + + /** Return to an existing login window without starting another authentication flow. */ + focus(): void { this.window?.show(); this.window?.focus() } + + /** + * @param origin Validated HTTPS policy origin; login always starts at its root with fresh gateway state. + * @param locale Shell-owned login title. + * @param parent Current application or mandatory-update window. + * @param record Fixed, nonsecret login outcomes for diagnostic evidence. + */ + constructor(private readonly origin: string, private readonly locale: DesktopLocale, + private readonly parent: () => BrowserWindow | undefined, + private readonly record: (event: 'opened' | DesktopPolicyLoginResult) => void) { + this.browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) }) + this.browserSession.setPermissionCheckHandler(() => false) + this.browserSession.setDevicePermissionHandler(() => false) + this.browserSession.on('will-download', (event) => { event.preventDefault() }) + this.browserSession.webRequest.onBeforeRequest((details, callback) => { + const document = details.resourceType === 'mainFrame' || details.resourceType === 'subFrame' + const cancel = document && !this.isLoadingDocument(details.url) && !this.allowed(details.url) + callback({ cancel }) + if (cancel) this.rejectLogin?.() + }) + } + + /** + * Send only the configured policy request through the login Session; redirects remain forbidden. + * @param input Policy URL supplied by the main-process coordinator. + * @param init Request headers, credentials, and cancellation owned by that coordinator. + * @returns Chromium response without exposing cookies to JavaScript. + */ + readonly request: typeof fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (this.disposed || url.origin !== this.origin || url.pathname !== '/api/v0/check_client_update' + || url.username !== '' || url.password !== '') { + return Promise.reject(new Error('desktop policy: disallowed authenticated request')) + } + return this.browserSession.fetch(url.href, { ...init, credentials: 'include', redirect: 'error', cache: 'no-store' }) + } + + /** + * Open only after a user action; repeated callers focus and join the same login. + * @returns Navigation, cancellation, or failure; the caller must query policy after returning. + */ + login(): Promise { + if (this.disposed) return Promise.resolve('cancelled') + if (this.pending !== undefined) { this.focus(); return this.pending } + const result = Promise.withResolvers() + const parent = this.parent() + const window = new BrowserWindow({ width: 720, height: 760, ...(parent === undefined ? {} : { parent }), + title: this.locale.messages.policyLoginTitle, autoHideMenuBar: true, + webPreferences: { session: this.browserSession, nodeIntegration: false, contextIsolation: true, + sandbox: true, webSecurity: true, webviewTag: false, devTools: true, spellcheck: false } }) + this.pending = result.promise + this.window = window + let settled = false + const finish = (outcome: DesktopPolicyLoginResult): void => { + if (settled) return + settled = true + this.window = undefined + this.pending = undefined + this.rejectLogin = undefined + if (!window.isDestroyed()) window.destroy() + result.resolve(outcome) + this.record(outcome) + } + this.rejectLogin = () => { finish('failed') } + window.setMenu(null) + window.on('closed', () => { finish('cancelled') }) + window.on('page-title-updated', (event) => { event.preventDefault() }) + const contents = window.webContents + contents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown' || input.key !== 'F12' || input.isAutoRepeat) return + event.preventDefault() + contents.openDevTools({ mode: 'detach' }) + }) + contents.setWindowOpenHandler(() => ({ action: 'deny' })) + contents.on('will-navigate', (event, url) => { + if (!this.allowed(url)) { event.preventDefault(); finish('failed') } + }) + contents.on('will-redirect', (event, url) => { + if (!this.allowed(url)) { event.preventDefault(); finish('failed') } + }) + contents.on('will-attach-webview', (event) => { event.preventDefault() }) + contents.on('login', (event, _details, _authInfo, callback) => { event.preventDefault(); callback() }) + contents.on('did-fail-load', (_event, code, _description, url, mainFrame) => { + if (mainFrame && code !== -3 && !this.isLoadingDocument(url)) finish('failed') + }) + contents.on('render-process-gone', () => { finish('failed') }) + contents.on('did-navigate', (_event, value) => { + const url = new URL(value) + if (url.origin === this.origin && (url.pathname === '/' || url.pathname === '/feishu_auth_callback')) finish('returned') + }) + this.record('opened') + const loadLogin = (): void => { + if (settled || window.isDestroyed()) return + void window.loadURL(`${this.origin}/`).catch(() => { finish('failed') }) + } + // The remote page can take seconds to paint. The packaged placeholder is the + // window's first document, so the window shows local feedback immediately + // and the first committed remote document replaces it: nothing overlays the + // third-party page, and no later subresource keeps a placeholder on screen. + // A placeholder that cannot load leaves the window blank, as before. + void window.loadFile(LOGIN_LOADING_PAGE, + { query: { label: this.locale.messages.policyLoginLoading } }).then(loadLogin, loadLogin) + return result.promise + } + + /** Close the login and erase session data after the policy coordinator has stopped its requests. */ + async dispose(): Promise { + this.disposed = true + this.window?.destroy() + await this.pending + await this.browserSession.closeAllConnections() + await this.browserSession.clearStorageData() + await this.browserSession.clearAuthCache() + } + + private allowed(value: string): boolean { + let url: URL + try { url = new URL(value) } catch { return false } + return url.protocol === 'https:' && url.username === '' && url.password === '' + && (url.origin === this.origin || FEISHU_ORIGINS.includes(url.origin)) + } + + /** + * Recognize the owned placeholder document. Only the request filter and the + * load-failure handler accept it; navigation events still require {@link allowed}, + * so the remote page cannot steer the window back to a local file. + */ + private isLoadingDocument(value: string): boolean { + let url: URL + try { url = new URL(value) } catch { return false } + return url.protocol === 'file:' && url.pathname.endsWith(`/${LOGIN_LOADING_FILE}`) + } +} diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 2ab066cc81..26b7334707 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,11 +1,26 @@ -/** Context-isolated application boot, local directory picker, and desktop carrier marker. */ +/** Origin-scoped boot, native directory selection, and update presentation with native confirmation actions. */ import { contextBridge, ipcRenderer } from 'electron' -import { DESKTOP_IPC, SCHEME } from './ipc.ts' +import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { markDocumentPlatform } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' +import { syncWindowsAppearance } from './preload-windows.ts' + +const product: DshDesktopProductApi = { + protocolVersion: 1, + updates: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, + open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdatePresentation): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesPresentation, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesPresentation, handle) } + }, + }, +} if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { + syncWindowsAppearance() contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) @@ -17,4 +32,5 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { markDocumentPlatform() syncNativeTheme() -contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 }) +// Main-process IPC also verifies the owning window and top frame. +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? product : { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload-mandatory.ts b/apps/desktop/src/preload-mandatory.ts new file mode 100644 index 0000000000..6396874ec7 --- /dev/null +++ b/apps/desktop/src/preload-mandatory.ts @@ -0,0 +1,16 @@ +/** Isolated bridge for the shell-owned mandatory-update modal only. */ +import { contextBridge, ipcRenderer } from 'electron' +import type { MandatoryUpdateApi, MandatoryUpdateView } from './mandatory-update-window.ts' +import { MANDATORY_IPC } from './mandatory-update-ipc.ts' + +const api: MandatoryUpdateApi = { + status: () => ipcRenderer.invoke(MANDATORY_IPC.status) as Promise, + action: (action, version, confirmationRevision) => + ipcRenderer.invoke(MANDATORY_IPC.action, action, version, confirmationRevision) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: MandatoryUpdateView): void => { listener(state) } + ipcRenderer.on(MANDATORY_IPC.state, handle) + return () => { ipcRenderer.off(MANDATORY_IPC.state, handle) } + }, +} +if (location.href === 'dsh-app://shell/mandatory-update.html') contextBridge.exposeInMainWorld('dshMandatoryUpdate', api) diff --git a/apps/desktop/src/preload-menu.ts b/apps/desktop/src/preload-menu.ts new file mode 100644 index 0000000000..aa2f54a423 --- /dev/null +++ b/apps/desktop/src/preload-menu.ts @@ -0,0 +1,112 @@ +/** Windows caption menu labels and native popup anchors, isolated from the Web client. */ +import { ipcRenderer } from 'electron' +import { DESKTOP_IPC } from './ipc.ts' +import { resolveDesktopLocale } from './locale.ts' + +/** + * Mount the Windows caption menubar without moving focus out of the active editor. + * @returns Language refresh and document teardown operations. + */ +export function installWindowsMenu(): { update(): void; dispose(): void } { + const host = document.createElement('div') + host.dataset.windowsMenu = '' + const shadow = host.attachShadow({ mode: 'open' }) + const style = document.createElement('style') + style.textContent = ` + :host { position: fixed; top: 0; left: var(--dsh-windows-menu-start, 48px); z-index: 1100; + height: var(--dsh-windows-titlebar-height); display: flex; align-items: center; + font-family: var(--dsw-font-family); -webkit-app-region: no-drag; } + [role=menubar] { display: flex; gap: 2px; } + button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; + background: transparent; color: var(--dsw-alias-label-secondary); + font: inherit; font-size: 14px; cursor: default; } + button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); } + button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; } + ` + const bar = document.createElement('div') + bar.setAttribute('role', 'menubar') + let restoreEditor = (): void => {} + const rememberEditor = (event: FocusEvent): void => { + const target = event.composedPath()[0] + if (!(target instanceof HTMLElement) || target === host || shadow.contains(target)) return + if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement) + && !target.matches('[contenteditable="true"]')) return + const selection = document.getSelection() + const ranges = selection === null ? [] : Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i).cloneRange()) + const input = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target : undefined + const start = input?.selectionStart + const end = input?.selectionEnd + const direction = input?.selectionDirection + restoreEditor = () => { + if (!target.isConnected) return + target.focus({ preventScroll: true }) + if (input !== undefined && start != null && end != null) input.setSelectionRange(start, end, direction ?? undefined) + else if (selection !== null && ranges.length > 0) { + selection.removeAllRanges() + for (const range of ranges) selection.addRange(range) + } + } + } + document.addEventListener('focusout', rememberEditor, true) + const createButton = (name: 'application' | 'edit', index: 0 | 1): HTMLButtonElement => { + const button = document.createElement('button') + button.type = 'button' + button.setAttribute('role', 'menuitem') + button.setAttribute('aria-haspopup', 'menu') + button.setAttribute('aria-expanded', 'false') + button.tabIndex = index === 0 ? 0 : -1 + button.addEventListener('pointerdown', (event) => { event.preventDefault() }) + button.addEventListener('mousedown', (event) => { event.preventDefault() }) + const open = async (): Promise => { + if (button.getAttribute('aria-expanded') === 'true') return + const rect = button.getBoundingClientRect() + button.setAttribute('aria-expanded', 'true') + if (document.activeElement === host) restoreEditor() + try { await ipcRenderer.invoke(DESKTOP_IPC.windowsMenu, name, rect.left, rect.bottom) } + catch (error) { console.error('Desktop caption menu failed', error) } + finally { button.setAttribute('aria-expanded', 'false') } + } + button.addEventListener('click', () => { void open() }) + button.addEventListener('keydown', (event) => { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + const next = buttons[index === 0 ? 1 : 0] + button.tabIndex = -1 + next.tabIndex = 0 + next.focus() + } else if (event.key === 'ArrowDown') { + event.preventDefault() + void open() + } + }) + bar.append(button) + return button + } + const buttons = [createButton('application', 0), createButton('edit', 1)] as const + shadow.append(style, bar) + const mount = (): void => { + // AppFrame owns this seat; boot readiness alone precedes the rendered application. + if (document.querySelector('[data-shell-overlay]') === null) return + document.body.append(host) + observer.disconnect() + } + const observer = new MutationObserver(mount) + observer.observe(document.body, { childList: true, subtree: true }) + mount() + const update = (): void => { + const { messages } = resolveDesktopLocale(document.documentElement.lang) + bar.setAttribute('aria-label', messages.menuBar) + buttons[0].textContent = messages.application + buttons[1].textContent = messages.edit + } + update() + return { + update, + dispose: () => { + observer.disconnect() + document.removeEventListener('focusout', rememberEditor, true) + host.remove() + }, + } +} diff --git a/apps/desktop/src/preload-update-dialog.ts b/apps/desktop/src/preload-update-dialog.ts new file mode 100644 index 0000000000..37a5757aac --- /dev/null +++ b/apps/desktop/src/preload-update-dialog.ts @@ -0,0 +1,14 @@ +/** Isolated response-only bridge for shell-owned update dialogs. */ +import { contextBridge, ipcRenderer } from 'electron' +import { UPDATE_DIALOG_IPC, type UpdateDialogApi, type UpdateDialogView } from './update-dialog.ts' + +const api: UpdateDialogApi = { + status: () => ipcRenderer.invoke(UPDATE_DIALOG_IPC.status) as Promise, + respond: (revision, index) => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, revision, index) as Promise, + subscribe: (listener) => { + const receive = (_event: Electron.IpcRendererEvent, view: UpdateDialogView | null): void => { listener(view) } + ipcRenderer.on(UPDATE_DIALOG_IPC.changed, receive) + return () => { ipcRenderer.removeListener(UPDATE_DIALOG_IPC.changed, receive) } + }, +} +if (location.href === 'dsh-app://shell/update-dialog.html') contextBridge.exposeInMainWorld('dshUpdateDialog', api) diff --git a/apps/desktop/src/preload-windows.ts b/apps/desktop/src/preload-windows.ts new file mode 100644 index 0000000000..5b9ed75661 --- /dev/null +++ b/apps/desktop/src/preload-windows.ts @@ -0,0 +1,62 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' +/** Synchronizes Windows context menus and caption colors with the application document. */ +import { ipcRenderer } from 'electron' +import { DESKTOP_IPC } from './ipc.ts' +import { installWindowsMenu } from './preload-menu.ts' + +/** Install the Windows-only titlebar marker and observe application language and palette changes. */ +export function syncWindowsAppearance(): void { + if (process.platform !== 'win32') return + const mark = (): void => { + const root = document.documentElement + root.dataset.windowsTitlebar = '' + root.style.setProperty('--dsh-windows-titlebar-height', `${WINDOWS_TITLEBAR_HEIGHT}px`) + } + // The root can be absent before the HTML parser creates it. + if ((document.documentElement as HTMLElement | null) !== null) mark() + const install = (): void => { + mark() + const root = document.documentElement + const menu = installWindowsMenu() + const probe = document.createElement('span') + probe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;background-color:var(--dsw-specific-sidebar-fill);color:var(--dsw-alias-label-primary)' + document.body.append(probe) + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 1 + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (context === null) throw new Error('Desktop caption requires a 2D canvas context') + const nativeColor = (color: string): string => { + context.clearRect(0, 0, 1, 1) + context.fillStyle = color + context.fillRect(0, 0, 1, 1) + const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data + return `rgba(${red}, ${green}, ${blue}, ${Number(alpha) / 255})` + } + let previous = '' + const send = (): void => { + const style = getComputedStyle(probe) + const color = nativeColor(style.backgroundColor) + const symbolColor = nativeColor(style.color) + const values = [root.lang, color, symbolColor] + const current = JSON.stringify(values) + if (current === previous) return + previous = current + menu.update() + ipcRenderer.send(DESKTOP_IPC.windowsAppearance, ...values) + } + const observer = new MutationObserver(send) + observer.observe(root, { attributes: true, attributeFilter: ['lang'] }) + observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'style'] }) + observer.observe(document.head, { childList: true, subtree: true, characterData: true }) + document.head.addEventListener('load', send, true) + window.addEventListener('pagehide', () => { + observer.disconnect() + menu.dispose() + probe.remove() + document.head.removeEventListener('load', send, true) + }, { once: true }) + send() + } + if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', install, { once: true }) + else install() +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts deleted file mode 100644 index c8e1eb1452..0000000000 --- a/apps/desktop/src/preload.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Context-isolated renderer bridge for desktop package and update operations. */ - -import { contextBridge, ipcRenderer } from 'electron' -import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' -import { markDocumentPlatform } from './preload-platform.ts' - -const api: DshDesktopApi = { - protocolVersion: 1, - locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise extends Promise ? T : never>, - plugins: { - list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise extends Promise ? T : never>, - add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise, - remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise, - toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise, - update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, - }, - updates: { - check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, - install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, - subscribe(listener) { - const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) } - ipcRenderer.on(DESKTOP_IPC.updatesState, handle) - return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) } - }, - }, -} - -markDocumentPlatform() -contextBridge.exposeInMainWorld('dshDesktop', api) diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index a1b1de1e37..af7804e1a5 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -1,13 +1,12 @@ -/** In-place owner of the desktop profile and its pnpm operations. */ +/** Desktop profile initialization and native recovery. */ -import { spawn } from 'node:child_process' import { existsSync, fsyncSync, - ftruncateSync, lstatSync, mkdirSync, openSync, + realpathSync, closeSync, readFileSync, unlinkSync, @@ -15,7 +14,6 @@ import { writeSync, } from 'node:fs' import { join } from 'node:path' -import { desktopNodeEnvironment } from './node-environment.ts' import { DESKTOP_HOST_PACKAGE, desktopCorePackageOverrides, @@ -23,56 +21,18 @@ import { } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' import type { DesktopRelease } from './release.ts' -import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' +import { readDesktopRuntime } from './runtime-tree.ts' import { - initProfile, PROFILE_TEMPLATES, readProfileManifest, readProfilePlugins, reconcileProfilePlugins, - unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate, + initProfile, PROFILE_TEMPLATES, sanitizeProfile, type ProfileTemplate, } from '@deepseek-ai/dsh-app-boot' import { migrateDesktopProfileLinks } from './profile-packages.ts' import { cleanProfileCorePackages } from './profile-core-cleanup.ts' -/** Desktop plugin record derived from the installed profile. */ -export interface DesktopPluginRecord { - readonly name: string - /** Installed version, or the dependency spec when installed metadata is unavailable. */ - readonly version: string - readonly enabled: boolean -} - -/** Exact executables the desktop shell bundles. */ -export interface DesktopRuntimeExecutables { - readonly node: string - readonly nodeBin?: string - readonly pnpm: string - readonly dsh: string -} - -/** Hooks that stop the backend before profile writes and restart it after each attempted change. */ -export interface DesktopProjectHooks { - /** Stop the active backend and await process exit before modifying its files. */ - beforeChange(): Promise - /** Start the current profile even when package preparation failed with partial writes. */ - afterChange(): Promise -} - -/** Supported dependency mutation. */ -export type DesktopProjectMutation = - | { readonly type: 'plugin-add'; readonly spec: string } - | { readonly type: 'plugin-remove'; readonly name: string } - | { readonly type: 'plugin-update'; readonly name: string; readonly version: string } - | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean } - const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime' const DSH_PACKAGE = '@deepseek-ai/dsh' const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local' const WEB_PROFILE = PROFILE_TEMPLATES.web as ProfileTemplate const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\n' -const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024 - -function errorOf(reason: unknown, fallback: string): Error { - return reason instanceof Error ? reason : new Error(fallback) -} - function writeJson(path: string, value: unknown): void { writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 }) } @@ -99,56 +59,24 @@ function migrateProfileSettings(projectDir: string): void { } } -/** Desktop npm project manager with direct writes and no rollback. */ +/** Initializes the Desktop profile and disables third-party bundles during recovery. */ export class DesktopProjectManager { - private lockDescriptor: number | undefined - private descriptor: DesktopRuntimeDescriptor | undefined - /** * @param paths - Electron-owned package state and reserved desktop profile paths. - * @param runtime - absolute Electron and pnpm entry paths. + * @param runtime - location of the bundled application runtime. */ constructor( readonly paths: DesktopPaths, - readonly runtime: DesktopRuntimeExecutables, + readonly runtime: { readonly dsh: string }, ) {} - /** Read the active desktop plugin inventory. */ - listPlugins(): readonly DesktopPluginRecord[] { - if (!existsSync(this.paths.profile)) return [] - return readProfilePlugins(this.pluginLocation(this.paths.profile)).dependencies - .map(({ name, version, enabled }) => ({ name, version, enabled })) - .sort((left, right) => left.name.localeCompare(right.name)) - } - /** - * Disable third-party bundles without loading application resources or deleting plugin files. - * @returns Completion of the locked profile write; the caller must stop the Host first. + * Back up the profile patch and disable third-party bundles without loading application resources. + * The caller must stop the Host first. + * @returns Backup path after the locked profile write, or undefined if the patch was absent. */ - async disableAllPlugins(): Promise { - await this.withLock(() => { - if (!existsSync(join(this.paths.profile, 'package.json'))) return - writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles) - }) - } - - /** Read the dsh version supplied by this application's verified resources. */ - dshVersion(): string { - return this.currentRuntime().release.version - } - - private currentRuntime(): DesktopRuntimeDescriptor { - if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded') - return this.descriptor - } - - private readRuntime(): DesktopRuntimeDescriptor { - this.descriptor = undefined - return readDesktopRuntime(this.runtime.dsh) - } - - private pluginLocation(profileDir: string) { - return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') } + async disableAllPlugins(): Promise { + return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles)) } /** @@ -157,132 +85,27 @@ export class DesktopProjectManager { */ async applyRelease(production = false): Promise { await this.withLock(() => { - this.descriptor = this.readRuntime() - cleanProfileCorePackages(this.paths.profile, this.descriptor.sharedPackages.map(entry => entry.name), production) + const descriptor = readDesktopRuntime(this.runtime.dsh) + cleanProfileCorePackages(this.paths.profile, descriptor.sharedPackages.map(entry => entry.name), production) migrateProfileSettings(this.paths.profile) migrateDesktopProfileLinks(this.paths.profile) createPluginProfile(this.paths.profile) }) } - /** Modify the current profile while its backend is stopped; failures retain partial changes. */ - async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { - await this.withLock(async () => { - this.currentRuntime() - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - await hooks.beforeChange() - try { - await this.applyMutation(this.paths.profile, mutation) - } catch (error) { - try { await hooks.afterChange() } catch (restartError) { - throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed') - } - throw error - } - await hooks.afterChange() - }) - } - - private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise { - const location = this.pluginLocation(projectDir) - const before = readProfilePlugins(location) - const bundles = before.manifest.dsh?.profile?.bundles ?? [] - switch (mutation.type) { - case 'plugin-add': - case 'plugin-update': { - const spec = mutation.type === 'plugin-add' ? mutation.spec : `${mutation.name}@${mutation.version}` - unlinkProfileModuleFallback(projectDir) - await this.runPnpm(projectDir, ['add', '--', spec]) - reconcileProfilePlugins({ ...location, before, preserveDisabled: true }) - return - } - case 'plugin-remove': - unlinkProfileModuleFallback(projectDir) - await this.runPnpm(projectDir, ['remove', '--', mutation.name]) - reconcileProfilePlugins({ ...location, before, preserveDisabled: true }) - return - case 'plugin-toggle': - writeProfileBundles(projectDir, before.manifest, mutation.enabled - ? bundles.includes(mutation.name) ? bundles : [...bundles, mutation.name] - : bundles.filter(name => name !== mutation.name)) - return - default: - mutation satisfies never - } - } - - private async runPnpm(projectDir: string, args: readonly string[]): Promise { - await new Promise((settle, reject) => { - const child = spawn(this.runtime.node, ['--expose-internals', this.runtime.pnpm, ...args], { - cwd: projectDir, - env: desktopNodeEnvironment(this.runtime.node, this.runtime.nodeBin, process.env), - stdio: ['ignore', 'pipe', 'pipe'], - }) - let failure: Error | undefined - let diagnostics = '' - let completed = false - const appendDiagnostics = (chunk: string): void => { - diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES) - } - child.stdout.setEncoding('utf8') - child.stdout.on('data', appendDiagnostics) - child.stderr.setEncoding('utf8') - child.stderr.on('data', appendDiagnostics) - const complete = (settleChild: () => void): void => { - if (completed) return - completed = true - try { - this.writeLockOwner(process.pid) - } catch (error) { - reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron')) - return - } - settleChild() - } - child.once('error', (error) => { failure = error }) - child.once('close', (code, signal) => { - complete(() => { - if (failure !== undefined) { reject(failure); return } - if (code === 0) { - settle() - return - } - reject(new Error( - `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`, - )) - }) - }) - try { - if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id') - this.writeLockOwner(child.pid) - } catch (error) { - failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm') - child.kill('SIGKILL') - } - }) - } - - private writeLockOwner(pid: number): void { - const descriptor = this.lockDescriptor - if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock') - const content = Buffer.from(`${String(pid)}\n`) - ftruncateSync(descriptor, 0) - writeSync(descriptor, content, 0, content.byteLength, 0) - fsyncSync(descriptor) - } - private async withLock(operation: () => T | Promise): Promise { mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 }) + const lockPath = join(realpathSync(this.paths.profile), 'lock') let descriptor: number try { - descriptor = openSync(this.paths.lock, 'wx', 0o600) + descriptor = openSync(lockPath, 'wx', 0o600) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - const lock = lstatSync(this.paths.lock) + const lock = lstatSync(lockPath) if (lock.isSymbolicLink() || !lock.isFile()) { - throw new Error('desktop project: package transaction lock is not a regular file') + throw new Error('desktop project: profile lock is not a regular file') } - const owner = Number.parseInt(readFileSync(this.paths.lock, 'utf8').trim(), 10) + const owner = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10) let active = !Number.isSafeInteger(owner) || owner <= 0 if (!active) { try { @@ -292,21 +115,20 @@ export class DesktopProjectManager { 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) + if (active) throw new Error('desktop project: another profile operation is active') + unlinkSync(lockPath) + descriptor = openSync(lockPath, 'wx', 0o600) } else { throw error } } try { - this.lockDescriptor = descriptor - this.writeLockOwner(process.pid) + writeSync(descriptor, `${String(process.pid)}\n`) + fsyncSync(descriptor) return await operation() } finally { - this.lockDescriptor = undefined closeSync(descriptor) - unlinkSync(this.paths.lock) + unlinkSync(lockPath) } } } diff --git a/apps/desktop/src/update-attention.ts b/apps/desktop/src/update-attention.ts new file mode 100644 index 0000000000..9d3c2335b6 --- /dev/null +++ b/apps/desktop/src/update-attention.ts @@ -0,0 +1,74 @@ +/** Best-effort background attention never grants update or task-stop authorization. */ +import { app, type BrowserWindow, Notification } from 'electron' +import type { DesktopLocale } from './locale.ts' + +/** Owns one reminder per downloaded version until reset for a new download. */ +export class DesktopUpdateAttention { + private version: string | undefined + private notification: Notification | undefined + private stop: (() => void) | undefined + + /** + * @param locale - Shell-owned notification copy. + * @param platform - Native attention implementation, replaceable for platform tests. + */ + constructor(private readonly locale: DesktopLocale, private readonly platform: string = process.platform) {} + + /** + * @param version - Prepared target whose confirmation is waiting. + * @param parent - Taskbar window; never restored or focused by the reminder. + * @param modal - Existing installation confirmation. + * @param returnToConfirmation - Rechecks current policy and returns to UI without installing. + */ + ready(version: string, parent: BrowserWindow, modal: BrowserWindow, returnToConfirmation: () => void): void { + if (this.version === version) return + this.version = version + if (parent.isFocused() || modal.isFocused()) return + const clear = (): void => { this.clear() } + let bounce: number | undefined + parent.on('focus', clear) + modal.on('focus', clear) + this.stop = () => { + parent.off('focus', clear) + modal.off('focus', clear) + if (this.platform === 'win32' && !parent.isDestroyed()) parent.flashFrame(false) + if (bounce !== undefined) app.dock?.cancelBounce(bounce) + } + try { + if (this.platform === 'win32') parent.flashFrame(true) + if (this.platform === 'darwin') bounce = app.dock?.bounce('informational') + } catch (error) { console.warn('desktop update: attention unavailable', error) } + try { + if (!Notification.isSupported()) return + const notification = new Notification({ title: this.locale.messages.mandatoryReady, + body: this.locale.messages.mandatoryNotification, silent: true }) + this.notification = notification + notification.on('failed', () => { + if (this.notification === notification) this.notification = undefined + notification.removeAllListeners() + }) + notification.once('click', () => { + if (this.notification !== notification) return + this.clear() + returnToConfirmation() + }) + notification.show() + } catch (error) { console.warn('desktop update: notification unavailable', error) } + } + + /** Release owned native reminders without scheduling another for the same target. */ + clear(): void { + const notification = this.notification + this.notification = undefined + notification?.removeAllListeners() + try { notification?.close() } + catch (error) { console.warn('desktop update: could not close notification', error) } + const stop = this.stop + this.stop = undefined + try { stop?.() } + catch (error) { console.warn('desktop update: could not clear attention', error) } + } + + /** Start a new download episode, or dispose all owned reminders. */ + reset(): void { this.clear(); this.version = undefined } +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index be629228d8..c7bf91c384 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -1,95 +1,192 @@ -/** One Electron release stream for the version-bound shell and bundled dsh runtime. */ +/** User-authorized downloads and separate installation of one version-bound Desktop release. */ import { existsSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' -import electronUpdater, { type AppUpdater } from 'electron-updater' +import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo } from 'electron-updater' +import { gt, valid } from 'semver' import type { DesktopUpdateState } from './ipc.ts' +import { DesktopUpdateHttpExecutor } from './update-http-executor.ts' +import { DesktopUpdatePreparationError } from './update-error.ts' + const { autoUpdater } = electronUpdater -/** Checks, downloads, and installs one complete Desktop release. */ +/** Owns one updater target until its download and installation settle. */ export class DesktopUpdateCoordinator { - private availableVersion: string | undefined + private current: DesktopUpdateState = { phase: 'idle' } + private candidate: string | undefined + private downloaded = false + private disposed = false private checkOperation: Promise | undefined + private downloadOperation: Promise | undefined private installOperation: Promise | undefined + private readonly onProgress = (progress: ProgressInfo): void => { + if (this.downloadOperation === undefined || this.downloaded) return + const percent = Math.min(100, Math.max(0, progress.percent)) + this.setState({ phase: percent >= 100 ? 'verifying' : 'downloading', ...this.target(), percent }) + } + + private readonly onDownloaded = (info: UpdateInfo): void => { + if (this.downloadOperation === undefined || info.version !== this.candidate) return + this.downloaded = true + } + + private readonly onError = (error: Error): void => { + // Check/download promises own their failures. Installation can fail after quitAndInstall returns. + if (this.current.phase === 'installing') { + this.setState(this.failure(error, 'install')) + } + } + /** - * @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. + * @param publish - Receives observable states for every Desktop window. + * @param beforeRestart - Completes task authorization, admission locking, and owned-process shutdown. + * @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary. + * @param enabled - Whether this process has a packaged update source. + * @param currentVersion - Actual installed application version. */ constructor( private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState, - private readonly beforeRestart: () => Promise = async () => {}, + private readonly beforeRestart: () => Promise, private readonly updater: AppUpdater = autoUpdater, - private readonly enabled: () => boolean = () => ( - app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')) - ), + private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), + private readonly currentVersion: () => string = () => app.getVersion(), ) { + if (updater === autoUpdater) { + // electron-updater omits this internal transport property from its public declarations. + // Real-Electron qualification exercises the pinned dependency integration. + const transportOwner = updater as AppUpdater & { httpExecutor: DesktopUpdateHttpExecutor } + transportOwner.httpExecutor = new DesktopUpdateHttpExecutor( + Number(process.env.DSH_DESKTOP_UPDATE_HTTP_IDLE_TIMEOUT_MS ?? 60_000), + (authInfo, callback) => { updater.emit('login', authInfo, callback) }, + ) + } this.updater.autoDownload = false this.updater.autoInstallOnAppQuit = false + this.updater.channel = 'nightly' + this.updater.allowPrerelease = true + // Selecting a channel can enable downgrade in electron-updater. + this.updater.allowDowngrade = false + this.updater.on('download-progress', this.onProgress) + this.updater.on('update-downloaded', this.onDownloaded) + this.updater.on('error', this.onError) } - /** Check the configured Desktop release stream and retain an available version. */ - async check(): Promise { - 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 + /** Latest observable state; complete download identity remains main-process-owned. */ + get state(): DesktopUpdateState { return this.current } + + /** + * Check metadata without downloading, joining any current check. + * @param manual - Whether a failed check must remain visible in the status indicator. + * @returns The check result, including a silent automatic failure when applicable. + */ + async check(manual = false): Promise { + this.assertLive() + if (this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current + if (!manual && this.current.phase === 'error' && this.current.failedOperation === 'download') return this.current + this.checkOperation ??= Promise.resolve().then(() => this.doCheck()) + .finally(() => { this.checkOperation = undefined }) + const result = await this.checkOperation + if (manual && result.phase === 'error') this.setState(result) + return result } - /** Wait for an in-flight check, then download and install its retained release. */ - async install(): Promise { - if (this.installOperation !== undefined) return this.installOperation - this.installOperation = (async () => { + /** + * @param version - Version shown in the user's download confirmation. + * @returns Download readiness or failure, without authorizing installation. + */ + async download(version: string): Promise { + this.assertLive() + if (this.downloaded || this.installOperation !== undefined) return this.current + this.downloadOperation ??= Promise.resolve().then(async () => { await this.checkOperation - return this.doInstall() - })().finally(() => { this.installOperation = undefined }) + this.assertLive() + if (this.candidate === undefined) throw new Error('desktop update: no checked update is available') + if (version !== this.candidate) throw new Error('desktop update: download confirmation is stale') + this.setState({ phase: 'downloading', version, percent: 0 }) + try { + await this.updater.downloadUpdate() + if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness') + return this.setState({ phase: 'ready', version }) + } catch (error) { + this.downloaded = false + return this.setState(this.failure(error, 'download')) + } + }).finally(() => { this.downloadOperation = undefined }) + return this.downloadOperation + } + + /** + * Install a prepared target after a separate user confirmation. + * @param version - Exact version displayed in the confirmation, never a renderer-selected URL. + * @returns Installation handoff or a recoverable preparation error. + */ + async install(version: string): Promise { + this.assertLive() + if (!this.downloaded || this.downloadOperation !== undefined || version !== this.candidate) throw new Error('desktop update: confirmed target is not ready') + this.installOperation ??= Promise.resolve().then(async () => { + this.setState({ phase: 'installing', version }) + try { + if (!await this.beforeRestart()) return this.setState({ phase: 'ready', version }) + this.assertLive() + this.updater.quitAndInstall(true, true) + return this.current + } catch (error) { + if (this.current.phase === 'error' && this.current.failedOperation === 'install') return this.current + return this.setState(this.failure(error, 'install')) + } + }).finally(() => { this.installOperation = undefined }) return this.installOperation } - private async doCheck(): Promise { - 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), - }) - } + /** Remove owned listeners and prevent pending library operations from publishing into closed UI. */ + dispose(): void { + this.disposed = true + this.updater.off('download-progress', this.onProgress) + this.updater.off('update-downloaded', this.onDownloaded) + // Pending updater promises can still emit EventEmitter errors during shutdown. + void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation]) + .then(() => { this.updater.off('error', this.onError) }) } - private async doInstall(): Promise { - const version = this.availableVersion - if (version === undefined) { - throw new Error('desktop update: no verified update is available') + private assertLive(): void { + if (this.disposed) throw new Error('desktop update: coordinator is disposed') + } + + private setState(state: DesktopUpdateState): DesktopUpdateState { + if (!this.disposed) { + this.current = state + this.publish(state) } - this.publish({ phase: 'installing', version }) + return state + } + + private failure(error: unknown, failedOperation: 'check' | 'download' | 'install'): DesktopUpdateState { + return { phase: 'error', ...this.target(), failedOperation, + message: error instanceof Error ? error.message : String(error), + ...(error instanceof DesktopUpdatePreparationError ? { + preparationFailure: error.kind, + ...(error.technicalDetails === undefined ? {} : { technicalDetails: error.technicalDetails }), + } : {}) } + } + + private target(): { version?: string } { + return this.candidate === undefined ? {} : { version: this.candidate } + } + + private async doCheck(): Promise { 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 + this.assertLive() + if (!this.enabled()) throw new Error('desktop update: this application has no packaged update source') + const result = await this.updater.checkForUpdates() + if (result === null) throw new Error('desktop update: no check result was returned') + const version = result.updateInfo.version + if (valid(version) === null) throw new Error('desktop update: feed version is invalid') + this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined + return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) } catch (error) { - return this.publish({ - phase: 'error', - version, - message: error instanceof Error ? error.message : String(error), - }) + return this.failure(error, 'check') } } } diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts new file mode 100644 index 0000000000..c307bda9e4 --- /dev/null +++ b/apps/desktop/src/update-dialog.ts @@ -0,0 +1,143 @@ +/** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */ +import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron' +import type { DesktopLocale } from './locale.ts' +import { createUpdateOverlay } from './update-overlay.ts' + +/** Channels available only to the isolated update-dialog document. */ +export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const + +/** Text and choices supplied by the main process, never by product documents. */ +export interface UpdateDialogView { + /** Identifies the displayed choices; a response from an older prompt is rejected. */ + readonly revision: number + readonly locale: string + readonly title: string + readonly message: string + readonly detail: string + readonly buttons: readonly string[] + readonly cancelId: number + readonly closeLabel: string + readonly technicalDetails: string + readonly technicalDetailsLabel: string +} + +/** Electron message options with separately expandable, main-owned diagnostics. */ +export interface UpdateDialogOptions extends MessageBoxOptions { + readonly technicalDetails?: string +} + +/** The document can select only a displayed response index. */ +export interface UpdateDialogApi { + status(): Promise + respond(revision: number, index: number): Promise + subscribe(listener: (view: UpdateDialogView | null) => void): () => void +} + +// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. +const page = 'dsh-app://shell/update-dialog.html' + +/** One fading backdrop with replaceable confirmation content; aborted checks and mandatory policy cancel ordinary prompts. */ +export class DesktopUpdateDialog { + private disposed = false + private revision = 0 + private window: BrowserWindow | undefined + private parent: BrowserWindow | undefined + private closing: ReturnType | undefined + private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined + + /** Focus the current explanation or confirmation without replacing it or granting permission. */ + focus(): void { this.active?.window.focus() } + + /** + * @param preload - Bundled isolated preload. + * @param locale - Shell-owned copy. + */ + constructor(private readonly preload: string, private readonly locale: DesktopLocale) { + ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null }) + ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => { + this.owned(event) + const active = this.active + if (active === undefined || revision !== active.view.revision) throw new Error('desktop update: stale dialog response') + if (typeof index !== 'number' || !Number.isInteger(index) + || (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) { + throw new Error('desktop update: invalid dialog response') + } + active.finish(index) + }) + } + + /** + * @param parent - Window blocked by this confirmation. + * @param options - Main-owned localized content, response choices, and optional cancellation signal. + * @returns A displayed response, or cancellation on replacement, abort, close, or load failure; backdrop dismissal fades independently. + */ + show(parent: BrowserWindow, options: UpdateDialogOptions): Promise { + const buttons = options.buttons ?? [this.locale.messages.updateAcknowledge] + const cancelId = options.cancelId ?? buttons.length - 1 + if (this.disposed || options.signal?.aborted === true || parent.isDestroyed()) { + return Promise.resolve({ response: cancelId, checkboxChecked: false }) + } + if (this.parent !== parent) { this.cancel(); this.close() } + this.active?.finish(this.active.view.cancelId, true) + clearTimeout(this.closing) + this.closing = undefined + const existing = this.window + const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle, false) + this.window = window + this.parent = parent + const view: UpdateDialogView = { revision: ++this.revision, locale: this.locale.id, title: options.title ?? '', message: options.message, + detail: options.detail ?? '', buttons, cancelId, closeLabel: this.locale.messages.updateClose, + technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: this.locale.messages.updateTechnicalDetails } + return new Promise((resolve) => { + const abort = (): void => { finish(cancelId) } + const finish = (response: number, retain = false): void => { + if (this.active?.view !== view) return + this.active = undefined + options.signal?.removeEventListener('abort', abort) + if (!retain && !window.isDestroyed()) { + window.webContents.send(UPDATE_DIALOG_IPC.changed, null) + this.closing = setTimeout(() => { this.close() }, 150) + } + resolve({ response, checkboxChecked: false }) + } + this.active = { window, view, finish } + options.signal?.addEventListener('abort', abort, { once: true }) + if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return } + const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } } + window.once('closed', failed) + window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() }) + window.webContents.once('render-process-gone', failed) + void window.loadURL(page).catch(failed) + }) + } + + /** Cancel the displayed prompt without authorizing any operation. */ + cancel(): void { this.active?.finish(this.active.view.cancelId) } + + /** Close the document and detach its private IPC handlers. */ + dispose(): void { + if (this.disposed) return + this.disposed = true + this.cancel() + this.close() + ipcMain.removeHandler(UPDATE_DIALOG_IPC.status) + ipcMain.removeHandler(UPDATE_DIALOG_IPC.respond) + } + + private close(): void { + clearTimeout(this.closing) + this.closing = undefined + const window = this.window + this.window = undefined + this.parent = undefined + if (window !== undefined && !window.isDestroyed()) window.destroy() + } + + private owned(event: IpcMainInvokeEvent): void { + const window = this.window + if (window === undefined || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) { + throw new Error('desktop update: rejected unowned dialog renderer') + } + } +} diff --git a/apps/desktop/src/update-error.ts b/apps/desktop/src/update-error.ts new file mode 100644 index 0000000000..adac72738b --- /dev/null +++ b/apps/desktop/src/update-error.ts @@ -0,0 +1,12 @@ +/** Localized update preparation failures with separately displayed diagnostics. */ +import type { DesktopUpdatePreparationFailureKind } from './ipc.ts' + +/** Only explicitly safe main-process facts may be exposed as technical details. */ +export class DesktopUpdatePreparationError extends Error { + /** + * @param kind - Stable preparation cause shared by native and Web presentations. + * @param message - Locale-owned recovery guidance. + * @param technicalDetails - Main-owned facts, excluding raw subprocess output and credentials. + */ + constructor(readonly kind: DesktopUpdatePreparationFailureKind, message: string, readonly technicalDetails?: string) { super(message) } +} diff --git a/apps/desktop/src/update-http-executor.ts b/apps/desktop/src/update-http-executor.ts new file mode 100644 index 0000000000..2d3a851275 --- /dev/null +++ b/apps/desktop/src/update-http-executor.ts @@ -0,0 +1,53 @@ +/** Electron-native inactivity deadlines for updater checks, full downloads, and blockmap requests. */ +import { ElectronHttpExecutor } from 'electron-updater/out/electronHttpExecutor.js' +import type { ClientRequest, IncomingMessage } from 'electron' + +/** Retains electron-updater transport and proxy handling while bounding silent connections. */ +export class DesktopUpdateHttpExecutor extends ElectronHttpExecutor { + /** + * @param idleTimeoutMs - Maximum silence before headers or between response chunks, not a total download deadline. + * @param proxyLogin - Existing updater login event forwarding. + */ + constructor(private readonly idleTimeoutMs: number, proxyLogin?: ConstructorParameters[0]) { + super(proxyLogin) + if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs < 1000 || idleTimeoutMs > 2_147_483_647) { + throw new Error('desktop update: HTTP idle timeout must be an integer from 1000 through 2147483647') + } + } + + override addErrorAndTimeoutHandlers(request: ClientRequest, reject: (error: Error) => void): void { + // The upstream socket timer is for Node HTTP; Electron ClientRequest has response/close events instead. + super.addErrorAndTimeoutHandlers(request, reject, this.idleTimeoutMs) + let response: IncomingMessage | undefined + let timer: ReturnType + const stop = (): void => { + clearTimeout(timer) + request.off('response', onResponse) + request.off('abort', stop) + request.off('error', stop) + response?.off('data', refresh) + response?.off('end', stop) + response?.off('error', stop) + } + const refresh = (): void => { + clearTimeout(timer) + timer = setTimeout(() => { + stop() + reject(Object.assign(new Error('Desktop update connection timed out'), { code: 'ETIMEDOUT' })) + request.abort() + }, this.idleTimeoutMs) + } + const onResponse = (incoming: IncomingMessage): void => { + response = incoming + response.on('data', refresh) + response.once('end', stop) + response.once('error', stop) + refresh() + } + request.once('response', onResponse) + // Electron 44 can emit writable close after finish, before response headers arrive. + request.once('abort', stop) + request.once('error', stop) + refresh() + } +} diff --git a/apps/desktop/src/update-journal.ts b/apps/desktop/src/update-journal.ts new file mode 100644 index 0000000000..bb6a179d6f --- /dev/null +++ b/apps/desktop/src/update-journal.ts @@ -0,0 +1,74 @@ +/** Opt-in qualification evidence outside the installation directory; no raw diagnostics or request data. */ +import { randomUUID } from 'node:crypto' +import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import type { DesktopUpdateState } from './ipc.ts' + +/** User and process milestones that connect update states across application restarts. */ +export type DesktopUpdateJournalAction = 'started' | 'workspace-ready' | 'workspace-failed' + | 'check-requested' | 'download-requested' | 'install-confirmed' | 'quit-requested' + | 'policy-login-opened' | 'policy-login-returned' | 'policy-login-cancelled' | 'policy-login-failed' + +const ERROR_CODES = ['ETIMEDOUT', 'ENOSPC', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_RESET', + 'ERR_CONNECTION_CLOSED', 'ERR_NAME_NOT_RESOLVED', 'ERR_UPDATER_INVALID_SIGNATURE', 'ERR_UPDATER_CHECKSUM_MISMATCH'] as const + +/** + * Whitelist one update state for disk; neither error text nor unexpected object fields survive. + * @param state Main-process-owned update state. + * @returns Only phase, target version, integer progress, operation, and a fixed error classification. + */ +export function desktopUpdateJournalState(state: DesktopUpdateState): object { + return { + phase: state.phase, + ...(state.version !== undefined ? { targetVersion: state.version } : {}), + ...(state.phase === 'downloading' && state.percent !== undefined ? { percent: Math.floor(state.percent) } : {}), + ...(state.phase === 'error' ? { + failedOperation: state.failedOperation, + errorCode: ERROR_CODES.find(code => state.message?.includes(code)) ?? 'UNCLASSIFIED', + } : {}), + } +} + +/** Process-owned JSONL evidence; each append is flushed before the caller continues. */ +export class DesktopUpdateJournal { + readonly path: string + private sequence = 0 + private previousState: string | undefined + + /** + * @param directory Absolute evidence directory, retained across installs; creation errors stop qualification. + * @param version Installed application version, not a version supplied by the feed. + */ + constructor(directory: string, private readonly version: string) { + if (!isAbsolute(directory)) throw new Error('desktop update journal: directory must be absolute') + mkdirSync(directory, { recursive: true }) + this.path = join(directory, `${Date.now()}-${randomUUID()}.jsonl`) + writeFileSync(this.path, '', { flag: 'wx', mode: 0o600, flush: true }) + this.action('started') + } + + /** + * Append a fixed action; failures propagate so incomplete qualification is never reported as traced. + * @param action Update operation or process milestone; no free-text fields are accepted. + * @returns Nothing after the record has been flushed. + */ + action(action: DesktopUpdateJournalAction): void { this.append({ event: action }) } + + /** + * Retain state changes and integer progress increments without raw errors, URLs, or request data. + * @param state Main-process-owned update state. + * @returns Nothing after the changed state has been flushed; duplicate states add no record. + */ + state(state: DesktopUpdateState): void { + const fields = desktopUpdateJournalState(state) + const encoded = JSON.stringify(fields) + if (encoded === this.previousState) return + this.append({ event: 'state', ...fields }) + this.previousState = encoded + } + + private append(fields: object): void { + appendFileSync(this.path, `${JSON.stringify({ schemaVersion: 1, sequence: this.sequence++, + time: new Date().toISOString(), pid: process.pid, version: this.version, ...fields })}\n`, { flush: true }) + } +} diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts new file mode 100644 index 0000000000..2ff0b8c0d4 --- /dev/null +++ b/apps/desktop/src/update-overlay.ts @@ -0,0 +1,65 @@ +/** Shell-owned modal windows cover the parent's content without replacing its native window controls. */ +import { BrowserWindow } from 'electron' + +/** + * @param parent - Product window whose content is blocked while the overlay is open. + * @param preload - Isolated shell-only preload. + * @param title - Localized window title. + * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. + * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. + */ +export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { + const window = new BrowserWindow({ + parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true, + ...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false, + skipTaskbar: true, hasShadow: false, title, + webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, + }) + // macOS native modals animate the entire viewport as a sheet. + const focus = (): void => { if (!window.isDestroyed()) window.focus() } + const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() } + if (!nativeModal && process.platform === 'darwin') { + parent.on('focus', focus) + parent.webContents.on('before-input-event', blockInput) + window.once('closed', () => { + parent.off('focus', focus) + parent.webContents.off('before-input-event', blockInput) + }) + } + const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } + parent.on('move', follow) + parent.on('resize', follow) + let closed = false + let blur: string | undefined + const unblur = (): void => { + if (blur === undefined || parent.isDestroyed()) return + void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) }) + blur = undefined + } + void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => { + blur = key + if (closed) unblur() + }).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) }) + window.once('closed', () => { closed = true; unblur() }) + window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) }) + window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) + window.setMenu(null) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + return window +} + +/** A native Windows modal retains its own title bar while the product window remains blocked. */ +export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string, + platform: NodeJS.Platform = process.platform): BrowserWindow { + if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false) + const window = new BrowserWindow({ + parent, modal: true, show: false, title, + width: 640, height: 560, minWidth: 480, minHeight: 360, + movable: true, resizable: true, maximizable: true, + backgroundColor: '#f5f5f5', + webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, + }) + window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + return window +} diff --git a/apps/desktop/src/update-presentation.ts b/apps/desktop/src/update-presentation.ts new file mode 100644 index 0000000000..42119d93a2 --- /dev/null +++ b/apps/desktop/src/update-presentation.ts @@ -0,0 +1,51 @@ +/** Semantic content for the Web-localized optional Desktop status indicator. */ +import type { DesktopUpdateFailureKind, DesktopUpdatePresentation, DesktopUpdateState } from './ipc.ts' +import type { DesktopMessages } from './locale.ts' + +const NETWORK_FAILURE = /\b(?:ERR_CONNECTION_CLOSED|ERR_CONNECTION_RESET|ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ETIMEDOUT)\b/u + +/** + * Classify one updater failure for both native and Web-localized summaries. + * @param state Update failure and its operation. + * @returns Stable presentation kind without raw diagnostics. + */ +function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailureKind { + if (state.failedOperation === 'install' && state.preparationFailure !== undefined) return state.preparationFailure + const operation = state.failedOperation ?? 'install' + return NETWORK_FAILURE.test(state.message ?? '') ? `${operation}-network` : operation +} + +/** + * Select user-facing error copy independently of raw updater diagnostics. + * @param state Update failure and its operation. + * @param messages Selected shell locale. + * @returns Localized summary suitable for both a dialog and a tooltip. + */ +export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: DesktopMessages): string { + const kind = desktopUpdateFailureKind(state) + const summaries: Readonly> = { + check: messages.updateCheckFailed, + 'check-network': messages.updateCheckNetworkFailed, + download: messages.updateDownloadFailed, + 'download-network': messages.updateDownloadNetworkFailed, + install: messages.updateInstallFailed, + 'install-network': messages.updateInstallNetworkFailed, + 'stop-failed': messages.updateStopFailed, + 'tasks-changed': messages.updateTasksChanged, + 'tasks-unavailable': messages.updateTasksUnavailable, + } + return summaries[kind] +} + +/** + * @param state - Main-process updater state. + * @returns Semantic status without localized copy, diagnostics, or installation controls. + */ +export function presentDesktopUpdate(state: DesktopUpdateState): DesktopUpdatePresentation { + return { + phase: state.phase, + ...(state.version === undefined ? {} : { version: state.version }), + ...(state.percent === undefined ? {} : { percent: Math.floor(state.percent) }), + ...(state.phase === 'error' ? { failure: desktopUpdateFailureKind(state) } : {}), + } +} diff --git a/apps/desktop/src/update-schedule.ts b/apps/desktop/src/update-schedule.ts new file mode 100644 index 0000000000..93c1c2670c --- /dev/null +++ b/apps/desktop/src/update-schedule.ts @@ -0,0 +1,111 @@ +/** Ordinary feed polling; policy queries and user-authorized transfers keep their own lifetimes. */ + +import type { DesktopUpdateCoordinator } from './update-coordinator.ts' +import type { DesktopUpdateState } from './ipc.ts' + +/** Validated polling delays; maxBackoffMs caps the final randomized delay. */ +export interface DesktopUpdateScheduleConfig { + readonly intervalMs: number + readonly maxBackoffMs: number + readonly jitter: number +} + +/** + * Resolve ordinary-update polling settings without changing mandatory-policy scheduling. + * @param env - Desktop process environment. + * @returns Validated durations and fractional jitter. + */ +export function resolveDesktopUpdateScheduleConfig(env: NodeJS.ProcessEnv): DesktopUpdateScheduleConfig { + function duration(name: string, fallback: number): number { + const value = Number(env[name] ?? fallback) + if (!Number.isSafeInteger(value) || value < 1_000 || value > 2_147_483_647) { + throw new Error(`${name} must be an integer from 1000 through 2147483647`) + } + return value + } + const intervalMs = duration('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', 600_000) + const maxBackoffMs = duration('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', Math.max(intervalMs, 3_600_000)) + const jitter = Number(env.DSH_DESKTOP_UPDATE_CHECK_JITTER ?? 0.2) + if (!Number.isFinite(jitter) || jitter < 0 || jitter > 1 || maxBackoffMs < intervalMs) { + throw new Error('desktop update: check jitter must be in [0, 1] and max backoff must cover the check interval') + } + return { intervalMs, maxBackoffMs, jitter } +} + +/** One completion-based deadline shared by periodic, foreground, resume, and explicit checks. */ +export class DesktopUpdateSchedule { + private timer: ReturnType | undefined + private pending: Promise | undefined + private activeChecks = 0 + private disposed = false + private nextCheck = -Infinity + private delay: number + + /** + * @param updates - Coordinator that joins network checks and retains prepared packages. + * @param config - Validated polling options. + * @param random - Instance-local uniform sample in [0, 1). + * @param now - Monotonic milliseconds, independent of wall-clock corrections. + */ + constructor( + private readonly updates: Pick, + private readonly config: DesktopUpdateScheduleConfig, + private readonly random: () => number = Math.random, + private readonly now: () => number = () => performance.now(), + ) { + this.delay = config.intervalMs + } + + /** + * Start immediately when due; explicit requests bypass the deadline and share in-flight work. + * @param manual - Whether a check failure must be visible, including when joining an automatic request. + * @param force - Whether policy arrival or explicit intent bypasses the automatic deadline. + * @returns Current state when not due, otherwise the coordinator result. Disposal rejects new work. + */ + async check(manual = false, force = manual): Promise { + if (this.disposed) throw new Error('desktop update: polling is disposed') + if (this.pending !== undefined && !manual) return this.pending + if (!force && this.now() < this.nextCheck) return this.updates.state + clearTimeout(this.timer) + this.timer = undefined + this.activeChecks++ + this.pending = Promise.resolve().then(() => { + if (this.disposed) throw new Error('desktop update: polling is disposed') + return this.updates.check(manual) + }).then( + (state) => { + this.complete(state.phase === 'error' && state.failedOperation === 'check') + return state + }, + (error: unknown) => { this.complete(true); throw error }, + ) + return this.pending + } + + /** Stop timers and prevent late completion from rearming; the coordinator owns pending network teardown. */ + dispose(): void { + this.disposed = true + clearTimeout(this.timer) + this.timer = undefined + } + + private complete(failed: boolean): void { + // Joined manual callers publish through the coordinator but advance the retry delay only once. + if (--this.activeChecks !== 0) return + this.pending = undefined + this.schedule(failed) + } + + private schedule(failed: boolean): void { + if (this.disposed) return + const { intervalMs, maxBackoffMs, jitter } = this.config + this.delay = failed ? Math.min(maxBackoffMs, this.delay * 2) : intervalMs + const lower = Math.max(1_000, this.delay * (1 - jitter)) + const upper = Math.min(maxBackoffMs, this.delay * (1 + jitter)) + const delay = Math.round(lower + (upper - lower) * this.random()) + this.nextCheck = this.now() + delay + this.timer = setTimeout(() => { + void this.check(false, true).catch((error: unknown) => { console.error(error) }) + }, delay) + } +} diff --git a/apps/desktop/src/windows-layout.ts b/apps/desktop/src/windows-layout.ts new file mode 100644 index 0000000000..0ba4ad5c7e --- /dev/null +++ b/apps/desktop/src/windows-layout.ts @@ -0,0 +1,4 @@ +/** Shared geometry for the Windows main-window caption. */ + +/** Windows application titlebar height in device-independent pixels. */ +export const WINDOWS_TITLEBAR_HEIGHT = 40 diff --git a/packages/experimental/agent-team-web-profile/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml similarity index 53% rename from packages/experimental/agent-team-web-profile/README.i18n.yaml rename to apps/desktop/tests/README.i18n.yaml index 9febb49882..791271a555 100644 --- a/packages/experimental/agent-team-web-profile/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -1,6 +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 packages/experimental/agent-team-web-profile/README.md -README.md: e21a7eeebc83a97d3e4630c95a9aeaa4213bc227 -README.zh.md: 9966697cb7225083a6fff515675b9dd9ecc51c82 +# pnpm run verify-translation-pairing --write apps/desktop/tests/README.md +README.md: 6171b058adad8e34a3275c013bd875e00f586afc +README.zh.md: fa18cd947e1315248b5449eaf9bc426bba3d18fc diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md new file mode 100644 index 0000000000..6171b058ad --- /dev/null +++ b/apps/desktop/tests/README.md @@ -0,0 +1,72 @@ +# Desktop local update verification + +English | [中文](README.zh.md) + +## Summary + +Local download and mandatory-dialog evidence is separate from production backend integration, visual acceptance, and installed-application upgrades. Run the command in the [Desktop README](../README.md) to produce a fresh isolated report. + +Installed qualification can opt into `DSH_DESKTOP_UPDATE_JOURNAL_DIR`, an absolute directory outside the installation tree that both versions retain. Each main process flushes a separate JSONL file with its installed version, state transitions, and manual-operation milestones. Raw diagnostics and request data are excluded; storage errors propagate. The [journal decision](../../../.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.md) defines evidence limits. Unit and main-entry tests cover the journal; a signed installed upgrade is still unverified. + +## Table of Contents + +- [Evidence](#verification-evidence) +- [Manual walkthrough](#verification-interactive) +- [Open verification](#verification-open) + + + +## Manual walkthrough + +For actual installation, publication after startup, failure/retry, and restart evidence, use the [installed-update operator checklist](installed-update/README.md). The interactive runner below intercepts installation and is a different qualification. + +With Host, client, and Desktop artifacts built, run `node --import tsx apps/desktop/scripts/test-workspace-updates.ts --interactive` from the repository root on Windows. The actual workspace stays open with a separate control window. Its menus select ordinary or mandatory updates, hold and release downloads, inject download failures, and queue or clear test tasks. Select a failure before starting the download. After installation approval and task shutdown, a fixture message reports the intercepted installer call; acknowledging it ends the walkthrough. Closing the control window also exits. Each run owns a private profile and loopback server; payloads are not installers. Restart the command for a fresh round. Omitting `--interactive` runs the automated scenarios with their 120-second deadline and automatic exit; interactive downloads have a ten-minute network deadline. + + + +## Evidence + +The local command builds Desktop and executes Electron 44 with the actual HTTP updater, policy client, sandboxed preload, and mandatory renderer. It records each scenario and preserves its report under `.desktop-build/qualification/local-updater-*`. The installer call, external browser, and clipboard are observation substitutes; downloaded bytes are not an executable installer. + +| Layer | Observed result | +|---|---| +| Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass | +| Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass | +| Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup | +| Actual mandatory window | Text-only server content, native Windows move/resize/maximize and restore, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy | +| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass | +| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload | +| Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking | +| Host task protection | The actual controller with substituted composition detects running agents, queued turns/steps, and global and agent jobs; API reads do not warn. Admission locking returns 503 for new requests, drains existing requests, and rechecks tasks; unlock restores admission | +| Visible output | Chinese mandatory-dialog DOM expectation and ordinary update presentation expectation pass; account-row component tests cover progress and persistent retry | +| Packaging configuration | Metadata embeds the configured application ID and policy; the built pre-pack hook rejects an HTTP policy origin without running packaging or upload | + +The focused dialog, main-entry, settings, and sidebar run passes 119 regression cases. Five carrier tests cover all statements, branches, functions, and lines of the shared update-status source. The real-Electron command passes 17 scenarios and captures ordinary-ready, task-warning, and mandatory-error dialogs. These isolated checks do not certify the complete workspace or a release. Full-repository gate results and environment limitations remain separate from this focused evidence. + +Chromium headless shell revision 1228 is installed in the ignored `.desktop-build/playwright` directory. The assembled settings and sidebar browser suites pass 18 cases. The [Desktop workspace browser scenario](../../web/tests/desktop-updates.e2e.ts) passes both Chinese and English cases, captures six screenshots per locale, and verifies bottom-row placement, compact progress with duplicate-click rejection, a top-toggle badge, persistent red retry with error tooltip, and a separate ready action. The presentation function, Host Web composition, client plugins, and CSS are real; the Desktop carrier is substituted. These cases do not exercise Electron IPC, menus, task authorization, or installation. + +The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written. + +The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. + +The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact. + +The [signed-download runner](../scripts/test-signed-updates.mjs) connects real Electron HTTP, `NsisUpdater`, Windows Authenticode, and the built coordinator. Four scenarios pass: valid-hash wrong-publisher rejection, valid-hash unsigned rejection, corrupt-transfer rejection before signature verification, and explicit retry to signed readiness with separate installation handoff. Rejected executable caches are empty, automatic checks issue no retry request, prepared downloads are retained, and original inputs keep their SHA-512. The synthetic feed and test application adapter do not establish installed-version compatibility; no installer or Host starts. + +With an old installer and both original blockmaps, the same runner additionally verifies single-range and multipart-range reconstruction, missing-old-blockmap fallback, and rejected-range fallback. Reconstructed executables pass SHA-512 and Authenticode checks. Request records distinguish differential payload bytes from full downloads; a full fallback cannot satisfy a differential-success assertion. These loopback results do not certify CDN Range support or an installed application's cache. + + + +## Open verification + +The following items are not passing evidence and must remain visible during review: + +- Electron `capturePage()` captures individual windows. Windows interactive observations include composed dialogs and native menu selection, but cross-platform Figma/layout acceptance and a complete recording remain unverified. The automated workspace runner invokes the menu handler directly. +- The development launcher encounters a dangling optional Linux ARM64 dependency junction in this Windows checkout; the qualification runners link the existing dependency graph directly and do not validate that launcher's projection. +- The fixture does not execute an installer, overwrite an installed application, or establish that a new version starts successfully. Signed Windows and macOS installed-version qualification remains required for release. +- Two isolated Windows test installers pass signed-package inspection, including their embedded feed configuration. Installed startup, failure/retry, automatic restart, and data retention remain operator verification; file inspection does not certify a release. +- Live policy origins, gateway behavior, rate limits, approved page origins, and deployed policy configuration are pending backend integration. Local responses are not proof of a live service. +- Policy, updater feed, and updater download stalls reach their real deadlines and recover. Injected download-write `ENOSPC` is covered; actual volume exhaustion and installed-upgrade disk pressure remain unverified. Differential download and publisher rejection are verified through real Electron downloads, but not yet in a newly packaged application's installed-upgrade path. +- Native clipboard writing passes on Windows. Default-browser launch is attempted, but destination verification stops because the automation tool cannot reliably identify the current URL. macOS browser and clipboard integration remain unverified. +- Mandatory and ordinary failures use localized summaries and folded diagnostics; copy failure reveals a selectable address. Ordinary error tooltips show summaries rather than raw diagnostics. Installed Windows/macOS attention, notification permission, and focus-mode behavior remain unverified. +- Full `doc-sync` encounters Windows file-symlink `EPERM` in the documentation-site test. This is not an updater failure and does not make the full documentation gate green. diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md new file mode 100644 index 0000000000..fa18cd947e --- /dev/null +++ b/apps/desktop/tests/README.zh.md @@ -0,0 +1,72 @@ +# Desktop 本地更新验证 + +[English](README.md) | 中文 + +## 概述 + +本地下载和强更弹窗证据与生产后端联调、视觉验收、已安装应用升级分开记录。运行 [Desktop README](../README.zh.md) 中的命令可生成新的隔离报告。 + +已安装应用验收可显式启用 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,使用安装目录树之外、两个版本共同保留的绝对路径。每个主进程将已安装版本、状态转换和人工操作标记刷新到单独的 JSONL 文件。原始诊断和请求数据被排除;存储错误会向上传播。[日志决策](../../../.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.zh.md)定义证据的局限。单元与主入口测试覆盖该日志;签名安装版升级仍未验证。 + +## 目录 + +- [验证证据](#verification-evidence) +- [手动演练](#verification-interactive) +- [待验证事项](#verification-open) + + + +## 手动演练 + +实际安装、启动后发布、失败重试与重启证据使用[已安装应用更新人工清单](installed-update/README.zh.md)。下方交互式运行器拦截安装,属于另一类验收。 + +Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录运行 `node --import tsx apps/desktop/scripts/test-workspace-updates.ts --interactive`。真实工作区持续打开,并提供独立控制窗口。其菜单可选择普通或强制更新、保持和放行下载、注入下载失败,以及添加或清空测试任务。失败模式须在开始下载前选择。确认安装并完成任务收尾后,fixture 消息提示安装器调用已被拦截;确认消息结束演练。关闭控制窗口也会退出。每次运行独占私有 profile 和回环服务器;载荷不是安装器。重新运行命令开始新一轮。省略 `--interactive` 则运行自动化场景,保留 120 秒截止时间和自动退出;交互模式下载的网络截止时间为十分钟。 + + + +## 验证证据 + +本地命令构建 Desktop,并在 Electron 44 中运行真实 HTTP updater、策略客户端、沙箱预加载和强更页面。它记录每个场景,并把报告保存在 `.desktop-build/qualification/local-updater-*`。安装器调用、外部浏览器和剪贴板均替换为观测记录;下载字节不是可执行安装器。 + +| 层次 | 观测结果 | +|---|---| +| 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 | +| 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 | +| 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 | +| 真实强更窗口 | 服务端纯文本内容、Windows 原生窗口移动/缩放/最大化及还原、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 | +| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 | +| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 | +| 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 | +| Host 任务保护 | 替换组合环境后的真实控制器识别运行中的 agent、排队的 turn/step、全局和 agent job;API 读取不触发警告。请求准入锁定向新请求返回 503,等待已有请求结束并复查任务;解锁恢复准入 | +| 可见输出 | 中文强更弹窗 DOM 预期输出和常规更新展示预期输出通过;账户行组件测试覆盖进度和持久重试 | +| 打包配置 | 元数据嵌入配置的应用 ID 和策略;构建后的打包前钩子拒绝 HTTP 策略源站,不执行打包或上传 | + +弹窗、主入口、设置和侧栏的定向运行通过 119 个回归用例。五个载体测试覆盖共享更新状态源的全部语句、分支、函数和代码行。真实 Electron 命令通过 17 个场景,并捕获常规就绪、任务警告和强更错误弹窗。这些隔离检查不认证完整工作区或发布。全仓门禁结果及环境限制与这些定向证据分开记录。 + +Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/playwright` 中。组装后的设置和侧栏浏览器套件通过 18 个用例。[Desktop 工作区浏览器场景](../../web/tests/desktop-updates.e2e.ts) 的中英文用例均通过,每种语言捕获六张截图,并验证底部账户行位置、紧凑进度及重复点击拦截、顶部展开按钮提示、带错误悬停详情的持久红色重试,以及独立的就绪操作。展示函数、Host Web 组合、客户端插件和 CSS 均为真实实现;Desktop 载体使用替身。这些用例不执行 Electron IPC、菜单、任务授权或安装。 + +[构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。 + +[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件操作页面按钮。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。 + +[Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。 + +[签名下载运行器](../scripts/test-signed-updates.mjs) 连接真实 Electron HTTP、`NsisUpdater`、Windows Authenticode 和构建后的协调器。四个场景通过:哈希正确但发布者错误时拒绝、哈希正确但未签名时拒绝、传输损坏先于验签被拒绝,以及显式重试后签名文件就绪并单独交接安装。被拒绝的可执行文件缓存为空,自动检查不发送重试请求,已准备的下载保持可用,原始输入的 SHA-512 不变。合成清单和测试应用适配器不证明已安装版本兼容性;不会启动安装器或 Host。 + +提供旧安装器和两个原始 blockmap 后,同一运行器还会验证单段 Range、多段 Range 重建、缺少旧 blockmap 时回退,以及 Range 被拒绝时回退。重建的可执行文件通过 SHA-512 和 Authenticode 检查。请求记录区分差分负载字节与全量下载;全量回退不能满足差分成功断言。这些回环结果不证明 CDN Range 支持或已安装应用的缓存可用。 + + + +## 待验证事项 + +以下事项不是通过证据,review 时必须保持可见: + +- Electron `capturePage()` 捕获单个窗口。Windows 交互观测包含合成弹窗和原生菜单选择,但跨平台 Figma/布局验收与完整录制仍未验证。自动化工作区运行器直接调用菜单处理器。 +- 开发启动器在此 Windows 工作区遇到指向缺失目标的可选 Linux ARM64 依赖 junction;验收运行器直接链接已有依赖图,不验证该启动器的依赖投影。 +- fixture(测试前置数据)不执行安装器,不覆盖已安装应用,也不证明新版本成功启动。发布前仍需签名 Windows 和 macOS 的已安装版本验收。 +- 两个隔离 Windows 测试安装包通过签名包检查,包括内嵌清单配置。安装后启动、失败重试、自动重启和数据保留仍待操作者验证;文件检查不认证发布。 +- 真实策略源站、网关行为、限流、批准的页面源站和已部署策略配置仍待后端联调。本地响应不能证明线上服务可用。 +- 策略、updater 清单与下载停滞均达到真实截止时间并可恢复。下载写入的 `ENOSPC` 故障注入已覆盖;真实卷耗尽与已安装版本升级的磁盘压力仍未验证。差分下载和发布者拒绝已通过真实 Electron 下载验证,但尚未在新打包应用的已安装版本升级路径中验证。 +- Windows 原生剪贴板写入通过。已尝试启动默认浏览器,但因自动化工具无法可靠识别当前 URL,目标地址验证停止。macOS 浏览器和剪贴板集成仍未验证。 +- 强更与常规失败使用本地化摘要和折叠诊断;复制失败展示可选择的地址。常规错误悬停提示显示摘要,不展示原始诊断。Windows/macOS 安装包的图标提醒、通知权限与专注模式行为仍未验证。 +- 完整 `doc-sync`(文档同步门禁)在文档站测试中遇到 Windows 文件符号链接 `EPERM`。这不是 updater 失败,也不代表完整文档门禁通过。 diff --git a/apps/desktop/tests/__snapshots__/preload-menu.client.spec.ts.snap b/apps/desktop/tests/__snapshots__/preload-menu.client.spec.ts.snap new file mode 100644 index 0000000000..4642f05dd1 --- /dev/null +++ b/apps/desktop/tests/__snapshots__/preload-menu.client.spec.ts.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`localizes caption entries and removes the menu on disposal > chinese 1`] = `"
    "`; + +exports[`localizes caption entries and removes the menu on disposal > english 1`] = `"
    "`; diff --git a/apps/desktop/tests/cos-loopback.ts b/apps/desktop/tests/cos-loopback.ts new file mode 100644 index 0000000000..078c82ec86 --- /dev/null +++ b/apps/desktop/tests/cos-loopback.ts @@ -0,0 +1,110 @@ +/** Loopback HTTP capture for tests that exercise real COS SDK request serialization. */ + +import { createServer, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import type COS from 'cos-nodejs-sdk-v5' + +/** One request the SDK actually sent, with bytes read from the wire. */ +export interface CosLoopbackRequest { + readonly method: string + /** Request path including the query string, as sent to the real endpoint. */ + readonly path: string + /** Lowercased request headers exactly as the SDK wrote them. */ + readonly headers: Record + /** Exact body bytes received. */ + readonly body: Buffer + /** Signed URL the SDK built before the test redirected it to this server. */ + readonly signedUrl: string | undefined +} + +/** Owns one request's reply; use {@link answer} for a complete response. */ +export type CosLoopbackResponder = (request: CosLoopbackRequest, response: ServerResponse) => void | Promise + +/** One isolated HTTP origin that captures SDK requests instead of reaching COS. */ +export interface CosLoopback { + readonly requests: CosLoopbackRequest[] + /** Redirect every SDK request to this server while keeping the signed headers intact. */ + redirect(cos: COS): void + /** Close the server; rejects with the first responder failure so no reply error stays silent. */ + close(): Promise +} + +/** + * Write a complete response and close the connection so the client cannot reuse its socket. + * @param socket Response object owned by the responder. + * @param status HTTP status code. + * @param body Complete response body. + * @param headers Additional response headers. + */ +export function answer(socket: ServerResponse, status = 200, body: string | Buffer = '', + headers: Record = {}): void { + socket.writeHead(status, { connection: 'close', ...headers }) + socket.end(body) +} + +/** + * Build a COS error body the SDK maps to its error code. + * @param code COS error code, such as `NoSuchKey` or `InternalError`. + * @param message Server message; tests use it to prove diagnostics stay out of retained records. + * @returns XML error document. + */ +export function cosError(code: string, message = 'fixture'): string { + return `${code}${message}` +} + +/** + * Start a loopback server that records each request before the responder answers it. + * @param responder Reply for each captured request; the default is an empty 200. + * @returns Recorded requests, the SDK redirect, and an idempotent close. + */ +export async function startCosLoopback( + responder: CosLoopbackResponder = (_request, response) => { answer(response) }, +): Promise { + const requests: CosLoopbackRequest[] = [] + const signedUrls: string[] = [] + const failures: unknown[] = [] + const server = createServer((incoming, response) => { + const chunks: Buffer[] = [] + incoming.on('data', (chunk: Buffer) => chunks.push(chunk)) + incoming.on('end', () => { + const request: CosLoopbackRequest = { + method: incoming.method ?? '', + path: incoming.url ?? '', + headers: incoming.headers, + body: Buffer.concat(chunks), + signedUrl: signedUrls[requests.length], + } + requests.push(request) + void Promise.resolve(responder(request, response)).catch((error: unknown) => { + failures.push(error) + response.destroy() + }) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() }) + }) + const { port } = server.address() as AddressInfo + let closed = false + return { + requests, + redirect(cos) { + cos.on('before-send', (options: { url: string }) => { + signedUrls.push(options.url) + const parsed = new URL(options.url) + options.url = `http://127.0.0.1:${port}${parsed.pathname}${parsed.search}` + }) + }, + async close() { + if (closed) return + closed = true + server.closeAllConnections() + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + if (failures.length > 0) throw failures[0] + }, + } +} diff --git a/apps/desktop/tests/cos-operation.spec.ts b/apps/desktop/tests/cos-operation.spec.ts new file mode 100644 index 0000000000..0a065363a0 --- /dev/null +++ b/apps/desktop/tests/cos-operation.spec.ts @@ -0,0 +1,105 @@ +import { Readable, Writable } from 'node:stream' +import type { ServerResponse } from 'node:http' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cosOperation } from '../scripts/cos-operation.ts' +import { createDesktopCos } from '../scripts/desktop-cos.ts' +import { answer, cosError, startCosLoopback, type CosLoopback } from './cos-loopback.ts' + +const loopbacks: CosLoopback[] = [] +afterEach(async () => { + vi.useRealTimers() + await Promise.all(loopbacks.splice(0).map(loopback => loopback.close())) +}) + +const object = { Bucket: 'fixture-1250000000', Region: 'ap-beijing', Key: 'object' } + +async function fixture(retry = false) { + const received = Promise.withResolvers() + const disconnected = Promise.withResolvers() + const loopback = await startCosLoopback((_request, response) => { + if (retry && loopback.requests.length === 1) { + vi.advanceTimersByTime(20_000) + answer(response, 500, cosError('InternalError')) + return + } + response.once('close', () => { disconnected.resolve(undefined) }) + received.resolve(response) + }) + loopbacks.push(loopback) + const cos = createDesktopCos({ secretId: 'fixture-id', secretKey: 'fixture-key' }) + loopback.redirect(cos) + // Only deadline timers are virtual; socket delivery and stream close remain real observations. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + return { cos, loopback, received: received.promise, disconnected: disconnected.promise } +} + +describe('COS operation total deadlines', () => { + it('aborts a stalled versioning retry within the original operation budget', async () => { + const f = await fixture(true) + const pending = cosOperation(f.cos, 30_000, () => f.cos.getBucketVersioning(object)) + const rejected = expect(pending).rejects.toMatchObject({ name: 'TimeoutError' }) + await f.received + vi.advanceTimersByTime(10_000) + await rejected + await f.disconnected + // The aborted signal also prevents the SDK from opening sockets for later retry attempts. + expect(f.loopback.requests).toHaveLength(2) + }) + + it('aborts an active download despite ongoing data and closes its request', async () => { + const f = await fixture() + let received = Promise.withResolvers() + let size = 0 + const output = new Writable({ write(bytes: Buffer, _encoding, done) { + size += bytes.length; received.resolve(undefined); done() + } }) + const pending = cosOperation(f.cos, 900_000, () => f.cos.getObject({ ...object, Output: output })) + const rejected = expect(pending).rejects.toMatchObject({ name: 'TimeoutError' }) + try { + const response = await f.received + response.writeHead(200, { 'content-length': '100' }) + for (let i = 0; i < 3; i++) { + received = Promise.withResolvers() + response.write('x') + await received.promise + vi.advanceTimersByTime(300_000) + } + await rejected + await f.disconnected + expect(size).toBe(3) + expect(f.loopback.requests).toHaveLength(1) + } finally { output.destroy() } + }) + + it('aborts a PUT awaiting confirmation without resending the object', async () => { + const f = await fixture() + const body = Readable.from(['bytes']) + const pending = cosOperation(f.cos, 900_000, () => f.cos.putObject({ ...object, Body: body, ContentLength: 5 })) + const rejected = expect(pending).rejects.toMatchObject({ name: 'TimeoutError' }) + try { + await f.received + vi.advanceTimersByTime(900_000) + await rejected + await f.disconnected + expect(f.loopback.requests).toHaveLength(1) + expect(f.loopback.requests[0]!.body.toString()).toBe('bytes') + } finally { body.destroy() } + }) + + it('clears the deadline after success and leaves another client running', async () => { + const first = await fixture() + const second = await fixture() + const read = (f: Awaited>) => cosOperation(f.cos, 30_000, () => f.cos.getBucketVersioning(object)) + const success = read(first) + const pending = read(second) + answer(await first.received, 200, '') + await success + await first.disconnected + const response = await second.received + vi.advanceTimersByTime(29_999) + answer(response, 200, '') + await pending + await second.disconnected + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index 71e368acda..747657042b 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -17,8 +17,8 @@ describe('desktop auto-update environment', () => { 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', + publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + keyPrefix: 'dsh-desk/feeds/mac-arm64', }) expect(resolveDesktopUploadConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', @@ -36,7 +36,7 @@ describe('desktop auto-update environment', () => { }, 'win32', 'x64')).toMatchObject({ environment: 'production', target: 'win-x64', - publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/', + publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', }) expect(resolveDesktopUploadConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', @@ -79,10 +79,10 @@ describe('desktop auto-update environment', () => { 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') + it('uses Nightly metadata for stable and prerelease Desktop versions', () => { + expect(desktopUpdateMetadataFilename('1.2.3', 'darwin')).toBe('nightly-mac.yml') + expect(desktopUpdateMetadataFilename('1.2.3-alpha.4', 'darwin')).toBe('nightly-mac.yml') + expect(desktopUpdateMetadataFilename('1.2.3-beta.2', 'win32')).toBe('nightly.yml') expect(() => desktopUpdateMetadataFilename('not-semver', 'darwin')).toThrow(/invalid Desktop version/u) expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u) }) diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 8f20891c82..50d9726550 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -6,7 +6,8 @@ import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from const WINDOWS = { platform: 'win32', arch: 'x64' } as const const MACOS = { platform: 'darwin', arch: 'arm64' } as const -const RELEASE = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com' } +const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com' } +const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com' } const MAC_IDENTITY = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234' } async function withDirectory(action: (directory: string) => Promise): Promise { @@ -22,9 +23,13 @@ describe('Desktop local packaging configuration', () => { it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => { await withDirectory(async (directory) => { await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n') - await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_APP_ID=com.example.mac\nAPPLE_KEYCHAIN_PROFILE=release\n') + await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_APP_ID=com.example.mac\nAPPLE_KEYCHAIN_PROFILE=release\nCSC_LINK=keys/signing.p12\nCSC_KEY_PASSWORD=" # literal "\n') const parent = { PATH: 'build-tools', DSH_DESKTOP_APP_ID: 'com.stale.desktop', + DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: '{"origin":"https://stale.example.com"}', + dsh_desktop_mandatory_update_config: 'stale-policy', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://stale.example.com', + dsh_desktop_mandatory_update_prod_origin: 'https://stale.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'stale-pin', APPLE_ID: 'stale-apple-id', CSC_LINK: 'stale-certificate', DOWNLOAD_TEST_ORIGIN: 'https://stale.example.com', dsh_desktop_windows_key_container: 'case-insensitive-stale-container', @@ -36,8 +41,26 @@ describe('Desktop local packaging configuration', () => { }) expect(loadDesktopPackageEnvironment('darwin', parent, directory)).toEqual({ PATH: 'build-tools', DSH_DESKTOP_APP_ID: 'com.example.mac', APPLE_KEYCHAIN_PROFILE: 'release', + CSC_LINK: join(directory, 'keys/signing.p12'), CSC_KEY_PASSWORD: ' # literal ', }) expect(parent.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('stale-pin') + expect(parent.dsh_desktop_mandatory_update_config).toBe('stale-policy') + }) + }) + + it('loads mandatory update origins and options only from the platform file without changing its parent', async () => { + await withDirectory(async (directory) => { + const windowsPolicy = JSON.stringify({ intervalMs: 5000, allowedPageOrigins: ['https://download.example.invalid'] }) + const macPolicy = JSON.stringify({ intervalMs: 6000, allowedPageOrigins: ['https://download.example.invalid'] }) + const origins = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.invalid', + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://prod.example.invalid' } + const lines = Object.entries(origins).map(([key, value]) => `${key}=${value}\n`).join('') + await writeFile(join(directory, '.env.windows'), `${lines}DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='${windowsPolicy}'\n`) + await writeFile(join(directory, '.env.macos'), `${lines}DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='${macPolicy}'\n`) + const parent = { DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: 'stale-policy' } + expect(loadDesktopPackageEnvironment('win32', parent, directory)).toEqual({ ...origins, DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: windowsPolicy }) + expect(loadDesktopPackageEnvironment('darwin', parent, directory)).toEqual({ ...origins, DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: macPolicy }) + expect(parent).toEqual({ DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: 'stale-policy' }) }) }) @@ -58,16 +81,16 @@ describe('Desktop local packaging configuration', () => { validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: 'invalid' }, WINDOWS) }).toThrow(/reverse-DNS/u) expect(() => { - validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS) + validateDesktopPackageEnvironment({ ...POLICY, DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS) }).toThrow(/DOWNLOAD_TEST_ORIGIN/u) expect(() => { validateDesktopPackageEnvironment(RELEASE, WINDOWS) }).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) expect(() => { - validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS, { unsigned: true }) + validateDesktopPackageEnvironment({ ...POLICY, DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS, { unsigned: true }) }).not.toThrow() expect(() => { - validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS, { prepareOnly: true }) + validateDesktopPackageEnvironment({ ...POLICY, DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }, WINDOWS, { prepareOnly: true }) }).not.toThrow() }) @@ -83,16 +106,16 @@ describe('Desktop local packaging configuration', () => { }).toThrow(/APPLE_API_KEY_ID/u) expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release' }, MACOS) - }).not.toThrow() + }).toThrow(/CSC_LINK/u) expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release', APPLE_API_KEY: '' }, MACOS) }).toThrow(/exactly one macOS notarization strategy/u) expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_ID: 'user@example.com', APPLE_APP_SPECIFIC_PASSWORD: 'fixture', APPLE_TEAM_ID: 'TEAMID1234' }, MACOS) - }).not.toThrow() + }).toThrow(/CSC_LINK/u) await withDirectory(async (directory) => { const appleApiKey = join(directory, 'AuthKey.p8') - const environment = { ...RELEASE, ...MAC_IDENTITY, APPLE_API_KEY: appleApiKey, APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555' } + const environment = { ...RELEASE, ...MAC_IDENTITY, CSC_LINK: appleApiKey, CSC_KEY_PASSWORD: '', APPLE_API_KEY: appleApiKey, APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555' } expect(() => { validateDesktopPackageEnvironment(environment, MACOS) }).toThrow(/APPLE_API_KEY must identify a readable local file/u) @@ -100,6 +123,11 @@ describe('Desktop local packaging configuration', () => { expect(() => { validateDesktopPackageEnvironment(environment, MACOS) }).not.toThrow() + expect(() => { + validateDesktopPackageEnvironment({ ...environment, CSC_KEY_PASSWORD: undefined }, MACOS) + }).toThrow(/CSC_KEY_PASSWORD/u) + expect(() => { validateDesktopPackageEnvironment({ ...environment, CSC_LINK: directory }, MACOS) }).toThrow(/CSC_LINK/u) + expect(() => { validateDesktopPackageEnvironment({ ...environment, CSC_LINK: 'missing.p12' }, MACOS, { prepareOnly: true }) }).toThrow(/CSC_LINK/u) expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release', APPLE_KEYCHAIN: directory }, MACOS) }).toThrow(/APPLE_KEYCHAIN/u) diff --git a/apps/desktop/tests/desktop-policy-environment.spec.ts b/apps/desktop/tests/desktop-policy-environment.spec.ts new file mode 100644 index 0000000000..8316b833fc --- /dev/null +++ b/apps/desktop/tests/desktop-policy-environment.spec.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest' +import { resolveDesktopPolicyEnvironment } from '../scripts/desktop-policy-environment.mjs' +import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { resolveDesktopPolicyConfig } from '../src/mandatory-update-policy.ts' + +const origins = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://harness-test.deepseek.com', + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://harness.deepseek.com' } + +it.each(['test', 'production'] as const)('selects the %s policy and authentication together', (deployment) => { + const policy = resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_AUTO_UPDATE_ENV: deployment }) + const origin = deployment === 'test' ? origins.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN : origins.DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN + expect(policy).toEqual({ origin, allowedPageOrigins: [origin], authentication: deployment === 'test' ? 'feishu-test' : 'anonymous' }) + expect(resolveDesktopPolicyConfig(policy)).toMatchObject(policy) +}) + +it('requires only the selected origin, defaults to test, and accepts explicit page restrictions', () => { + const policy = resolveDesktopPolicyEnvironment({ + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origins.DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN, + DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedPageOrigins: ['https://download.deepseek.com'], intervalMs: 5000 }) }) + expect(policy).toMatchObject({ authentication: 'feishu-test', intervalMs: 5000, allowedPageOrigins: ['https://download.deepseek.com'] }) + expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' })).toThrow('production') +}) + +it.each([undefined, '', 'http://harness-test.deepseek.com', 'https://user:secret@harness-test.deepseek.com', + 'https://harness-test.deepseek.com/api', 'https://harness-test.deepseek.com/?secret=value'])('rejects invalid selected origin %s', (origin) => { + expect(() => resolveDesktopPolicyEnvironment({ DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).toThrow('HTTPS origin') + expect(() => resolveDesktopPolicyEnvironment({ DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: origin })).not.toThrow('secret=value') +}) + +it.each(['{', 'null', '[]', '{"origin":"https://old.example.com"}', '{"authentication":"anonymous"}', + '{"allowedPageOrigins":[]}', '{"allowedPageOrigins":["http://example.com"]}'])('rejects invalid or conflicting shared options %s', (options) => { + expect(() => resolveDesktopPolicyEnvironment({ ...origins, DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: options })).toThrow() +}) + +it.each([{ unsigned: true }, { prepareOnly: true }, {}])('fails before signing/preparation when policy is absent in %j', (options) => { + for (const platform of ['win32', 'darwin'] as const) { + expect(() => { validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: 'com.example.test' }, { platform, arch: 'x64' }, options) }) + .toThrow('DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN') + } +}) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index 4a34b0afbe..d65a44a923 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -1,8 +1,10 @@ import { createHash } from 'node:crypto' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { load } from 'js-yaml' 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' @@ -11,6 +13,11 @@ const temporaryDirectories: string[] = [] const TEST_ORIGIN = 'https://desktop-updates.example.com' const TEST_BUCKET = 'test-download-bucket' const PRODUCTION_BUCKET = 'production-download-bucket' +const require = createRequire(import.meta.url) +const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as { + createBlockmap: (file: string, target: object, packager: { info: { emitArtifactBuildCompleted(event: object): Promise } }, + safeArtifactName: string) => Promise<{ size: number; sha512: string }> +} interface Fixture { readonly repositoryRoot: string @@ -47,7 +54,7 @@ async function fixture( target, version, environment, - publicUrl: `${origin}/_/harness/desktop/stable/${target}/`, + publicUrl: `${origin}/dsh-desk/feeds/${target}/`, })}\n`) if (os === 'mac') { @@ -63,13 +70,14 @@ async function fixture( else { const executable = 'signed NSIS executable fixture' await writeFile(join(artifactsRoot, `${base}.exe`), executable) + const info = await createBlockmap(join(artifactsRoot, `${base}.exe`), {}, + { info: { emitArtifactBuildCompleted: async () => {} } }, `${base}.exe`) + expect(Object.hasOwn(info, 'blockMapSize')).toBe(false) await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ version, files: [{ url: `${base}.exe`, - size: Buffer.byteLength(executable), - sha512: digest(executable), - blockMapSize: 128, + ...info, }], })}\n`) } @@ -98,24 +106,44 @@ afterEach(async () => { }) describe('desktop upload plan', () => { + it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { + const paths = await fixture('win-x64', '1.2.3', 'production') + const plan = await createDesktopUploadPlan('win-x64', paths) + expect(plan.artifacts.map(artifact => artifact.key)).toEqual([ + 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe', + 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap', + 'dsh-desk/feeds/win-x64/nightly.yml', + 'dsh-desk/feeds/win-x64/latest.yml', + ]) + expect(load(plan.artifacts[2]!.contents!)).toMatchObject({ + version: '1.2.3', + files: [{ + url: 'https://download.deepseek.com/dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe', + sha512: digest('signed NSIS executable fixture'), + }], + }) + expect(plan.artifacts[2]!.contents).toBe(plan.artifacts[3]!.contents) + expect(plan.artifacts.every(artifact => !('cacheControl' in artifact))).toBe(true) + }) + 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/', + publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/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', + 'nightly-mac.yml', 'latest-mac.yml', ]) expect(plan.artifacts.at(-1)).toMatchObject({ channelMetadata: true, - cacheControl: 'no-cache', }) }) @@ -126,35 +154,31 @@ describe('desktop upload plan', () => { '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', + 'nightly-mac.yml', ]) }) - it('validates the Windows installer with its embedded blockmap and production destination', async () => { + it('validates the Windows installer with the emitted external 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', + 'deepseek-harness-2.0.0-win-x64.exe.blockmap', + 'nightly.yml', 'latest.yml', ]) expect(plan).toMatchObject({ - publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/', + publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', bucket: PRODUCTION_BUCKET, }) }) - it('rejects Windows metadata without an embedded blockmap size', async () => { + it.each(['missing', 'empty'])('rejects a %s Windows blockmap before publishing its feed', async (condition) => { 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) + const path = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-win-x64.exe.blockmap') + if (condition === 'missing') await rm(path) + else await writeFile(path, '') + await expect(createDesktopUploadPlan('win-x64', paths)).rejects.toThrow(/missing or empty artifact.*\.exe\.blockmap/u) }) it('rejects a completed build from another dsh version or deployment', async () => { @@ -176,7 +200,7 @@ describe('desktop upload plan', () => { 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 metadataPath = join(paths.artifactsRoot, 'nightly-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) diff --git a/apps/desktop/tests/desktop-upload-run.spec.ts b/apps/desktop/tests/desktop-upload-run.spec.ts new file mode 100644 index 0000000000..b4c91c2df4 --- /dev/null +++ b/apps/desktop/tests/desktop-upload-run.spec.ts @@ -0,0 +1,194 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import COS from 'cos-nodejs-sdk-v5' +import { afterEach, describe, expect, it } from 'vitest' +import { createDesktopCos, DESKTOP_COS_REGION } from '../scripts/desktop-cos.ts' +import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { answer, cosError, startCosLoopback, type CosLoopback, type CosLoopbackRequest } from './cos-loopback.ts' + +const roots: string[] = [] +const loopbacks: CosLoopback[] = [] +afterEach(async () => { + const closers = loopbacks.splice(0) + const paths = roots.splice(0) + try { await Promise.all(closers.map(loopback => loopback.close())) } + finally { await Promise.all(paths.map(path => rm(path, { recursive: true, force: true }))) } +}) + +const BINARY_BYTES = 'binary fixture' +const FEED_BYTES = 'version: 1.2.3\n' + +async function fixture(environment: 'test' | 'production' = 'test') { + const root = await mkdtemp(join(tmpdir(), 'dsh-upload-audit-')) + roots.push(root) + const binary = join(root, 'package.exe') + await writeFile(binary, BINARY_BYTES) + const bucket = `${environment}-fixture-1250000000` + const plan: DesktopUploadPlan = { + environment, target: 'win-x64', version: '1.2.3', bucket, + publicUrl: `https://${environment}.invalid/feed/`, secretIdEnvName: 'UNREAD_ID', secretKeyEnvName: 'UNREAD_KEY', + artifacts: [ + { path: binary, filename: 'package.exe', key: 'bin/package.exe', contentType: 'application/octet-stream', channelMetadata: false }, + { path: join(root, 'nightly.yml'), filename: 'nightly.yml', key: 'feeds/nightly.yml', contentType: 'application/yaml', channelMetadata: true, + contents: FEED_BYTES }, + ], + } + const records = join(root, 'records') + const directory = async () => join(records, (await readdir(records))[0]!) + return { root, records, plan, bucket, directory } +} + +/** Production client redirecting to one isolated loopback origin. */ +async function transport(responder?: Parameters[0]) { + const loopback = await startCosLoopback(responder) + loopbacks.push(loopback) + const cos = createDesktopCos({ secretId: 'test-credential-id', secretKey: 'test-credential-secret' }) + loopback.redirect(cos) + return { cos, loopback } +} + +interface RecordedPlan { + environment: string + artifacts: ({ contents?: string } & { md5: string; sha512: string; size: number })[] + sourceSha256: Record +} + +async function json(path: string): Promise { return JSON.parse(await readFile(path, 'utf8')) as T } + +async function journal(path: string): Promise<{ type: string; time: string }[]> { + return (await readFile(path, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { type: string; time: string }) +} + +/** Assert the wire form shared by every upload: exact bytes, one content-length, no transfer encoding. */ +function expectExactObject(request: CosLoopbackRequest, expected: string, contentType: string, bucket: string): void { + expect(request.method).toBe('PUT') + expect(request.body.toString('utf8')).toBe(expected) + expect(request.headers['content-length']).toBe(String(Buffer.byteLength(expected))) + expect(request.headers['content-type']).toBe(contentType) + expect(request.headers['transfer-encoding']).toBeUndefined() + expect(request.headers['content-encoding']).toBeUndefined() + expect(request.headers['content-md5']).toBe(createHash('md5').update(expected).digest('base64')) + const signed = new URL(request.signedUrl!) + expect(signed.protocol).toBe('https:') + expect(signed.host).toBe(`${bucket}.cos.${DESKTOP_COS_REGION}.myqcloud.com`) + expect(signed.search).toBe('') +} + +describe('release upload audit with the real COS SDK and an isolated loopback origin', () => { + it.each(['test', 'production'] as const)('retains %s plan, feed bytes, intent, receipt and hashes before advancing', async (environment) => { + const f = await fixture(environment) + const { cos, loopback } = await transport(async (request, response) => { + const directory = await f.directory() + const plan = await json(join(directory, 'plan.json')) + expect(plan.environment).toBe(environment) + expect(plan.artifacts[1]!.contents).toBe(FEED_BYTES) + const lines = await journal(join(directory, 'events.jsonl')) + expect(lines.at(-1)).toMatchObject({ type: 'put-intent', key: f.plan.artifacts[loopback.requests.length - 1]!.key }) + expect(request.headers['content-md5']).toBe(plan.artifacts[loopback.requests.length - 1]!.md5) + answer(response, 200, '', { 'x-cos-request-id': 'fixture-request' }) + }) + const directory = await uploadDesktopRelease(f.plan, cos, f.records) + expect(loopback.requests).toHaveLength(2) + expectExactObject(loopback.requests[0]!, BINARY_BYTES, 'application/octet-stream', f.bucket) + expectExactObject(loopback.requests[1]!, FEED_BYTES, 'application/yaml', f.bucket) + expect(loopback.requests[0]!.path).toBe('/bin/package.exe') + expect(loopback.requests[1]!.path).toBe('/feeds/nightly.yml') + for (const request of loopback.requests) { + const authorization = String(request.headers.authorization) + expect(authorization).toContain('q-sign-algorithm=sha1') + expect(authorization).toContain('content-md5') + expect(authorization).not.toContain('test-credential-secret') + } + const plan = await json(join(directory, 'plan.json')) + expect(plan.artifacts[0]!.sha512).toBe(createHash('sha512').update(BINARY_BYTES).digest('base64')) + expect(plan.sourceSha256['upload-target.ts']).toBe(createHash('sha256') + .update(await readFile(join(import.meta.dirname, '../scripts/upload-target.ts'))).digest('hex')) + expect(plan.sourceSha256['desktop-cos.ts']).toBe(createHash('sha256') + .update(await readFile(join(import.meta.dirname, '../scripts/desktop-cos.ts'))).digest('hex')) + const result = await json(join(directory, 'result.json')) + expect(result).toMatchObject({ success: true, stage: 'complete', confirmedPuts: 2, publicReadback: 'not-performed' }) + const events = await journal(join(directory, 'events.jsonl')) + expect(`${events.map(event => event.type).join('\n')}\n`).toBe(await readFile(join(import.meta.dirname, 'expected/desktop-upload-audit.txt'), 'utf8')) + expect(events[2]).toMatchObject({ httpStatus: 200, requestId: 'fixture-request', attempts: 1 }) + for (const event of events) expect(Number.isFinite(Date.parse(event.time))).toBe(true) + expect(JSON.stringify({ plan, result, events })).not.toMatch(/test-credential|authorization|UNREAD_KEY/iu) + }) + + it.each([1, 2])('retains failure at PUT %s without retry or later upload, excluding raw SDK diagnostics', async (failAt) => { + const f = await fixture() + const { cos, loopback } = await transport((_request, response) => { + if (loopback.requests.length === failAt) { + answer(response, 500, cosError('InternalError', 'test-credential-secret'), + { 'content-type': 'application/xml', 'x-cos-request-id': 'fixture-request' }) + return + } + answer(response, 200, '', { 'x-cos-request-id': 'fixture-request' }) + }) + await expect(uploadDesktopRelease(f.plan, cos, f.records)).rejects.toThrow('stopped at put') + expect(loopback.requests).toHaveLength(failAt) + const directory = await f.directory() + const result = await json(join(directory, 'result.json')) + expect(result).toMatchObject({ success: false, confirmedPuts: failAt - 1, stage: 'put', failure: { errorCode: 'InternalError', httpStatus: 500, requestId: 'fixture-request' } }) + expect(await readFile(join(directory, 'result.json'), 'utf8')).not.toContain('test-credential-secret') + expect((await readFile(join(directory, 'events.jsonl'), 'utf8')).trim().split('\n')).toHaveLength(failAt * 2) + }) + + it('cannot repeat the write when a client leaves every SDK retry option at its default', async () => { + const f = await fixture() + const loopback = await startCosLoopback((_request, response) => { + answer(response, 500, cosError('InternalError'), { 'content-type': 'application/xml', 'x-cos-request-id': 'fixture-request' }) + }) + loopbacks.push(loopback) + // Deliberately retry-permissive: a buffer body for the manifest would let the SDK send it up to four times. + const cos = new COS({ SecretId: 'test-credential-id', SecretKey: 'test-credential-secret', Protocol: 'https:' }) + loopback.redirect(cos) + await expect(uploadDesktopRelease(f.plan, cos, f.records)).rejects.toThrow('stopped at put') + expect(loopback.requests.map(request => request.path)).toEqual(['/bin/package.exe']) + expect(loopback.requests[0]!.body.toString('utf8')).toBe(BINARY_BYTES) + }) + + it.each([1, 2])('reports a dropped connection at PUT %s once without sending the object again', async (dropAt) => { + const f = await fixture() + const { cos, loopback } = await transport((_request, response) => { + if (loopback.requests.length === dropAt) { response.destroy(); return } + answer(response, 200, '', { 'x-cos-request-id': 'fixture-request' }) + }) + await expect(uploadDesktopRelease(f.plan, cos, f.records)).rejects.toThrow('stopped at put') + expect(loopback.requests).toHaveLength(dropAt) + const result = await json(join(await f.directory(), 'result.json')) + expect(result).toMatchObject({ success: false, confirmedPuts: dropAt - 1, stage: 'put', failure: { errorCode: 'ECONNRESET' } }) + }) + + it('refuses networking when the audit parent cannot be created', async () => { + const f = await fixture() + const { cos, loopback } = await transport() + await expect(uploadDesktopRelease(f.plan, cos, f.plan.artifacts[0]!.path)).rejects.toThrow() + expect(loopback.requests).toHaveLength(0) + }) + + it('stops before the next PUT when a response cannot be recorded', async () => { + const f = await fixture() + const { cos, loopback } = await transport(async (_request, response) => { + const directory = await f.directory() + await rename(join(directory, 'events.jsonl'), join(directory, 'events-before-failure.jsonl')) + await mkdir(join(directory, 'events.jsonl')) + answer(response, 200, '', { 'x-cos-request-id': 'fixture-request' }) + }) + await expect(uploadDesktopRelease(f.plan, cos, f.records)).rejects.toThrow('stopped at record-response') + expect(loopback.requests).toHaveLength(1) + expect(await json(join(await f.directory(), 'result.json'))).toMatchObject({ success: false, stage: 'record-response', confirmedPuts: 1 }) + }) + + it('keeps independent runs without overwriting prior evidence', async () => { + const f = await fixture() + const { cos } = await transport() + const first = await uploadDesktopRelease(f.plan, cos, f.records) + const before = await readFile(join(first, 'result.json'), 'utf8') + const second = await uploadDesktopRelease(f.plan, cos, f.records) + expect(second).not.toBe(first) + expect(await readFile(join(first, 'result.json'), 'utf8')).toBe(before) + }) +}) diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts index a68bcf6af5..0fe54ab1a3 100644 --- a/apps/desktop/tests/development-project.spec.ts +++ b/apps/desktop/tests/development-project.spec.ts @@ -6,7 +6,6 @@ import { prepareDevelopmentProject } from '../scripts/development-project.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { DesktopProjectManager } from '../src/project-manager.ts' import { resolveDesktopPaths } from '../src/paths.ts' -import { writePackage } from './runtime-fixture.ts' import type { DesktopRelease } from '../src/release.ts' const roots: string[] = [] @@ -69,16 +68,9 @@ describe('desktop development project', () => { expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3') expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3') const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), { - node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh: project, + dsh: project, }) - const hooks = { beforeChange: async () => {}, afterChange: async () => {} } await manager.applyRelease() - const local = writePackage(join(root, 'packages'), 'development-plugin', { dsh: { bundle: { patch: 'bundle.yml' } } }) - writeFileSync(join(local, 'bundle.yml'), '[]\n') - await manager.mutate({ type: 'plugin-add', spec: `file:${local}` }, hooks) - expect(manager.listPlugins()).toEqual([{ name: 'development-plugin', version: '1.0.0', enabled: true }]) - await manager.mutate({ type: 'plugin-remove', name: 'development-plugin' }, hooks) - expect(manager.listPlugins()).toEqual([]) await manager.disableAllPlugins() expect(readFileSync(join(cli, 'package.json'), 'utf8')).toBe('{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n') expect(readFileSync(join(host, 'lib', 'index.js'), 'utf8')).toBe('') diff --git a/apps/desktop/tests/expected/about-panel.json b/apps/desktop/tests/expected/about-panel.json new file mode 100644 index 0000000000..6900f525d3 --- /dev/null +++ b/apps/desktop/tests/expected/about-panel.json @@ -0,0 +1,28 @@ +{ + "en-US": { + "menu": [ + { "label": "About DeepSeek Harness", "role": "about" }, + { "type": "separator" } + ], + "options": { + "applicationName": "DeepSeek Harness", + "applicationVersion": "1.0.0", + "version": "", + "copyright": "", + "iconPath": "" + } + }, + "zh-CN": { + "menu": [ + { "label": "关于 DeepSeek Harness", "role": "about" }, + { "type": "separator" } + ], + "options": { + "applicationName": "DeepSeek Harness", + "applicationVersion": "1.0.0", + "version": "", + "copyright": "", + "iconPath": "" + } + } +} diff --git a/apps/desktop/tests/expected/desktop-upload-audit.txt b/apps/desktop/tests/expected/desktop-upload-audit.txt new file mode 100644 index 0000000000..dd36bc4cd4 --- /dev/null +++ b/apps/desktop/tests/expected/desktop-upload-audit.txt @@ -0,0 +1,6 @@ +upload-start +put-intent +put-confirmed +put-intent +put-confirmed +upload-complete diff --git a/apps/desktop/tests/expected/fatal-address-in-use-en.txt b/apps/desktop/tests/expected/fatal-address-in-use-en.txt new file mode 100644 index 0000000000..1e9a9b62d0 --- /dev/null +++ b/apps/desktop/tests/expected/fatal-address-in-use-en.txt @@ -0,0 +1,5 @@ +DeepSeek Harness is unavailable +The application could not start or stopped unexpectedly. +Another DSH instance (such as dsh web or the desktop app) is running. They cannot start at the same time. Quit the other running DSH instance, then restart. +Exit +Restart diff --git a/apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt b/apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt new file mode 100644 index 0000000000..30cad3d061 --- /dev/null +++ b/apps/desktop/tests/expected/fatal-address-in-use-zh-CN.txt @@ -0,0 +1,5 @@ +DeepSeek Harness 无法使用 +应用无法启动或已意外停止。 +有其他正在运行的 DSH(如其他 dsh web、桌面端),无法同时启动,请退出其他正在运行的 DSH 后重启。 +退出 +重启 diff --git a/apps/desktop/tests/expected/fatal-dialog-en.txt b/apps/desktop/tests/expected/fatal-dialog-en.txt index fc22fb8c3b..0dd0a62eb3 100644 --- a/apps/desktop/tests/expected/fatal-dialog-en.txt +++ b/apps/desktop/tests/expected/fatal-dialog-en.txt @@ -6,4 +6,4 @@ Plugin initialization failed If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. Exit Restart -Disable all third-party plugins and restart +Disable third-party plugins, back up profile patch, and restart diff --git a/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt b/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt index 26fd3b0c3f..6c40644c09 100644 --- a/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt +++ b/apps/desktop/tests/expected/fatal-dialog-zh-CN.txt @@ -6,4 +6,4 @@ Plugin initialization failed 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 退出 重启 -禁用全部第三方插件并重启 +禁用第三方插件、备份 profile patch 并重启 diff --git a/apps/desktop/tests/expected/mandatory-update-zh.json b/apps/desktop/tests/expected/mandatory-update-zh.json new file mode 100644 index 0000000000..fbe0356bcc --- /dev/null +++ b/apps/desktop/tests/expected/mandatory-update-zh.json @@ -0,0 +1,10 @@ +{ + "title": "仍有进行中的任务", + "detail": "重启更新可能中断这些任务,是否要继续更新?", + "status": "", + "version": "V1.0.1-nightly.1", + "buttons": [ + "停止任务并更新", + "稍后更新" + ] +} diff --git a/apps/desktop/tests/expected/policy-login-en.json b/apps/desktop/tests/expected/policy-login-en.json new file mode 100644 index 0000000000..3415d20680 --- /dev/null +++ b/apps/desktop/tests/expected/policy-login-en.json @@ -0,0 +1,18 @@ +{ + "returned": [ + "Checking for updates…", + "No updates available. Current version: V1.0.0", + "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update." + ], + "cancelled": [ + "Checking for updates…", + "No updates available. Current version: V1.0.0", + "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update." + ], + "failed": [ + "Checking for updates…", + "No updates available. Current version: V1.0.0", + "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.", + "Test environment sign-in did not complete. Please check your connection and try again." + ] +} diff --git a/apps/desktop/tests/expected/update-status-zh.json b/apps/desktop/tests/expected/update-status-zh.json new file mode 100644 index 0000000000..d34e3bcf9c --- /dev/null +++ b/apps/desktop/tests/expected/update-status-zh.json @@ -0,0 +1,38 @@ +[ + { + "phase": "idle", + "version": "0.1.5-rc.2", + "percent": 58 + }, + { + "phase": "available", + "version": "0.1.5-rc.2", + "percent": 58 + }, + { + "phase": "downloading", + "version": "0.1.5-rc.2", + "percent": 58 + }, + { + "phase": "verifying", + "version": "0.1.5-rc.2", + "percent": 58 + }, + { + "phase": "ready", + "version": "0.1.5-rc.2", + "percent": 58 + }, + { + "phase": "installing", + "version": "0.1.5-rc.2", + "percent": 58 + }, + { + "phase": "error", + "version": "0.1.5-rc.2", + "percent": 58, + "failure": "install" + } +] diff --git a/apps/desktop/tests/expected/upload-credentials-check.txt b/apps/desktop/tests/expected/upload-credentials-check.txt new file mode 100644 index 0000000000..dfaa4e70bb --- /dev/null +++ b/apps/desktop/tests/expected/upload-credentials-check.txt @@ -0,0 +1,2 @@ +desktop credentials: encrypted fields loaded; child environment verified; no network request made. +parent environment unchanged diff --git a/apps/desktop/tests/expected/windows-installer.json b/apps/desktop/tests/expected/windows-installer.json index 71135a2553..a785e81b47 100644 --- a/apps/desktop/tests/expected/windows-installer.json +++ b/apps/desktop/tests/expected/windows-installer.json @@ -5,6 +5,7 @@ "enter-validates-current-path-and-unchecked-launch", "completion-preserves-window-position", "welcome-ready-before-first-show", + "welcome-presented-on-first-show", "successful-install-paints-100-before-finish", "registered-directory-with-trailing-separators", "registered-directory-and-checked-launch", diff --git a/apps/desktop/tests/fatal-recovery.spec.ts b/apps/desktop/tests/fatal-recovery.spec.ts index adc1954f44..05fb893c0d 100644 --- a/apps/desktop/tests/fatal-recovery.spec.ts +++ b/apps/desktop/tests/fatal-recovery.spec.ts @@ -19,6 +19,35 @@ function fixture(locale = 'en') { afterEach(() => { vi.restoreAllMocks() }) +it.each(['en', 'zh-CN'])('offers only exit and restart for a listener conflict in %s', async (locale) => { + const { operations, choice, stopped, recovery } = fixture(locale) + const pending = recovery.report(new AggregateError([ + new Error('webserver (@deepseek-ai/dsh-host-webserver): Error: listen EADDRINUSE: address already in use 127.0.0.1:19387'), + ], 'required startup failure')) + const options = operations.show.mock.calls[0]![0] + expect(options.buttons).toEqual([operations.messages().exitApplication, operations.messages().restartApplication]) + await expect([options.title, options.message, options.detail, ...options.buttons!].join('\n') + '\n') + .toMatchFileSnapshot(`expected/fatal-address-in-use-${locale}.txt`) + choice.resolve({ response: 1, checkboxChecked: false }) + stopped.resolve(undefined) + await pending + expect(operations.restart).toHaveBeenCalledOnce() + expect(operations.disablePlugins).not.toHaveBeenCalled() +}) + +it.each(['win32', 'darwin', 'linux'] as const)('offers the same listener conflict recovery on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const { operations, choice, stopped, recovery } = fixture() + const pending = recovery.report(new Error('listen EADDRINUSE: address already in use')) + expect(operations.show.mock.calls[0]![0].buttons).toEqual([ + operations.messages().exitApplication, operations.messages().restartApplication, + ]) + expect(operations.show.mock.calls[0]![0].detail).toBe(operations.messages().startupAddressInUse) + choice.resolve({ response: 0, checkboxChecked: false }) + stopped.resolve(undefined) + await pending +}) + it.each(['en', 'zh-CN'])('records the %s native recovery dialog', async (locale) => { const { operations, choice, stopped, recovery } = fixture(locale) const pending = recovery.report(new AggregateError([new Error('Plugin initialization failed')], 'Desktop Host failed')) diff --git a/apps/desktop/tests/fixtures/artifact-update-server.mjs b/apps/desktop/tests/fixtures/artifact-update-server.mjs new file mode 100644 index 0000000000..f3644b457b --- /dev/null +++ b/apps/desktop/tests/fixtures/artifact-update-server.mjs @@ -0,0 +1,109 @@ +/** Loopback delivery of read-only executable inputs with correct hashes and optional transfer corruption. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { dump } from 'js-yaml' + +/** + * Hash an executable without modifying or buffering the complete file. + * @param {string} file Input file. + * @returns {Promise} Base64 SHA-512. + */ +export async function artifactDigest(file) { + const hash = createHash('sha512') + for await (const chunk of createReadStream(file)) hash.update(chunk) + return hash.digest('base64') +} + +/** + * Serve supplied executables through a synthetic Nightly feed on a private port. + * @param {{ signed: string, unsigned: string, old?: string, signedBlockmap?: string, oldBlockmap?: string }} files Read-only input paths. + * @returns {Promise} Feed controls, request evidence, input hashes, and awaited close. + */ +export async function createArtifactUpdateServer(files) { + const artifacts = Object.fromEntries(await Promise.all(Object.entries(files).map(async ([name, file]) => + [name, { file, size: (await stat(file)).size, sha512: await artifactDigest(file) }]))) + const requests = [] + const failures = [] + const transfers = new Set() + let selected = 'signed' + let corrupt = false + let fault + let origin + const server = createServer((request, response) => { + const path = new URL(request.url, origin).pathname + const artifact = artifacts[selected] + const damage = corrupt + const record = { path, artifact: selected, corrupt: damage, range: request.headers.range, bytes: 0 } + requests.push(record) + response.setHeader('Cache-Control', 'no-store') + if (path === '/nightly.yml') { + response.setHeader('Content-Type', 'application/yaml') + response.end(dump({ version: '1.0.1-nightly.1', files: [{ + url: `${origin}/payload-1.0.1-nightly.1.exe`, size: artifact.size, sha512: artifact.sha512, + }], path: 'payload-1.0.1-nightly.1.exe', sha512: artifact.sha512 })) + return + } + const blockmap = path === '/payload-1.0.0.exe.blockmap' ? artifacts.oldBlockmap + : path === '/payload-1.0.1-nightly.1.exe.blockmap' ? artifacts.signedBlockmap : undefined + if (blockmap && !(fault === 'missing-old-blockmap' && blockmap === artifacts.oldBlockmap)) { + response.writeHead(200, { 'Content-Length': blockmap.size }) + track(pipeline(createReadStream(blockmap.file), response)) + return + } + if (path !== '/payload-1.0.1-nightly.1.exe') { response.writeHead(404).end(); return } + const range = request.headers.range + if (range && fault === 'reject-ranges') { response.writeHead(416).end(); return } + const ranges = range?.replace(/^bytes=/u, '').split(',').map(part => part.trim().split('-').map(Number)) + if (ranges?.some(([start, end]) => !Number.isSafeInteger(start) || !Number.isSafeInteger(end) + || start < 0 || end < start || end >= artifact.size)) { response.writeHead(416).end(); return } + record.bytes = ranges?.reduce((sum, [start, end]) => sum + end - start + 1, 0) ?? artifact.size + const boundary = 'desktop-update-qualification' + const headers = { 'Content-Type': 'application/octet-stream', 'Accept-Ranges': 'bytes' } + if (!ranges) headers['Content-Length'] = artifact.size + else if (ranges.length === 1) { + headers['Content-Range'] = `bytes ${ranges[0][0]}-${ranges[0][1]}/${artifact.size}` + headers['Content-Length'] = record.bytes + } + else headers['Content-Type'] = `multipart/byteranges; boundary=${boundary}` + response.writeHead(ranges ? 206 : 200, headers) + async function* body() { + for (const [start, end] of ranges ?? [[0, artifact.size - 1]]) { + if (ranges?.length > 1) yield Buffer.from(`--${boundary}\r\nContent-Type: application/octet-stream\r\nContent-Range: bytes ${start}-${end}/${artifact.size}\r\n\r\n`) + yield* createReadStream(artifact.file, { start, end }) + if (ranges?.length > 1) yield Buffer.from('\r\n') + } + if (ranges?.length > 1) yield Buffer.from(`--${boundary}--\r\n`) + } + let first = true + const transform = new Transform({ transform(chunk, encoding, callback) { + if (damage && first) chunk[0] ^= 1 + first = false + callback(null, chunk) + } }) + track(pipeline(Readable.from(body()), transform, response)) + }) + function track(promise) { + const transfer = promise + .catch(error => { failures.push(String(error)) }) + .finally(() => transfers.delete(transfer)) + transfers.add(transfer) + } + const listening = once(server, 'listening') + server.listen(0, '127.0.0.1') + await listening + origin = `http://127.0.0.1:${server.address().port}` + return { + url: `${origin}/`, requests, failures, artifacts, + select(name, damage = false, failure) { selected = name; corrupt = damage; fault = failure }, + async close() { + const closed = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + server.closeAllConnections() + await Promise.all([closed, ...transfers]) + }, + } +} diff --git a/apps/desktop/tests/fixtures/host-update-control.mjs b/apps/desktop/tests/fixtures/host-update-control.mjs new file mode 100644 index 0000000000..8c499ac920 --- /dev/null +++ b/apps/desktop/tests/fixtures/host-update-control.mjs @@ -0,0 +1,4 @@ +/** Test-only access to services in the actual Loader-composed Desktop Host. */ +export const ready = Promise.withResolvers() +export const inject = ['agents', 'jobs', 'llm', 'agentPresets', 'connection'] +export function apply(ctx) { ready.resolve(ctx) } diff --git a/apps/desktop/tests/fixtures/host-update-qualification.mjs b/apps/desktop/tests/fixtures/host-update-qualification.mjs new file mode 100644 index 0000000000..4d09d86a87 --- /dev/null +++ b/apps/desktop/tests/fixtures/host-update-qualification.mjs @@ -0,0 +1,206 @@ +/** Built Host qualification; the profile and all session data belong to the caller's private directory. */ +import assert from 'node:assert/strict' +import { writeFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { installDesktopUpdateTaskControl } from '../../../desktop-host/lib/types/update-tasks.js' +import { ready } from './host-update-control.mjs' + +const root = process.argv[2] +assert.ok(root) +const project = join(root, 'project') +const require = createRequire(join(project, 'package.json')) +const { LlmAdapter, createUserMessage } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-llm')).href) +const observed = [] +const { runProfile } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh/profile-boot')).href) +const { loadLayeredEnv, loadProfileDirectory } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-app-boot')).href) +const installAnchor = require.resolve('@deepseek-ai/dsh/package.json') +const running = await runProfile({ environment: loadLayeredEnv('dsh'), profile: 'desktop', + resolvedProfile: { profile: loadProfileDirectory('dsh', project, installAnchor), installAnchor }, + patchFiles: [], args: ['--no-open', '--port', '0'] }) +const host = { updateTasks: installDesktopUpdateTaskControl(running.ctx), dispose: () => running.shutdown.shutdown(0) } +const applicationUrl = running.ctx.connection.authenticatedUrl(`http://127.0.0.1:${running.ctx.webServer.port}`) +const exchange = await fetch(applicationUrl, { redirect: 'manual' }) +const cookie = exchange.headers.get('set-cookie')?.split(';', 1)[0] +assert.ok(cookie) +const apiUrl = new URL('/api/qualification', applicationUrl) +try { + const ctx = await ready.promise + const entered = Promise.withResolvers() + class HeldModel extends LlmAdapter { + nextTool + toolIssued = false + async *stream(options) { + if (this.nextTool !== undefined) { + const tool = this.nextTool + this.nextTool = undefined + this.toolIssued = true + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: `qualification-${tool.name}`, + name: tool.name, arguments: JSON.stringify(tool.arguments) } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + if (this.toolIssued) { + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + entered.resolve(options) + await new Promise(resolve => { + if (options.signal.aborted) resolve() + else options.signal.addEventListener('abort', resolve, { once: true }) + }) + yield { type: 'finish', reason: { kind: 'aborted' } } + } + } + const model = new HeldModel() + ctx.effect(() => ctx.llm.registerAdapter(['update-qualification'], model)) + assert.equal(await host.updateTasks('inspect'), false) + const owned = await ctx.agents.create({ sessionId: 'desktop-update-qualification', meta: { cwd: root }, + agentOptions: { provider: 'update-qualification', model: 'held' }, + setup: async agentCtx => { await ctx.agentPresets.mount(agentCtx, 'standard') } }) + try { + const agent = owned.agent + assert.equal(ctx.agents.get(agent.id), agent) + assert.equal(await host.updateTasks('inspect'), false) + for (const createsTask of [false, true]) { + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + const remove = ctx.connection.fetch.register({ path: '/api/update-qualification', methods: ['POST'], requestBody: 'buffered', + async fetch() { + entered.resolve() + await finish.promise + if (createsTask) agent.send(createUserMessage({ content: [{ type: 'text', text: 'Admitted write creates queued work' }], + source: { kind: 'user' } }), 'next-turn', false) + return new Response('finished') + } }) + const pending = fetch(new URL('/api/update-qualification', applicationUrl), { method: 'POST', headers: { cookie } }) + .then(async response => ({ status: response.status, body: await response.text() })) + try { + await entered.promise + assert.equal(await host.updateTasks('inspect'), false) + let drained = false + const locking = host.updateTasks('lock').then(active => { drained = true; return active }) + assert.equal((await fetch(apiUrl, { headers: { cookie } })).status, 503) + assert.equal(drained, false) + finish.resolve() + assert.equal(await locking, createsTask) + assert.deepEqual(await pending, { status: 200, body: 'finished' }) + assert.equal(agent.inbox.nextTurn.length, createsTask ? 1 : 0) + agent.inbox.clear() + await host.updateTasks('unlock') + observed.push(createsTask ? 'admitted-write-task-rechecked-after-drain' : 'read-request-drained-without-task-warning') + } finally { finish.resolve(); await pending; await remove() } + } + for (const [target, field] of [['next-turn', 'nextTurn'], ['next-step', 'nextStep']]) { + agent.send(createUserMessage({ content: [{ type: 'text', text: 'Queued qualification input' }], source: { kind: 'user' } }), target, false) + assert.equal(agent.status, 'idle') + assert.equal(agent.inbox[field].length, 1) + assert.equal(await host.updateTasks('inspect'), true) + agent.inbox.clear() + assert.equal(await host.updateTasks('inspect'), false) + observed.push(target) + } + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Hold the qualification model request' }], source: { kind: 'user' } })) + const request = await entered.promise + assert.equal(agent.status, 'running') + assert.equal(await host.updateTasks('inspect'), true) + assert.equal(request.signal.aborted, false) + assert.equal(await host.updateTasks('lock'), true) + assert.equal((await fetch(apiUrl, { headers: { cookie } })).status, 503) + assert.equal(request.signal.aborted, false) + assert.equal(agent.status, 'running') + assert.equal(await host.updateTasks('unlock'), true) + assert.equal((await fetch(apiUrl, { headers: { cookie } })).status, 404) + agent.cancel({ kind: 'user' }) + await agent.whenIdle() + assert.equal(request.signal.aborted, true) + assert.equal(await host.updateTasks('inspect'), false) + observed.push('running-model', 'admission-lock-preserves-running-work', 'unlock-restores-requests') + + for (const event of ['user-questions/request', 'approval/request']) { + const asked = Promise.withResolvers() + // Hold human input before the Remote answerer, which otherwise waits for a connected Client. + const remove = agent.ctx.on(event, (question, next) => { + if (question.agent !== agent) return next() + asked.resolve(question) + return new Promise((resolve, reject) => { + const abort = () => event === 'approval/request' ? resolve('cancelled') : reject(question.signal.reason) + if (question.signal.aborted) abort() + else question.signal.addEventListener('abort', abort, { once: true }) + }) + }, { prepend: true }) + try { + model.nextTool = event === 'user-questions/request' + ? { name: 'ask_user_question', arguments: { questions: [{ id: 'update', question: 'Keep waiting for qualification?' }] } } + : { name: process.platform === 'win32' ? 'pwsh' : 'bash', arguments: { + command: process.platform === 'win32' ? "Write-Output 'qualification'" : "printf qualification", + description: 'Hold a harmless command pending approval', + sandbox_permissions: 'danger-full-access', justification: 'Qualification holds this approval without running the command', + } } + agent.followup(createUserMessage({ content: [{ type: 'text', text: `Exercise ${event}` }], source: { kind: 'user' } })) + const question = await Promise.race([asked.promise, agent.whenIdle().then(() => { + throw new Error(`Agent finished without reaching ${event}`) + })]) + assert.equal(question.signal.aborted, false) + assert.equal(agent.status, 'running') + assert.equal(await host.updateTasks('inspect'), true) + assert.equal(await host.updateTasks('lock'), true) + assert.equal(question.signal.aborted, false) + await host.updateTasks('unlock') + agent.cancel({ kind: 'user' }) + await agent.whenIdle() + assert.equal(question.signal.aborted, true) + assert.equal(await host.updateTasks('inspect'), false) + observed.push(event) + } finally { + agent.cancel({ kind: 'user' }) + await agent.whenIdle() + remove() + } + } + + ctx.effect(() => ctx.jobs.attachController('update-qualification')) + for (const owner of [undefined, agent]) { + let child + let done + const id = ctx.jobs.start({ kind: 'update-qualification', label: 'Private Node process waiting for stdin EOF', owner, + run() { + child = spawn(process.execPath, ['-e', "process.stdout.write('ready');process.stdin.resume()"], { + cwd: root, env: process.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + }) + done = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => resolve({ code, signal })) + }) + return { cancel: () => { child.stdin.end() }, + done: done.then(result => ({ status: result.code === 0 && result.signal === null ? 'killed' : 'failed' })) } + } }) + try { + await once(child.stdout, 'data', { signal: AbortSignal.timeout(15_000) }) + assert.equal(ctx.jobs.get(id, owner).status, 'running') + assert.equal(await host.updateTasks('inspect'), true) + assert.equal(child.exitCode, null) + assert.equal(ctx.jobs.kill(id, owner, 'Explicit qualification stop'), 'requested') + assert.equal(ctx.jobs.get(id, owner).status, 'stopping') + assert.equal(await host.updateTasks('inspect'), true) + assert.equal((await ctx.jobs.wait(id, 15_000, owner)).status, 'killed') + assert.deepEqual(await done, { code: 0, signal: null }) + assert.equal(await host.updateTasks('inspect'), false) + observed.push(owner === undefined ? 'global-node-job' : 'agent-node-job') + } finally { + child.stdin.end() + await done + } + } + } finally { await owned.dispose() } +} finally { await host.dispose() } +await assert.rejects(host.updateTasks('inspect'), /Host is stopping/) +await assert.rejects(host.updateTasks('lock'), /Host is stopping/) +observed.push('disposed-host-refuses-task-inspection') +await writeFile(join(root, 'result.json'), JSON.stringify({ realHost: true, realAgent: true, observed, + model: 'scripted/held adapter', humanAnswers: 'held answerers', jobs: 'actual Node subprocesses', + hostDisposed: true, installerExecuted: false }, null, 2) + '\n') diff --git a/apps/desktop/tests/fixtures/installed-update-network.ps1 b/apps/desktop/tests/fixtures/installed-update-network.ps1 new file mode 100644 index 0000000000..2e84f0d6f9 --- /dev/null +++ b/apps/desktop/tests/fixtures/installed-update-network.ps1 @@ -0,0 +1,44 @@ +param([string]$Script, [string]$Plan, [string]$Case) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new() +$spec = Get-Content -LiteralPath $Plan -Raw -Encoding UTF8 | ConvertFrom-Json +$global:dshNetworkRules = @() +$global:dshNetworkCreated = 0 +$global:dshNetworkRemoved = 0 +function Get-NetFirewallRule { param($PolicyStore) return $global:dshNetworkRules } +function Get-NetFirewallApplicationFilter { + param([Parameter(ValueFromPipeline = $true)]$InputObject) + process { return [pscustomobject]@{ Program = $InputObject.Program } } +} +function New-NetFirewallRule { + param($Name, $DisplayName, $Description, $Direction, $Program, $Action, $Profile, $Enabled, $PolicyStore) + $global:dshNetworkCreated++ + if ($Case -eq 'creation-failure') { throw 'inert creation failure' } + $global:dshNetworkRules = @([pscustomobject]@{ Name = $Name; Description = $Description; Direction = $Direction; + Program = $Program; Action = $Action; Enabled = $Enabled; Profile = $Profile; PolicyStore = $PolicyStore }) + return $global:dshNetworkRules +} +function Remove-NetFirewallRule { + param([Parameter(ValueFromPipeline = $true)]$InputObject) + process { + $global:dshNetworkRemoved++ + if ($Case -ne 'restore-failure') { $global:dshNetworkRules = @() } + } +} +function Read-Host { + param($Prompt) + if ($Case -in @('declined', 'restore-declined')) { return 'NO' } + if ($Case -eq 'changed-during-confirmation') { [IO.File]::WriteAllText($spec.executable, 'changed during confirmation') } + if ($Prompt -like 'Type RESTORE*') { return "RESTORE $($spec.runId)" } + return "BLOCK $($spec.runId)" +} +if ($Case -in @('existing', 'foreign', 'restore', 'restore-failure', 'restore-declined', 'status')) { + $global:dshNetworkRules = @([pscustomobject]@{ Name = $spec.ruleName; Direction = 'Outbound'; Action = 'Block'; + Program = $spec.executable; Enabled = 'True'; Description = "dsh-update-qualification:$($spec.runId):$($spec.sha512Hex)" }) + if ($Case -eq 'foreign') { $global:dshNetworkRules[0].Program = 'C:\not-the-test-application.exe' } +} +$action = if ($Case -like 'restore*') { 'Restore' } elseif ($Case -eq 'status') { 'Status' } else { 'Block' } +$failure = $null +try { & $Script -Plan $Plan -Action $action } catch { $failure = $_.Exception.Message } +@{ failure = $failure; created = $global:dshNetworkCreated; removed = $global:dshNetworkRemoved; + remaining = $global:dshNetworkRules.Count } | ConvertTo-Json -Compress diff --git a/apps/desktop/tests/fixtures/installed-update-packaging.mjs b/apps/desktop/tests/fixtures/installed-update-packaging.mjs new file mode 100644 index 0000000000..bf9d1070eb --- /dev/null +++ b/apps/desktop/tests/fixtures/installed-update-packaging.mjs @@ -0,0 +1,19 @@ +/** Inert subprocess fixture for packaging supervision; no signing or executable entry runs. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { join } from 'node:path' + +const [manifest, version] = process.argv.slice(2) +const run = JSON.parse(await readFile(manifest, 'utf8')) +process.stdout.write(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN) +if (process.env.DSH_TEST_PACKAGING_FAIL === '1') process.exitCode = 2 +else { + const directory = join(run.root, version, 'installer') + await mkdir(directory, { recursive: true }) + const filename = `deepseek-harness-${version}-win-x64.exe` + const bytes = Buffer.from('inert test bytes, not an installer') + await writeFile(join(directory, filename), bytes) + await writeFile(join(directory, `${filename}.blockmap`), 'inert test map') + await writeFile(join(directory, 'nightly.yml'), JSON.stringify({ version, files: [{ url: filename, + size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }] })) +} diff --git a/apps/desktop/tests/fixtures/local-updater.mjs b/apps/desktop/tests/fixtures/local-updater.mjs new file mode 100644 index 0000000000..060000fc35 --- /dev/null +++ b/apps/desktop/tests/fixtures/local-updater.mjs @@ -0,0 +1,381 @@ +/** Actual Electron HTTP/download qualification; installer execution is forbidden. */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { join, sep } from 'node:path' +import { app, BrowserWindow, protocol, shell, clipboard } from 'electron' +import { fileURLToPath } from 'node:url' +import updaterModule from 'electron-updater' +import { DesktopUpdateCoordinator } from '../../lib/types/update-coordinator.js' +import { presentDesktopUpdate } from '../../lib/types/update-presentation.js' +import { resolveDesktopLocale } from '../../lib/types/locale.js' +import { createUpdateServer } from './update-server.mjs' +import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig } from '../../lib/types/mandatory-update-policy.js' +import { DesktopMandatoryUpdateWindow } from '../../lib/types/mandatory-update-window.js' +import { DesktopUpdatePreparationError } from '../../lib/types/update-error.js' +import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' +import { qualifyUpdateDialogs } from './update-dialogs.mjs' + +const root = process.env.DSH_LOCAL_UPDATE_TEST_ROOT +assert.ok(root, 'The launcher must allocate a private test root') +app.setPath('userData', join(root, 'electron')) +// Sequential window scenarios share one Electron process; main() owns its final exit. +app.on('window-all-closed', () => {}) +protocol.registerSchemesAsPrivileged([{ scheme: 'dsh-app', privileges: { standard: true, secure: true, supportFetchAPI: true } }]) +const cases = [] +const presentations = [] +let screenshot = { captured: false, reason: 'not attempted' } +let dialogScreenshots = [] +const { NsisUpdater } = updaterModule +const messages = resolveDesktopLocale('zh-CN').messages + +async function main() { + await app.whenReady() + const server = await createUpdateServer() + let serial = 0 + const fixtures = [] + async function fixture(mode = 'healthy', timeoutMs = 60_000) { + server.select(mode) + const directory = join(root, String(++serial)) + await mkdir(directory, { recursive: true }) + const config = join(directory, 'app-update.yml') + // Unsigned inert bytes are confined to this fixture; production configuration is never loaded or changed. + await writeFile(config, `updaterCacheDirName: cache\n`) + const forbidden = () => { throw new Error('Unexpected application quit or relaunch') } + const updater = new NsisUpdater(undefined, { + version: '1.0.0', name: 'local-update-qualification', isPackaged: true, + appUpdateConfigPath: config, userDataPath: directory, baseCachePath: directory, + whenReady: async () => {}, quit: forbidden, relaunch: forbidden, onQuit: forbidden, + }) + updater.logger = null + updater.httpExecutor = new DesktopUpdateHttpExecutor(timeoutMs) + updater.disableDifferentialDownload = true + updater.disableWebInstaller = true + updater.setFeedURL({ provider: 'generic', url: server.url, channel: 'nightly' }) + const installations = [] + const states = [] + let restart = async () => true + updater.quitAndInstall = (...args) => { installations.push(args) } + const coordinator = new DesktopUpdateCoordinator(state => { states.push(state); return state }, + () => restart(), updater, () => true, () => '1.0.0') + const result = { updater, coordinator, installations, states, directory, restart: handler => { restart = handler } } + fixtures.push(result) + return result + } + async function scenario(name, run) { await run(); cases.push(name); console.log(`local updater: ${name}`) } + try { + await scenario('same-version-no-update', async () => { + const f = await fixture() + server.select('healthy', '1.0.0') + assert.deepEqual(await f.coordinator.check(true), { phase: 'idle' }) + }) + await scenario('older-version-no-downgrade', async () => { + const f = await fixture() + server.select('healthy', '0.9.0') + assert.equal((await f.coordinator.check(true)).phase, 'idle') + }) + for (const mode of ['feed-404', 'feed-408', 'invalid-yaml']) { + await scenario(`${mode}-silent-auto-visible-manual-recovery`, async () => { + const f = await fixture(mode) + assert.equal((await f.coordinator.check()).failedOperation, 'check') + assert.equal(f.states.length, 0) + assert.equal((await f.coordinator.check(true)).failedOperation, 'check') + assert.equal(f.coordinator.state.phase, 'error') + server.select('healthy') + assert.equal((await f.coordinator.check(true)).phase, 'available') + }) + } + await scenario('stalled-feed-request-times-out-and-recovers', async () => { + const f = await fixture('feed-stall', 1000) + const failure = await f.coordinator.check(true) + assert.equal(failure.failedOperation, 'check') + assert.match(failure.message, /timed out/) + server.select('healthy') + assert.equal((await f.coordinator.check(true)).phase, 'available') + }) + await scenario('concurrent-checks-use-one-request', async () => { + const f = await fixture('hold-check') + const before = server.requests.length + const checks = [f.coordinator.check(), f.coordinator.check(true)] + await server.arrived() + try { assert.equal(server.requests.length - before, 1) } + finally { server.release() } + assert.ok((await Promise.all(checks)).every(state => state.phase === 'available')) + }) + await scenario('same-feed-url-observes-new-release', async () => { + const f = await fixture() + assert.equal((await f.coordinator.check()).version, '1.0.1-nightly.1') + server.select('healthy', '1.0.1-nightly.2') + assert.equal((await f.coordinator.check()).version, '1.0.1-nightly.2') + await assert.rejects(f.coordinator.download('1.0.1-nightly.1'), /stale/) + }) + await scenario('user-download-progress-hash-and-separate-install', async () => { + const f = await fixture('hold-download') + const before = server.requests.length + const available = await f.coordinator.check() + assert.equal(available.phase, 'available') + assert.equal(server.requests.length - before, 1) + await assert.rejects(f.coordinator.install(available.version), /not ready/) + await assert.rejects(f.coordinator.download('1.0.9-nightly.1'), /stale/) + const downloads = [f.coordinator.download(available.version), f.coordinator.download(available.version)] + await server.arrived() + try { + assert.equal(f.coordinator.state.phase, 'downloading') + assert.equal((await f.coordinator.check(true)).phase, 'downloading') + assert.equal(server.requests.length - before, 2) + assert.equal(f.installations.length, 0) + } finally { server.release() } + assert.ok((await Promise.all(downloads)).every(state => state.phase === 'ready')) + assert.deepEqual(await readFile(f.updater.installerPath), server.payload) + assert.equal(f.installations.length, 0) + assert.ok(f.states.some(state => state.phase === 'verifying')) + const readyRequests = server.requests.length + server.select('healthy', '1.0.1-nightly.2') + await f.coordinator.check(true) + await f.coordinator.download(available.version) + assert.equal(server.requests.length, readyRequests) + assert.equal(f.coordinator.state.version, '1.0.1-nightly.1') + f.restart(async () => false) + assert.equal((await f.coordinator.install(available.version)).phase, 'ready') + assert.equal(f.installations.length, 0) + f.restart(async () => { throw new Error('Host clean stop failed') }) + assert.equal((await f.coordinator.install(available.version)).failedOperation, 'install') + assert.equal(f.installations.length, 0) + f.restart(async () => true) + assert.equal((await f.coordinator.install(available.version)).phase, 'installing') + assert.deepEqual(f.installations, [[true, true]]) + presentations.push(...f.states.map(state => ({ phase: state.phase, ...presentDesktopUpdate(state) }))) + }) + for (const mode of ['download-404', 'corrupt', 'disconnect', 'download-stall']) { + await scenario(`${mode}-explicit-retry`, async () => { + const f = await fixture(mode, mode === 'download-stall' ? 1000 : 60_000) + const available = await f.coordinator.check() + const failure = await f.coordinator.download(available.version) + assert.equal(failure.phase, 'error') + assert.equal(failure.failedOperation, 'download') + if (mode === 'corrupt') assert.match(failure.message, /checksum mismatch/i) + if (mode === 'download-stall') assert.match(failure.message, /timed out/) + assert.equal(f.updater.installerPath, null) + const presentation = presentDesktopUpdate(failure) + assert.equal(presentation.phase, 'error') + assert.equal(presentation.failure, 'download') + assert.equal(f.installations.length, 0) + const before = server.requests.length + await f.coordinator.check() + assert.equal(server.requests.length, before) + server.select('healthy') + assert.equal((await f.coordinator.download(available.version)).phase, 'ready') + }) + } + await scenario('download-write-enospc-clears-partial-file-and-retries', async () => { + const f = await fixture() + const available = await f.coordinator.check() + const original = fs.createWriteStream + let written = 0 + const closed = [] + // Only this fixture's executable stream injects ENOSPC, after a real partial write. + fs.createWriteStream = (file, options) => { + if (typeof file !== 'string' || !file.startsWith(f.directory + sep) || !file.endsWith('.exe')) return original(file, options) + const stream = original(file, { ...options, fs: { + open: fs.open, close: fs.close, + write(fd, buffer, offset, length, position, callback) { + fs.write(fd, buffer, offset, Math.min(length, 1024), position, (error, bytes) => { + written += bytes ?? 0 + callback(error ?? Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' }), bytes) + }) + }, + } }) + closed.push(new Promise(resolve => stream.once('close', resolve))) + return stream + } + try { + const failure = await f.coordinator.download(available.version) + assert.equal(failure.phase, 'error') + assert.equal(failure.failedOperation, 'download') + assert.match(failure.message, /ENOSPC/) + assert.ok(written > 0 && written < server.payload.length, 'The fault must follow a partial file write') + assert.equal(f.updater.installerPath, null) + assert.ok(!f.states.some(state => state.phase === 'ready')) + await assert.rejects(f.coordinator.install(available.version), /not ready/) + const count = server.requests.length + await f.coordinator.check() + assert.equal(server.requests.length, count, 'Automatic checks must not retry a failed write') + } finally { fs.createWriteStream = original; await Promise.all(closed) } + assert.equal(fs.createWriteStream, original) + const cache = await readdir(join(f.directory, 'cache'), { recursive: true }) + assert.ok(!cache.some(file => file.endsWith('.exe') || file.endsWith('update-info.json')), 'Partial bytes must not remain installable') + const presentation = presentDesktopUpdate(f.coordinator.state) + assert.equal(presentation.phase, 'error') + assert.equal(presentation.failure, 'download') + assert.equal((await f.coordinator.download(available.version)).phase, 'ready') + assert.deepEqual(await readFile(f.updater.installerPath), server.payload) + assert.equal(f.installations.length, 0) + }) + await scenario('disposed-check-does-not-publish', async () => { + const f = await fixture('hold-check') + const check = f.coordinator.check() + await server.arrived() + f.coordinator.dispose() + server.release() + await check + assert.equal(f.states.length, 0) + }) + await scenario('ordinary-real-dialog-cancel-and-explicit-install', async () => { + dialogScreenshots = await qualifyUpdateDialogs(root, fixture) + }) + await scenario('mandatory-real-window-policy-download-retry-and-clear', async () => { + protocol.handle('dsh-app', async request => { + const path = new URL(request.url).pathname.slice(1) + assert.ok(['mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path)) + const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html' + return new Response(await readFile(new URL(`../../renderer/${path}`, import.meta.url)), { headers: { 'content-type': mime } }) + }) + const parent = new BrowserWindow({ show: true, width: 900, height: 650 }) + await parent.loadURL('data:text/html;charset=utf-8,Local updater qualification') + const f = await fixture('hold-download') + const config = resolveDesktopPolicyConfig({ origin: server.url, allowedPageOrigins: ['https://downloads.example.com'], + intervalMs: 600_000, timeoutMs: 1000 }, true) + let modal + const policy = new DesktopMandatoryUpdatePolicy(config, { platform: 'desktop-win', arch: 'x64', + version: '1.0.0', bundledDshVersion: '1.0.0', bundleId: 'com.deepseek.dsh', locale: 'zh-CN' }, () => { modal?.sync() }) + modal = new DesktopMandatoryUpdateWindow({ + preload: fileURLToPath(new URL('../../lib/preload-mandatory.cjs', import.meta.url)), + locale: resolveDesktopLocale('zh-CN'), allowedPageOrigins: config.allowedPageOrigins, + parent: () => parent, policy: () => policy.state, update: () => f.coordinator.state, + refresh: async () => { await Promise.all([policy.check('manual', true), f.coordinator.check(true)]); modal.sync() }, + download: async version => { const pending = f.coordinator.download(version); await Promise.resolve(); modal.sync(); + const state = await pending; modal.sync(); + return state.phase === 'ready' ? f.coordinator.install(version) : state }, + install: async version => { const state = await f.coordinator.install(version); modal.sync(); return state }, + }) + const originalOpen = shell.openExternal + f.restart(() => modal.confirm(f.coordinator.state.version, true)) + const originalRead = clipboard.readText + const originalCopy = clipboard.writeText + const opened = [] + const copied = [] + const copyComplete = Promise.withResolvers() + shell.openExternal = async url => { opened.push(url); throw new Error('Simulated unavailable browser') } + clipboard.writeText = async value => { copied.push(value); copyComplete.resolve() } + clipboard.readText = async () => copied.at(-1) + try { + server.policy('force') + await policy.check('launch') + const window = modal.confirmationWindow + assert.ok(window?.isModal()) + window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) }) + window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) }) + async function until(expression) { + try { return await window.webContents.executeJavaScript(`new Promise((resolve, reject) => { + const observer = new MutationObserver(check); + const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Modal DOM condition timed out')); }, 10000); + function check() { if (${expression}) { observer.disconnect(); clearTimeout(deadline); resolve(true); } } + observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true }); check(); + })`) } catch (error) { + console.error('Modal condition:', expression, await window.webContents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })')) + throw error + } + } + await until("document.getElementById('title')?.textContent === '需要更新'") + assert.equal(await window.webContents.executeJavaScript("document.querySelector('#title b') === null"), true) + if (process.platform === 'win32') { + assert.equal(window.isMovable(), true) + assert.equal(window.isResizable(), true) + assert.equal(window.isMaximizable(), true) + const bounds = window.getBounds() + window.setPosition(bounds.x + 20, bounds.y + 20) + assert.notDeepEqual(window.getBounds(), bounds) + window.maximize() + assert.equal(window.isMaximized(), true) + window.unmaximize() + assert.equal(window.isMaximized(), false) + } + assert.equal(await window.webContents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false) + assert.equal(window.isDestroyed(), false) + assert.equal(f.installations.length, 0) + await window.webContents.executeJavaScript("document.getElementById('page').click()") + await until("document.getElementById('browser-message').textContent.includes('无法打开浏览器')") + assert.deepEqual(opened, ['https://downloads.example.com/desktop']) + await window.webContents.executeJavaScript("document.getElementById('copy').click()") + await copyComplete.promise + await until("document.getElementById('copy').textContent === '已复制链接'") + assert.deepEqual(copied, opened) + await f.coordinator.check() + modal.sync() + await until("!document.getElementById('update').hidden") + await window.webContents.executeJavaScript("document.getElementById('update').click()") + await server.arrived() + assert.equal(f.coordinator.state.phase, 'downloading') + assert.equal(await window.webContents.executeJavaScript("document.getElementById('page').hidden"), true) + server.release() + await until("document.getElementById('update').textContent === '停止任务并更新' && !document.getElementById('update').disabled") + assert.equal(f.installations.length, 0) + assert.equal(modal.confirmationWindow, window) + server.policy('stall') + await policy.check('manual', true) + assert.equal(window.isDestroyed(), false) + assert.equal(await window.webContents.executeJavaScript("document.getElementById('error').hidden"), true) + const snapshot = await window.webContents.executeJavaScript(`({ + title: document.getElementById('title').textContent, detail: document.getElementById('detail').textContent, + status: document.getElementById('status').textContent, version: document.getElementById('version').textContent, + buttons: [...document.querySelectorAll('button')].filter(button => button.getClientRects().length > 0).map(button => button.textContent), + })`) + assert.deepEqual(snapshot, JSON.parse(await readFile(new URL('../expected/mandatory-update-zh.json', import.meta.url), 'utf8'))) + await window.webContents.executeJavaScript("document.getElementById('later').click()") + await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled") + assert.equal(f.installations.length, 0) + let capture + try { capture = await window.webContents.capturePage() } + catch (error) { screenshot = { captured: false, reason: String(error) } } + if (capture !== undefined) { + await writeFile(join(root, 'mandatory-update.png'), capture.toPNG()) + screenshot = { captured: true } + } + const restartRequested = Promise.withResolvers() + f.restart(async () => { restartRequested.resolve(); return false }) + await window.webContents.executeJavaScript("document.getElementById('update').click()") + await restartRequested.promise + await until("!document.getElementById('update').disabled") + assert.equal(f.installations.length, 0) + assert.equal(policy.state.blocking, true) + f.restart(async () => { throw new DesktopUpdatePreparationError('stop-failed', resolveDesktopLocale('zh-CN').messages.updateStopFailed, + 'exit 0; shutdown acknowledged false') }) + await window.webContents.executeJavaScript("document.getElementById('update').click()") + await until("!document.getElementById('technical-details').hidden && !document.getElementById('update').disabled") + assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details').open"), false) + for (const expanded of [false, true]) { + if (expanded) { + assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true) + } + const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png' + await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG()) + dialogScreenshots.push(name) + } + assert.equal(policy.state.blocking, true) + assert.equal(f.installations.length, 0) + server.policy('clear') + await policy.check('manual', true) + assert.equal(window.isDestroyed(), true) + assert.equal(parent.isDestroyed(), false) + } finally { + server.release() + modal.dispose() + await policy.dispose() + parent.destroy() + shell.openExternal = originalOpen + clipboard.writeText = originalCopy + clipboard.readText = originalRead + protocol.unhandle('dsh-app') + } + }) + assert.deepEqual(server.failures, []) + console.log(`LOCAL_UPDATER_RESULT=${JSON.stringify({ cases, presentations, actualElectron: process.versions.electron, + actualTransport: 'DesktopUpdateHttpExecutor (ElectronHttpExecutor)', installerExecuted: false, screenshot, requests: server.requests.length })}`) + await writeFile(join(root, 'result.json'), `${JSON.stringify({ cases, presentations, installerExecuted: false, screenshot, dialogScreenshots }, null, 2)}\n`) + } finally { + for (const f of fixtures) f.coordinator.dispose() + await server.close() + } +} + +main().then(() => app.exit(0), error => { console.error(error); app.exit(1) }) diff --git a/apps/desktop/tests/fixtures/owned-directory-smoke.mjs b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs new file mode 100644 index 0000000000..52315992ba --- /dev/null +++ b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs @@ -0,0 +1,29 @@ +/** Built Desktop cleanup smoke; run with Electron and ELECTRON_RUN_AS_NODE=1. */ + +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeOwnedDirectory } from '../../lib/types/owned-directory.js' +import { inventoryDesktopRuntime } from '../../lib/types/runtime-tree.js' + +assert.ok(process.versions.electron, 'This regression must run under Electron') +const root = mkdtempSync(join(tmpdir(), 'desktop-electron-cleanup-')) +try { + const resources = join(root, 'resources') + mkdirSync(resources) + writeFileSync(join(resources, 'sentinel'), 'installed runtime bytes') + const inventory = inventoryDesktopRuntime(resources) + const profile = join(root, 'rollback-profile') + const packages = join(profile, 'node_modules', '@unknown') + mkdirSync(packages, { recursive: true }) + symlinkSync(resources, join(packages, 'host-package'), process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(join(packages, 'private-file'), 'removed') + removeOwnedDirectory(profile) + assert.equal(existsSync(profile), false) + assert.deepEqual(inventoryDesktopRuntime(resources), inventory) + assert.equal(readFileSync(join(resources, 'sentinel'), 'utf8'), 'installed runtime bytes') + console.log(JSON.stringify({ electron: process.versions.electron, node: process.version, preservedLinkTarget: true })) +} finally { + removeOwnedDirectory(root) +} diff --git a/apps/desktop/tests/fixtures/packaging-failure.mjs b/apps/desktop/tests/fixtures/packaging-failure.mjs new file mode 100644 index 0000000000..33d851663e --- /dev/null +++ b/apps/desktop/tests/fixtures/packaging-failure.mjs @@ -0,0 +1,14 @@ +/** Keep an owned descendant alive until the packaging supervisor handles a simulated fatal error. */ +import { spawn } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { failPackagingRun } from '../../scripts/packaging-run.mjs' + +const child = spawn(process.execPath, ['-e', "process.send('ready');setInterval(()=>{},1000)"], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, +}) +child.once('message', () => { + writeFileSync(join(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'descendant.json'), JSON.stringify({ pid: child.pid })) + failPackagingRun(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'simulated-signing-failure') +}) +setInterval(() => {}, 1000) diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index a069118006..41f6b4223b 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -15,14 +15,16 @@ const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), ' assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the Electron Node runtime version') assert.equal(process.platform, descriptor.platform) assert.equal(process.arch, descriptor.arch) +const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime') +const { verifyDesktopRuntime } = await import('../../lib/types/runtime-tree.js') +await verifyDesktopRuntime(root, descriptor.release.version, { platform: process.platform, arch: process.arch }) const requireRuntime = createRequire(join(root, 'package.json')) const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) /** Run a package script with only the shipped node launcher available on PATH. */ function checkPnpm() { - const resources = dirname(root) - const bin = join(resources, 'runtime', 'bin') - const pnpm = join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') + const bin = join(resourcesRuntime, 'bin') + const pnpm = join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.mjs') writeFileSync(join(scratch, 'package.json'), JSON.stringify({ name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' }, })) @@ -54,7 +56,7 @@ async function checkPty() { ))) Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }) env.DSH_DESKTOP_NODE_EXECUTABLE = process.execPath - env.PATH = `${join(dirname(root), 'runtime', 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}` + env.PATH = `${join(resourcesRuntime, 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}` // A Windows GUI executable needs a console-owning shell when launched inside ConPTY. const executable = process.platform === 'win32' ? process.env.ComSpec : process.execPath const args = process.platform === 'win32' ? ['/d', '/c', 'node', script] : [script] @@ -137,6 +139,8 @@ function checkHtml() { } try { + const builtin = requireRuntime('node-addon-require-builtin') + assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') checkPnpm() checkKoffi() await checkSharp() diff --git a/apps/desktop/tests/fixtures/signed-updates.mjs b/apps/desktop/tests/fixtures/signed-updates.mjs new file mode 100644 index 0000000000..8b50cad678 --- /dev/null +++ b/apps/desktop/tests/fixtures/signed-updates.mjs @@ -0,0 +1,177 @@ +/** Actual Electron download, checksum, Authenticode, and coordinator recovery; installer execution is forbidden. */ +import assert from 'node:assert/strict' +import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { app } from 'electron' +import updaterModule from 'electron-updater' +import { DesktopUpdateCoordinator } from '../../lib/types/update-coordinator.js' +import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' +import { resolveWindowsUpdatePublisher } from '../../scripts/windows-sign.mjs' +import { artifactDigest, createArtifactUpdateServer } from './artifact-update-server.mjs' + +const root = process.env.DSH_SIGNED_UPDATE_TEST_ROOT +assert.ok(root, 'Launcher must supply a private test root') +app.setPath('userData', join(root, 'runtime', 'electron')) +const { NsisUpdater } = updaterModule +const report = { cases: [], installerExecuted: false, passed: false } + +async function main() { + await app.whenReady() + const publisher = resolveWindowsUpdatePublisher(process.env.DSH_SIGNED_UPDATE_CERTIFICATE) + const server = await createArtifactUpdateServer({ + signed: process.env.DSH_SIGNED_UPDATE_SIGNED, + unsigned: process.env.DSH_SIGNED_UPDATE_UNSIGNED, + ...(process.env.DSH_SIGNED_UPDATE_OLD ? { + old: process.env.DSH_SIGNED_UPDATE_OLD, + oldBlockmap: `${process.env.DSH_SIGNED_UPDATE_OLD}.blockmap`, + signedBlockmap: `${process.env.DSH_SIGNED_UPDATE_SIGNED}.blockmap`, + } : {}), + }) + report.inputs = server.artifacts + report.publisherName = publisher + report.simulatedInstalledVersion = '1.0.0' + report.syntheticFeedVersion = '1.0.1-nightly.1' + const fixtures = [] + let installed = 0 + async function fixture(expectedPublisher = publisher, differential = false, multipleRanges = true) { + const directory = join(root, 'runtime', `case-${fixtures.length}`) + await mkdir(directory) + const config = join(directory, 'app-update.yml') + await writeFile(config, JSON.stringify({ publisherName: [expectedPublisher], updaterCacheDirName: 'cache' })) + const forbidden = () => { throw new Error('Unexpected application quit or relaunch') } + const updater = new NsisUpdater(undefined, { + version: '1.0.0', name: 'signed-download-qualification', isPackaged: true, + appUpdateConfigPath: config, userDataPath: directory, baseCachePath: directory, + whenReady: async () => {}, quit: forbidden, relaunch: forbidden, onQuit: forbidden, + }) + const logs = [] + const errors = [] + const states = [] + updater.logger = Object.fromEntries(['info', 'warn', 'error', 'debug'].map(level => [level, value => logs.push(String(value))])) + updater.httpExecutor = new DesktopUpdateHttpExecutor(60_000) + updater.disableDifferentialDownload = !differential + updater.disableWebInstaller = true + updater.setFeedURL({ provider: 'generic', url: server.url, channel: 'nightly', useMultipleRangeRequest: multipleRanges }) + updater.on('error', error => errors.push(error.code)) + updater.quitAndInstall = () => { installed++ } + let authorized = false + const coordinator = new DesktopUpdateCoordinator(state => { states.push(state); return state }, + async () => authorized, updater, () => true, () => '1.0.0') + const f = { coordinator, updater, logs, errors, states, directory, authorize: () => { authorized = true } } + fixtures.push(f) + return f + } + async function download(f) { + const count = server.requests.length + assert.equal((await f.coordinator.check(true)).phase, 'available') + assert.equal(server.requests.length - count, 1, 'Checking must not download') + assert.equal(server.requests.at(-1).path, '/nightly.yml') + return f.coordinator.download('1.0.1-nightly.1') + } + async function rejected(f, code) { + const state = await download(f) + assert.equal(state.phase, 'error') + assert.equal(state.failedOperation, 'download') + assert.ok(f.errors.includes(code), `Expected updater rejection ${code}, received ${f.errors}`) + assert.ok(!f.states.some(item => item.phase === 'ready')) + await assert.rejects(f.coordinator.install('1.0.1-nightly.1'), /not ready/u) + const cache = await readdir(join(f.directory, 'cache'), { recursive: true }) + assert.ok(!cache.some(file => file.endsWith('.exe')), 'Rejected executables must be removed from cache') + const count = server.requests.length + await f.coordinator.check() + assert.equal(server.requests.length, count, 'Automatic checks must not retry rejected downloads') + assert.equal(installed, 0) + } + try { + server.select('signed') + const wrong = await fixture('CN=Not the release publisher') + await rejected(wrong, 'ERR_UPDATER_INVALID_SIGNATURE') + report.cases.push('valid-hash-wrong-publisher-rejected-and-cleared') + + server.select('unsigned') + const unsigned = await fixture() + await rejected(unsigned, 'ERR_UPDATER_INVALID_SIGNATURE') + report.cases.push('valid-hash-unsigned-rejected-and-cleared') + + server.select('signed', true) + const corrupt = await fixture() + await rejected(corrupt, 'ERR_CHECKSUM_MISMATCH') + assert.ok(!corrupt.logs.some(line => line.startsWith('Verifying signature ')), 'Corrupt bytes must fail before signature verification') + report.cases.push('corrupt-transfer-rejected-before-signature') + + server.select('signed') + assert.equal((await download(unsigned)).phase, 'ready') + assert.equal(await artifactDigest(unsigned.updater.installerPath), server.artifacts.signed.sha512) + assert.ok(unsigned.states.some(state => state.phase === 'verifying')) + assert.ok(unsigned.logs.some(line => line.startsWith('Verifying signature '))) + const count = server.requests.length + await unsigned.coordinator.check(true) + await unsigned.coordinator.download('1.0.1-nightly.1') + assert.equal(server.requests.length, count, 'Prepared targets must not download again') + assert.equal(installed, 0) + assert.equal((await unsigned.coordinator.install('1.0.1-nightly.1')).phase, 'ready') + assert.equal(installed, 0) + unsigned.authorize() + assert.equal((await unsigned.coordinator.install('1.0.1-nightly.1')).phase, 'installing') + assert.equal(installed, 1, 'Installation handoff requires separate approval') + report.cases.push('explicit-retry-signed-ready-and-separate-install-handoff') + if (server.artifacts.old) { + report.differential = [] + for (const [name, multipleRanges, fault] of [ + ['multipart-range-reconstruction', true], + ['single-range-reconstruction', false], + ['missing-old-blockmap-full-fallback', true, 'missing-old-blockmap'], + ['rejected-range-full-fallback', true, 'reject-ranges'], + ]) { + const f = await fixture(publisher, true, multipleRanges) + await mkdir(join(f.directory, 'cache')) + await copyFile(server.artifacts.old.file, join(f.directory, 'cache', 'installer.exe')) + server.select('signed', false, fault) + const start = server.requests.length + assert.equal((await download(f)).phase, 'ready') + assert.equal(await artifactDigest(f.updater.installerPath), server.artifacts.signed.sha512) + assert.ok(f.logs.some(line => line.startsWith('Verifying signature '))) + assert.equal(installed, 1, 'Download must not invoke another installer handoff') + const requests = server.requests.slice(start) + const payload = requests.filter(request => request.path.endsWith('.exe')) + const fallback = f.logs.some(line => line.includes('fallback to full download')) + assert.ok(requests.some(request => request.path.endsWith('1.0.0.exe.blockmap'))) + assert.ok(requests.some(request => request.path.endsWith('1.0.1-nightly.1.exe.blockmap'))) + if (!fault) { + assert.equal(fallback, false, 'A full fallback cannot pass differential qualification') + assert.ok(payload.length > 0 && payload.every(request => request.range)) + assert.equal(payload.some(request => request.range.includes(',')), multipleRanges) + assert.ok(payload.reduce((bytes, request) => bytes + request.bytes, 0) < server.artifacts.signed.size, + 'Reconstruction must reuse bytes from the cached installer') + } + else { + assert.equal(fallback, true) + assert.equal(payload.filter(request => !request.range).length, 1) + assert.equal(payload.at(-1).bytes, server.artifacts.signed.size) + if (fault === 'reject-ranges') assert.ok(payload.some(request => request.range)) + } + report.differential.push({ name, requests, fallback, + payloadBytes: payload.reduce((bytes, request) => bytes + request.bytes, 0), + fullSize: server.artifacts.signed.size }) + report.cases.push(name) + } + } + assert.ok(fixtures.every(f => !f.logs.some(line => line.includes('Ignoring signature validation')))) + assert.deepEqual(server.failures, []) + for (const artifact of Object.values(server.artifacts)) assert.equal(await artifactDigest(artifact.file), artifact.sha512) + report.passed = true + } + finally { + for (const f of fixtures) f.coordinator.dispose() + await server.close() + report.requests = server.requests + report.fixtures = fixtures.map(f => ({ errors: f.errors, phases: f.states.map(state => state.phase) })) + await writeFile(join(root, 'result.json'), `${JSON.stringify(report, null, 2)}\n`) + } +} + +main().then(() => app.exit(0), async (error) => { + console.error(error) + await writeFile(join(root, 'failure.txt'), `${error.stack ?? error}\n`) + app.exit(1) +}) diff --git a/apps/desktop/tests/fixtures/signing-interlock.mjs b/apps/desktop/tests/fixtures/signing-interlock.mjs new file mode 100644 index 0000000000..222c2f51ff --- /dev/null +++ b/apps/desktop/tests/fixtures/signing-interlock.mjs @@ -0,0 +1,6 @@ +/** Acquire only filesystem state; this fixture never loads a signer or opens a token. */ +import { beginWindowsSigningAttempt } from '../../scripts/windows-signing-state.mjs' +try { + beginWindowsSigningAttempt({ runDirectory: process.argv[2], stateDirectory: process.argv[3], target: 'fixture.exe' }) + process.stdout.write('acquired') +} catch { process.stdout.write('blocked'); process.exitCode = 1 } diff --git a/apps/desktop/tests/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs new file mode 100644 index 0000000000..e748f54db7 --- /dev/null +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -0,0 +1,121 @@ +/** Real isolated update dialogs; installer handoff is recorded by the caller's inert updater. */ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { app, BrowserWindow, protocol } from 'electron' +import { DesktopUpdateDialog } from '../../lib/types/update-dialog.js' +import { formatDesktopMessage, resolveDesktopLocale } from '../../lib/types/locale.js' + +async function rendered(window) { + await window.webContents.executeJavaScript(`new Promise((resolve, reject) => { + const observer = new MutationObserver(check); + const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Dialog did not render')); }, 10000); + function check() { if (document.querySelector('main:not([hidden])')) { observer.disconnect(); clearTimeout(deadline); resolve(); } } + observer.observe(document, { subtree: true, attributes: true }); check(); + })`) +} + +/** Exercise cancellation and explicit installation through the production preload and renderer. */ +export async function qualifyUpdateDialogs(root, fixture) { + const locale = resolveDesktopLocale('zh-CN') + const messages = locale.messages + const screenshots = [] + protocol.handle('dsh-app', async request => { + const name = new URL(request.url).pathname.slice(1) + assert.ok(['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name)) + const mime = name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css' + : name.endsWith('.svg') ? 'image/svg+xml' : 'text/html' + return new Response(await readFile(new URL(`../../renderer/${name}`, import.meta.url)), { headers: { 'content-type': mime } }) + }) + const parent = new BrowserWindow({ show: true, width: 900, height: 650 }) + const dialogs = new DesktopUpdateDialog(fileURLToPath(new URL('../../lib/preload-update-dialog.cjs', import.meta.url)), locale) + try { + await parent.loadURL('data:text/html;charset=utf-8,Update dialog qualification

    Local updater

    ') + const f = await fixture() + const available = await f.coordinator.check() + await f.coordinator.download(available.version) + assert.equal(f.installations.length, 0) + for (const active of [false, true]) { + const options = { + title: messages.updateTitle, + message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: available.version }), + detail: active ? messages.updateActiveTasksDetail : messages.updateDownloadedDetail, + buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], cancelId: 1, + } + f.restart(async () => (await dialogs.show(parent, options)).response === 0) + const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) }) + const pending = f.coordinator.install(available.version) + const [, window] = await created + await once(window.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) }) + await rendered(window) + const view = await window.webContents.executeJavaScript(`({ + title: document.getElementById('title').textContent, + detail: document.getElementById('detail').textContent, + buttons: [...document.querySelectorAll('#actions button')].map(button => button.textContent), + width: document.querySelector('main').getBoundingClientRect().width, + radius: getComputedStyle(document.querySelector('main')).borderRadius, + primary: getComputedStyle(document.querySelector('.primary')).backgroundColor, + focused: document.activeElement.id, isolated: typeof window.require === 'undefined', + })`) + assert.deepEqual(view, { title: options.message, detail: options.detail, buttons: options.buttons, + width: 380, radius: '24px', primary: 'rgb(15, 17, 21)', focused: 'dialog', isolated: true }) + assert.equal(await parent.webContents.executeJavaScript('getComputedStyle(document.body).filter'), 'blur(2px)') + assert.equal(f.installations.length, 0) + assert.equal(await window.webContents.executeJavaScript(` + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })); + document.activeElement.id`), 'close') + assert.equal(await window.webContents.executeJavaScript(` + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })); + document.activeElement.textContent`), options.buttons.at(-1)) + await window.webContents.executeJavaScript("document.getElementById('dialog').focus()") + const name = active ? 'update-active-tasks.png' : 'update-ready.png' + await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG()) + screenshots.push(name) + const closed = once(window, 'closed', { signal: AbortSignal.timeout(10_000) }) + if (active) { + await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined") + } else { + window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) + } + const result = await pending + await closed + assert.equal(window.isDestroyed(), true) + assert.equal(result.phase, active ? 'installing' : 'ready') + assert.equal(f.installations.length, active ? 1 : 0) + } + assert.deepEqual(f.installations, [[true, true]]) + const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) }) + const diagnostics = 'exit 0; shutdown acknowledged false\n' + 'at internal/diagnostic/path\n'.repeat(1000) + const pending = dialogs.show(parent, { title: messages.updateFailedTitle, message: messages.updateStopFailed, + technicalDetails: diagnostics }) + const [, errorWindow] = await created + await once(errorWindow.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) }) + await rendered(errorWindow) + assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details').open"), false) + for (const expanded of [false, true]) { + if (expanded) { + assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true) + assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details-content').textContent"), diagnostics) + assert.equal(await errorWindow.webContents.executeJavaScript(`(() => { + const detail = document.getElementById('technical-details-content'); + const button = document.querySelector('.primary').getBoundingClientRect(); + return detail.scrollHeight > detail.clientHeight && detail.getBoundingClientRect().height <= 180 + && button.top >= 0 && button.bottom <= innerHeight; + })()`), true) + } + const name = expanded ? 'update-error-expanded.png' : 'update-error-collapsed.png' + await writeFile(join(root, name), (await errorWindow.webContents.capturePage()).toPNG()) + screenshots.push(name) + } + assert.deepEqual(f.installations, [[true, true]]) + errorWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) + assert.equal((await pending).response, 0) + return screenshots + } finally { + dialogs.dispose() + parent.destroy() + protocol.unhandle('dsh-app') + } +} diff --git a/apps/desktop/tests/fixtures/update-server.mjs b/apps/desktop/tests/fixtures/update-server.mjs new file mode 100644 index 0000000000..0b2c080031 --- /dev/null +++ b/apps/desktop/tests/fixtures/update-server.mjs @@ -0,0 +1,91 @@ +/** Loopback-only update feeds and deterministic transport failures for Electron qualification. */ +import { createHash } from 'node:crypto' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { dump } from 'js-yaml' + +/** + * Allocate a private listener with request barriers and no remote publication. + * @returns {Promise} Feed URL, scenario controls, observations, and awaited disposal. + */ +export async function createUpdateServer() { + const payload = Buffer.alloc(256 * 1024, 0x5a) + const sha512 = createHash('sha512').update(payload).digest('base64') + const requests = [] + const failures = [] + let mode = 'healthy' + let version = '1.0.1-nightly.1' + let policyMode = 'clear' + let gate = Promise.withResolvers() + let arrived = Promise.withResolvers() + let origin + const server = createServer((request, response) => { + const path = new URL(request.url, origin).pathname + requests.push(path) + const selectedMode = mode + const selectedGate = gate + response.setHeader('Cache-Control', 'no-store') + void (async () => { + if (path === '/api/v0/check_client_update') { + response.setHeader('Content-Type', 'application/json') + if (policyMode === 'stall') return + if (policyMode === 'failure') { response.writeHead(503).end('{"code":500}'); return } + response.end(JSON.stringify(policyMode === 'force' ? { + code: 40005, data: { show_content: { title: '需要更新', detail: '现有任务继续运行,请更新后继续操作。' }, + desktop_app_link: 'https://downloads.example.com/desktop' }, + } : { code: 0, data: { biz_code: 0, biz_data: null } })) + return + } + if (path === '/nightly.yml') { + if (selectedMode === 'feed-stall') return + if (selectedMode === 'feed-404') { response.writeHead(404).end(); return } + if (selectedMode === 'feed-408') { response.writeHead(408).end(); return } + if (selectedMode === 'invalid-yaml') { response.end('files: ['); return } + if (selectedMode === 'hold-check') { arrived.resolve(); await selectedGate.promise } + response.setHeader('Content-Type', 'application/yaml') + response.end(dump({ version, files: [{ url: `${origin}/payload.exe`, size: payload.length, sha512 }], + path: 'payload.exe', sha512, releaseDate: '2026-09-10T00:00:00.000Z' })) + return + } + if (path !== '/payload.exe' || selectedMode === 'download-404') { response.writeHead(404).end(); return } + response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': payload.length }) + if (selectedMode === 'download-stall') { response.write(payload.subarray(0, 1024)); return } + if (selectedMode === 'corrupt') { response.end(Buffer.alloc(payload.length, 0x21)); return } + if (selectedMode === 'disconnect') { + response.write(payload.subarray(0, 1024), () => response.destroy()) + return + } + if (selectedMode === 'hold-download') { + response.write(payload.subarray(0, 1024)) + arrived.resolve() + await selectedGate.promise + if (!response.destroyed) response.end(payload.subarray(1024)) + return + } + response.end(payload) + })().catch(error => { failures.push(String(error)); response.destroy() }) + }) + const listening = once(server, 'listening') + server.listen(0, '127.0.0.1') + await listening + origin = `http://127.0.0.1:${server.address().port}` + return { + url: `${origin}/`, payload, requests, failures, + policy(next) { policyMode = next }, + select(nextMode, nextVersion = '1.0.1-nightly.1') { + gate.resolve() + gate = Promise.withResolvers() + arrived = Promise.withResolvers() + mode = nextMode + version = nextVersion + }, + arrived: () => arrived.promise, + release: () => gate.resolve(), + async close() { + gate.resolve() + const closed = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + server.closeAllConnections() + await closed + }, + } +} diff --git a/apps/desktop/tests/fixtures/windows-signing-stage.mjs b/apps/desktop/tests/fixtures/windows-signing-stage.mjs new file mode 100644 index 0000000000..9a2621b06c --- /dev/null +++ b/apps/desktop/tests/fixtures/windows-signing-stage.mjs @@ -0,0 +1,27 @@ +/** Independent process exercising stage ownership and cache reuse with a hardware-free signer. */ +import { appendFile, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { withWindowsSigningStage } from '../../scripts/windows-signing-stage.mjs' +import { createCachedSigner, maintainSignatureCache } from '../../scripts/windows-signature-cache.mjs' + +const [root, input, mode] = process.argv.slice(2) +const release = new Promise(resolve => process.once('message', resolve)) +await withWindowsSigningStage({ stateDirectory: join(root, 'state'), stage: 'fixture', record: event => process.send(event) }, async () => { + process.send({ type: 'entered' }) + await release + if (mode === 'clear') { + await maintainSignatureCache(join(root, 'cache'), true) + return + } + const thumbprint = 'A'.repeat(40) + const signer = createCachedSigner({ root: join(root, 'cache'), identity: 'fixture', thumbprint, + record: event => process.send(event), + inspect: async path => ({ status: (await readFile(path, 'utf8')).startsWith('signed:') ? 'Valid' : 'NotSigned', timestamped: true, thumbprint }), + sign: async ({ path }) => { + await appendFile(join(root, 'hardware-calls'), 'call\n') + await writeFile(path, `signed:${await readFile(path, 'utf8')}`) + }, + }) + await signer({ path: input, hash: 'sha256', isNest: false }) +}) +process.disconnect() diff --git a/apps/desktop/tests/fixtures/workspace-update-adapters.mjs b/apps/desktop/tests/fixtures/workspace-update-adapters.mjs new file mode 100644 index 0000000000..8b9a735282 --- /dev/null +++ b/apps/desktop/tests/fixtures/workspace-update-adapters.mjs @@ -0,0 +1,33 @@ +/** Replace only update delivery/installation and inspector allocation for the compiled main-entry scenario. */ +import { DesktopUpdateCoordinator as Coordinator } from '../../lib/types/update-coordinator.js' +import { DesktopHostProcess as Host } from '../../lib/types/host-process.js' +export { DesktopHostUncleanExitError } from '../../lib/types/host-process.js' +export { DesktopUpdatePreparationError } from '../../lib/types/update-error.js' + +export const fixture = { updater: undefined, coordinator: undefined, host: undefined, ready: Promise.withResolvers(), readyHosts: new WeakSet(), states: [], installations: [], taskQueries: [] } +export class DesktopUpdateCoordinator extends Coordinator { + constructor(publish, beforeRestart) { + super(state => { fixture.states.push(state); return publish(state) }, beforeRestart, fixture.updater, () => true, () => '0.1.5-rc.1') + fixture.coordinator = this + } +} +export class DesktopHostProcess extends Host { + async updateTasks(action) { + const result = await super.updateTasks(action) + fixture.taskQueries.push({ at: Date.now(), action, active: result }) + return result + } + constructor(node, runtime, project, _inspectPort, environment, onFailure) { + // Port zero lets the OS allocate the development inspector; no fixed listener is acquired. + super(node, runtime, project, 0, environment, onFailure) + fixture.host = this + } + async start() { + try { + const result = await super.start() + fixture.readyHosts.add(this) + fixture.ready.resolve(result.url) + return result + } catch (error) { fixture.ready.reject(error); throw error } + } +} diff --git a/apps/desktop/tests/fixtures/workspace-update-host.mjs b/apps/desktop/tests/fixtures/workspace-update-host.mjs new file mode 100644 index 0000000000..4c56ced554 --- /dev/null +++ b/apps/desktop/tests/fixtures/workspace-update-host.mjs @@ -0,0 +1,51 @@ +/** Private, token-authenticated test controls for tasks in the real Desktop Host process. */ +import { createServer } from 'node:http' +import { randomUUID } from 'node:crypto' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' + +export const inject = ['agents', 'agentPresets'] +export async function apply(ctx) { + const root = process.env.DSH_WORKSPACE_UPDATE_ROOT + const token = process.env.DSH_WORKSPACE_UPDATE_TOKEN + if (!root || !token) throw new Error('Private workspace qualification configuration is missing') + const require = createRequire(join(process.cwd(), 'package.json')) + const { createUserMessage } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-llm')).href) + let owned + let holdShutdown = false + const requests = new Set() + const requestHistory = [] + ctx.on('connection/request', async (request, _response, next) => { + const entry = { method: request.method, url: request.url, startedAt: Date.now() } + requests.add(entry) + requestHistory.push(entry) + try { await next() } finally { entry.finishedAt = Date.now(); requests.delete(entry) } + }) + const server = createServer((request, response) => { + if (request.method !== 'POST' || request.headers['x-qualification-token'] !== token) { response.writeHead(403).end(); return } + void (async () => { + if (request.url === '/queue') { + owned ??= await ctx.agents.create({ sessionId: `update-confirmation-race-${randomUUID()}`, meta: { cwd: root }, + setup: async agentCtx => { await ctx.agentPresets.mount(agentCtx, 'standard') } }) + owned.agent.send(createUserMessage({ content: [{ type: 'text', text: 'Keep this queued task until explicitly approved' }], source: { kind: 'user' } }), 'next-turn', false) + } else if (request.url === '/clear') owned?.agent.inbox.clear() + else if (request.url === '/hold-shutdown') holdShutdown = true + else if (request.url !== '/status') throw new Error('Unknown qualification operation') + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify({ queued: owned?.agent.inbox.nextTurn.length ?? 0, holdShutdown, + requests: [...requests], requestHistory, agents: ctx.agents.list().map(agent => ({ status: agent.status, + nextTurn: agent.inbox.nextTurn.length, nextStep: agent.inbox.nextStep.length })), + })) + })().catch(error => { response.writeHead(500).end(String(error)) }) + }) + ctx.effect(() => async () => { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + if (holdShutdown) await new Promise(() => {}) + await owned?.dispose() + }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + await writeFile(join(root, 'host-control.json'), JSON.stringify({ url: `http://127.0.0.1:${server.address().port}` })) +} diff --git a/apps/desktop/tests/fixtures/workspace-updates-interactive.mjs b/apps/desktop/tests/fixtures/workspace-updates-interactive.mjs new file mode 100644 index 0000000000..011aceff62 --- /dev/null +++ b/apps/desktop/tests/fixtures/workspace-updates-interactive.mjs @@ -0,0 +1,82 @@ +/** Operator-controlled qualification using the real workspace; synthetic payloads never execute. */ +import { app, BrowserWindow, Menu, dialog } from 'electron' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +/** + * Keep the private workspace open until the operator closes the control window or exits the application. + * @param {object} options Real workspace and fixture-owned delivery, task, and installation controls. + * @returns {Promise} Resolves after operator exit and removal of control listeners. + */ +export async function runInteractiveUpdates({ mainWindow, server, fixture, checkMenu, control, root }) { + const panel = new BrowserWindow({ title: '本地升级验收控制', width: 640, height: 460, + webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true } }) + const finished = Promise.withResolvers() + const finish = () => finished.resolve() + panel.once('closed', finish) + app.once('before-quit', finish) + let closing = false + const originalInstall = fixture.updater.quitAndInstall + fixture.updater.quitAndInstall = (...args) => { + fixture.installations.push(args) + void dialog.showMessageBox(panel, { type: 'info', title: '本地验收结束', + message: '已完成下载、校验、安装确认和任务收尾。', + detail: '安装器调用已拦截,未安装或重启到新版本。确认后退出本轮演练;重新运行命令可开始下一轮。', + buttons: ['结束演练'] }).then(finish).catch(finish) + } + const action = (label, operation) => ({ label, click: () => { + if (closing) return + void Promise.resolve().then(operation).catch(error => { + console.error(error) + if (!panel.isDestroyed()) dialog.showErrorBox('本地验收操作失败', String(error)) + }) + } }) + const select = mode => server.select(mode, '0.1.6-nightly.1') + select('hold-download') + const check = () => checkMenu.click() + panel.setMenu(Menu.buildFromTemplate([ + { label: '更新场景', submenu: [ + action('普通更新(点击下载后保持进度)', () => { server.policy('clear'); select('hold-download'); check() }), + action('强制更新(点击下载后保持进度)', () => { server.policy('force'); select('hold-download'); check() }), + action('放行当前下载 → 校验与安装确认', () => server.release()), + { type: 'separator' }, + action('下一次下载:校验失败', () => select('corrupt')), + action('下一次下载:404 失败', () => select('download-404')), + action('下一次下载:恢复正常', () => select('healthy')), + action('检查失败', () => { select('feed-404'); check() }), + action('没有可用更新(未下载前使用)', () => { server.select('healthy', '0.1.5-rc.1'); check() }), + action('解除强更阻塞', () => { server.policy('clear'); check() }), + ] }, + { label: '任务状态', submenu: [ + action('添加排队任务', () => control('queue')), + action('清空排队任务', () => control('clear')), + action('模拟任务停止失败(本轮有效)', () => control('hold-shutdown')), + ] }, + { label: '窗口', submenu: [ + action('返回应用', () => { mainWindow.restore(); mainWindow.show(); mainWindow.focus() }), + action('结束演练', finish), + ] }, + ])) + try { + await panel.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(` + + +

    手动升级验收 · 不执行安装器

    +

    使用本窗口顶部的更新场景菜单选择普通更新或强制更新,再到应用里点击下载。

    +

    下载会保持进度,供你查看。回到这里选择放行当前下载,才会进入校验和安装确认。

    +

    用任务状态菜单添加排队任务,查看安装前的任务警告。失败模式须在下载前选择。

    +

    数据和更新服务器均为本地隔离测试。下载地址为示例地址。关闭此窗口结束演练;重跑命令重置状态。

    + `)}`) + console.log(`Interactive updater ready: ${root}`) + await finished.promise + closing = true + await writeFile(join(root, 'interactive-result.json'), JSON.stringify({ installerExecuted: false, + interceptedInstallations: fixture.installations.length, phases: fixture.states.map(state => state.phase) }, null, 2) + '\n') + } finally { + closing = true + fixture.updater.quitAndInstall = originalInstall + app.off('before-quit', finish) + panel.off('closed', finish) + if (!panel.isDestroyed()) panel.destroy() + } +} diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs new file mode 100644 index 0000000000..f6302750d9 --- /dev/null +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -0,0 +1,399 @@ +/** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */ +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { appendFile, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { app, BrowserWindow, Menu } from 'electron' +import updaterModule from 'electron-updater' +import { createUpdateServer } from './update-server.mjs' +import { fixture } from './workspace-update-adapters.mjs' +import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js' +import { resolveDesktopLocale } from '../../lib/types/locale.js' + +const root = process.env.DSH_WORKSPACE_UPDATE_ROOT +assert.ok(root) +const interactive = process.argv.includes('--interactive') +const application = join(root, 'app') +app.setAppPath(application) +app.setPath('userData', join(root, 'electron')) +const entry = pathToFileURL(join(application, 'lib/main.js')).href +const adapters = new URL('./workspace-update-adapters.mjs', import.meta.url).href +const hooks = registerHooks({ resolve(specifier, context, next) { + if (context.parentURL === entry && ['./update-coordinator.js', './host-process.js', './update-error.js'].includes(specifier)) { + return { url: adapters, shortCircuit: true } + } + return next(specifier, context) +} }) +const server = await createUpdateServer() +server.select('healthy', '0.1.6-nightly.1') +process.env.DSH_DESKTOP_APP_ID = 'com.deepseek.qualification' +process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG = JSON.stringify({ origin: new URL(server.url).origin, + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 600_000, timeoutMs: 5000, maxBackoffMs: 600_000, jitter: 0 }) +const config = join(root, 'app-update.yml') +await writeFile(config, 'updaterCacheDirName: private-workspace-cache\n') +const forbidden = () => { throw new Error('Qualification must not quit or relaunch through updater') } +const updater = new updaterModule.NsisUpdater(undefined, { + version: '0.1.5-rc.1', name: 'workspace-update-qualification', isPackaged: true, + appUpdateConfigPath: config, userDataPath: root, baseCachePath: root, + whenReady: () => app.whenReady(), quit: forbidden, relaunch: forbidden, onQuit: forbidden, +}) +updater.logger = null +updater.httpExecutor = new DesktopUpdateHttpExecutor(interactive ? 600_000 : 10_000) +updater.disableDifferentialDownload = true +updater.disableWebInstaller = true +updater.setFeedURL({ provider: 'generic', url: server.url, channel: 'nightly' }) +updater.quitAndInstall = (...args) => { fixture.installations.push(args) } +fixture.updater = updater + +async function observed(window, subject, operation) { + const record = event => appendFile(join(root, 'operations.jsonl'), JSON.stringify({ at: Date.now(), subject, ...event }) + '\n') + await record({ phase: 'start', url: window.webContents.getURL() }) + const contents = window.webContents + const throttled = contents.getBackgroundThrottling() + // Concurrent test windows may occlude this page; its real layout animations must still advance. + contents.setBackgroundThrottling(false) + let timer + try { + const result = await Promise.race([operation(), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Renderer operation timed out: ${subject}`)), 20_000) + })]) + await record({ phase: 'done' }) + return result + } catch (error) { + const state = { destroyed: window.isDestroyed() } + if (!state.destroyed) { + Object.assign(state, { visible: window.isVisible(), focused: window.isFocused(), enabled: window.isEnabled() }) + let diagnosticTimer + try { + state.renderer = await Promise.race([window.webContents.executeJavaScript(`({ + visibility: document.visibilityState, focused: document.hasFocus(), + animations: document.getAnimations().map(animation => ({ playState: animation.playState, + pending: animation.pending, currentTime: animation.currentTime, playbackRate: animation.playbackRate, + timing: animation.effect?.getComputedTiming() })) + })`), new Promise((_, reject) => { diagnosticTimer = setTimeout(() => reject(new Error('Renderer diagnostics timed out')), 2000) })]) + } catch (diagnosticError) { state.diagnosticError = String(diagnosticError) } + finally { clearTimeout(diagnosticTimer) } + } + await record({ phase: 'failed', error: String(error), state }) + throw error + } finally { + clearTimeout(timer) + if (!contents.isDestroyed()) { + contents.setBackgroundThrottling(throttled) + assert.equal(contents.getBackgroundThrottling(), throttled) + } + } +} + +async function documentReady(window, expression) { + return observed(window, `document: ${expression}`, () => window.webContents.executeJavaScript(`new Promise((resolve, reject) => { + const test = () => { if (${expression}) { observer.disconnect(); clearTimeout(timer); resolve(true); } }; + const observer = new MutationObserver(test); + const timer = setTimeout(() => { observer.disconnect(); reject(new Error(${JSON.stringify(`Document condition timed out: ${expression}`)})); }, 20000); + observer.observe(document, { childList: true, subtree: true, attributes: true }); test(); + })`)) +} +async function windowAt(url) { + const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url) + if (existing) return existing + return new Promise((resolve, reject) => { + const watched = [] + const cleanup = () => { clearTimeout(timer); app.off('browser-window-created', watch); for (const [contents, check] of watched) contents.off('did-finish-load', check) } + const watch = (_event, window) => { + const contents = window.webContents + const check = () => { if (contents.getURL() === url) { cleanup(); resolve(window) } } + watched.push([contents, check]); contents.on('did-finish-load', check); check() + } + const timer = setTimeout(() => { cleanup(); reject(new Error(`Window did not load ${url}`)) }, 25000) + app.on('browser-window-created', watch) + for (const window of BrowserWindow.getAllWindows()) watch(undefined, window) + }) +} +async function control(action) { + const { url } = JSON.parse(await readFile(join(root, 'host-control.json'), 'utf8')) + const response = await fetch(`${url}/${action}`, { method: 'POST', headers: { 'x-qualification-token': process.env.DSH_WORKSPACE_UPDATE_TOKEN } }) + assert.equal(response.status, 200, response.status === 200 ? undefined : await response.text()) + return response.json() +} +async function screenshot(window, name) { + await observed(window, `screenshot layout: ${name}`, () => window.webContents.executeJavaScript(`(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + const finite = document.getAnimations().filter(animation => animation.effect + && Number.isFinite(animation.effect.getComputedTiming().endTime)); + await Promise.all(finite.map(animation => animation.finished)); + })()`)) + await writeFile(join(root, name), (await observed(window, `capture: ${name}`, () => window.webContents.capturePage())).toPNG()) +} +async function waitFor(check, subject) { + const deadline = Date.now() + 20_000 + while (!await check()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${subject}`) + await new Promise(resolve => setTimeout(resolve, 50)) + } +} +async function press(window, expression) { + window.focus() + await documentReady(window, `(() => { const target = ${expression}; return target && !target.disabled && target.getClientRects().length > 0; })()`) + const point = await observed(window, `click layout: ${expression}`, () => window.webContents.executeJavaScript(`(async () => { + const target = ${expression}; + const movements = document.getAnimations().filter(animation => animation.effect instanceof KeyframeEffect + && animation.effect.target instanceof Element && animation.effect.target.contains(target) + && Number.isFinite(animation.effect.getComputedTiming().endTime)); + await Promise.all(movements.map(animation => animation.finished)); + const rect = target.getBoundingClientRect(); + const point = { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) }; + if (!target.contains(document.elementFromPoint(point.x, point.y))) throw new Error('Click target is obscured'); + return point; + })()`)) + window.webContents.sendInputEvent({ type: 'mouseMove', ...point }) + window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) + window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) +} +async function clickText(window, label) { + await press(window, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(label)})`) + await waitFor(() => window.isDestroyed(), `dialog response: ${label}`) +} +async function dialogWith(message) { + let found + await waitFor(async () => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.webContents.getURL() !== 'dsh-app://shell/update-dialog.html') continue + try { + const ready = await window.webContents.executeJavaScript(`document.getElementById('title')?.textContent === ${JSON.stringify(message)} && !!document.querySelector('#actions button')`) + if (ready) { found = window; return true } + } catch (error) { if (!window.isDestroyed()) throw error } + } + return false + }, `dialog: ${message}`) + return found +} +async function qualify() { + let mainWindow + try { + await import(entry) + console.log('workspace qualification: compiled main module loaded') + const applicationUrl = await fixture.ready.promise + console.log('workspace qualification: Host process ready') + mainWindow = await windowAt(new URL('/', applicationUrl).href) + await documentReady(mainWindow, `document.querySelector('[class*="frame"]') && window.dshDesktop?.updates`) + assert.equal(mainWindow.isVisible(), true, 'Workspace qualification requires a visible application window') + console.log('workspace qualification: workspace document ready') + const acknowledgeNotice = `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === '继续')` + await press(mainWindow, acknowledgeNotice) + await waitFor(async () => !await mainWindow.webContents.executeJavaScript(`!!(${acknowledgeNotice})`), 'first-run notice dismissal') + console.log('workspace qualification: first-run notice dismissed') + await waitFor(async () => !await fixture.host.updateTasks('inspect'), 'workspace startup API requests to settle') + console.log('workspace qualification: startup API requests settled') + const messages = resolveDesktopLocale(app.getLocale()).messages + await screenshot(mainWindow, 'workspace.png') + const menu = Menu.getApplicationMenu().items[0].submenu.items + const checkMenu = menu.find(item => item.label === messages.checkUpdatesMenu) + assert.ok(checkMenu) + if (interactive) { + const { runInteractiveUpdates } = await import('./workspace-updates-interactive.mjs') + await runInteractiveUpdates({ mainWindow, server, fixture, checkMenu, control, root }) + return + } + const cases = ['real-workspace-preload-and-host'] + + server.select('hold-check', '0.1.5-rc.1') + checkMenu.click() + await server.arrived() + const checking = await dialogWith(messages.updateChecking) + await screenshot(checking, 'checking.png') + server.release() + await waitFor(() => checking.isDestroyed(), 'checking dialog to close') + console.log('workspace qualification: checking dialog closed') + const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion())) + await clickText(current, messages.updateAcknowledge) + assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0) + cases.push('native-menu-checking-and-current-without-download') + console.log('workspace qualification: manual no-update feedback complete') + + server.select('healthy', '0.1.6-nightly.1') + checkMenu.click() + const available = await dialogWith(messages.updateAvailable) + server.select('corrupt', '0.1.6-nightly.1') + await clickText(available, messages.updateDownload) + await waitFor(() => fixture.coordinator.state.phase === 'error', 'checksum failure') + const downloadError = await dialogWith(messages.updateDownloadFailed) + assert.equal(await downloadError.webContents.executeJavaScript("document.getElementById('technical-details').open"), false) + assert.equal(await downloadError.webContents.executeJavaScript("document.getElementById('technical-details-content').textContent"), fixture.coordinator.state.message) + await screenshot(downloadError, 'download-error.png') + await clickText(downloadError, messages.updateAcknowledge) + await documentReady(mainWindow, `document.querySelector('button[data-error="true"]')`) + await screenshot(mainWindow, 'retry.png') + await press(mainWindow, `document.querySelector('button[aria-label="收起侧边栏"]')`) + await documentReady(mainWindow, `document.querySelector('button[aria-label="打开侧边栏"] [role="img"][data-error="true"]')`) + await screenshot(mainWindow, 'collapsed-error.png') + await press(mainWindow, `document.querySelector('button[aria-label="打开侧边栏"]')`) + await documentReady(mainWindow, `document.querySelector('button[data-error="true"]')`) + cases.push('download-integrity-error-and-persistent-retry') + + server.select('hold-download', '0.1.6-nightly.1') + await press(mainWindow, `document.querySelector('button[data-error="true"]')`) + await server.arrived() + await documentReady(mainWindow, `document.querySelector('button[aria-disabled="true"]')`) + assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false) + await screenshot(mainWindow, 'downloading.png') + server.release() + const ready = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1')) + assert.equal(fixture.installations.length, 0) + await screenshot(ready, 'install-confirmation.png') + cases.push('sidebar-retry-direct-download-and-separate-install-dialog') + + assert.equal((await control('queue')).queued, 1) + await clickText(ready, messages.installAndRestart) + await waitFor(() => fixture.coordinator.state.phase === 'error', 'task-change protection') + assert.equal(fixture.coordinator.state.message, messages.updateTasksChanged) + assert.equal(fixture.installations.length, 0) + assert.equal((await control('status')).queued, 1) + const changed = await dialogWith(messages.updateTasksChanged) + await screenshot(changed, 'new-task-refusal.png') + await clickText(changed, messages.updateAcknowledge) + cases.push('new-task-during-idle-confirmation-refuses-install-and-preserves-work') + + await press(mainWindow, `document.querySelector('button[data-error="true"]')`) + const warning = await dialogWith(messages.updateActiveTasks) + await screenshot(warning, 'active-task-warning.png') + await clickText(warning, messages.updateLater) + await waitFor(() => fixture.coordinator.state.phase === 'ready', 'deferred install readiness') + assert.equal((await control('status')).queued, 1) + assert.equal(fixture.installations.length, 0) + cases.push('task-warning-deferral-preserves-work-and-ready-package') + console.log('workspace qualification: real task confirmation and deferral complete') + + server.policy('force') + checkMenu.click() + console.log('workspace qualification: mandatory check dispatched') + const mandatory = await windowAt('dsh-app://shell/mandatory-update.html') + console.log('workspace qualification: mandatory window loaded') + await documentReady(mandatory, `document.getElementById('title')?.textContent === '需要更新'`) + console.log('workspace qualification: mandatory title rendered') + assert.equal(await mandatory.webContents.executeJavaScript(`document.getElementById('title').children.length`), 0) + assert.equal(mandatory.isMovable(), true) + assert.equal(mandatory.isResizable(), true) + assert.equal(mandatory.isMaximizable(), true) + const originalBounds = mandatory.getBounds() + mandatory.setPosition(originalBounds.x + 20, originalBounds.y + 20) + assert.notDeepEqual(mandatory.getBounds(), originalBounds) + mandatory.maximize() + await waitFor(() => mandatory.isMaximized(), 'mandatory maximize') + mandatory.unmaximize() + await waitFor(() => !mandatory.isMaximized(), 'mandatory restore') + mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) + mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) + assert.equal((await control('status')).queued, 1) + await screenshot(mandatory, 'mandatory-block.png') + server.policy('failure') + checkMenu.click() + await waitFor(async () => (await mandatory.webContents.executeJavaScript('window.dshMandatoryUpdate.status()')).policy.error === 'unavailable', 'retained policy failure') + assert.equal(await mandatory.webContents.executeJavaScript("document.getElementById('error').hidden"), true) + assert.equal((await control('status')).queued, 1) + await screenshot(mandatory, 'mandatory-retained-error.png') + server.policy('clear') + checkMenu.click() + await waitFor(() => mandatory.isDestroyed(), 'fresh no-force response to clear the modal') + assert.equal(mainWindow.isEnabled(), true) + assert.equal(fixture.installations.length, 0) + cases.push('mandatory-main-entry-block-retains-real-work-through-failure-and-clearance') + + await press(mainWindow, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(messages.installAndRestart)})`) + const stop = await dialogWith(messages.updateActiveTasks) + const stoppedHost = fixture.host + await control('hold-shutdown') + await clickText(stop, messages.updateStopTasks) + await documentReady(mainWindow, `[...document.querySelectorAll('button')].some(button => button.textContent.trim() === ${JSON.stringify(messages.updateInstalling)})`) + assert.equal(await mainWindow.webContents.executeJavaScript("document.body.innerText.includes('重新连接中')"), false) + await screenshot(mainWindow, 'installing-before-host-exit.png') + await waitFor(() => fixture.coordinator.state.phase === 'error', 'unclean Host stop rejection') + assert.equal(fixture.coordinator.state.message, messages.updateStopFailed) + assert.match(fixture.coordinator.state.technicalDetails, /did not complete graceful task teardown/) + assert.equal(fixture.installations.length, 0) + const failedStop = await dialogWith(fixture.coordinator.state.message) + await screenshot(failedStop, 'stop-failure.png') + cases.push('actual-host-teardown-timeout-refuses-installer') + await clickText(failedStop, messages.updateAcknowledge) + await waitFor(() => fixture.host !== stoppedHost && fixture.readyHosts.has(fixture.host), 'replacement Host after non-graceful shutdown') + await waitFor(async () => !await fixture.host.updateTasks('inspect'), 'replacement Host ready without active work') + await press(mainWindow, `document.querySelector('button[data-error="true"]')`) + const retryInstall = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1')) + assert.equal(fixture.installations.length, 0) + await screenshot(retryInstall, 'recovered-install-confirmation.png') + await press(retryInstall, `document.getElementById('close')`) + await waitFor(() => retryInstall.isDestroyed(), 'recovered confirmation dismissal') + await waitFor(() => fixture.coordinator.state.phase === 'ready', 'recovered update ready for explicit retry') + assert.equal(fixture.installations.length, 0) + cases.push('non-graceful-stop-restores-host-and-requires-fresh-install-confirmation') + + server.policy('force') + checkMenu.click() + const forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html') + await press(forcedRecovery, `document.getElementById('update')`) + await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`) + assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false) + const forcedHost = fixture.host + await control('hold-shutdown') + await press(forcedRecovery, `document.getElementById('update')`) + await waitFor(() => fixture.coordinator.state.phase === 'error', 'mandatory Host stop rejection') + await documentReady(forcedRecovery, `document.getElementById('error')?.textContent === ${JSON.stringify(messages.updateStopFailed)}`) + await screenshot(forcedRecovery, 'mandatory-stop-failure.png') + await waitFor(() => fixture.host !== forcedHost && fixture.readyHosts.has(fixture.host), 'mandatory replacement Host readiness') + assert.equal(mainWindow.isEnabled(), false) + assert.equal(fixture.installations.length, 0) + await control('queue') + await press(forcedRecovery, `document.getElementById('update')`) + await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.updateStopTasks)}`) + await screenshot(forcedRecovery, 'mandatory-recovered-confirmation.png') + await press(forcedRecovery, `document.getElementById('later')`) + await waitFor(() => fixture.coordinator.state.phase === 'ready', 'mandatory deferred retry readiness') + assert.equal(forcedRecovery.isDestroyed(), false) + assert.equal(mainWindow.isEnabled(), false) + assert.equal(fixture.installations.length, 0) + server.policy('clear') + checkMenu.click() + await waitFor(() => forcedRecovery.isDestroyed(), 'mandatory recovery policy clearance') + cases.push('mandatory-stop-recovery-preserves-block-and-requires-fresh-install-confirmation') + await writeFile(join(root, 'result.json'), JSON.stringify({ realElectron: true, realHostProcess: true, + realPreload: true, compiledMainEntry: true, installerExecuted: false, cases, + menu: menu.map(item => item.label), phases: fixture.states.map(state => state.phase) }, null, 2) + '\n') + } catch (error) { + await writeFile(join(root, 'failure.txt'), String(error.stack ?? error)) + await writeFile(join(root, 'failure-update-state.json'), JSON.stringify(fixture.coordinator?.state, null, 2)) + await writeFile(join(root, 'task-queries.json'), JSON.stringify(fixture.taskQueries, null, 2)) + try { await writeFile(join(root, 'failure-host-state.json'), JSON.stringify(await control('status'), null, 2)) } + catch (diagnosticError) { await writeFile(join(root, 'failure-host-state.txt'), String(diagnosticError)) } + const windows = [] + for (const window of BrowserWindow.getAllWindows()) { + const state = { url: window.webContents.getURL(), visible: window.isVisible(), title: window.getTitle() } + try { + state.document = await observed(window, 'failure window content', () => window.webContents.executeJavaScript(`({ + title: document.getElementById('title')?.textContent, + buttons: [...document.querySelectorAll('button')].map(button => button.textContent), + })`)) + } catch (diagnosticError) { state.error = String(diagnosticError) } + windows.push(state) + } + await writeFile(join(root, 'failure-windows.json'), JSON.stringify(windows, null, 2)) + if (mainWindow && !mainWindow.isDestroyed()) { + try { + await writeFile(join(root, 'failure-buttons.json'), JSON.stringify(await observed(mainWindow, 'failure buttons', () => + mainWindow.webContents.executeJavaScript( + `[...document.querySelectorAll('button')].map(button => ({ text: button.textContent, aria: button.getAttribute('aria-label') }))`)), null, 2)) + await screenshot(mainWindow, 'failure-workspace.png') + } catch (diagnosticError) { + await writeFile(join(root, 'failure-artifact-error.txt'), String(diagnosticError.stack ?? diagnosticError)) + } + } + throw error + } finally { + hooks.deregister() + await server.close() + app.quit() + } +} +const fail = error => { console.error(error); app.exit(1) } +process.on('uncaughtException', fail) +process.on('unhandledRejection', fail) +void qualify().catch(fail) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index eaa4aced65..45f5d4d9c5 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { DesktopHostProcess } from '../src/host-process.ts' +import { DesktopHostProcess, DesktopHostUncleanExitError } from '../src/host-process.ts' const roots: string[] = [] const hosts: DesktopHostProcess[] = [] @@ -30,10 +30,14 @@ server.listen(0, '127.0.0.1', () => { process.send({ type: 'ready', url: 'http://127.0.0.1:' + server.address().port + '/?token=fixture' }) }) process.on('message', message => { + if (message.type === 'update-tasks') { + process.send({ type: 'update-tasks', requestId: message.requestId, active: message.action === 'lock' }) + return + } if (message.type !== 'shutdown') return server.close(() => { writeFileSync(join(process.argv[3], 'stopped'), '') - process.disconnect() + process.send({ type: 'shutdown-complete' }, () => process.disconnect()) }) server.closeAllConnections() }) @@ -63,6 +67,35 @@ afterEach(async () => { }) describe('desktop host process', () => { + it('correlates task inspections and admission changes over private IPC', async () => { + const host = hostProcess(projectWithHost()) + await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable') + await host.start() + expect(await Promise.all([host.updateTasks('inspect'), host.updateTasks('lock'), host.updateTasks('unlock')])) + .toEqual([false, true, false]) + await host.stop(true) + await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable') + }) + + it.each([ + 'process.exit(17)', + 'process.exit(0)', + ])('refuses installation when exit lacks successful teardown acknowledgement: %s', async (exit) => { + const host = hostProcess(projectWithHost(` + process.send({ type: 'ready', url: 'http://127.0.0.1:3080/' }) + process.on('message', message => { + if (message.type === 'shutdown') process.stderr.write('token=fixture-secret', () => { ${exit} }) + }) + `)) + await host.start() + const error = await host.stop(true).then(() => undefined, (error: unknown) => error) + expect(error).toBeInstanceOf(DesktopHostUncleanExitError) + expect(String(error)).toContain('shutdown acknowledged false') + expect(String(error)).toContain('graceful deadline exceeded false') + expect(String(error)).not.toContain('fixture-secret') + await expect(host.stop()).resolves.toBeUndefined() + }) + it('returns the Web authentication URL and waits for graceful shutdown', async () => { const runtime = projectWithHost() const failure = vi.fn() diff --git a/apps/desktop/tests/host-update-tasks.spec.ts b/apps/desktop/tests/host-update-tasks.spec.ts new file mode 100644 index 0000000000..0b2a1cd050 --- /dev/null +++ b/apps/desktop/tests/host-update-tasks.spec.ts @@ -0,0 +1,143 @@ +import { Context } from '@deepseek-ai/cordis' +import { Readable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installDesktopUpdateTaskControl } from '../../desktop-host/src/update-tasks.ts' + +type AgentState = { status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } } +type JobState = { status: 'running' | 'stopping' | 'finished' } + +let ctx: Context +let inspect: ReturnType +const agents: AgentState[] = [] +const jobs = new Map() + +beforeEach(() => { + agents.length = 0 + jobs.clear() + ctx = new Context() + // Narrow service doubles exercise the inspector against a real Cordis event lifecycle. + ctx.provide('agents', { list: () => agents } as unknown as Context['agents']) + ctx.provide('jobs', { list: (agent?: AgentState) => jobs.get(agent) ?? [] } as unknown as Context['jobs']) + inspect = installDesktopUpdateTaskControl(ctx) +}) + +afterEach(async () => { await ctx.fiber.dispose() }) + +function idleAgent(): AgentState { return { status: 'idle', inbox: { nextTurn: [], nextStep: [] } } } + +function request(next: () => Promise) { + const incoming = Readable.from([]) as unknown as IncomingMessage + const response = { writeHead: vi.fn(), end: vi.fn() } + return { response, done: ctx.waterfall('connection/request', incoming, response as unknown as ServerResponse, next) } +} + +describe('Desktop Host update task protection', () => { + it.each(['running', 'nextTurn', 'nextStep'] as const)('counts %s agent work without stopping it', async (kind) => { + const agent = idleAgent() + if (kind === 'running') agent.status = 'running' + else agent.inbox[kind].push({ queued: true }) + agents.push(agent) + expect(await inspect('inspect')).toBe(true) + agent.status = 'idle' + agent.inbox.nextTurn.length = 0 + agent.inbox.nextStep.length = 0 + expect(await inspect('inspect')).toBe(false) + }) + + it.each(['running', 'stopping'] as const)('counts global and agent-owned %s jobs', async (status) => { + const agent = idleAgent() + agents.push(agent) + for (const owner of [undefined, agent]) { + jobs.set(owner, [{ status }]) + expect(await inspect('inspect')).toBe(true) + jobs.set(owner, [{ status: 'finished' }]) + expect(await inspect('inspect')).toBe(false) + } + }) + + it('does not warn for reads but drains admitted requests before completing the lock', async () => { + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + const next = vi.fn(async () => { entered.resolve(undefined); await finish.promise }) + const pending = request(next) + try { + await entered.promise + expect(await inspect('inspect')).toBe(false) + let drained = false + const locking = inspect('lock').then((active) => { drained = true; return active }) + const refused = request(next) + await refused.done + expect(refused.response.writeHead).toHaveBeenCalledWith(503) + expect(refused.response.end).toHaveBeenCalledOnce() + expect(next).toHaveBeenCalledOnce() + expect(drained).toBe(false) + finish.resolve(undefined) + expect(await locking).toBe(false) + expect(await inspect('unlock')).toBe(false) + const admitted = vi.fn(async () => {}) + await request(admitted).done + expect(admitted).toHaveBeenCalledOnce() + } finally { + finish.resolve(undefined) + await pending.done + } + expect(await inspect('inspect')).toBe(false) + }) + + it('detects task creation by an admitted write before handing off the lock', async () => { + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + const pending = request(async () => { + entered.resolve(undefined) + await finish.promise + const agent = idleAgent() + agent.inbox.nextTurn.push({ queued: true }) + agents.push(agent) + }) + try { + await entered.promise + expect(await inspect('inspect')).toBe(false) + const locking = inspect('lock') + finish.resolve(undefined) + expect(await locking).toBe(true) + } finally { finish.resolve(undefined); await pending.done } + }) + + it.each(['unlock', 'dispose'] as const)('rejects a draining lock invalidated by %s', async (action) => { + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + const pending = request(async () => { entered.resolve(undefined); await finish.promise }) + try { + await entered.promise + const locking = inspect('lock') + const rejected = expect(locking).rejects.toThrow(action === 'unlock' ? 'lock was superseded' : 'Host is stopping') + if (action === 'unlock') await inspect('unlock') + else await ctx.fiber.dispose() + finish.resolve(undefined) + await rejected + } finally { finish.resolve(undefined); await pending.done } + }) + + it('releases request accounting when the bridge rejects', async () => { + await expect(request(async () => { throw new Error('disconnected') }).done).rejects.toThrow('disconnected') + expect(await inspect('inspect')).toBe(false) + }) + + it('removes admission on disposal and refuses further inspection', async () => { + expect(await inspect('lock')).toBe(false) + await ctx.fiber.dispose() + const next = vi.fn(async () => {}) + await request(next).done + expect(next).toHaveBeenCalledOnce() + await expect(inspect('inspect')).rejects.toThrow('Host is stopping') + }) + + it('does not classify unavailable task services as idle', async () => { + const empty = new Context() + try { + const unavailable = installDesktopUpdateTaskControl(empty) + await expect(unavailable('lock')).rejects.toThrow('task services are unavailable') + } finally { await empty.fiber.dispose() } + }) +}) diff --git a/apps/desktop/tests/installed-update-bootstrap.spec.ts b/apps/desktop/tests/installed-update-bootstrap.spec.ts new file mode 100644 index 0000000000..6bb97e6a2f --- /dev/null +++ b/apps/desktop/tests/installed-update-bootstrap.spec.ts @@ -0,0 +1,93 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { createInstalledUpdateRun, readInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { prepareInstalledUpdateBootstrap } from '../scripts/prepare-installed-update-bootstrap.ts' +import { configureInstalledUpdateIdentity } from '../scripts/installed-update-identity.mjs' + +const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const +const source = { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] } + +async function fixture(body: (directory: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-update-bootstrap-')) + try { return await body(directory) } + finally { await rm(directory, { recursive: true, force: true }) } +} + +describe('installed-update bootstrap', () => { + it('executes the generated entry for both versions with isolated persistent paths before main imports', async () => { + await fixture(async (directory) => { + const run = await createInstalledUpdateRun(directory, versions, source) + const bootstrap = await prepareInstalledUpdateBootstrap(join(run.root, 'run.json')) + await mkdir(join(bootstrap, 'node_modules/electron'), { recursive: true }) + await mkdir(join(bootstrap, 'lib')) + await writeFile(join(bootstrap, 'node_modules/electron/package.json'), JSON.stringify({ type: 'module', exports: './index.mjs' })) + await writeFile(join(bootstrap, 'node_modules/electron/index.mjs'), ` +export const app = { + paths: {}, + getAppPath: () => ${JSON.stringify(bootstrap)}, + getPath: () => ${JSON.stringify(join(directory, 'application-data'))}, + setPath(name, value) { this.paths[name] = value } +} +`) + await writeFile(join(bootstrap, 'lib/main.js'), ` +import { app } from 'electron' +console.log(JSON.stringify({ paths: app.paths, home: process.env.DSH_HOME, journals: process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR })) +`) + const results = [] + for (const version of versions) { + await writeFile(join(bootstrap, 'package.json'), JSON.stringify({ type: 'module', version, dshDesktopAppId: run.appId })) + const { stdout, stderr } = await promisify(execFile)(process.execPath, [join(bootstrap, 'qualification-bootstrap.mjs')], { + windowsHide: true, env: { SystemRoot: process.env.SystemRoot, PATH: process.env.PATH, + DSH_HOME: 'must-not-be-used', DSH_DESKTOP_UPDATE_JOURNAL_DIR: 'must-not-be-used' }, + }) + expect(stderr).toBe('') + results.push(JSON.parse(stdout)) + } + const root = join(directory, 'application-data/dsh-update-qualification', run.id) + expect(results).toEqual([0, 1].map(() => ({ paths: { userData: join(root, 'user-data'), sessionData: join(root, 'user-data') }, + home: join(root, 'dsh-home'), journals: join(root, 'journals') }))) + expect((await readdir(root)).sort()).toEqual(['dsh-home', 'journals', 'user-data']) + await expect(prepareInstalledUpdateBootstrap(join(run.root, 'run.json'))).rejects.toMatchObject({ code: 'EEXIST' }) + }) + }) + + it('refuses foreign metadata before creating any data directory or changing the environment', async () => { + await fixture(async (directory) => { + const env = { DSH_HOME: 'existing' } + const app = { getPath: () => directory, setPath: (): never => { throw new Error('unexpected path change') } } + expect(() => configureInstalledUpdateIdentity(app, { id: 'a'.repeat(24), versions }, + { version: versions[0], dshDesktopAppId: 'com.deepseek.dsh' }, env)).toThrow('identity') + expect(await readdir(directory)).toEqual([]) + expect(env).toEqual({ DSH_HOME: 'existing' }) + }) + }) + + it.each(['origin', 'bucket', 'appId', 'root', 'feedKey', 'binPrefix', 'versions', 'source'])( + 'rejects altered %s in a retained manifest', async (field) => { + await fixture(async (directory) => { + const run = await createInstalledUpdateRun(directory, versions, source) + const path = join(run.root, 'run.json') + const changed = { ...run, [field]: field === 'versions' ? [...versions].reverse() : 'unexpected' } + await writeFile(path, JSON.stringify(changed)) + await expect(readInstalledUpdateRun(path)).rejects.toThrow() + expect(await readdir(dirname(path))).toEqual(['run.json']) + }) + }, + ) + + it('loads the original manifest without adding credential fields to bootstrap output', async () => { + await fixture(async (directory) => { + const run = await createInstalledUpdateRun(directory, versions, source) + expect(await readInstalledUpdateRun(join(run.root, 'run.json'))).toEqual(run) + const bootstrap = await prepareInstalledUpdateBootstrap(join(run.root, 'run.json')) + const entry = await readFile(join(bootstrap, 'qualification-bootstrap.mjs'), 'utf8') + expect(entry).not.toContain('SECRET') + expect(entry).not.toContain('TOKEN') + expect(entry).toContain(run.id) + }) + }) +}) diff --git a/apps/desktop/tests/installed-update-builder.spec.ts b/apps/desktop/tests/installed-update-builder.spec.ts new file mode 100644 index 0000000000..781a68687e --- /dev/null +++ b/apps/desktop/tests/installed-update-builder.spec.ts @@ -0,0 +1,121 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { prepareInstalledUpdateBootstrap } from '../scripts/prepare-installed-update-bootstrap.ts' +import { prepareInstalledUpdateApplication, verifyInstalledUpdateApplication } from '../scripts/prepare-installed-update-application.ts' +import { createInstalledUpdateBuilderConfig } from '../scripts/installed-update-builder.ts' +import { runtimeFixture } from './runtime-fixture.ts' +import { writeDesktopRuntime } from '../src/runtime-tree.ts' + +vi.mock('../scripts/windows-sign.mjs', () => ({ + createWindowsTokenSigner: () => () => { throw new Error('test must not invoke hardware signing') }, + installWindowsNsisBootstrapSigner: () => undefined, + resolveWindowsUpdatePublisher: () => 'CN=Fixture,O=Fixture,C=CN', + scrubWindowsSigningEnvironment: (environment: NodeJS.ProcessEnv) => environment, +})) + +const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const +const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'bj-toc-download-test-1320056602' } +const require = createRequire(import.meta.url) +const { validateConfiguration } = require('app-builder-lib/out/util/config/config.js') as { + validateConfiguration: (config: object, logger: { isEnabled: false }) => Promise +} + +async function fixture(body: (manifest: string, source: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-update-builder-')) + try { + const run = await createInstalledUpdateRun(root, versions, { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] }) + const manifest = join(run.root, 'run.json') + await prepareInstalledUpdateBootstrap(manifest) + const source = join(root, 'app') + await mkdir(join(source, 'lib'), { recursive: true }) + await mkdir(join(source, 'renderer')) + for (const file of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) { + await writeFile(join(source, 'lib', file), '// inert fixture\n') + } + await writeFile(join(source, 'renderer', 'index.html'), '

    fixture

    ') + await writeFile(join(source, '.env.windows'), 'must-not-copy') + for (const version of versions) { + const dsh = join(run.root, version, 'dsh') + const runtime = runtimeFixture(dsh, version) + writeDesktopRuntime(dsh, runtime.release, runtime.sharedPackages.map(entry => entry.name), { platform: 'win32', arch: 'x64' }) + } + return await body(manifest, source) + } finally { await rm(root, { recursive: true, force: true }) } +} + +describe('installed-update application inputs and builder configuration', () => { + it('freezes shipped files once without copying credentials or claiming dependency and boot qualification', async () => { + await fixture(async (manifest, source) => { + expect(await prepareInstalledUpdateApplication(manifest, source)) + .toMatchObject({ dependenciesFrozen: false, signed: false, bootTested: false }) + const directory = await verifyInstalledUpdateApplication(join(manifest, '..')) + expect((await readdir(directory)).sort()).toEqual(['installed-update-identity.mjs', 'lib', 'qualification-bootstrap.mjs', 'renderer']) + await writeFile(join(source, 'lib/main.js'), '// later checkout\n') + expect(await readFile(join(directory, 'lib/main.js'), 'utf8')).toBe('// inert fixture\n') + await expect(prepareInstalledUpdateApplication(manifest, source)).rejects.toMatchObject({ code: 'EEXIST' }) + }) + }) + + it.each(['modified', 'added', 'missing'] as const)('rejects %s frozen application bytes', async (failure) => { + await fixture(async (manifest, source) => { + await prepareInstalledUpdateApplication(manifest, source) + const root = join(manifest, '..') + const directory = await verifyInstalledUpdateApplication(root) + if (failure === 'missing') await rm(join(directory, 'lib/main.js')) + else await writeFile(join(directory, failure === 'added' ? 'lib/unrecorded.js' : 'lib/main.js'), '// invalid\n') + await expect(verifyInstalledUpdateApplication(root)).rejects.toThrow() + }) + }) + + it('keeps signed NSIS hooks and one isolated identity/feed while selecting each private runtime and output', async () => { + await fixture(async (manifest, source) => { + await prepareInstalledUpdateApplication(manifest, source) + const [first, second] = await Promise.all(versions.map(version => createInstalledUpdateBuilderConfig(manifest, version, environment))) + expect(first!.appId).toBe(second!.appId) + expect(first!.extraMetadata.name).toBe(second!.extraMetadata.name) + expect(first!.files.slice(0, 2)).toEqual(second!.files.slice(0, 2)) + expect(first!.publish).toEqual(second!.publish) + for (const [index, config] of [first!, second!].entries()) { + await validateConfiguration(config, { isEnabled: false }) + expect(config.extraMetadata.version).toBe(versions[index]) + expect(config.extraMetadata.main).toBe('qualification-bootstrap.mjs') + expect(config.directories.output).toBe(join(manifest, '..', versions[index]!, 'installer')) + const dsh = join(manifest, '..', versions[index]!, 'dsh') + expect(config.files[2]).toMatchObject({ from: dsh, to: 'dsh' }) + expect(config.files[3]).toMatchObject({ from: join(dsh, 'node_modules'), to: 'dsh/node_modules' }) + expect(config.win.forceCodeSigning).toBe(true) + expect(config.win.signtoolOptions.publisherName).toBe('CN=Fixture,O=Fixture,C=CN') + expect(typeof config.win.signtoolOptions.sign).toBe('function') + expect(typeof config.beforeBuild).toBe('function') + expect(config.nsis.include).toMatch(/scripts[\\/]installer\.nsh$/u) + expect(config.publish[0]!.url) + .toMatch(/^https:\/\/download-test\.deepseek\.com\/dsh-desk\/feeds\/qualification\/[a-f0-9]{24}\/win-x64\/$/u) + } + }) + }) + + it('retains a failed preparation without a completion receipt and refuses an unrelated version', async () => { + await fixture(async (manifest, source) => { + await rm(join(source, 'lib/preload-app.cjs')) + await expect(prepareInstalledUpdateApplication(manifest, source)).rejects.toThrow('rebuild Desktop') + expect((await readdir(join(manifest, '../application'))).sort()).toEqual(['failed.json', 'started.json']) + await expect(createInstalledUpdateBuilderConfig(manifest, '9.0.0', environment)).rejects.toThrow('outside') + }) + }) + + it.each([ + { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' }, + { DSH_DESKTOP_UNSIGNED: '1' }, + { DOWNLOAD_TEST_ORIGIN: 'https://download.deepseek.com' }, + ])('rejects incompatible qualification settings %j', async (override) => { + await fixture(async (manifest) => { + await expect(createInstalledUpdateBuilderConfig(manifest, versions[0], { ...environment, ...override })).rejects.toThrow('test deployment') + }) + }) +}) diff --git a/apps/desktop/tests/installed-update-cos.spec.ts b/apps/desktop/tests/installed-update-cos.spec.ts new file mode 100644 index 0000000000..ca8dd93ec0 --- /dev/null +++ b/apps/desktop/tests/installed-update-cos.spec.ts @@ -0,0 +1,204 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type COS from 'cos-nodejs-sdk-v5' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createInstalledUpdateCos } from '../scripts/installed-update-cos.ts' +import { answer, cosError, startCosLoopback, type CosLoopback, type CosLoopbackResponder } from './cos-loopback.ts' + +const state = vi.hoisted(() => ({ + settings: 'test', + redirect: undefined as ((cos: COS) => void) | undefined, +})) + +vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: () => ({ + DSH_DESKTOP_AUTO_UPDATE_ENV: state.settings, DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', + DOWNLOAD_TEST_COS_BUCKET: 'bj-toc-download-test-1320056602', DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', + DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin-not-for-sdk', +}) })) + +vi.mock('../scripts/desktop-cos.ts', async (original) => { + const actual = await original() + return { ...actual, createDesktopCos: (credentials: Parameters[0]) => { + const cos = actual.createDesktopCos(credentials) + state.redirect?.(cos) + return cos + } } +}) + +const loopbacks: CosLoopback[] = [] +const roots: string[] = [] +afterEach(async () => { + const closers = loopbacks.splice(0) + const paths = roots.splice(0) + vi.useRealTimers() + state.settings = 'test' + state.redirect = undefined + try { + try { await Promise.all(closers.map(loopback => loopback.close())) } + finally { vi.unstubAllGlobals() } + } + finally { await Promise.all(paths.map(path => rm(path, { recursive: true, force: true }))) } +}) + +const BUCKET = 'bj-toc-download-test-1320056602' +const key = `dsh-desk/bin/qualification/${'a'.repeat(24)}/win-x64/package.exe` + +/** Store whose SDK client sends every request to a fresh loopback origin. */ +async function store(responder: CosLoopbackResponder) { + const loopback = await startCosLoopback(responder) + loopbacks.push(loopback) + state.redirect = (cos) => { loopback.redirect(cos) } + return { store: createInstalledUpdateCos(), loopback } +} + +function expectedHost(): string { + return `${BUCKET}.cos.ap-beijing.myqcloud.com` +} + +describe('qualification COS transport with real SDK serialization over a loopback origin', () => { + it('sends one streamed PUT with explicit length, MD5, no-store policy and a signed non-overwrite header', async () => { + const { store: cos, loopback } = await store((_request, response) => { + answer(response, 200, '', { 'x-cos-request-id': 'fixture-request' }) + }) + expect(loopback.requests).toHaveLength(0) + expect(await cos.put(key, { source: { contents: 'bytes' }, size: 5, + sha512: createHash('sha512').update('bytes').digest('base64'), forbidOverwrite: true })).toEqual({ requestId: 'fixture-request' }) + expect(loopback.requests).toHaveLength(1) + const request = loopback.requests[0]! + expect(request.method).toBe('PUT') + expect(request.path).toBe(`/${key}`) + expect(request.body.toString('utf8')).toBe('bytes') + expect(request.headers['content-length']).toBe('5') + expect(request.headers['content-md5']).toBe(createHash('md5').update('bytes').digest('base64')) + expect(request.headers['cache-control']).toBe('no-store') + expect(request.headers['x-cos-forbid-overwrite']).toBe('true') + expect(request.headers['transfer-encoding']).toBeUndefined() + expect(request.headers['content-encoding']).toBeUndefined() + const signed = new URL(request.signedUrl!) + expect(signed.protocol).toBe('https:') + expect(signed.host).toBe(expectedHost()) + const authorization = String(request.headers.authorization) + expect(authorization).toContain('q-sign-algorithm=sha1') + expect(authorization).toContain('x-cos-forbid-overwrite') + expect(JSON.stringify(request.headers)).not.toContain('fixture-pin') + expect(JSON.stringify(request.headers)).not.toContain('fixture-key') + }) + + it('streams a file-backed object with its declared length', async () => { + const { store: cos, loopback } = await store((_request, response) => { answer(response, 200) }) + const root = await mkdtemp(join(tmpdir(), 'dsh-cos-file-')) + roots.push(root) + const path = join(root, 'package.exe') + await writeFile(path, 'file bytes') + expect(await cos.put(key, { source: { path }, size: 10, + sha512: createHash('sha512').update('file bytes').digest('base64'), forbidOverwrite: true })).toEqual({}) + expect(loopback.requests).toHaveLength(1) + expect(loopback.requests[0]!.body.toString('utf8')).toBe('file bytes') + expect(loopback.requests[0]!.headers['content-length']).toBe('10') + expect(loopback.requests[0]!.headers['content-type']).toBe('application/octet-stream') + expect(loopback.requests[0]!.headers['x-cos-forbid-overwrite']).toBe('true') + }) + + it.each([ + ['', true], + ['Enabled', false], + ['Suspended', false], + ] as const)('reads bucket versioning %j as disabled=%s', async (status, disabled) => { + const { store: cos, loopback } = await store((_request, response) => { + answer(response, 200, `${status}`, + { 'content-type': 'application/xml' }) + }) + expect(await cos.versioningDisabled()).toBe(disabled) + expect(loopback.requests).toHaveLength(1) + expect(loopback.requests[0]!.path).toContain('versioning') + }) + + it('does not retry an uncertain PUT failure', async () => { + const { store: cos, loopback } = await store((_request, response) => { + answer(response, 500, cosError('InternalError'), { 'content-type': 'application/xml', 'x-cos-request-id': 'fixture-request' }) + }) + await expect(cos.put(key, { source: { contents: 'x' }, size: 1, + sha512: createHash('sha512').update('x').digest('base64'), forbidOverwrite: true })).rejects.toThrow() + expect(loopback.requests).toHaveLength(1) + }) + + it.each(['versioning', 'read', 'put'] as const)('enforces the qualification %s total deadline', async (operation) => { + const received = Promise.withResolvers() + const closed = Promise.withResolvers() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const { store: cos, loopback } = await store((_request, response) => { + response.once('close', () => { closed.resolve(undefined) }) + received.resolve(undefined) + }) + const pending = operation === 'versioning' ? cos.versioningDisabled() + : operation === 'read' ? cos.read(key) + : cos.put(key, { source: { contents: 'x' }, size: 1, + sha512: createHash('sha512').update('x').digest('base64'), forbidOverwrite: true }) + const rejected = expect(pending).rejects.toMatchObject({ name: 'TimeoutError' }) + await received.promise + vi.advanceTimersByTime(operation === 'versioning' ? 30_000 : 900_000) + await rejected + await closed.promise + expect(loopback.requests).toHaveLength(1) + }) + + it('hashes exact received object bytes without buffering the whole object', async () => { + const { store: cos, loopback } = await store((_request, response) => { answer(response, 200, 'object bytes') }) + expect(await cos.read(key)).toEqual({ size: 12, sha512: createHash('sha512').update('object bytes').digest('base64') }) + expect(loopback.requests).toHaveLength(1) + expect(loopback.requests[0]!.method).toBe('GET') + expect(loopback.requests[0]!.path).toBe(`/${key}`) + }) + + it('distinguishes confirmed object absence from permission errors', async () => { + const { store: cos, loopback } = await store((_request, response) => { + const status = loopback.requests.length === 1 ? 404 : 403 + answer(response, status, cosError(status === 404 ? 'NoSuchKey' : 'AccessDenied'), + { 'content-type': 'application/xml', 'x-cos-request-id': 'fixture-request' }) + }) + expect(await cos.read(key)).toBeNull() + await expect(cos.read(key)).rejects.toThrow() + expect(loopback.requests).toHaveLength(2) + }) + + it('fails a read whose transfer ends before the declared length', async () => { + const { store: cos, loopback } = await store((_request, response) => { + response.writeHead(200, { 'content-length': '12', connection: 'close' }) + response.write('part') + response.socket?.destroy() + }) + await expect(cos.read(key)).rejects.toThrow() + expect(loopback.requests).toHaveLength(1) + }) + + it('hashes public response bytes at the exact URL and rejects redirects or unrelated destinations', async () => { + const fetch = vi.fn(async () => new Response('public bytes')) + vi.stubGlobal('fetch', fetch) + const { store: cos } = await store((_request, response) => { answer(response) }) + const url = `https://download-test.deepseek.com/${key}` + expect(await cos.publicRead(url)).toEqual({ size: 12, sha512: createHash('sha512').update('public bytes').digest('base64') }) + expect(fetch).toHaveBeenCalledWith(url, expect.objectContaining({ redirect: 'error', cache: 'no-store' })) + await expect(cos.publicRead(`${url}?fresh=1`)).rejects.toThrow('exact test public URL') + await expect(cos.publicRead(url.replace('download-test', 'download'))).rejects.toThrow('exact test public URL') + await expect(cos.read('dsh-desk/feeds/nightly.yml')).rejects.toThrow('qualification namespace') + }) + + it('refuses production settings without creating a request', async () => { + state.settings = 'production' + const loopback = await startCosLoopback((_request, response) => { answer(response) }) + loopbacks.push(loopback) + state.redirect = (cos) => { loopback.redirect(cos) } + expect(() => createInstalledUpdateCos()).toThrow('test upload settings') + expect(loopback.requests).toHaveLength(0) + }) + + it('rejects mismatched upload bytes and unsafe keys before sending a request', async () => { + const { store: cos, loopback } = await store((_request, response) => { answer(response) }) + await expect(cos.put(key, { source: { contents: 'changed' }, size: 1, sha512: 'wrong', forbidOverwrite: true })) + .rejects.toThrow('input bytes changed') + await expect(cos.read(key.replace('package.exe', '..'))).rejects.toThrow('qualification namespace') + expect(loopback.requests).toHaveLength(0) + }) +}) diff --git a/apps/desktop/tests/installed-update-distribution.spec.ts b/apps/desktop/tests/installed-update-distribution.spec.ts new file mode 100644 index 0000000000..100a9723ec --- /dev/null +++ b/apps/desktop/tests/installed-update-distribution.spec.ts @@ -0,0 +1,64 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { load } from 'js-yaml' +import { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { planInstalledUpdateDistribution } from '../scripts/installed-update-distribution.ts' + +const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const + +async function fixture(body: (manifest: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-update-distribution-')) + try { + const run = await createInstalledUpdateRun(directory, versions, { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] }) + for (const version of versions) { + const output = join(run.root, version, 'installer') + await mkdir(output, { recursive: true }) + const filename = `deepseek-harness-${version}-win-x64.exe` + const body = Buffer.from(`inert fixture ${version}`) + await writeFile(join(output, filename), body) + await writeFile(join(output, `${filename}.blockmap`), 'inert map') + await writeFile(join(output, 'nightly.yml'), JSON.stringify({ version, + files: [{ url: filename, size: body.length, sha512: createHash('sha512').update(body).digest('base64') }] })) + } + return await body(join(run.root, 'run.json')) + } finally { await rm(directory, { recursive: true, force: true }) } +} + +describe('qualification distribution file planning', () => { + it('keeps two binary sets separate from successive bodies at one fixed feed URL without publication authority', async () => { + await fixture(async (manifest) => { + const [old, next] = await Promise.all(versions.map(version => planInstalledUpdateDistribution(manifest, version))) + expect(old!.feed.url).toBe(next!.feed.url) + expect(old!.feed.sha512).not.toBe(next!.feed.sha512) + expect(old!.binaries.map(file => file.key)).not.toEqual(next!.binaries.map(file => file.key)) + expect(next!.binaries).toHaveLength(2) + expect(next!.binaries.every(file => file.key.startsWith('dsh-desk/bin/qualification/'))).toBe(true) + expect(next!.feed.key).toMatch(/^dsh-desk\/feeds\/qualification\/[a-f0-9]{24}\/win-x64\/nightly.yml$/u) + expect(next!.publicationAuthorized).toBe(false) + expect(next!.verified).toBe('file-integrity-only') + expect(load(next!.feed.contents)).toMatchObject({ version: versions[1], + files: [{ url: `https://download-test.deepseek.com/${next!.binaries[0]!.key}` }] }) + }) + }) + + it.each(['changed-bytes', 'missing-map', 'foreign-url', 'wrong-version', 'legacy-hash'])( + 'rejects %s before producing any distribution plan', async (failure) => { + await fixture(async (manifest) => { + const plan = await planInstalledUpdateDistribution(manifest, versions[0]) + const installer = plan.binaries[0]!.path + const metadataPath = join(installer, '..', 'nightly.yml') + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as { version: string; files: { url: string }[]; sha512?: string } + if (failure === 'changed-bytes') await writeFile(installer, 'changed') + if (failure === 'missing-map') await rm(plan.binaries[1]!.path) + if (failure === 'foreign-url') metadata.files[0]!.url = 'https://other.invalid/unrelated.exe' + if (failure === 'wrong-version') metadata.version = versions[1] + if (failure === 'legacy-hash') metadata.sha512 = 'wrong' + await writeFile(metadataPath, JSON.stringify(metadata)) + await expect(planInstalledUpdateDistribution(manifest, versions[0])).rejects.toThrow() + }) + }, + ) +}) diff --git a/apps/desktop/tests/installed-update-network.spec.ts b/apps/desktop/tests/installed-update-network.spec.ts new file mode 100644 index 0000000000..1cc5f3f03d --- /dev/null +++ b/apps/desktop/tests/installed-update-network.spec.ts @@ -0,0 +1,94 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { prepareInstalledUpdateNetwork } from '../scripts/prepare-installed-update-network.ts' +import { installedUpdateFileHash } from '../scripts/installed-update-signature.mjs' + +async function fixture(body: (manifest: string, executable: string, receipt: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-network-plan-试验-')) + try { + const run = await createInstalledUpdateRun(root, ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'], + { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] }) + const executable = join(root, `${run.productName}.exe`) + await writeFile(executable, 'inert app fixture, never executed') + const directory = join(run.root, run.versions[0], 'verification/check-fixture') + await mkdir(directory, { recursive: true }) + const receipt = join(directory, 'result.json') + await writeFile(receipt, JSON.stringify({ schemaVersion: 1, runId: run.id, version: run.versions[0], passed: true, + contents: { appId: run.appId, version: run.versions[0] }, applicationSignature: { + valid: true, timestamped: true, updaterVerificationInvoked: true, sha512: await installedUpdateFileHash(executable), + } })) + await body(join(run.root, 'run.json'), executable, receipt) + } finally { await rm(root, { recursive: true, force: true }) } +} + +describe('installed update network plan', () => { + it('binds an exclusive local plan to verified original application bytes without network changes', async () => { + await fixture(async (manifest, executable, receipt) => { + const plan = await prepareInstalledUpdateNetwork(manifest, executable, receipt) + expect(JSON.parse(await readFile(plan, 'utf8'))).toMatchObject({ executable: await realpath(executable), + sha512Hex: Buffer.from(await installedUpdateFileHash(executable), 'base64').toString('hex').toUpperCase(), networkChanged: false }) + await expect(prepareInstalledUpdateNetwork(manifest, executable, receipt)).rejects.toMatchObject({ code: 'EEXIST' }) + }) + }) + + it.each(['failed-receipt', 'wrong-run', 'wrong-version', 'invalid-signature', 'changed-file', 'renamed-file', 'outside-receipt'])( + 'rejects %s before creating a fault plan', async (failure) => { + await fixture(async (manifest, executable, receipt) => { + const data = JSON.parse(await readFile(receipt, 'utf8')) as { + passed: boolean + runId: string + version: string + applicationSignature: { valid: boolean } + } + if (failure === 'failed-receipt') data.passed = false + if (failure === 'wrong-run') data.runId = 'b'.repeat(24) + if (failure === 'wrong-version') data.version = '0.1.6-nightly.20260914.2' + if (failure === 'invalid-signature') data.applicationSignature.valid = false + if (failure === 'changed-file') await writeFile(executable, 'changed') + if (failure === 'renamed-file') executable = join(executable, '../ordinary-app.exe') + if (failure === 'outside-receipt') receipt = join(receipt, '../outside.json') + await writeFile(receipt, JSON.stringify(data)) + await expect(prepareInstalledUpdateNetwork(manifest, executable, receipt)).rejects.toThrow() + expect(await readdir(join(manifest, '..'))).not.toContain('network-fault') + }) + }) +}) + +describe.runIf(process.platform === 'win32')('operator firewall script with all firewall cmdlets substituted', () => { + it.each(['block', 'creation-failure', 'existing', 'foreign', 'declined', 'changed', 'changed-during-confirmation', + 'restore', 'restore-absent', 'restore-failure', 'restore-declined', 'status'])( + 'records %s without touching host firewall state', async (scenario) => { + await fixture(async (manifest, executable, receipt) => { + const plan = await prepareInstalledUpdateNetwork(manifest, executable, receipt) + if (scenario === 'changed') await writeFile(executable, 'changed after plan') + if (scenario === 'restore') await rm(executable) + const environment = Object.fromEntries(Object.entries(process.env) + .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^PSModulePath$|^NODE_OPTIONS$/iu.test(name))) + const { stdout } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', + resolve(import.meta.dirname, 'fixtures/installed-update-network.ps1'), '-Script', + resolve(import.meta.dirname, '../scripts/installed-update-network.ps1'), '-Plan', plan, '-Case', scenario], + { env: environment, encoding: 'utf8', windowsHide: true, timeout: 20_000 }) + const result = JSON.parse(stdout.trim().split(/\r?\n/u).at(-1)!) as { + failure: string | null + created: number + removed: number + remaining: number + } + expect(result.created, JSON.stringify(result)).toBe(['block', 'creation-failure'].includes(scenario) ? 1 : 0) + expect(result.removed).toBe(['restore', 'restore-failure'].includes(scenario) ? 1 : 0) + expect(result.failure === null).toBe(['block', 'restore', 'restore-absent', 'status'].includes(scenario)) + expect(result.remaining).toBe(['block', 'existing', 'foreign', 'restore-failure', 'restore-declined', 'status'].includes(scenario) ? 1 : 0) + const records = join(plan, '../records') + const entries = await readdir(records) + expect(entries).toHaveLength(1) + const lines = (await readFile(join(records, entries[0]!, 'events.jsonl'), 'utf8')).trim().split('\n') + expect(JSON.parse(lines.at(-1)!)).toMatchObject({ stage: 'finished', data: { success: result.failure === null } }) + if (scenario === 'creation-failure') expect(lines.some(line => line.includes('removing-rule'))).toBe(false) + }) + }) +}) diff --git a/apps/desktop/tests/installed-update-package-content.spec.ts b/apps/desktop/tests/installed-update-package-content.spec.ts new file mode 100644 index 0000000000..ddc53dbe50 --- /dev/null +++ b/apps/desktop/tests/installed-update-package-content.spec.ts @@ -0,0 +1,260 @@ +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { prepareInstalledUpdateBootstrap } from '../scripts/prepare-installed-update-bootstrap.ts' +import { prepareInstalledUpdateApplication } from '../scripts/prepare-installed-update-application.ts' +import { verifyInstalledUpdatePackageContent } from '../scripts/installed-update-package-content.ts' +import { readDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' +import { validateInstalledUpdateArchivePaths, verifyInstalledUpdatePackage } from '../scripts/verify-installed-update-package.ts' + +type ArchiveExecute = (tool: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => Promise<{ + stdout: string + stderr: string +}> +const external = vi.hoisted(() => ({ archive: vi.fn(), signature: vi.fn() })) +vi.mock('node:child_process', async (original) => { + const actual = await original() + const { promisify } = await import('node:util') + return { ...actual, execFile: Object.assign(vi.fn(), { [promisify.custom]: external.archive }) } +}) +vi.mock('../scripts/installed-update-signature.mjs', async original => ({ + ...await original(), verifyInstalledUpdateSignature: external.signature, +})) +vi.mock('../scripts/windows-sign.mjs', () => ({ resolveWindowsUpdatePublisher: () => 'CN=Fixture,O=Fixture,C=CN' })) +afterEach(() => { external.archive.mockReset(); external.signature.mockReset(); vi.restoreAllMocks() }) + +const require = createRequire(import.meta.url) +const builderRequire = createRequire(require.resolve('app-builder-lib/package.json')) +const { createPackageWithOptions } = builderRequire('@electron/asar') as { + createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise +} +const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const +const publisher = 'CN=Fixture,O=Fixture,C=CN' + +async function fixture(body: (context: { + manifest: string + payload: string + source: string + version: string + seal: () => Promise + resealRuntime: () => Promise +}) => Promise, version: string = versions[0]): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-package-content-')) + try { + const run = await createInstalledUpdateRun(root, versions, { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] }) + const manifest = join(run.root, 'run.json') + await prepareInstalledUpdateBootstrap(manifest) + const source = join(root, 'source') + await mkdir(join(source, 'lib'), { recursive: true }) + await mkdir(join(source, 'renderer')) + for (const name of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) { + await writeFile(join(source, 'lib', name), '// inert fixture\n') + } + await writeFile(join(source, 'renderer/index.html'), '

    test

    ') + await prepareInstalledUpdateApplication(manifest, source) + await cp(join(run.root, 'application/files'), source, { recursive: true }) + await writeFile(join(source, 'package.json'), JSON.stringify({ name: `dsh-update-test-${run.id}`, version, + dshDesktopAppId: run.appId, main: 'qualification-bootstrap.mjs', type: 'module', + dshMandatoryUpdatePolicy: { origin: 'https://policy.example.com', allowedPageOrigins: ['https://policy.example.com'], + authentication: 'feishu-test' } })) + for (const name of ['electron-updater', 'semver']) { + await mkdir(join(source, 'node_modules', name), { recursive: true }) + await cp(require.resolve(`${name}/package.json`), join(source, 'node_modules', name, 'package.json')) + } + const dsh = join(run.root, version, 'dsh') + const descriptor = runtimeFixture(dsh, version) + await writeFile(join(dsh, 'tool.exe'), 'inert executable fixture') + const reseal = (directory: string): void => { + writeDesktopRuntime(directory, descriptor.release, descriptor.sharedPackages.map(entry => entry.name), { platform: 'win32', arch: 'x64' }) + } + reseal(dsh) + await cp(dsh, join(source, 'dsh'), { recursive: true }) + const payload = join(root, 'payload') + await mkdir(join(payload, 'resources'), { recursive: true }) + await writeFile(join(payload, 'resources/app-update.yml'), JSON.stringify({ provider: 'generic', channel: 'nightly', + url: `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`, publisherName: [publisher], + updaterCacheDirName: `dsh-update-test-${run.id}-updater` })) + const seal = async () => { + await createPackageWithOptions(source, join(payload, 'resources/app.asar'), { unpack: '**/*.exe' }) + } + await seal() + await body({ manifest, source, payload, version, seal, resealRuntime: async () => { + reseal(join(source, 'dsh')) + await seal() + } }) + } finally { await rm(root, { recursive: true, force: true }) } +} + +describe('installed update archive contents', () => { + it.each(versions)('reads real ASAR and runtime inventories for %s without claiming installation', async (version) => { + await fixture(async ({ manifest, payload }) => { + expect(await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).toMatchObject({ + version, applicationFiles: 7, dependenciesFrozen: false, installed: false, + resignedExecutables: [join(payload, 'resources/app.asar.unpacked/dsh/tool.exe')], + }) + }, version) + }) + + it.each(['name', 'version', 'dshDesktopAppId', 'main', 'type', 'dshMandatoryUpdatePolicy'])( + 'rejects mismatched packaged %s', async (field) => { + await fixture(async ({ manifest, source, payload, version, seal }) => { + const path = join(source, 'package.json') + const data = JSON.parse(await readFile(path, 'utf8')) as Record + data[field] = 'wrong' + await writeFile(path, JSON.stringify(data)) + await seal() + await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)) + .rejects.toThrow(field === 'dshMandatoryUpdatePolicy' ? 'desktop policy' : 'identity') + }) + }) + + it.each(['changed', 'missing', 'additional', 'credential-file'])( + 'rejects %s application content from the archive', async (failure) => { + await fixture(async ({ manifest, source, payload, version, seal }) => { + if (failure === 'missing') await rm(join(source, 'lib/main.js')) + else await writeFile(join(source, failure === 'changed' ? 'lib/main.js' + : failure === 'additional' ? 'renderer/unrecorded.js' : '.env.windows'), 'inert unexpected bytes') + await seal() + await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow() + }) + }) + + it.each(['url', 'channel', 'provider', 'updaterCacheDirName', 'publisherName'])( + 'rejects mismatched packaged update %s', async (field) => { + await fixture(async ({ manifest, payload, version }) => { + const path = join(payload, 'resources/app-update.yml') + const data = JSON.parse(await readFile(path, 'utf8')) as Record + data[field] = field === 'publisherName' ? [publisher, 'CN=Other'] : 'wrong' + await writeFile(path, JSON.stringify(data)) + await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow('packaged feed') + }) + }) + + it('rejects an updater package whose version differs from the verifier', async () => { + await fixture(async ({ manifest, source, payload, version, seal }) => { + await writeFile(join(source, 'node_modules/electron-updater/package.json'), '{"name":"electron-updater","version":"0.0.0"}') + await seal() + await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow('updater dependency') + }) + }) + + it('accepts only the builder transformation of runtime dependency manifests', async () => { + await fixture(async ({ manifest, source, payload, version, seal }) => { + const preparedRoot = join(dirname(manifest), version, 'dsh') + const packagePath = 'node_modules/@deepseek-ai/dsh/package.json' + const preparedPath = join(preparedRoot, packagePath) + const data = JSON.parse(await readFile(preparedPath, 'utf8')) as Record + data.scripts = { test: 'inert' } + data.bugs = { url: 'https://example.com/issues' } + await writeFile(preparedPath, JSON.stringify(data)) + const descriptor = readDesktopRuntime(preparedRoot) + writeDesktopRuntime(preparedRoot, descriptor.release, descriptor.sharedPackages.map(entry => entry.name), + { platform: 'win32', arch: 'x64' }) + await cp(join(preparedRoot, 'desktop-runtime.json'), join(source, 'dsh/desktop-runtime.json')) + const packagedPath = join(source, 'dsh', packagePath) + const packaged = { ...data } + delete packaged.scripts + delete packaged.bugs + await writeFile(packagedPath, JSON.stringify(packaged, null, 2)) + await seal() + await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).resolves.toMatchObject({ version }) + packaged.version = '0.0.0' + await writeFile(packagedPath, JSON.stringify(packaged, null, 2)) + await seal() + await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow('runtime bytes differ') + }) + }) + + it.each(['unsealed', 'resealed-change', 'resealed-addition'])( + 'rejects %s runtime content even when metadata is regenerated', async (failure) => { + await fixture(async ({ manifest, source, payload, version, seal, resealRuntime }) => { + await writeFile(join(source, 'dsh', failure === 'resealed-addition' ? 'extra.js' : 'package.json'), '{}') + if (failure !== 'unsealed') await resealRuntime() + else await seal() + await expect(verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).rejects.toThrow() + }) + }) + + it('identifies changed executable resources for separate signature verification', async () => { + await fixture(async ({ manifest, source, payload, version, resealRuntime }) => { + const executable = join(payload, 'resources/app.asar.unpacked/dsh/tool.exe') + await writeFile(join(source, 'dsh/tool.exe'), 'inert changed executable, not a signature') + await resealRuntime() + expect((await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).resignedExecutables).toEqual([executable]) + }) + }) +}) + +describe('installed update verification records', () => { + it('accepts relative Windows archive entries', () => { + expect(validateInstalledUpdateArchivePaths('Path = resources\r\nFolder = +\r\n\r\nPath = resources\\app.asar\r\nFolder = -\r\n')).toBe(2) + }) + + it.each(['../escape', '/absolute', 'C:\\absolute', 'file:stream', 'a//b', 'a/./b', 'a/../b', 'CON', 'NUL.txt', + 'name.', 'name ', 'a\\..\\b', 'a\u0000b', 'a?b'])( + 'rejects unsafe extraction path %j', (path) => { + expect(() => validateInstalledUpdateArchivePaths(`Path = ${path}\n`)).toThrow('unsafe path') + }) + + it.each(['', 'Path = a\nPath = A\n', 'Path = a\nSymbolic Link = ../outside\n', 'Path = a\nHard Link = b\n'])( + 'rejects missing, duplicate, or link entries %j', (listing) => { + expect(() => validateInstalledUpdateArchivePaths(listing)).toThrow() + }) + + it.each(['success', 'signature', 'unsafe-path', 'extraction', 'content', 'changed-input'])( + 'retains %s evidence and stops later stages after failure', async (failure) => { + await fixture(async ({ manifest, payload, version }) => { + vi.spyOn(console, 'log').mockImplementation(() => undefined) + const root = join(manifest, '..') + const output = join(root, version, 'installer') + await mkdir(output) + const name = `deepseek-harness-${version}-win-x64.exe` + const bytes = Buffer.from('inert installer fixture, never executed') + const installer = join(output, name) + await writeFile(installer, bytes) + await writeFile(join(output, `${name}.blockmap`), 'inert blockmap') + await writeFile(join(output, 'nightly.yml'), JSON.stringify({ version, + files: [{ url: name, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }] })) + const certificate = join(root, 'public.cer') + const tool = join(root, 'reviewed-archive.exe') + await writeFile(certificate, 'inert public certificate, parser substituted') + await writeFile(tool, 'inert archive tool, process substituted') + external.signature.mockResolvedValue({ valid: true, timestamped: true }) + if (failure === 'signature') external.signature.mockRejectedValueOnce(new Error('fixture signature failure')) + external.archive.mockImplementation(async (command, args, options) => { + expect(command).toBe(tool) + expect(args.at(-1)).toBe(installer) + expect(Object.keys(options.env).some(key => /KEY|SECRET|TOKEN|PASSWORD/iu.test(key))).toBe(false) + if (args[0] === 'l') return { stdout: `Path = ${failure === 'unsafe-path' ? '../outside' : 'resources/app.asar'}\n`, stderr: '' } + if (failure === 'extraction') throw new Error('fixture extraction failure') + const target = args.find(arg => arg.startsWith('-o'))!.slice(2) + expect(target).toBe(join(options.cwd, 'payload')) + await cp(payload, target, { recursive: true }) + if (failure === 'content') await writeFile(join(target, 'resources/app-update.yml'), '{}') + if (failure === 'changed-input') await writeFile(installer, 'modified installer') + return { stdout: '', stderr: '' } + }) + const operation = verifyInstalledUpdatePackage(manifest, version, certificate, tool) + if (failure === 'success') await expect(operation).resolves.toMatch(/result\.json$/u) + else await expect(operation).rejects.toThrow() + const { readdir } = await import('node:fs/promises') + const checks = await readdir(join(root, version, 'verification')) + expect(checks).toHaveLength(1) + const record = join(root, version, 'verification', checks[0]!) + const result = JSON.parse(await readFile(join(record, 'result.json'), 'utf8')) as Record + expect(result).toMatchObject({ version, passed: failure === 'success', installerExecuted: false, published: false, + manualChecks: ['installer-registration', 'startup', 'upgrade', 'data-retention'] }) + const stage = { success: 'complete', signature: 'installer-signature', 'unsafe-path': 'archive-paths', + extraction: 'extraction', content: 'payload-content', 'changed-input': 'unchanged-inputs' }[failure] + expect(result.stage).toBe(stage) + expect(external.archive).toHaveBeenCalledTimes(failure === 'signature' ? 0 : failure === 'unsafe-path' ? 1 : 2) + expect(external.signature).toHaveBeenCalledTimes(['success', 'changed-input'].includes(failure) ? 3 : 1) + expect(await readFile(join(record, 'events.jsonl'), 'utf8')).toContain(`"stage":"${stage}"`) + }) + }) +}) diff --git a/apps/desktop/tests/installed-update-packaging.spec.ts b/apps/desktop/tests/installed-update-packaging.spec.ts new file mode 100644 index 0000000000..4abb1dfb03 --- /dev/null +++ b/apps/desktop/tests/installed-update-packaging.spec.ts @@ -0,0 +1,140 @@ +import { mkdir, mkdtemp, readFile, readdir, 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 { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { assertInstalledUpdateSigningClear, installedUpdatePackagingEnvironment, packageInstalledUpdate } from '../scripts/installed-update-packaging.ts' + +const state = vi.hoisted(() => ({ home: '', settings: {} as NodeJS.ProcessEnv, loads: 0 })) +vi.mock('node:os', async original => ({ ...await original(), homedir: () => state.home })) +vi.mock('../scripts/desktop-package-environment.mjs', () => ({ + loadDesktopPackageEnvironment: () => { state.loads++; return state.settings }, + validateDesktopPackageEnvironment: () => undefined, +})) +vi.mock('../scripts/installed-update-builder.ts', () => ({ createInstalledUpdateBuilderConfig: async () => ({}) })) +vi.mock('../scripts/packaging-run.mjs', async (original) => { + const actual = await original() + return { ...actual, createPackagingRun: (...args: Parameters) => { + const record = actual.createPackagingRun(...args) + return { ...record, run: (stage: string, executable: string, argv: readonly string[], options: { + cwd: string + env: NodeJS.ProcessEnv + timeoutMs?: number + }) => record.run(stage, executable, + [resolve(import.meta.dirname, 'fixtures/installed-update-packaging.mjs'), ...argv.slice(-2)], options) } + } } +}) + +const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const + +async function fixture(body: (manifest: string, root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-packaging-entry-')) + const previous = { ...state } + try { + state.home = root + state.loads = 0 + const run = await createInstalledUpdateRun(root, versions, { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] }) + const certificate = join(root, 'certificate.cer') + const tool = join(root, 'signtool.exe') + await writeFile(certificate, 'inert public certificate fixture') + await writeFile(tool, 'inert unused signer fixture') + await mkdir(join(run.root, 'application')) + await writeFile(join(run.root, 'application/result.json'), '{}') + await mkdir(join(run.root, versions[0], 'dsh'), { recursive: true }) + await writeFile(join(run.root, versions[0], 'dsh/desktop-runtime.json'), '{}') + state.settings = { ...Object.fromEntries(Object.entries(process.env) + .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$/iu.test(name))), + DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DSH_DESKTOP_WINDOWS_SIGNTOOL: tool, + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-secret-pin', DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container' } + await body(join(run.root, 'run.json'), root) + } finally { Object.assign(state, previous); await rm(root, { recursive: true, force: true }) } +} + +describe('operator-driven packaging entry', () => { + it('refuses the interlock before loading credentials, confirmation, or child allocation and preserves it byte-for-byte', async () => { + await fixture(async (manifest, root) => { + const lock = join(root, '.dsh-desktop-signing/attempt.json') + await mkdir(join(root, '.dsh-desktop-signing')) + await writeFile(lock, 'retained incident record') + const confirm = vi.fn(async () => true) + await expect(packageInstalledUpdate(manifest, versions[0], { execute: true, confirm })).rejects.toThrow('interlock exists') + expect(state.loads).toBe(0) + expect(confirm).not.toHaveBeenCalled() + expect(await readFile(lock, 'utf8')).toBe('retained incident record') + expect(await readdir(join(manifest, '..', versions[0]))).toEqual(['dsh']) + await expect(assertInstalledUpdateSigningClear(lock)).rejects.toThrow('interlock exists') + }) + }) + + it('strips unrelated secrets and preload overrides while retaining signing inputs and the Windows archive filter', () => { + expect(installedUpdatePackagingEnvironment({ DSH_DESKTOP_TARGET_PLATFORM: 'win32', PATH: 'tool-path', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'pin', DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'container', + DEEPSEEK_API_KEY: 'llm', DOWNLOAD_TEST_COS_SECRET_KEY: 'cos', NODE_OPTIONS: 'preload', NODE_PATH: 'injected' })) + .toEqual({ DSH_DESKTOP_TARGET_PLATFORM: 'win32', PATH: 'tool-path', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'pin', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'container', DSH_DESKTOP_UNSIGNED: '0', ELECTRON_BUILDER_7Z_FILTER: 'BCJ' }) + }) + + it('checks without confirmation or allocating a packaging directory', async () => { + await fixture(async (manifest) => { + const confirm = vi.fn(async () => true) + expect(await packageInstalledUpdate(manifest, versions[0], { execute: false, confirm })) + .toMatchObject({ mode: 'check', childLaunched: false, signed: false }) + expect(confirm).not.toHaveBeenCalled() + expect(await readdir(join(manifest, '..', versions[0]))).toEqual(['dsh']) + }) + }) + + it.runIf(process.platform === 'win32' && process.arch === 'x64').each([true, false])( + 'supervises an inert child with failure=%s, retains redaction and refuses reuse', async (failure) => { + await fixture(async (manifest) => { + state.settings.DSH_TEST_PACKAGING_FAIL = failure ? '1' : '0' + const result = packageInstalledUpdate(manifest, versions[0], { execute: true, confirm: async () => true }) + if (failure) await expect(result).rejects.toThrow('signed-installer failed') + else expect(await result).toMatchObject({ builderCompleted: true, packageVerification: 'pending', published: false }) + const parent = join(manifest, '..', versions[0], 'packaging') + const record = join(parent, (await readdir(parent))[0]!) + expect(await readFile(join(record, 'stdout.log'), 'utf8')).toBe('[REDACTED]') + expect(JSON.parse(await readFile(join(record, 'result.json'), 'utf8'))).toMatchObject({ success: !failure }) + const events = await readFile(join(record, 'events.jsonl'), 'utf8') + expect(events.match(/stage-spawn/gu)).toHaveLength(1) + expect(events).toContain('"timedOut":false') + const confirm = vi.fn(async () => true) + await expect(packageInstalledUpdate(manifest, versions[0], { execute: true, confirm })).rejects.toThrow('existing packaging') + expect(confirm).not.toHaveBeenCalled() + }) + }, + ) + + it.runIf(process.platform === 'win32' && process.arch === 'x64')('does not allocate a build after declined confirmation or an intervening interlock', async () => { + await fixture(async (manifest, root) => { + await expect(packageInstalledUpdate(manifest, versions[0], { execute: true, confirm: async () => false })) + .rejects.toThrow('did not confirm') + await expect(packageInstalledUpdate(manifest, versions[0], { execute: true, confirm: async () => { + await mkdir(join(root, '.dsh-desktop-signing')) + await writeFile(join(root, '.dsh-desktop-signing/attempt.json'), 'another operation acquired the token') + return true + } })).rejects.toThrow('interlock exists') + expect(await readdir(join(manifest, '..', versions[0]))).toEqual(['dsh']) + }) + }) + + it.runIf(process.platform === 'win32' && process.arch === 'x64').each(['output', 'source'] as const)( + 'refuses an intervening %s change before launching the builder', async (change) => { + await fixture(async (manifest, root) => { + await expect(packageInstalledUpdate(manifest, versions[0], { execute: true, confirm: async () => { + if (change === 'source') await writeFile(join(root, 'certificate.cer'), 'changed certificate bytes') + else { + await mkdir(join(manifest, '..', versions[0], 'installer')) + await writeFile(join(manifest, '..', versions[0], 'installer/owner.txt'), 'preserve') + } + return true + } })).rejects.toThrow() + const parent = join(manifest, '..', versions[0], 'packaging') + const record = join(parent, (await readdir(parent))[0]!) + expect(await readFile(join(record, 'events.jsonl'), 'utf8')).not.toContain('stage-spawn') + expect(JSON.parse(await readFile(join(record, 'result.json'), 'utf8'))).toMatchObject({ success: false }) + if (change === 'output') expect(await readFile(join(manifest, '..', versions[0], 'installer/owner.txt'), 'utf8')).toBe('preserve') + }) + }, + ) +}) diff --git a/apps/desktop/tests/installed-update-publication.spec.ts b/apps/desktop/tests/installed-update-publication.spec.ts new file mode 100644 index 0000000000..8c6bc4f0ca --- /dev/null +++ b/apps/desktop/tests/installed-update-publication.spec.ts @@ -0,0 +1,179 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { planInstalledUpdateDistribution } from '../scripts/installed-update-distribution.ts' +import { installedUpdateFileHash } from '../scripts/installed-update-signature.mjs' +import { executeInstalledUpdatePublication, verifiedInstalledUpdateDistribution, + type InstalledUpdatePublicationStore } from '../scripts/installed-update-publication.ts' + +const digest = (bytes: Buffer) => ({ sha512: createHash('sha512').update(bytes).digest('base64'), size: bytes.length }) +afterEach(() => { vi.restoreAllMocks() }) + +class Store implements InstalledUpdatePublicationStore { + readonly objects = new Map() + readonly writes: { key: string; immutable: boolean }[] = [] + disabled = true + failure: 'none' | 'write' | 'public' = 'none' + async versioningDisabled() { return this.disabled } + async read(key: string) { const value = this.objects.get(key); return value === undefined ? null : digest(value) } + async publicRead(url: string) { + if (this.failure === 'public') return digest(Buffer.from('stale public bytes')) + expect(url).not.toContain('?') + return this.read(new URL(url).pathname.slice(1)) + } + async put(key: string, object: Parameters[1]) { + this.writes.push({ key, immutable: object.forbidOverwrite }) + if (this.failure === 'write') throw Object.assign(new Error('fixture-secret-must-not-be-recorded'), { statusCode: 503 }) + if (object.forbidOverwrite && this.objects.has(key)) throw new Error('object exists') + const bytes = 'path' in object.source ? await readFile(object.source.path) : Buffer.from(object.source.contents) + expect(digest(bytes)).toEqual({ sha512: object.sha512, size: object.size }) + this.objects.set(key, bytes) + return { requestId: 'fixture-request' } + } +} + +async function fixture(body: (context: { + manifest: string + versions: readonly [string, string] + receipts: string[] + journals: string + store: Store +}) => Promise): Promise { + const parent = await mkdtemp(join(tmpdir(), 'dsh-publication-')) + try { + vi.spyOn(console, 'log').mockImplementation(() => undefined) + const run = await createInstalledUpdateRun(parent, ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'], + { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] }) + const manifest = join(run.root, 'run.json') + const receipts: string[] = [] + for (const version of run.versions) { + const directory = join(run.root, version, 'installer') + await mkdir(directory, { recursive: true }) + const name = `deepseek-harness-${version}-win-x64.exe` + const bytes = Buffer.from(`inert installer ${version}`) + await writeFile(join(directory, name), bytes) + await writeFile(join(directory, `${name}.blockmap`), 'inert map') + await writeFile(join(directory, 'nightly.yml'), JSON.stringify({ version, files: [{ url: name, ...digest(bytes) }] })) + const check = join(run.root, version, 'verification/check-fixture') + await mkdir(check, { recursive: true }) + const receipt = join(check, 'result.json') + const distribution = await planInstalledUpdateDistribution(manifest, version) + await writeFile(receipt, JSON.stringify({ schemaVersion: 1, runId: run.id, version, passed: true, stage: 'complete', + contents: { appId: run.appId, version }, installerSignature: { valid: true, timestamped: true, + updaterVerificationInvoked: true, sha512: digest(bytes).sha512 } })) + await writeFile(join(check, 'inputs.json'), JSON.stringify({ manifestSha512: await installedUpdateFileHash(manifest), distribution })) + receipts.push(receipt) + } + const journals = join(parent, 'dsh-update-qualification', run.id, 'journals') + await mkdir(journals, { recursive: true }) + const time = new Date(Date.now() - 60_000).toISOString() + await writeFile(join(journals, '1-00000000-0000-0000-0000-000000000000.jsonl'), ['started', 'workspace-ready'] + .map((event, sequence) => JSON.stringify({ schemaVersion: 1, pid: 1, version: run.versions[0], event, sequence, time })).join('\n') + '\n') + await body({ manifest, versions: run.versions, receipts, journals, store: new Store() }) + } finally { await rm(parent, { recursive: true, force: true }) } +} + +describe('qualification publication sequencing', () => { + it('uploads without advertising, publishes version 1, then requires version 1 startup before publishing version 2', async () => { + await fixture(async ({ manifest, versions, receipts, journals, store }) => { + for (const [index, version] of versions.entries()) { + await executeInstalledUpdatePublication(manifest, version, receipts[index]!, 'upload-binaries', store) + } + expect(store.writes).toHaveLength(4) + expect(store.writes.every(write => write.immutable && write.key.includes('/bin/'))).toBe(true) + expect([...store.objects.keys()].some(key => key.endsWith('nightly.yml'))).toBe(false) + const reads = vi.spyOn(store, 'read') + const publicReads = vi.spyOn(store, 'publicRead') + const versioning = vi.spyOn(store, 'versioningDisabled') + await executeInstalledUpdatePublication(manifest, versions[0], receipts[0]!, 'publish-feed', store) + const result = await executeInstalledUpdatePublication(manifest, versions[1], receipts[1]!, 'publish-feed', store, journals) + expect(store.writes.slice(-2).map(write => write.immutable)).toEqual([true, false]) + expect(store.writes.at(-1)!.key).toBe(store.writes.at(-2)!.key) + expect(JSON.parse(await readFile(result, 'utf8'))).toMatchObject({ success: true, action: 'publish-feed', startup: { sequence: 1 } }) + const reconciled = await executeInstalledUpdatePublication(manifest, versions[1], receipts[1]!, 'publish-feed', store, journals) + expect(store.writes).toHaveLength(6) + expect(JSON.parse(await readFile(reconciled, 'utf8'))).toMatchObject({ success: true, alreadyPublished: true }) + expect(reads.mock.calls.every(([key]) => key.endsWith('nightly.yml'))).toBe(true) + expect(publicReads.mock.calls.every(([url]) => url.endsWith('nightly.yml'))).toBe(true) + expect(versioning).not.toHaveBeenCalled() + const publication = JSON.parse(await readFile(result, 'utf8')) as { binaryUploadReceipt: { sha512: string } } + expect(publication.binaryUploadReceipt.sha512).toBeTypeOf('string') + expect(await readdir(join(manifest, '..'))).not.toContain('publication.lock') + }) + }) + + it('rechecks matching immutable objects on a separately requested upload without rewriting them', async () => { + await fixture(async ({ manifest, versions, receipts, store }) => { + await executeInstalledUpdatePublication(manifest, versions[0], receipts[0]!, 'upload-binaries', store) + await executeInstalledUpdatePublication(manifest, versions[0], receipts[0]!, 'upload-binaries', store) + expect(store.writes).toHaveLength(2) + }) + }) + + it.each(['failed', 'wrong-version', 'different-plan'])('refuses a %s upload receipt without reading binaries', async (failure) => { + await fixture(async ({ manifest, versions, receipts, store }) => { + const resultPath = await executeInstalledUpdatePublication(manifest, versions[0], receipts[0]!, 'upload-binaries', store) + if (failure === 'different-plan') { + await writeFile(join(resultPath, '../plan.json'), '{}') + } else { + const result = JSON.parse(await readFile(resultPath, 'utf8')) as Record + if (failure === 'failed') result.success = false + else result.version = versions[1] + await writeFile(resultPath, JSON.stringify(result)) + } + const read = vi.spyOn(store, 'read') + const publicRead = vi.spyOn(store, 'publicRead') + store.writes.length = 0 + await expect(executeInstalledUpdatePublication(manifest, versions[0], receipts[0]!, 'publish-feed', store)).rejects.toThrow('publication stopped') + expect(read).not.toHaveBeenCalled() + expect(publicRead).not.toHaveBeenCalled() + expect(store.writes).toHaveLength(0) + }) + }) + + it.each(['versioning', 'existing-object', 'write', 'public', 'missing-binary', 'wrong-feed'])( + 'stops on %s and retains a failed operation without leaking adapter errors', async (failure) => { + await fixture(async ({ manifest, versions, receipts, store }) => { + const { distribution } = await verifiedInstalledUpdateDistribution(manifest, versions[0], receipts[0]!) + if (failure === 'versioning') store.disabled = false + if (failure === 'existing-object') store.objects.set(distribution.binaries[0]!.key, Buffer.from('foreign')) + if (failure === 'write' || failure === 'public') store.failure = failure + if (failure === 'wrong-feed') { + await executeInstalledUpdatePublication(manifest, versions[0], receipts[0]!, 'upload-binaries', store) + store.writes.length = 0 + store.objects.set(distribution.feed.key, Buffer.from('foreign feed')) + } + const action = ['missing-binary', 'wrong-feed'].includes(failure) ? 'publish-feed' : 'upload-binaries' + await expect(executeInstalledUpdatePublication(manifest, versions[0], receipts[0]!, action, store)).rejects.toThrow('publication stopped') + expect(store.writes).toHaveLength(['write', 'public'].includes(failure) ? 1 : 0) + const records = join(manifest, '../publication-records') + const paths = await readdir(records) + const results = await Promise.all(paths.map(path => readFile(join(records, path, 'result.json'), 'utf8'))) + const record = results.find(value => (JSON.parse(value) as { success: unknown }).success === false)! + expect(JSON.parse(record)).toMatchObject({ success: false }) + if (failure === 'write') expect(JSON.parse(record)).toMatchObject({ httpStatus: 503 }) + expect(record).not.toContain('fixture-secret') + expect(await readdir(join(manifest, '..'))).not.toContain('publication.lock') + }) + }) + + it.each(['missing-journal', 'wrong-journal-directory', 'changed-binary', 'failed-receipt', 'lock'])( + 'rejects %s before remote writes', async (failure) => { + await fixture(async ({ manifest, versions, receipts, journals, store }) => { + if (failure === 'missing-journal') await rm(journals, { recursive: true }) + if (failure === 'wrong-journal-directory') journals = join(journals, '..') + if (failure === 'changed-binary') { + const plan = await planInstalledUpdateDistribution(manifest, versions[1]) + await writeFile(plan.binaries[0]!.path, 'changed') + } + if (failure === 'failed-receipt') await writeFile(receipts[1]!, '{"passed":false}') + if (failure === 'lock') await mkdir(join(manifest, '../publication.lock')) + await expect(executeInstalledUpdatePublication(manifest, versions[1], receipts[1]!, 'publish-feed', store, journals)).rejects.toThrow() + expect(store.writes).toHaveLength(0) + if (failure === 'lock') expect(await readdir(join(manifest, '..'))).toContain('publication.lock') + }) + }) +}) diff --git a/apps/desktop/tests/installed-update-qualification.spec.ts b/apps/desktop/tests/installed-update-qualification.spec.ts new file mode 100644 index 0000000000..49dabbca12 --- /dev/null +++ b/apps/desktop/tests/installed-update-qualification.spec.ts @@ -0,0 +1,254 @@ +import { createHash, randomUUID } from 'node:crypto' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { collectInstalledUpdateJournals, createInstalledUpdateRun, inspectInstalledUpdateJournals } from '../scripts/installed-update-qualification.ts' +import { DesktopUpdateJournal } from '../src/update-journal.ts' + +const versions = ['0.1.6-alpha.1.20260916.1', '0.1.6-alpha.1.20260916.2'] as const +const source = { version: '0.1.6-alpha.1', commit: 'a'.repeat(40), dirtyFiles: [' M apps/desktop/example.ts'] } +interface CollectionReport { + readonly evidence: unknown + readonly files: readonly { path: string; sha256: string; bytes: number }[] +} + +async function fixture(body: (directory: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-installed-update-')) + try { return await body(directory) } + finally { await rm(directory, { recursive: true, force: true }) } +} + +function failedDownload(journal: DesktopUpdateJournal): void { + journal.action('workspace-ready') + journal.action('download-requested') + journal.state({ phase: 'downloading', version: versions[1], percent: 1 }) + journal.state({ phase: 'error', version: versions[1], failedOperation: 'download', message: 'ERR_CONNECTION_RESET secret' }) +} + +function completeRetry(journal: DesktopUpdateJournal): void { + journal.action('download-requested') + journal.state({ phase: 'ready', version: versions[1] }) + journal.action('install-confirmed') + journal.action('quit-requested') +} + +describe('installed-update qualification materials', () => { + it('allocates independent test-only namespaces and does not produce packages or overwrite an earlier manifest', async () => { + await fixture(async (directory) => { + const [first, next] = await Promise.all([createInstalledUpdateRun(directory, versions, source), + createInstalledUpdateRun(directory, versions, source)]) + expect(first.root).not.toBe(next.root) + expect(first.appId).not.toBe(next.appId) + expect(first.feedKey).not.toBe(next.feedKey) + expect(first.origin).toBe('https://download-test.deepseek.com') + expect(first.environment).toBe('test') + expect(first.feedKey).toBe(`dsh-desk/feeds/qualification/${first.id}/win-x64/nightly.yml`) + expect(JSON.parse(await readFile(join(first.root, 'run.json'), 'utf8'))).toEqual(first) + expect(await readdir(first.root)).toEqual(['run.json']) + }) + }) + + it.each([['1.0.0', '1.0.1'], [versions[1], versions[0]], [versions[0], versions[0]], + ['garbage', versions[1]], ['0.1.6-nightly.abc', versions[1]], ['0.1.6-nightly.01', versions[1]]])( + 'rejects unusable version pair %s to %s before allocating material', async (old, next) => { + await fixture(async (directory) => { + await expect(createInstalledUpdateRun(directory, [old, next], source)).rejects.toThrow('increasing') + expect(await readdir(directory)).toEqual([]) + }) + }, + ) + + it.each(['alpha.1', 'beta.2', 'rc.3', 'test'])('accepts dated %s versions', async (prefix) => { + await fixture(async (directory) => { + const pair = [`0.1.6-${prefix}.20260916.1`, `0.1.6-${prefix}.20260916.2`] as const + const run = await createInstalledUpdateRun(directory, pair, source) + expect(run.versions).toEqual(pair) + }) + }) + + it('rejects an invalid source commit before allocating material', async () => { + await fixture(async (directory) => { + await expect(createInstalledUpdateRun(directory, versions, { ...source, commit: 'unknown' })).rejects.toThrow('Git commit') + expect(await readdir(directory)).toEqual([]) + }) + }) + + it('reads real journal output while keeping observed flow separate from installation and data acceptance', async () => { + await fixture(async (directory) => { + const original = new DesktopUpdateJournal(directory, versions[0]) + failedDownload(original) + completeRetry(original) + const successor = new DesktopUpdateJournal(directory, versions[1]) + successor.action('workspace-ready') + const before = await readFile(original.path, 'utf8') + const result = await inspectInstalledUpdateJournals(directory, versions) + expect(result.recordedFlow).toBe('complete') + expect(result.missing).toEqual([]) + expect(Object.keys(result.milestones)).toHaveLength(10) + expect(result.operatorVerificationRequired).toContain('test-data-preserved') + expect(result.operatorVerificationRequired).toContain('installer-completion-and-installed-path') + expect(JSON.stringify(result)).not.toContain('secret') + expect(await readFile(original.path, 'utf8')).toBe(before) + }) + }) + + it('does not join original-process fragments or count a successor startup by itself', async () => { + await fixture(async (directory) => { + failedDownload(new DesktopUpdateJournal(directory, versions[0])) + completeRetry(new DesktopUpdateJournal(directory, versions[0])) + new DesktopUpdateJournal(directory, versions[1]).action('workspace-ready') + const result = await inspectInstalledUpdateJournals(directory, versions) + expect(result.recordedFlow).toBe('incomplete') + expect(result.missing).toContain('manual-retry') + expect(result.missing).toContain('successor-workspace') + }) + }) + + it('requires a fresh retry and readiness after a later failure', async () => { + await fixture(async (directory) => { + const journal = new DesktopUpdateJournal(directory, versions[0]) + failedDownload(journal) + journal.action('download-requested') + journal.state({ phase: 'ready', version: versions[1] }) + journal.state({ phase: 'error', failedOperation: 'install', version: versions[1] }) + journal.action('install-confirmed') + journal.action('quit-requested') + new DesktopUpdateJournal(directory, versions[1]).action('workspace-ready') + expect((await inspectInstalledUpdateJournals(directory, versions)).missing).toContain('download-ready') + }) + }) + + it('reports absent evidence as incomplete', async () => { + await fixture(async (directory) => { + const result = await inspectInstalledUpdateJournals(directory, versions) + expect(result.recordedFlow).toBe('incomplete') + expect(result.missing).toHaveLength(10) + }) + }) + + it('distinguishes a connection failure from an interrupted transfer with recorded progress', async () => { + await fixture(async (directory) => { + const journal = new DesktopUpdateJournal(directory, versions[0]) + journal.action('workspace-ready') + journal.action('download-requested') + journal.state({ phase: 'downloading', version: versions[1], percent: 0 }) + journal.state({ phase: 'error', failedOperation: 'download', version: versions[1] }) + completeRetry(journal) + new DesktopUpdateJournal(directory, versions[1]).action('workspace-ready') + expect((await inspectInstalledUpdateJournals(directory, versions)).missing).toContain('transfer-started') + }) + }) + + it.each(['partial', 'unknown-fields', 'wrong-sequence', 'mixed-version', 'invalid-json', 'unknown-phase', 'mixed-pid', 'raw-error', 'raw-operation', 'action-phase'])( + 'refuses %s evidence without printing raw input', async (variant) => { + await fixture(async (directory) => { + const journal = new DesktopUpdateJournal(directory, versions[0]) + const initial = await readFile(journal.path, 'utf8') + const row = { ...JSON.parse(initial) as Record, sequence: 1, event: 'state', phase: 'idle' } + if (variant === 'unknown-fields') Object.assign(row, { secret: 'private-value' }) + if (variant === 'wrong-sequence') row.sequence = 9 + if (variant === 'mixed-version') Object.assign(row, { version: versions[1] }) + if (variant === 'unknown-phase') row.phase = 'unknown' + if (variant === 'mixed-pid') Object.assign(row, { pid: 999999 }) + if (variant === 'raw-error') Object.assign(row, { errorCode: 'private-value' }) + if (variant === 'raw-operation') Object.assign(row, { failedOperation: 'private-value' }) + if (variant === 'action-phase') Object.assign(row, { event: 'download-requested', phase: 'private-value' }) + const next = variant === 'invalid-json' ? 'private-value' : JSON.stringify(row) + await writeFile(journal.path, initial + next + (variant === 'partial' ? '' : '\n')) + await expect(inspectInstalledUpdateJournals(directory, versions)).rejects.not.toThrow('private-value') + }) + }, + ) + + it('does not accept an already-running successor as evidence of a later restart', async () => { + await fixture(async (directory) => { + const journal = new DesktopUpdateJournal(directory, versions[0]) + failedDownload(journal) + completeRetry(journal) + const rows = ['started', 'workspace-ready'].map((event, sequence) => JSON.stringify({ schemaVersion: 1, + pid: 123, sequence, time: '2000-01-01T00:00:00.000Z', version: versions[1], event })) + await writeFile(join(directory, `1-${randomUUID()}.jsonl`), `${rows.join('\n')}\n`) + expect((await inspectInstalledUpdateJournals(directory, versions)).missing).toContain('successor-started') + }) + }) + + it('collects exact validated journal bytes in independent snapshots without copying other files or declaring acceptance', async () => { + await fixture(async (directory) => { + const run = await createInstalledUpdateRun(directory, versions, source) + const journals = join(directory, 'dsh-update-qualification', run.id, 'journals') + const original = new DesktopUpdateJournal(journals, versions[0]) + failedDownload(original) + await writeFile(join(journals, '.env'), 'private-value') + const first = await collectInstalledUpdateJournals(join(run.root, 'run.json'), journals) + const firstReport = JSON.parse(await readFile(join(first, 'report.json'), 'utf8')) as CollectionReport + expect(firstReport).toMatchObject({ runId: run.id, operatorAcceptance: 'pending', evidence: { recordedFlow: 'incomplete' } }) + expect(await inspectInstalledUpdateJournals(join(first, 'journals'), versions)).toEqual(firstReport.evidence) + completeRetry(original) + new DesktopUpdateJournal(journals, versions[1]).action('workspace-ready') + const second = await collectInstalledUpdateJournals(join(run.root, 'run.json'), journals) + expect(second).not.toBe(first) + const report = JSON.parse(await readFile(join(second, 'report.json'), 'utf8')) as CollectionReport + expect(report).toMatchObject({ operatorAcceptance: 'pending', evidence: { recordedFlow: 'complete', filesRead: 2 } }) + expect(await inspectInstalledUpdateJournals(join(second, 'journals'), versions)).toEqual(report.evidence) + expect(await readdir(join(second, 'journals'))).toEqual((await readdir(journals)).filter(file => file.endsWith('.jsonl'))) + for (const file of report.files) { + const bytes = await readFile(join(second, file.path)) + expect(bytes.length).toBe(file.bytes) + expect(createHash('sha256').update(bytes).digest('hex')).toBe(file.sha256) + expect(bytes.toString()).not.toContain('secret') + } + expect(JSON.parse(await readFile(join(first, 'report.json'), 'utf8'))).toEqual(firstReport) + expect(await readFile(join(journals, '.env'), 'utf8')).toBe('private-value') + }) + }) + + it('rejects invalid or wrong-run journals before allocating a collection', async () => { + await fixture(async (directory) => { + const run = await createInstalledUpdateRun(directory, versions, source) + const manifest = join(run.root, 'run.json') + await expect(collectInstalledUpdateJournals(manifest, directory)).rejects.toThrow('matching installed-app') + const journals = join(directory, 'dsh-update-qualification', run.id, 'journals') + await mkdir(journals, { recursive: true }) + await writeFile(join(journals, `1-${randomUUID()}.jsonl`), 'private-value\n') + await expect(collectInstalledUpdateJournals(manifest, journals)).rejects.not.toThrow('private-value') + expect(await readdir(run.root)).toEqual(['run.json']) + }) + }) + + it.each(['file', 'snapshot'])('rejects an oversized %s before collecting bytes', async (variant) => { + await fixture(async (directory) => { + const run = await createInstalledUpdateRun(directory, versions, source) + const journals = join(directory, 'dsh-update-qualification', run.id, 'journals') + await mkdir(journals, { recursive: true }) + const record = JSON.stringify({ schemaVersion: 1, sequence: 0, pid: 1, time: '2026-09-14T00:00:00.000Z', + version: versions[0], event: 'started' }) + const text = record + ' '.repeat((variant === 'file' ? 11 : 9) * 1024 * 1024) + '\n' + for (let index = 0; index < (variant === 'file' ? 1 : 6); index++) { + await writeFile(join(journals, `${index}-${randomUUID()}.jsonl`), text) + } + await expect(collectInstalledUpdateJournals(join(run.root, 'run.json'), journals)).rejects.toThrow('limit') + expect(await readdir(run.root)).toEqual(['run.json']) + }) + }) + + it('runs the documented source CLI and retains an incomplete report instead of claiming operator acceptance', async () => { + await fixture(async (directory) => { + const run = await createInstalledUpdateRun(directory, versions, source) + const journals = join(directory, 'dsh-update-qualification', run.id, 'journals') + new DesktopUpdateJournal(journals, versions[0]).action('workspace-ready') + // This test owns the documented source-script entry, not a built application or Cordis profile. + const result = await promisify(execFile)(process.execPath, ['--import', 'tsx', 'apps/desktop/scripts/prepare-installed-update.ts', + 'collect', join(run.root, 'run.json'), journals], { cwd: resolve(import.meta.dirname, '../../..'), windowsHide: true, + env: Object.fromEntries(Object.entries(process.env) + .filter(([key]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$/iu.test(key))) }) + const output = JSON.parse(result.stdout) as { collection: string; operatorAcceptance: string } + expect(output.operatorAcceptance).toBe('pending') + expect(JSON.parse(await readFile(join(output.collection, 'report.json'), 'utf8'))).toMatchObject({ + evidence: { recordedFlow: 'incomplete', filesRead: 1 }, operatorAcceptance: 'pending', + }) + }) + }) +}) diff --git a/apps/desktop/tests/installed-update-runtime.spec.ts b/apps/desktop/tests/installed-update-runtime.spec.ts new file mode 100644 index 0000000000..80c7b79f30 --- /dev/null +++ b/apps/desktop/tests/installed-update-runtime.spec.ts @@ -0,0 +1,73 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createInstalledUpdateRun } from '../scripts/installed-update-qualification.ts' +import { prepareInstalledUpdateRuntime } from '../scripts/prepare-installed-update-runtime.ts' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const +const source = { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] } + +async function fixture(body: (directory: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-update-materials-')) + try { return await body(directory) } + finally { await rm(directory, { recursive: true, force: true }) } +} + +describe('installed-update runtime preparation', () => { + it('retags only release-owned packages and dependencies while preserving source and third-party versions', async () => { + await fixture(async (directory) => { + const original = join(directory, 'source') + runtimeFixture(original, source.version) + const before = await readFile(join(original, 'desktop-runtime.json')) + const run = await createInstalledUpdateRun(join(directory, 'runs'), versions, source) + const result = await prepareInstalledUpdateRuntime(join(run.root, 'run.json'), original) + expect(result).toMatchObject({ signed: false, bootTested: false }) + for (const version of versions) { + const runtime = join(run.root, version, 'dsh') + const descriptor = await verifyDesktopRuntime(runtime, version) + expect(descriptor.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis')?.version).toBe(source.version) + expect(descriptor.sharedPackages.find(entry => entry.name === '@deepseek-ai/dsh')?.version).toBe(version) + const metadata = JSON.parse(await readFile(join(runtime, 'node_modules/@deepseek-ai/dsh/package.json'), 'utf8')) as { + dependencies: Record + } + expect(metadata.dependencies['@deepseek-ai/dsh-desktop-host']).toBe(version) + expect(metadata.dependencies['@deepseek-ai/cordis']).toBe(source.version) + } + expect(await readFile(join(original, 'desktop-runtime.json'))).toEqual(before) + await expect(verifyDesktopRuntime(original, source.version)).resolves.toBeDefined() + await expect(prepareInstalledUpdateRuntime(join(run.root, 'run.json'), original)).rejects.toMatchObject({ code: 'EEXIST' }) + }) + }) + + it('stops after a failed version and retains partial materials without a completion receipt', async () => { + await fixture(async (directory) => { + const original = join(directory, 'source') + runtimeFixture(original, source.version) + const run = await createInstalledUpdateRun(join(directory, 'runs'), versions, source) + await mkdir(join(run.root, versions[1])) + await writeFile(join(run.root, versions[1], 'owner.txt'), 'do not overwrite') + await expect(prepareInstalledUpdateRuntime(join(run.root, 'run.json'), original)).rejects.toMatchObject({ code: 'EEXIST' }) + const files = await readdir(join(run.root, 'runtime-preparation')) + expect(files.sort()).toEqual(['failed.json', 'source.json', 'started.json']) + expect(JSON.parse(await readFile(join(run.root, 'runtime-preparation/failed.json'), 'utf8'))) + .toMatchObject({ failed: true, completedVersions: 1, retryAllowed: false }) + expect(await readFile(join(run.root, versions[1], 'owner.txt'), 'utf8')).toBe('do not overwrite') + }) + }) + + it('rejects stale or damaged prepared resources before copying either version', async () => { + await fixture(async (directory) => { + const original = join(directory, 'source') + runtimeFixture(original, source.version) + await writeFile(join(original, 'package.json'), '{}') + const run = await createInstalledUpdateRun(join(directory, 'runs'), versions, source) + await expect(prepareInstalledUpdateRuntime(join(run.root, 'run.json'), original)).rejects.toThrow('integrity') + expect((await readdir(run.root)).sort()).toEqual(['run.json', 'runtime-preparation']) + expect(JSON.parse(await readFile(join(run.root, 'runtime-preparation/failed.json'), 'utf8'))) + .toMatchObject({ failed: true, completedVersions: 0 }) + }) + }) +}) diff --git a/apps/desktop/tests/installed-update-signature.spec.ts b/apps/desktop/tests/installed-update-signature.spec.ts new file mode 100644 index 0000000000..94e52a1a89 --- /dev/null +++ b/apps/desktop/tests/installed-update-signature.spec.ts @@ -0,0 +1,122 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { installedUpdateFileHash, verifyInstalledUpdateSignature } from '../scripts/installed-update-signature.mjs' + +type Execute = (command: string, args: string[], options: { + env: NodeJS.ProcessEnv + windowsHide: boolean + timeout: number + shell?: boolean +}) => Promise<{ stdout: string; stderr: string }> +const calls = vi.hoisted(() => ({ execute: vi.fn() })) +vi.mock('node:child_process', async (original) => { + const actual = await original() + const { promisify } = await import('node:util') + return { ...actual, execFile: Object.assign(vi.fn(), { [promisify.custom]: calls.execute }) } +}) + +const details = { valid: true, timestamped: true, signer: 'A'.repeat(40), timestamp: 'B'.repeat(40) } +afterEach(() => { calls.execute.mockReset(); vi.unstubAllEnvs() }) + +async function fixture(body: (file: string, root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-signature-read-')) + try { + const file = join(root, "inert ' executable.exe") + await writeFile(file, 'inert bytes, never executed') + await body(file, root) + } finally { await rm(root, { recursive: true, force: true }) } +} + +function responses(): void { + calls.execute.mockResolvedValueOnce({ stdout: '{"updaterVerificationInvoked":true}', stderr: '' }) + .mockResolvedValueOnce({ stdout: JSON.stringify(details), stderr: '' }) +} + +describe('installed update read-only signature evidence', () => { + it('hashes actual bytes using the feed algorithm', async () => { + await fixture(async (file) => { + expect(await installedUpdateFileHash(file)).toBe(createHash('sha512').update(await readFile(file)).digest('base64')) + }) + }) + + it.runIf(process.platform !== 'win32')('refuses signature checks on non-Windows hosts without spawning', async () => { + await fixture(async (file, root) => { + await expect(verifyInstalledUpdateSignature(file, 'CN=Fixture', root)).rejects.toThrow('requires Windows') + expect(calls.execute).not.toHaveBeenCalled() + }) + }) + + it.runIf(process.platform === 'win32')('isolates verification processes and records only public attributes for unchanged bytes', async () => { + await fixture(async (file, root) => { + vi.stubEnv('DSH_DESKTOP_WINDOWS_TOKEN_PIN', 'fixture-pin') + vi.stubEnv('DOWNLOAD_TEST_COS_SECRET_KEY', 'fixture-key') + vi.stubEnv('PSModulePath', 'incompatible-powershell-modules') + vi.stubEnv('NODE_OPTIONS', '--require unused') + responses() + expect(await verifyInstalledUpdateSignature(file, 'CN=Fixture', root)).toEqual({ + sha512: await installedUpdateFileHash(file), valid: true, timestamped: true, + signerThumbprint: details.signer, timestampThumbprint: details.timestamp, updaterVerificationInvoked: true, + }) + expect(JSON.parse(await readFile(join(root, 'signature-config.json'), 'utf8'))).toEqual({ publisherName: ['CN=Fixture'] }) + expect(calls.execute.mock.calls[0]![0]).toBe(process.execPath) + expect(calls.execute.mock.calls[0]![1]).toEqual([expect.stringMatching(/installed-update-signature\.mjs$/u), '--updater', join(root, 'signature-config.json'), file]) + expect(calls.execute.mock.calls[1]![0]).toBe('powershell.exe') + expect(calls.execute.mock.calls[1]![1].join(' ')).not.toContain(file) + for (const [, , options] of calls.execute.mock.calls) { + const inheritedSecret = Object.keys(options.env) + .some(name => /KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^NODE_PATH$|^PSModulePath$/iu.test(name)) + expect(inheritedSecret).toBe(false) + expect(options).toMatchObject({ windowsHide: true, timeout: 60_000 }) + expect(options.shell).toBeUndefined() + } + expect(calls.execute.mock.calls[1]![2].env.DSH_VERIFY_FILE).toBe(file) + expect(await readFile(file, 'utf8')).toBe('inert bytes, never executed') + await expect(verifyInstalledUpdateSignature(file, 'CN=Fixture', root)).rejects.toMatchObject({ code: 'EEXIST' }) + expect(calls.execute).toHaveBeenCalledTimes(2) + }) + }) + + it.runIf(process.platform === 'win32').each(['rejected', 'unconfirmed', 'malformed'])( + 'stops on an %s updater result without reading the timestamp', async (failure) => { + await fixture(async (file, root) => { + if (failure === 'rejected') calls.execute.mockRejectedValueOnce(new Error('signature rejected')) + else calls.execute.mockResolvedValueOnce({ stdout: failure === 'malformed' ? 'not JSON' : '{}', stderr: '' }) + await expect(verifyInstalledUpdateSignature(file, 'CN=Fixture', root)).rejects.toThrow() + expect(calls.execute).toHaveBeenCalledTimes(1) + }) + }) + + it.runIf(process.platform === 'win32').each([ + { ...details, valid: false }, { ...details, timestamped: false }, { ...details, signer: 'invalid' }, + { ...details, timestamp: null }, + ])('refuses invalid public signature attributes %j', async (invalid) => { + await fixture(async (file, root) => { + calls.execute.mockResolvedValueOnce({ stdout: '{"updaterVerificationInvoked":true}', stderr: '' }) + .mockResolvedValueOnce({ stdout: JSON.stringify(invalid), stderr: '' }) + await expect(verifyInstalledUpdateSignature(file, 'CN=Fixture', root)).rejects.toThrow('valid timestamped signature') + expect(calls.execute).toHaveBeenCalledTimes(2) + }) + }) + + it.runIf(process.platform === 'win32')('rejects PowerShell errors even when stdout describes valid attributes', async () => { + await fixture(async (file, root) => { + calls.execute.mockResolvedValueOnce({ stdout: '{"updaterVerificationInvoked":true}', stderr: '' }) + .mockResolvedValueOnce({ stdout: JSON.stringify(details), stderr: 'module load failed' }) + await expect(verifyInstalledUpdateSignature(file, 'CN=Fixture', root)).rejects.toThrow('valid timestamped signature') + }) + }) + + it.runIf(process.platform === 'win32')('rejects a file changed while verification was running', async () => { + await fixture(async (file, root) => { + calls.execute.mockResolvedValueOnce({ stdout: '{"updaterVerificationInvoked":true}', stderr: '' }) + .mockImplementationOnce(async () => { + await writeFile(file, 'changed bytes') + return { stdout: JSON.stringify(details), stderr: '' } + }) + await expect(verifyInstalledUpdateSignature(file, 'CN=Fixture', root)).rejects.toThrow('file changed') + }) + }) +}) diff --git a/apps/desktop/tests/installed-update/README.i18n.yaml b/apps/desktop/tests/installed-update/README.i18n.yaml new file mode 100644 index 0000000000..8ea70c5cbc --- /dev/null +++ b/apps/desktop/tests/installed-update/README.i18n.yaml @@ -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/tests/installed-update/README.md +README.md: 139a80246d6539898cc870129fe55ad726b2673d +README.zh.md: 2b140cceb37ac104c1984c35322da0814ac365b7 diff --git a/apps/desktop/tests/installed-update/README.md b/apps/desktop/tests/installed-update/README.md new file mode 100644 index 0000000000..139a80246d --- /dev/null +++ b/apps/desktop/tests/installed-update/README.md @@ -0,0 +1,132 @@ +--- +description: "Operator checklist for a Windows installed update with a failed download, explicit retry, and retained evidence." +--- + +# Windows installed-update walkthrough + +English | [中文](README.zh.md) + +## Summary + +Prepare a private test application and fresh test distribution namespace, then let the operator install, inject a fault, retry, and confirm restart. A local manifest or complete journal sequence does not certify an installer or preserved data. The installed-app steps below remain unverified until the operator performs them with verified packages. + +## Table of Contents + +- [Prepare materials](#prepare) +- [Operator sequence](#sequence) +- [Evidence and recovery](#evidence) +- [Dev Note](#dev-note) + + + +## Prepare materials + +From the repository root with dependencies installed, allocate a run with two increasing derived test versions. This writes only an ignored local manifest; it does not build, sign, upload, install, or read credentials. + +The examples use base version `0.1.6-alpha.1`. Before creating new material, substitute the actual base, Asia/Shanghai date, and unused index according to the [release version rules](../../README.md#release-versions). + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 +``` + +Retain the returned `run.json` and reuse its random identity and `qualification/` paths for both packages. The source commit and dirty-file list identify the starting checkout, not the contents of a later package. Record final build source identifiers and artifact hashes separately; do not regenerate the manifest midway through an update. + +Prepare fresh source resources with the repository's preparation-only workflow, then generate the shared bootstrap, two private version-bound runtime copies, and frozen application files. Replace `` with the allocator's manifest path. Preparation-only builds and checks the source runtime but stops before signing; the other commands never invoke a signer or installer. These commands refuse to overwrite existing per-run outputs; retain partial material after an error instead of retrying over it. + +```powershell +node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run prepare:package win-x64 +node --import tsx apps/desktop/scripts/prepare-installed-update.ts bootstrap "" +node --import tsx apps/desktop/scripts/prepare-installed-update.ts runtime "" +node --import tsx apps/desktop/scripts/prepare-installed-update.ts application "" +``` + +Package both files from the generated `bootstrap` directory beside `lib/`, and set the package main entry to `qualification-bootstrap.mjs`. That entry validates the installed application ID and version before loading production main. It assigns Electron user/session data, Harness home, and journals under the operating system's application-data directory at `dsh-update-qualification//`; both versions use the same paths. The runtime copier changes only release-family package versions and matching dependency references, preserves third-party versions, and re-verifies both copied inventories and the untouched source. Its `runtime-preparation/result.json` records hashes and explicitly does not claim signing or startup qualification of the copied versions. These synthetic versions are test materials, not npm releases. + +The `application` command copies built main/preload modules, renderer files, and the prepared bootstrap into `application/files`, with SHA-256 values in `application/result.json`. It does not copy the app's root `.env.windows` or freeze `node_modules` and build tools. The [qualification builder configuration](../../scripts/installed-update-builder.ts) verifies this inventory and the selected private runtime, selects the shared frozen files and isolated package metadata, and preserves ordinary installer and signing hooks. Its tests substitute the signer and pass the pinned builder's configuration validator; they do not produce an installer. A separately authorized supervised build must still record dependency/tool inputs and verify final package contents and signatures. + +The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. Run this from the repository root with one exact version: + +```powershell +node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check +``` + +Actual packaging remains unverified and requires separate hardware-recovery approval and a present operator. The `--execute` mode requires a terminal and an exact confirmation containing the version and run ID; there is no piped approval option. It rechecks the interlock and exclusively allocates that version's `packaging` directory. The supervised child builds only that version, disables publication, strips unrelated credentials, and stops on failure or a 15-minute overall deadline. This deadline does not bound individual CSP authentication attempts. Records retain source/tool hashes, redacted output, events, and artifact file hashes; an existing attempt or output refuses reuse. `builderCompleted` and a successful supervisor result do not establish package verification, signature acceptance, or installation success; `packageVerification` remains `pending` until those checks are performed independently. + +Before the operator starts, supply both verified installers, generated metadata and blockmaps, packaging and signature records, the exact installed executable path, and fixed test feed URL. Both packages must use the same test identity, isolated data directories, and absolute external `DSH_DESKTOP_UPDATE_JOURNAL_DIR` on every launch, including an installer-triggered restart. A variable set only in the original launch terminal is insufficient. The [Windows signing rules](../../README.md#windows-ev-signing) still apply; a manifest never clears a signing interlock. + +The Windows [read-only signature helper](../../scripts/installed-update-signature.mjs) uses the real updater verifier with a publisher derived from the trusted public certificate, then requires valid Authenticode and timestamp attributes and unchanged SHA-512. Verification children receive no inherited signing/upload secrets or PowerShell module-path overrides. The helper never loads `.env.windows`, signs a file, or executes the inspected executable. Signed-probe acceptance and unsigned-installer rejection have been observed locally; they do not certify either prepared version, package identity, or installation behavior. + +The [package verifier](../../scripts/verify-installed-update-package.ts) checks the final installer before extracting it with a reviewed local 7-Zip executable. Supply absolute paths for the manifest, trusted public certificate, and tool; never select a tool extracted from the installer being verified. This command creates a fresh `verification/check-*` directory, preserves partial records, and fails immediately when the installer is absent: + +```powershell +node --import tsx apps/desktop/scripts/verify-installed-update-package.ts "" 0.1.6-alpha.1.20260916.1 "" "" +``` + +The verifier checks actual archived application identity, entry, frozen application bytes, updater dependency versions, feed/cache/publisher configuration, and the bundled Harness runtime against prepared inputs; the mandatory test policy must be valid. It extracts the runtime from `app.asar` and checks executable signatures at their `app.asar.unpacked` paths. Changed runtime executables require separate signature checks; other runtime bytes must match the prepared bytes after electron-builder's dependency-manifest transformation. It rejects unsafe archive paths before extraction and records installer, application, and runtime-executable signatures. Before success, it rechecks installer, feed/blockmap, manifest, certificate, and tool hashes. `passed` covers these checks only: dependency bytes are not frozen, and installer registration, startup, upgrade, and data retention remain explicit manual checks. Real archive reading has been observed on a retained older package; complete signed-package verification for the prepared versions remains pending. + +Follow the separate [upload and publication procedure](publication/README.md). Leave the fixed feed absent for the 404 case, then publish version 1 for the same-version case. Keep version 2 metadata local until both cases finish in the installed version 1 application. Retain publication records and remote objects for diagnosis; do not delete a published feed to recreate an earlier case. + +The local `files` command accepts `` and one exact run version. It reads that version's `installer/nightly.yml`, verifies the expected installer name, size, SHA-512, and nonempty external blockmap, and prints separate binary destinations and a fixed-feed body. Missing installers fail validation. It writes nothing and reads no credentials. Its `file-integrity-only` result cannot authorize upload or replace verification of signed package contents and application identity; those remain required before publication. + + + +## Operator sequence + +Proceed manually only after preparation passes. Use disposable test conversations and settings, never a production workspace or unsaved work. + +1. Install version 1. Record installer path, hash, signature result, and time. Start the installed shortcut and verify its executable path and displayed version; do not launch an unpacked build or development server. +2. Confirm version 1 `started` and `workspace-ready` in a new journal. Create an identifiable test conversation and change a harmless setting; record expected values privately. Complete the two feed checks below and keep version 1 running. + - **Missing feed:** retain a timestamped GET of the configured fixed URL returning 404. Observe startup automatic checking without a disruptive dialog, then manually check: expect checking followed by failure, not “no updates,” and no download or installation. Save screenshots and journals before publishing anything. + - **Same-version feed:** after separate authorization, publish version 1 to that same URL and retain the complete 200 response, version, and SHA-512. Check manually in the still-running version 1: expect no available update and its current version, no download or installation, and no stale error indicator from the 404 case. Automatic no-update checking must remain quiet. Retain evidence before authorizing version 2. +3. Authorize version 2 metadata publication at the existing fixed feed URL. Re-read that URL and verify version 2 and its binary hash. Preserve publication evidence after version 1 startup evidence. +4. In version 1, check manually and confirm availability without automatic download. Prepare the reviewed [application-scoped fault and recovery](network/README.md). Never disable the adapter, VPN, shared proxy, remote-control connection, or domain-wide access. +5. Click download and interrupt only the test application's transfer after progress begins. Capture one failure and persistent retry feedback; confirm no installation or silent retry. A connection failure before progress begins is a different observation and does not satisfy a transfer-interruption test. +6. Remove the exact run-owned fault and verify recovery. Click retry yourself. Record progress, verification, and readiness. Expect a separate installation confirmation; download completion alone must not approve restart. +7. Confirm installation. With active tasks, inspect the warning and explicitly authorize stopping them; without tasks, the confirmation must not claim tasks exist. Record the last version 1 window and let installation and restart finish without manually starting another instance. +8. Verify the installed version 2 executable and displayed version. Check the conversation and setting from step 2 and capture the result. Find version 2 `started` and `workspace-ready` in the same external evidence directory. Report missing automatic restart separately from successful manual startup; one cannot substitute for the other. + + + +## Evidence and recovery + +The intentional 404 is a check-failure case, not the later interrupted-download case. A browser response alone does not prove application behavior. Keep each observation's URL, UTC time, HTTP status, feed body/hash when present, screenshot, and journal snapshot together. The journal inspector does not certify the two feed cases or record raw HTTP diagnostics; the operator supplies those observations. A later successful manual check must recover without restarting the application. + +Retain the manifest, build records, original feeds, binary hashes, publication receipts and readbacks, fault setup/removal evidence, screenshots, and all process journals. Installer logs and test data may contain private paths or content; review before sharing. The inspector is read-only and copies only milestone references, not raw diagnostics. Replace the directory placeholder and use the manifest's exact versions: + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update.ts inspect 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 "" +``` + +Exit 0 means the ordered journal milestones are present; exit 2 means missing milestones; exit 1 means input or command validation failed. A failure/retry sequence cannot combine different version 1 processes. A successor started before the original process quit is not counted; clock changes can leave evidence incomplete and require investigation. `recordedFlow: complete` is not overall acceptance. The report always requires independent operator checks for publication timing, network failure and recovery, installer completion and path, preserved data, and screenshots and confirmations. + +To retain a local journal snapshot and its report, use `collect` with the matching installed application's `dsh-update-qualification//journals` directory. It validates all selected records before creating a fresh `evidence/collection-*` under the material run. The copied journals and `report.json` describe the same in-memory snapshot, with SHA-256 values and `operatorAcceptance: pending`; original files are unchanged. Other files, settings, conversations, build output, and credentials are not collected. Keep their separately reviewed evidence alongside the report, not inside the journal directory. + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update.ts collect "" "" +``` + +Collection exit 0 means files were saved, even if the recorded flow is incomplete; inspect `report.json` before drawing conclusions. Failed writes retain partial output and a failure marker when storage permits. Repeated collection creates another directory without replacing earlier evidence. Inspection and collection reject partial tails, unknown fields or diagnostic values, files over 10 MiB, and snapshots over 50 MiB. Collect after the relevant actions settle; a live application can append records after the snapshot, and a racing partial append requires a later collection rather than truncating the original log. Real installed-app collection remains an operator step; tests use the actual journal writer with synthetic lifecycle actions. + +Use this report template; leave unobserved results pending: + +| Item | Evidence location | Result | +|---|---|---| +| Both installers and signatures verified | | pending | +| Feed 404: automatic quiet failure and manual visible failure, no download | | pending | +| Version 1 feed: no available update in running version 1, prior error cleared | | pending | +| Version 1 running before version 2 publication | | pending | +| Test-only fault and observed download failure | | pending | +| Fault removed and explicit retry reached readiness | | pending | +| Separate confirmation and installer completion | | pending | +| Automatic restart into installed version 2 | | pending | +| Test conversation and setting preserved | | pending | +| Journals and screenshots retained | | pending | +| Test network changes absent afterward | | pending | + +Stop at the first failed prerequisite. Signing failure stops packaging and preserves its record; do not retry PIN authentication automatically. A feed mismatch stops the test before download. If a fault affects remote control, stop and restore only its owned change with the prepared recovery action. Do not uninstall, clear caches, delete evidence, or overwrite failed batches to make a later attempt appear successful. Allocate a new run for a clean attempt and retain the failed one. + + + +## Dev Note + +Local allocation and journal inspection have automated tests. Signed package production, isolated network-fault tooling, late feed publication, installer-triggered restart, and this full operator sequence require separate qualification; this page does not report them as completed. diff --git a/apps/desktop/tests/installed-update/README.zh.md b/apps/desktop/tests/installed-update/README.zh.md new file mode 100644 index 0000000000..2b140cceb3 --- /dev/null +++ b/apps/desktop/tests/installed-update/README.zh.md @@ -0,0 +1,132 @@ +--- +description: "Windows 已安装应用更新的人工清单,覆盖下载失败、显式重试与证据保留。" +--- + +# Windows 已安装应用更新演练 + +[English](README.md) | 中文 + +## 摘要 + +准备独立测试应用和全新的 test 分发命名空间,再由操作者亲手安装、注入故障、重试并确认重启。本地清单或完整日志序列不能证明安装器合格或数据保留。下述已安装应用步骤在操作者使用已验证安装包执行之前仍未验证。 + +## 目录 + +- [准备物料](#prepare) +- [人工操作顺序](#sequence) +- [证据与恢复](#evidence) +- [开发备注](#dev-note) + + + +## 准备物料 + +在已安装依赖的仓库根目录中,用两个递增的派生测试版本创建批次。这只写入已忽略的本地清单,不构建、签名、上传、安装或读取凭据。 + +示例以 `0.1.6-alpha.1` 为基础版本。创建新物料前,按[发布版本规则](../../README.zh.md#release-versions)替换为实际基础版本、北京时间日期和未使用的序号。 + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 +``` + +保留返回的 `run.json`,两个安装包复用其中的随机身份与 `qualification/` 路径。源提交与脏文件列表标识起始工作区,不代表后续安装包内容。最终构建来源和产物哈希另行记录;不要在更新中途重新生成清单。 + +通过仓库的仅准备流程生成新源码资源,再生成共享启动入口、两份独立的版本绑定运行时和冻结的应用文件。将 `` 替换为批次清单路径。仅准备流程构建并检查原始运行时,但在签名前停止;其余命令绝不调用签名器或安装器。这些命令拒绝覆盖已有批次产物;出错后保留不完整物料,不在其上重试。 + +```powershell +node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run prepare:package win-x64 +node --import tsx apps/desktop/scripts/prepare-installed-update.ts bootstrap "" +node --import tsx apps/desktop/scripts/prepare-installed-update.ts runtime "" +node --import tsx apps/desktop/scripts/prepare-installed-update.ts application "" +``` + +将生成的 `bootstrap` 目录中的两个文件放在包内 `lib/` 同级,将包主入口设为 `qualification-bootstrap.mjs`。该入口在加载生产主程序前验证已安装应用的 ID 与版本。它将 Electron 用户/会话数据、Harness home 和日志放在操作系统应用数据目录下的 `dsh-update-qualification//`;两个版本使用相同路径。运行时复制器仅修改发布家族包版本和对应依赖引用,保留第三方版本,并重新校验两个副本清单和未修改的原始资源。其 `runtime-preparation/result.json` 记录哈希,并明确不宣称签名或副本版本的启动验收通过。这些合成版本属于测试物料,不是 npm 发布版本。 + +`application` 命令将已构建的主程序/预加载模块、界面文件和准备好的启动入口复制到 `application/files`,并在 `application/result.json` 中记录 SHA-256。它不复制应用根目录的 `.env.windows`,也不冻结 `node_modules` 与构建工具。[验收打包配置](../../scripts/installed-update-builder.ts)校验该清单与选定的独立运行时,选用共享冻结文件和隔离包元数据,并保留常规安装器与签名钩子。其测试替换签名器,并通过固定版本构建器的配置校验;测试不生成安装器。另行授权的受监督构建仍须记录依赖/工具输入,并验证最终包内容及签名。 + +[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。在仓库根目录使用一个准确版本执行: + +```powershell +node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check +``` + +实际打包仍未验证,必须另行获得硬件恢复授权并有操作者在场。`--execute` 模式要求终端和包含版本、批次 ID 的准确确认,没有管道批准选项。它再次检查保护锁,并独占创建该版本的 `packaging` 目录。受监督子进程只构建该版本、禁用发布、移除无关凭据,失败或达到 15 分钟整体期限时停止。该期限不限制单次 CSP 内部认证尝试。记录保留源码/工具哈希、脱敏输出、事件和产物文件哈希;已有尝试或输出拒绝复用。`builderCompleted` 与监督程序成功结果不证明包验证、验签或安装成功;独立完成这些检查前,`packageVerification` 保持 `pending`。 + +操作者开始前,提供两个已验证安装包、生成的元数据与 blockmap、打包和签名记录、准确的安装后可执行文件路径与固定 test feed URL。两个安装包必须使用相同的测试身份、隔离数据目录,并在每次启动时设置同一个安装目录外的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,包括安装器触发的重启。仅在首次启动终端中设置变量是不够的。[Windows 签名规则](../../README.zh.md#windows-ev-signing)仍适用;清单绝不解除签名保护锁。 + +Windows [只读签名检查器](../../scripts/installed-update-signature.mjs)使用真实 updater 验签,发布者来自可信公钥证书,随后要求 Authenticode 与时间戳属性有效且 SHA-512 未变。验签子进程不继承签名/上传秘密或 PowerShell 模块路径覆盖。检查器绝不加载 `.env.windows`、签名文件或执行被检查的可执行文件。本地已观察到签名探针通过、未签名安装包被拒绝;这些结果不能证明任一准备版本、包身份或安装行为已验收。 + +[安装包检查器](../../scripts/verify-installed-update-package.ts)先校验最终安装包,再使用经过审查的本地 7-Zip 可执行文件解包。清单、可信公钥证书和工具均须提供绝对路径;绝不选择从待验安装包中解出的工具。此命令创建全新的 `verification/check-*` 目录,保留部分记录,安装包缺失时立即失败: + +```powershell +node --import tsx apps/desktop/scripts/verify-installed-update-package.ts "" 0.1.6-alpha.1.20260916.1 "" "" +``` + +检查器核对实际归档内的应用身份、入口、冻结应用字节、updater 依赖版本、feed/缓存/发布者配置,并将内置 Harness 运行时与准备输入比较;测试强更策略也必须有效。它从 `app.asar` 提取运行时,并在 `app.asar.unpacked` 路径核对可执行文件签名。发生变化的运行时可执行文件须单独验签;其他运行时字节在经过 electron-builder 的依赖 manifest 转换后必须与准备输入一致。它在解包前拒绝不安全归档路径,记录安装器、应用及运行时可执行文件的签名。成功前再次核对安装包、feed/blockmap、清单、证书和工具哈希。`passed` 仅覆盖这些检查:依赖字节未冻结,安装器注册、启动、升级和数据保留仍是明确的人工检查项。保留的旧包已完成真实归档读取;本批次准备版本的完整签名包检查仍待执行。 + +遵循独立的[上传与发布步骤](publication/README.zh.md)。先保持固定 feed 不存在以测试 404,再发布版本 1 以测试同版本。两个场景在已安装的版本 1 应用内完成之前,版本 2 元数据保留在本地。保留发布记录及远端对象用于诊断,不删除已发布的 feed 来重建较早场景。 + +本地 `files` 命令接受 `` 与本批次的一个准确版本。它读取该版本的 `installer/nightly.yml`,验证预期安装包文件名、大小、SHA-512 和非空外部 blockmap,输出分开的二进制目标与固定 feed 内容。安装包缺失会导致验证失败。它不写文件,也不读取凭据。其 `file-integrity-only` 结果不能授权上传,也不能替代签名包内容和应用身份验证;这些仍是发布前的必要条件。 + + + +## 人工操作顺序 + +仅在准备通过后亲手操作。使用可丢弃的测试会话和设置,不使用生产工作区或未保存的工作。 + +1. 安装版本 1。记录安装包路径、哈希、签名结果和时间。通过安装后的快捷方式启动,核对可执行文件路径与界面版本;不要启动解包目录中的应用或开发服务器。 +2. 确认新日志包含版本 1 的 `started` 和 `workspace-ready`。创建可辨认的测试会话并修改一项无害设置,在本地记录预期值。完成下面两项 feed 检查,并保持版本 1 运行。 + - **清单缺失:**保留对配置的固定 URL 执行 GET 返回 404 的带时间戳记录。观察启动自动检查不弹出打扰性弹窗,再手动检查:预期先检查、后提示失败,而非“暂无更新”,且不下载、不安装。发布任何清单前先保存截图和日志。 + - **同版本清单:**另行授权后将版本 1 发布到相同 URL,保留完整的 200 响应、版本与 SHA-512。在仍运行的版本 1 内手动检查:预期显示暂无可用更新及当前版本,不下载、不安装,且不残留此前 404 的错误图标。自动检查无更新时应静默。授权版本 2 前先保留证据。 +3. 授权将版本 2 元数据发布到已有的固定 feed URL。回读该 URL,验证版本 2 及其二进制哈希。保留晚于版本 1 启动证据的发布证据。 +4. 在版本 1 中手动检查,确认提示可用版本但不自动下载。准备经过审查的[仅针对测试应用的故障与恢复](network/README.zh.md)。绝不禁用网卡、VPN、共享代理、远控连接或整个域名的访问。 +5. 点击下载,在进度开始后仅中断测试应用的传输。截取一次失败与持续重试提示,确认不安装或静默重试。进度开始前的连接失败是另一种观测,不能满足传输中断测试。 +6. 移除本批次的准确故障设置并验证恢复。亲手点击重试。记录进度、校验和就绪。预期出现独立安装确认;下载完成本身不得授权重启。 +7. 确认安装。有任务时检查警告并显式授权停止;没有任务时,确认内容不得声称存在任务。记录版本 1 最后的窗口,让安装和重启自然结束,不手动启动另一实例。 +8. 验证安装后的版本 2 可执行文件与界面版本。检查第 2 步的会话和设置并截图。在同一外部证据目录中找到版本 2 的 `started` 和 `workspace-ready`。自动重启缺失与手动启动成功分开报告,二者不能互相替代。 + + + +## 证据与恢复 + +故意保留的 404 属于检查失败,不是后续的下载中断场景。仅有浏览器响应不能证明应用行为。每次观测的 URL、UTC 时间、HTTP 状态、存在时的 feed 原文及哈希、截图和日志快照应一同保存。日志检查器不认证这两个 feed 场景,也不记录原始 HTTP 诊断;这些观察结果由操作者提供。后续手动检查成功应无需重启应用即可恢复。 + +保留清单、构建记录、原始 feed、二进制哈希、发布回执与回读、故障设置和移除证据、截图及全部进程日志。安装器日志与测试数据可能包含私有路径或内容,分享前须审查。检查器只读,仅复制里程碑引用,不复制原始诊断。替换目录占位符并使用清单中的准确版本: + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update.ts inspect 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 "" +``` + +退出码 0 表示按序日志标记齐全;退出码 2 表示缺少标记;退出码 1 表示输入或命令验证失败。失败重试序列不能拼接不同版本 1 进程。早于原进程退出就启动的后继进程不计入;系统时间变化可能导致证据不完整,需要调查。`recordedFlow: complete` 不等于整体验收通过。报告始终要求独立人工检查发布时间、网络失败与恢复、安装器完成与路径、数据保留以及截图和确认。 + +使用 `collect` 及匹配安装应用的 `dsh-update-qualification//journals` 目录保存本地日志快照与报告。它先验证所有选中记录,再在物料批次下创建新的 `evidence/collection-*`。复制的日志与 `report.json` 描述同一份内存快照,包含 SHA-256 和 `operatorAcceptance: pending`;原文件保持不变。不收集其他文件、设置、会话、构建输出或凭据。将这些另外审查过的证据与报告一同保留,不放进日志目录。 + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update.ts collect "" "" +``` + +收集退出码 0 表示文件已保存,即使记录流程不完整;下结论前检查 `report.json`。写入失败时保留部分输出,并在存储允许时留下失败标记。重复收集创建新目录,不替换旧证据。检查和收集拒绝不完整结尾、未知字段或诊断值、超过 10 MiB 的文件及超过 50 MiB 的快照。相关操作稳定后再收集;运行中的应用可能在快照之后追加记录,竞争中的不完整追加要求稍后再收集,不能截断原日志。真实安装应用的收集仍需人工执行;测试使用真实日志写入器和合成生命周期操作。 + +使用下表记录结果,未观测项保留待验证: + +| 项目 | 证据位置 | 结果 | +|---|---|---| +| 两个安装包及签名已验证 | | 待验证 | +| feed 404:自动静默失败、手动可见失败且不下载 | | 待验证 | +| 版本 1 feed:运行中的版本 1 无可用更新,旧错误已清除 | | 待验证 | +| 版本 1 运行早于版本 2 发布 | | 待验证 | +| 仅测试应用故障且观察到下载失败 | | 待验证 | +| 故障移除后显式重试达到就绪 | | 待验证 | +| 独立确认与安装器完成 | | 待验证 | +| 自动重启到已安装的版本 2 | | 待验证 | +| 测试会话与设置保留 | | 待验证 | +| 日志与截图保留 | | 待验证 | +| 结束后不存在测试网络改动 | | 待验证 | + +首个前置条件失败时即停止。签名失败终止打包并保留记录,不自动重试 PIN 认证。feed 不符则在下载前停止测试。故障影响远控时停止,并通过准备好的恢复动作仅还原其拥有的改动。不要卸载、清缓存、删除证据或覆盖失败批次,让后续尝试看起来成功。需要全新尝试时新建批次,并保留失败批次。 + + + +## 开发备注 + +本地批次创建与日志检查已有自动化测试。签名产物生成、隔离网络故障工具、延后 feed 发布、安装器触发重启和完整人工流程仍需单独验收;本页不将其报告为已完成。 diff --git a/apps/desktop/tests/installed-update/network/README.i18n.yaml b/apps/desktop/tests/installed-update/network/README.i18n.yaml new file mode 100644 index 0000000000..cc8eafa073 --- /dev/null +++ b/apps/desktop/tests/installed-update/network/README.i18n.yaml @@ -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/tests/installed-update/network/README.md +README.md: 51b75c9985de8a01274ffee504c778e581adbfe3 +README.zh.md: 2a1003f980a8eb729a2443081c4728eb829adcfb diff --git a/apps/desktop/tests/installed-update/network/README.md b/apps/desktop/tests/installed-update/network/README.md new file mode 100644 index 0000000000..51b75c9985 --- /dev/null +++ b/apps/desktop/tests/installed-update/network/README.md @@ -0,0 +1,58 @@ +--- +description: "Operator-owned Windows network fault and recovery for one verified installed update test application." +--- + +# Test-application network fault + +English | [中文](README.zh.md) + +## Summary + +Interrupt only the installed qualification application's outbound traffic, then remove that exact rule before retrying the update. The tools never disable an adapter, VPN, proxy, or firewall profile. Real traffic interruption remains an operator observation; tests substitute all firewall cmdlets. + +## Table of Contents + +- [Prepare](#prepare) +- [Block and restore](#operate) +- [Evidence and limitations](#evidence) +- [Dev Note](#dev-note) + + + +## Prepare + +Complete version 1 package verification and install that version through the [walkthrough](../README.md). Supply the installed executable, not the extracted payload. From the repository root, prepare its plan: + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update-network.ts "" "" "" +``` + +The preparer requires version 1 identity and signature evidence, the exact test filename, and matching executable bytes. It resolves Windows short paths and refuses executables inside the material run. It exclusively writes `network-fault/plan.json` without changing network state. Retain the original plan; it identifies the one removable rule. Installation registration still requires independent operator verification. + + + +## Block and restore + +These are pending manual steps, not a report of real firewall qualification. Use an administrator terminal and prepare a second terminal with the Restore command before blocking. Each invocation writes a new record. `Status` is the default and does not change firewall state. The execution-policy option affects only this PowerShell process; it does not change system policy. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File apps/desktop/scripts/installed-update-network.ps1 -Plan "" -Action Status +powershell.exe -NoProfile -ExecutionPolicy Bypass -File apps/desktop/scripts/installed-update-network.ps1 -Plan "" -Action Block +powershell.exe -NoProfile -ExecutionPolicy Bypass -File apps/desktop/scripts/installed-update-network.ps1 -Plan "" -Action Restore +``` + +Start Block, leave its confirmation waiting, then start the download. After positive progress, type the displayed `BLOCK ` confirmation. The script rechecks executable bytes and rule absence before creating one program-specific outbound block. Observe and capture an actual failed download; rule creation alone is insufficient. Run Restore and type `RESTORE ` to remove the matching rule, then click retry yourself. Status must report the rule absent. Never change unrelated rules to force a failure. + + + +## Evidence and limitations + +The script flushes timestamped stages and final success/failure into `network-fault/records//events.jsonl`. It stops on errors and never retries or silently removes a rule after failed creation. Run Restore explicitly after any uncertain result. Recovery validates the rule's name, ownership description, direction, action, and exact program path; it does not require the executable to still exist. An absent rule is a safe no-op; a foreign matching rule is not removed. + +Windows supports [program-specific outbound rules](https://learn.microsoft.com/en-us/windows/security/operating-system-security/network-security/windows-firewall/configure-with-command-line). Local policy, existing connections, and a download finishing before confirmation can prevent the intended interruption. A rule in PersistentStore does not prove effective traffic blocking. Keep the application open and observe its failure and subsequent successful retry; if either is absent, record the attempt as incomplete. The rule persists until removed, including across restart. Keep the plan and recovery terminal available; never install the update with the test rule still present. + + + +## Dev Note + +Local plan validation and the actual PowerShell control flow have inert tests. Real rule creation, effective interruption, and recovery require the operator's authorized installed-app run. diff --git a/apps/desktop/tests/installed-update/network/README.zh.md b/apps/desktop/tests/installed-update/network/README.zh.md new file mode 100644 index 0000000000..2a1003f980 --- /dev/null +++ b/apps/desktop/tests/installed-update/network/README.zh.md @@ -0,0 +1,58 @@ +--- +description: "由操作者控制的 Windows 网络故障与恢复,仅针对已验证的安装版更新测试应用。" +--- + +# 测试应用网络故障 + +[English](README.md) | 中文 + +## 摘要 + +仅中断已安装验收应用的出站流量,再移除该准确规则后重试更新。工具绝不禁用网卡、VPN、代理或防火墙配置文件。真实流量中断仍须由操作者观察;测试替换了全部防火墙命令。 + +## 目录 + +- [准备](#prepare) +- [阻断与恢复](#operate) +- [证据与局限](#evidence) +- [开发备注](#dev-note) + + + +## 准备 + +按照[演练指南](../README.zh.md)完成版本 1 安装包验证并安装该版本。提供已安装可执行文件,而非解包产物。在仓库根目录准备计划: + +```powershell +node --import tsx apps/desktop/scripts/prepare-installed-update-network.ts "" "" "" +``` + +准备器要求版本 1 身份与签名证据、准确的测试文件名以及匹配的可执行文件字节。它解析 Windows 短路径,拒绝物料批次目录内的可执行文件。它独占写入 `network-fault/plan.json`,不改变网络状态。保留原始计划,它标识唯一可移除的规则。安装注册仍须操作者独立验证。 + + + +## 阻断与恢复 + +以下是待执行的人工步骤,不是真实防火墙验收通过报告。使用管理员终端,并在阻断前准备第二个终端及 Restore 命令。每次调用写入新记录。`Status` 是默认动作,不改变防火墙状态。执行策略选项只影响本次 PowerShell 进程,不修改系统策略。 + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File apps/desktop/scripts/installed-update-network.ps1 -Plan "" -Action Status +powershell.exe -NoProfile -ExecutionPolicy Bypass -File apps/desktop/scripts/installed-update-network.ps1 -Plan "" -Action Block +powershell.exe -NoProfile -ExecutionPolicy Bypass -File apps/desktop/scripts/installed-update-network.ps1 -Plan "" -Action Restore +``` + +启动 Block,停留在确认提示,再开始下载。出现正进度后,输入提示中的 `BLOCK ` 确认。脚本再次检查可执行文件字节与规则不存在,再创建一条指定程序的出站阻断规则。观察并截取实际下载失败;仅创建规则不够。执行 Restore 并输入 `RESTORE `,移除匹配规则,再亲手点击重试。Status 必须报告规则不存在。绝不修改无关规则来强行制造失败。 + + + +## 证据与局限 + +脚本将带时间的阶段及最终成功/失败刷入 `network-fault/records//events.jsonl`。出错即停,绝不重试,也不在创建失败后静默删除规则。结果不确定时显式运行 Restore。恢复检查规则名、归属说明、方向、动作和准确程序路径,不要求可执行文件仍存在。规则不存在时安全地不操作;不会移除匹配名称的外来规则。 + +Windows 支持[指定程序的出站规则](https://learn.microsoft.com/en-us/windows/security/operating-system-security/network-security/windows-firewall/configure-with-command-line)。本地策略、已有连接或确认前下载已结束,都可能使预期中断没有发生。PersistentStore 中有规则不证明流量确实被阻断。保持应用打开,观察其失败以及后续成功重试;任一现象缺失都将本次尝试记录为未完成。规则会持续存在直至移除,重启也不会自动删除。保留计划和恢复终端;测试规则仍存在时绝不安装更新。 + + + +## 开发备注 + +本地计划验证与实际 PowerShell 控制流程已有无网络改动测试。真实规则创建、有效中断与恢复须由操作者在获准的已安装应用演练中执行。 diff --git a/apps/desktop/tests/installed-update/publication/README.i18n.yaml b/apps/desktop/tests/installed-update/publication/README.i18n.yaml new file mode 100644 index 0000000000..22d7b5eb5c --- /dev/null +++ b/apps/desktop/tests/installed-update/publication/README.i18n.yaml @@ -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/tests/installed-update/publication/README.md +README.md: ae91020f4d14cb6d9190562c873afc5ee2c7d072 +README.zh.md: fa119a23718dedc341bc0d6150ef22b902696251 diff --git a/apps/desktop/tests/installed-update/publication/README.md b/apps/desktop/tests/installed-update/publication/README.md new file mode 100644 index 0000000000..ae91020f4d --- /dev/null +++ b/apps/desktop/tests/installed-update/publication/README.md @@ -0,0 +1,72 @@ +--- +description: "Separate test binary uploads from operator-authorized fixed-feed publication for installed Windows update qualification." +--- + +# Test update upload and publication + +English | [中文](README.zh.md) + +## Summary + +Upload both verified versions without advertising version 2, then publish its fixed feed after the operator starts installed version 1. Default commands check local materials without credentials or network access. Actual COS writes require separate operator authorization and verification. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Operator sequence](#sequence) +- [Evidence and recovery](#evidence) +- [Dev Note](#dev-note) + + + +## Prerequisites + +Complete both package verifications in the [walkthrough](../README.md). Retain successful `verification/check-*/result.json` receipts and unchanged materials. The publisher revalidates each receipt against current installer, blockmap, feed, and manifest bytes. It never signs or installs anything. + +The existing `.env.windows` loader supplies `DSH_DESKTOP_AUTO_UPDATE_ENV=test`, `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`, `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602`, and `DOWNLOAD_TEST_COS_SECRET_ID` / `DOWNLOAD_TEST_COS_SECRET_KEY`. Only those two upload secrets enter the COS client; never put them in commands or records. The transport restricts requests to the test bucket and private Windows qualification paths. + +The binary-upload operation adds a bucket-versioning query and refuses writes unless the query succeeds with neither enabled nor suspended status. Feed publication reuses a successful binary-upload receipt instead of repeating that query or downloading binaries. COS documents that [`x-cos-forbid-overwrite`](https://cloud.tencent.cn/document/product/436/7749) does not protect objects in versioned buckets. A denied query leaves that configuration unknown; it does not establish that uploads are denied. + +Use one publisher across all machines. A local `publication.lock` excludes overlapping operations sharing the material directory, not other machines. Version 2 publication checks the existing version 1 feed before replacement; this read and write are not an atomic compare-and-swap. No other publisher may write the same feed concurrently. + + + +## Operator sequence + +These are pending manual steps, not completed remote qualification. Run from the repository root with the retained manifest, exact version, and matching verification receipt. Without `--execute`, both commands print a local plan and send no request: + +```powershell +node --import tsx apps/desktop/scripts/publish-installed-update.ts upload-binaries "" "" "" +node --import tsx apps/desktop/scripts/publish-installed-update.ts publish-feed "" "" "" +``` + +1. Check both versions locally. For each separately authorized upload, append `--execute` to `upload-binaries` and type `UPLOAD ` in an interactive terminal. Installer and blockmap uploads never publish a feed. Matching existing binaries are read back, not overwritten; different bytes stop the operation. +2. Leave the fixed feed absent. Install and start version 1 through its installed shortcut; confirm its displayed version and journal `workspace-ready`. Complete the walkthrough's 404 case before authorizing any feed write. Keep version 1 running. +3. Authorize version 1 `publish-feed` with `--execute` and `PUBLISH `. Its binaries must already exist and pass public readback. The initial feed must be absent or contain the exact version 1 bytes. After full feed readback, complete the same-version case in the running version 1 application; retain evidence and obtain separate authorization before publishing version 2. +4. Authorize version 2 publication with the command below and its exact confirmation. Supply the original installed application's `dsh-update-qualification//journals` directory. Startup evidence and the expected previous feed are required. The operator independently verifies the installed path and that version 1 is still running. + +```powershell +node --import tsx apps/desktop/scripts/publish-installed-update.ts publish-feed "" "" "" --journals "" --execute +``` + +Continue the walkthrough only after successful publication and public readback. The tool uses fixed public URLs without cache-busting parameters, uploads with `Cache-Control: no-store`, and verifies returned bytes and hashes. Binary uploads retain complete public readback. Feed publication reads only the feed remotely and requires a successful `upload-binaries` result with an exactly matching local plan under this run's `publication-records/operation-*`; missing, failed, or mismatched receipts stop publication. The publication result records the reused receipt path and receipt/plan hashes. This proves prior delivery, not continued availability of immutable objects; do not delete or replace those objects. SDK writes have no automatic retries. + + + +## Evidence and recovery + +Each operation retains `publication-records/operation-*`: plan, timestamped stages, final result, and feed body when applicable. Records include safe request IDs and available HTTP status, not raw SDK errors or authorization headers. Failure stops later writes; a timeout does not prove that the server rejected an earlier write. Preserve records and remote objects, inspect the last stage and actual remote state, and obtain separate confirmation before another operation. + +Diagnose `403 AccessDenied` by the exact API: `GetBucketVersioning`, `GetObject`, and `PutObject` require distinct permissions. Before requesting broader access or changing CDN settings, compare retained successful runs and check the failing operation with the current credentials. An added configuration query must not be reported as an upload failure when no upload was attempted. Successful reads alone do not prove current write permission; prefer reviewing an unnecessary new preflight over expanding a least-privilege upload identity. + +A manually requested later operation verifies matching objects without rewriting them. `alreadyPublished: true` means reconciliation of an existing desired feed, not a new publication; use the original operation's evidence for publication timing. Unexpected feed bytes stop the operation, including attempted replacement of version 2 with version 1. A crash can leave `publication.lock`; confirm no publisher remains and preserve failed records before an operator removes that exact empty directory. Never delete a lock to bypass an active publisher. + +Qualification COS version queries have a 30-second total deadline, and object reads and PUTs have a 15-minute total deadline. Expiration aborts the underlying HTTP requests and waits for closure before releasing the operation lock; ongoing transfer activity does not extend the budget. + + + +## Dev Note + +Sequencing tests use an in-memory store; transport tests exercise real SDK serialization with a substituted HTTP handler. Neither certifies COS writes. On 2026-09-14, the current credentials read the retained test probe with HTTP 200 and matching SHA-512, while `GetBucketVersioning` returned 403; no PUT was attempted. A separate 2026-09-11 probe verified test uploads, fixed-feed refresh, and updater download. TODO: review the extra bucket-configuration prerequisite without weakening namespace isolation, integrity checks, or fail-stop behavior; its removal is not implemented by this documentation correction. + +The later operator-authorized run `installed-update-r5dYNH` retains `object-overwrite-probe/result.json` and `binary-upload/result.json` under `.desktop-build/qualification/`. Creating a new 49-byte test object succeeds; a second PUT of identical bytes with overwrite forbidden returns `409 FileAlreadyExists`, and origin/public hashes remain unchanged. A fixed-run binary-only driver then uploads both signed installers and their blockmaps with the same header, revalidates package receipts, and verifies all four complete public responses. It records four successful new-object PUTs, no retries, no feed or configuration writes, and keeps the probe. A HEAD observation of the private feed URL returns 404. This operator run uses observed object-level protection instead of querying bucket configuration; the general CLI prerequisite above remains unchanged. These receipts establish test-object delivery, not feed publication, installed startup, or an installed upgrade. diff --git a/apps/desktop/tests/installed-update/publication/README.zh.md b/apps/desktop/tests/installed-update/publication/README.zh.md new file mode 100644 index 0000000000..fa119a2371 --- /dev/null +++ b/apps/desktop/tests/installed-update/publication/README.zh.md @@ -0,0 +1,72 @@ +--- +description: "将测试二进制上传与人工授权的固定 feed 发布分离,用于 Windows 安装版更新验收。" +--- + +# 测试更新上传与发布 + +[English](README.md) | 中文 + +## 摘要 + +上传两个已验证版本而不公布版本 2,待操作者启动已安装的版本 1 后才发布版本 2 的固定 feed。默认命令检查本地物料,不读取凭据或联网。实际 COS 写入需要另行获得人工授权并验证。 + +## 目录 + +- [前提条件](#prerequisites) +- [人工操作顺序](#sequence) +- [证据与恢复](#evidence) +- [开发备注](#dev-note) + + + +## 前提条件 + +完成[演练指南](../README.zh.md)中的两个安装包验证。保留通过的 `verification/check-*/result.json` 回执及未修改物料。发布工具将回执与当前安装包、blockmap、feed 和清单字节重新核对。它绝不签名或安装任何文件。 + +现有 `.env.windows` 加载器提供 `DSH_DESKTOP_AUTO_UPDATE_ENV=test`、`DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`、`DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及 `DOWNLOAD_TEST_COS_SECRET_ID` / `DOWNLOAD_TEST_COS_SECRET_KEY`。只有这两个上传秘密进入 COS 客户端;绝不放入命令或记录。传输实现将请求限制在 test 存储桶和独立的 Windows 验收路径内。 + +二进制上传操作额外查询存储桶版本控制;查询成功且既非启用也非暂停状态之前,它拒绝写入。feed 发布复用成功的二进制上传回执,不重复查询桶配置或下载安装包。COS 文档说明 [`x-cos-forbid-overwrite`](https://cloud.tencent.cn/document/product/436/7749) 无法保护开启版本控制的存储桶中的对象。查询被拒绝仅意味着该配置未知,不能证明上传被拒绝。 + +所有机器只使用一个发布者。本地 `publication.lock` 排除共享物料目录的重叠操作,不限制其他机器。版本 2 发布在替换前检查现有版本 1 feed;该读取与写入不是原子的比较并交换操作。其他发布者不得并发写入同一个 feed。 + + + +## 人工操作顺序 + +以下是待人工执行的步骤,不是已完成的远端验收。在仓库根目录使用保留的清单、准确版本及匹配的验包回执执行。不加 `--execute` 时,两个命令都只打印本地计划,不发送请求: + +```powershell +node --import tsx apps/desktop/scripts/publish-installed-update.ts upload-binaries "" "" "" +node --import tsx apps/desktop/scripts/publish-installed-update.ts publish-feed "" "" "" +``` + +1. 先在本地检查两个版本。每次单独授权上传后,给 `upload-binaries` 加上 `--execute`,在交互终端输入 `UPLOAD `。上传安装包和 blockmap 绝不发布 feed。已存在且匹配的二进制仅回读、不覆盖;字节不符立即停止。 +2. 保持固定 feed 不存在。安装版本 1 并通过安装后的快捷方式启动,确认显示版本和日志中的 `workspace-ready`。授权任何 feed 写入前,先完成演练指南中的 404 场景。保持版本 1 运行。 +3. 给版本 1 的 `publish-feed` 加上 `--execute`,输入 `PUBLISH ` 授权发布。其二进制必须已存在并通过公网回读。初始 feed 必须不存在,或包含完全一致的版本 1 字节。完整回读 feed 后,在仍运行的版本 1 应用内完成同版本场景;保留证据,另行授权后才发布版本 2。 +4. 使用下述命令及其准确确认授权版本 2 发布。提供原安装应用的 `dsh-update-qualification//journals` 目录。要求启动证据及符合预期的旧 feed。操作者独立核对安装路径及版本 1 仍在运行。 + +```powershell +node --import tsx apps/desktop/scripts/publish-installed-update.ts publish-feed "" "" "" --journals "" --execute +``` + +发布和公网回读成功后,才继续演练指南。工具使用固定公网 URL,不加破缓存参数,上传时设置 `Cache-Control: no-store`,并验证返回字节及哈希。二进制上传保留完整公网回读。feed 发布仅在远端读取 feed,要求本批次 `publication-records/operation-*` 下存在成功的 `upload-binaries` 结果,且其本地计划完全匹配;回执缺失、失败或不匹配时停止发布。发布结果记录复用回执的路径及回执/计划哈希。这证明此前分发成功,不证明不可变对象持续可用;不得删除或替换这些对象。SDK 写入没有自动重试。 + + + +## 证据与恢复 + +每次操作保留 `publication-records/operation-*`:计划、带时间戳的阶段、最终结果,以及适用时的 feed 原文。记录包含可安全保留的请求 ID 和可用 HTTP 状态,不含原始 SDK 错误或认证头。失败即停止后续写入;超时不能证明服务器拒绝了此前写入。保留记录和远端对象,检查最后阶段及真实远端状态,另行确认后才执行下一次操作。 + +按准确 API 诊断 `403 AccessDenied`:`GetBucketVersioning`、`GetObject` 与 `PutObject` 需要不同权限。要求扩权或修改 CDN 配置前,先对照保留的成功记录,并用当前凭据检查失败操作。没有尝试上传时,不能把新增配置查询失败报告为上传失败。读取成功也不能证明当前写入权限;优先审查不必要的新增前置检查,而不是扩大最小权限上传身份的授权。 + +人工请求的后续操作验证已匹配对象,不重写它们。`alreadyPublished: true` 表示核对已存在的目标 feed,不是一次新发布;发布时间使用原操作证据。非预期 feed 字节会使操作停止,包括尝试用版本 1 替换版本 2。崩溃可能留下 `publication.lock`;确认没有发布者运行并保留失败记录后,才由操作者移除这个准确的空目录。绝不为了绕过活跃发布者而删除锁。 + +qualification COS 版本查询的总截止时间为 30 秒,对象读取与 PUT 的总截止时间为 15 分钟。到期会中止底层 HTTP 请求,并在释放操作锁前等待请求关闭;持续传输不会延长时限。 + + + +## 开发备注 + +顺序测试使用内存存储替身;传输测试使用替代 HTTP 处理器执行真实 SDK 序列化。两者均不能证明 COS 写入已验收。2026-09-14,当前凭据读取保留的 test 探针返回 HTTP 200 且 SHA-512 匹配,而 `GetBucketVersioning` 返回 403;没有尝试 PUT。另一次 2026-09-11 探针已验证 test 上传、固定 feed 刷新和 updater 下载。TODO:在不削弱命名空间隔离、完整性检查与失败即停的前提下审查额外的桶配置前置条件;本次文档纠正并未实现移除该条件。 + +随后经操作者授权的批次 `installed-update-r5dYNH` 在 `.desktop-build/qualification/` 下保留 `object-overwrite-probe/result.json` 和 `binary-upload/result.json`。新建 49 字节测试对象成功;再次携带禁止覆盖参数 PUT 相同字节时返回 `409 FileAlreadyExists`,源站及公网哈希保持不变。限定本批次的纯二进制驱动随后使用相同请求头上传两个签名安装包及其 blockmap,重新核对验包回执,并验证全部四个完整公网响应。记录包含四次成功的新对象 PUT,没有重试、feed 或配置写入,探针保持保留。对独立 feed URL 的 HEAD 观测返回 404。本次人工操作以观测到的对象级防覆盖行为替代桶配置查询;上方通用 CLI 的前置条件未修改。这些回执证明测试对象已分发,不证明 feed 发布、安装后启动或已安装版本升级。 diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 94e3ae1fa0..1eaa829407 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -1,4 +1,5 @@ import { tmpdir } from 'node:os' +import { readFileSync } from 'node:fs' import { Arch, Platform } from 'electron-builder' import { Packager } from 'app-builder-lib' import { describe, expect, it, vi } from 'vitest' @@ -11,10 +12,18 @@ vi.mock('node:child_process', async (importOriginal) => { }) describe('installer preparation preserves application dependencies', () => { + it.each(['win32', 'darwin'] as const)('rejects a missing production policy before signing on %s', async (platform) => { + const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') + expect(() => createElectronBuilderConfig({ DSH_DESKTOP_APP_ID: 'com.example.installer', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://harness-test.deepseek.com', + }, platform, 'x64')).toThrow('DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN') + }) it.each(['win32', 'darwin'] as const)('keeps electron-builder responsible for node_modules on %s', async (platform) => { execute.mockClear() const env = { DSH_DESKTOP_APP_ID: 'com.example.installer', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_TARGET_PLATFORM: platform, DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: platform === 'win32' ? '1' : '0', @@ -27,6 +36,9 @@ describe('installer preparation preserves application dependencies', () => { try { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(env, platform, 'x64') + const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png') + expect(aboutIcon).toBeDefined() + expect(readFileSync(aboutIcon!.from)).toEqual(readFileSync(new URL('../resources/icon-windows.png', import.meta.url))) const packager = new Packager({ projectDir: tmpdir() }) // A foreign source-build target avoids rebuilding modules; the real dependency ownership decision still runs. Object.defineProperties(packager, { diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts index de7a7a7048..99dde404aa 100644 --- a/apps/desktop/tests/locale.spec.ts +++ b/apps/desktop/tests/locale.spec.ts @@ -1,13 +1,12 @@ -import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts' describe('desktop locale dictionaries', () => { it('ships the same key set in English and Chinese', () => { expect(Object.keys(zh)).toEqual(Object.keys(en)) - expect(resolveDesktopLocale('zh-Hans-CN')).toEqual({ id: 'zh-CN', messages: zh }) - expect(resolveDesktopLocale('en-US')).toEqual({ id: 'en', messages: en }) - expect(resolveDesktopLocale('fr-FR')).toEqual({ id: 'en', messages: en }) + expect(resolveDesktopLocale('zh-Hans-CN').messages).toEqual(zh) + expect(resolveDesktopLocale('en-US').messages).toEqual(en) + expect(resolveDesktopLocale('fr-FR').messages).toEqual(en) }) it('formats named values without consuming unknown placeholders', () => { @@ -15,9 +14,4 @@ describe('desktop locale dictionaries', () => { .toBe('plugin@1.2.3 {missing}') }) - it('keeps visible plugin-manager HTML copy in the locale dictionaries', () => { - const html = readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8') - const staticText = [...html.matchAll(/>([^<]*\p{L}[^<]*) match[1]?.trim()) - expect(staticText).toEqual([]) - }) }) diff --git a/apps/desktop/tests/macos-app-update-config.spec.ts b/apps/desktop/tests/macos-app-update-config.spec.ts new file mode 100644 index 0000000000..72a003734a --- /dev/null +++ b/apps/desktop/tests/macos-app-update-config.spec.ts @@ -0,0 +1,66 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createMacOSAppUpdateConfig, + resolveMacOSAppUpdateFeed, + verifyMacOSAppUpdateConfig, + writeMacOSAppUpdateConfig, +} from '../scripts/macos-app-update-config.mjs' + +const roots: string[] = [] +const update = { publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/' } + +async function fixture(): Promise<{ appPath: string; resourcesDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'desktop-macos-update-config-')) + roots.push(root) + const appPath = join(root, 'DeepSeek Harness.app') + const resourcesDir = join(appPath, 'Contents', 'Resources') + await mkdir(resourcesDir, { recursive: true }) + return { appPath, resourcesDir } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +describe('macOS packaged updater configuration', () => { + it('uses the final generic Nightly provider configured for the build', () => { + expect(resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }])) + .toEqual(update) + for (const publish of [undefined, [], [{ provider: 'github', channel: 'nightly', url: update.publicUrl }], + [{ provider: 'generic', channel: 'latest', url: update.publicUrl }]]) { + expect(() => resolveMacOSAppUpdateFeed(publish)).toThrow(/macOS update config/u) + } + }) + + it('writes and verifies the fixed release feed before signing', async () => { + const paths = await fixture() + expect(createMacOSAppUpdateConfig(update, 'deepseek-harness-updater')).toEqual({ + provider: 'generic', + url: update.publicUrl, + channel: 'nightly', + updaterCacheDirName: 'deepseek-harness-updater', + }) + await writeMacOSAppUpdateConfig(paths.resourcesDir, update, 'deepseek-harness-updater') + await expect(verifyMacOSAppUpdateConfig(paths.appPath, update, 'deepseek-harness-updater')).resolves.toBeUndefined() + }) + + it.each([ + ['missing', undefined], + ['wrong feed', 'provider: generic\nurl: https://wrong.example.com/\nchannel: nightly\nupdaterCacheDirName: fixture\n'], + ['missing cache directory', `provider: generic\nurl: ${update.publicUrl}\nchannel: nightly\n`], + ] as const)('rejects %s updater configuration', async (_label, contents) => { + const paths = await fixture() + if (contents !== undefined) await writeFile(join(paths.resourcesDir, 'app-update.yml'), contents) + await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/macOS update config/u) + }) + + it('rejects another updater cache directory when the signed value is known', async () => { + const paths = await fixture() + await writeMacOSAppUpdateConfig(paths.resourcesDir, update, 'wrong-updater') + await expect(verifyMacOSAppUpdateConfig(paths.appPath, update, 'deepseek-harness-updater')) + .rejects.toThrow(/expected deepseek-harness-updater/u) + }) +}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 3286f23e89..eaf5f03920 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -13,6 +13,7 @@ import { const RELEASE_ENVIRONMENT = { DSH_DESKTOP_APP_ID: 'com.example.desktop', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_TARGET_PLATFORM: 'darwin', DSH_DESKTOP_TARGET_ARCH: 'arm64', DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', @@ -40,7 +41,7 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') - expect(config.extraResources).toHaveLength(1) + expect(config.extraResources).toHaveLength(2) expect(config.extraResources[0]?.to).toBe('runtime') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') const [dshFiles, dshNodeModules] = config.files.slice(-2) @@ -69,7 +70,8 @@ describe('desktop macOS release signature', () => { }, publish: [{ provider: 'generic', - url: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/', + url: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + channel: 'nightly', }], }) expect(typeof config.artifactBuildCompleted).toBe('function') @@ -94,6 +96,7 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') expect(() => createElectronBuilderConfig({ DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_TARGET_PLATFORM: 'win32', }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) }) @@ -102,6 +105,7 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig({ DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_UNSIGNED: '1', }, 'win32', 'x64') diff --git a/apps/desktop/tests/macos-signing-keychain.spec.ts b/apps/desktop/tests/macos-signing-keychain.spec.ts new file mode 100644 index 0000000000..3f28a27c02 --- /dev/null +++ b/apps/desktop/tests/macos-signing-keychain.spec.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs' +import { dirname } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { withMacOSSigningKeychain } from '../scripts/macos-signing-keychain.mjs' + +const environment = { CSC_LINK: '/signing.p12', CSC_KEY_PASSWORD: 'export-secret', DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example (TEAMID1234)' } + +describe('temporary macOS signing identity', () => { + it('scopes signing to the imported identity and removes credentials before invoking the build', async () => { + const run = vi.fn<(command: string, args: string[]) => void>() + let keychain = '' + await withMacOSSigningKeychain(environment, async (env) => { + keychain = env.CSC_KEYCHAIN! + expect(existsSync(dirname(keychain))).toBe(true) + expect(env.CSC_LINK).toBeUndefined() + expect(env.CSC_KEY_PASSWORD).toBeUndefined() + expect(run.mock.calls.some(([command, args]) => command === '/usr/bin/codesign' && args.includes(keychain))).toBe(true) + expect(run.mock.calls.at(-1)?.[1]).toContain('--verify') + }, run) + expect(run.mock.calls.at(-1)?.[1]).toEqual(['delete-keychain', keychain]) + expect(existsSync(dirname(keychain))).toBe(false) + expect(environment.CSC_KEY_PASSWORD).toBe('export-secret') + const create = run.mock.calls.find(([, args]) => args[0] === 'create-keychain')![1] + const partition = run.mock.calls.find(([, args]) => args[0] === 'set-key-partition-list')![1] + expect(partition[partition.indexOf('-k') + 1]).toBe(create[2]) + expect(create[2]).not.toBe(environment.CSC_KEY_PASSWORD) + }) + + it.each(['import', '--force', 'build'])('cleans up and prevents subsequent work after %s fails', async (stage) => { + const action = vi.fn(async () => { if (stage === 'build') throw Error('build failure') }) + const run = vi.fn((_command: string, args: string[]) => { if (args[0] === stage) throw Error('tool failure') }) + await expect(withMacOSSigningKeychain(environment, action, run)).rejects.toThrow(/failure/u) + const keychain = run.mock.calls[0]![1].at(-1)! + expect(run.mock.calls.at(-1)?.[1]).toEqual(['delete-keychain', keychain]) + expect(existsSync(dirname(keychain))).toBe(false) + expect(action).toHaveBeenCalledTimes(stage === 'build' ? 1 : 0) + }) + + it('allocates distinct keychains for overlapping invocations', async () => { + const paths: string[] = [] + let release!: () => void + const bothReady = new Promise((resolve) => { release = resolve }) + const action = async (env: NodeJS.ProcessEnv) => { + paths.push(env.CSC_KEYCHAIN!) + if (paths.length === 2) release() + await bothReady + expect(paths.every(path => existsSync(dirname(path)))).toBe(true) + } + await Promise.all([withMacOSSigningKeychain(environment, action, () => {}), withMacOSSigningKeychain(environment, action, () => {})]) + expect(new Set(paths).size).toBe(2) + expect(paths.every(path => !existsSync(dirname(path)))).toBe(true) + }) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 940ae49f47..d68d076971 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,8 +1,18 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { IpcMainInvokeEvent } from 'electron' import { join } from 'node:path' -import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' -import { DESKTOP_IPC } from '../src/ipc.ts' +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron' +import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts' +import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts' +import { DesktopHostUncleanExitError } from '../src/host-process.ts' +import { en } from '../src/locale.ts' +import { DesktopUpdatePreparationError } from '../src/update-error.ts' + +type InvokeEvent = { sender?: unknown; senderFrame: { url: string } } +type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() })) @@ -16,40 +26,75 @@ const harness = await vi.hoisted(async () => { } const windows: FakeWindow[] = [] let windowFailure: Error | undefined + const powerMonitor = new EventEmitter() const hosts: FakeHost[] = [] - const handlers = new Map unknown>() + const handlers = new Map() let pluginsEnabled = false + let prepareUpdate: (() => Promise) | undefined + let publishUpdate: ((state: DesktopUpdateState) => DesktopUpdateState) | undefined let preparing = deferred() let prepared = deferred() let hostStarted = deferred() let navigated = deferred() let dialogShown = deferred() let quitCompleted = deferred() + let policyBlocked = deferred() + let embeddedPolicy: unknown + let closeWindowsOnQuit = false + let updateState: DesktopUpdateState = { phase: 'idle' } + const updateCheck = vi.fn(async (_manual?: boolean): Promise => updateState) + const updateDownload = vi.fn(async (_version: string): Promise => updateState) + const updateInstall = vi.fn(async (_version: string): Promise => updateState) + const popup = vi.fn<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>() + const menuBuilder = vi.fn<(template: MenuItemConstructorOptions[]) => { popup: typeof popup }>(() => ({ popup })) + const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() }) class FakeWindow extends EventEmitter { destroyed = false readonly urls: string[] = [] readonly webContents = Object.assign(new EventEmitter(), { id: 42, - mainFrame: { url: 'dsh-app://app/' }, setWindowOpenHandler: vi.fn(), + insertCSS: vi.fn(async () => 'blur'), + removeInsertedCSS: vi.fn(async () => {}), openDevTools: vi.fn(), getURL: () => this.urls.at(-1) ?? '', + mainFrame: { url: '' }, + getZoomFactor: () => 1, + focus: vi.fn(), + sendInputEvent: vi.fn(), send: vi.fn(), }) readonly show = vi.fn() + readonly hide = vi.fn() readonly focus = vi.fn() readonly restore = vi.fn() - constructor(readonly options: { show: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) } + readonly setSize = vi.fn() + readonly setTitleBarOverlay = vi.fn() + constructor(readonly options: { show: boolean; modal?: boolean }) { + super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve() + } isDestroyed() { return this.destroyed } isMinimized() { return false } + isFocused() { return true } async loadURL(url: string) { this.urls.push(url) + this.webContents.mainFrame.url = url if (url === 'dsh-app://app/') navigated.resolve() } static getAllWindows() { return windows.filter(window => !window.destroyed) } - close() { this.destroyed = true; this.emit('closed') } + setMenu() {} + getContentBounds() { return { x: 0, y: 0, width: 900, height: 650 } } + setBounds() {} + setTitle = vi.fn() + destroy() { this.destroyed = true; this.emit('closed') } + close() { + const event = { preventDefault: vi.fn() } + this.emit('close', event) + if (event.preventDefault.mock.calls.length === 0) this.destroy() + } } class FakeHost { + readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false) url = 'http://127.0.0.1:3080/?token=test' readonly ready = deferred() readonly exited = deferred() @@ -71,52 +116,82 @@ const harness = await vi.hoisted(async () => { isPackaged: true, name: 'Desktop test', whenReady: () => Promise.resolve(), - getLocale: () => 'en-US', + getLocale: (): string => 'en-US', getVersion: () => '1.0.0', getAppPath: () => 'desktop-test-app', + setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(), requestSingleInstanceLock: () => true, exit: vi.fn(), relaunch: vi.fn(), quit: vi.fn(() => { const event = { preventDefault: vi.fn() } app.emit('before-quit', event) - if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve() + if (event.preventDefault.mock.calls.length === 0) { + if (closeWindowsOnQuit) for (const window of [...windows]) if (!window.isDestroyed()) window.close() + if (!closeWindowsOnQuit || windows.every(window => window.isDestroyed())) quitCompleted.resolve() + } }), }) - const popup = vi.fn() return { - windows, hosts, handlers, app, FakeWindow, FakeHost, failWindow(error: Error) { windowFailure = error }, - popup, - socketHeaders: vi.fn(), - menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) }, - dialog: { - showOpenDialog: vi.fn(), - showErrorBox: vi.fn(), - showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>(), - }, + windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, + menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, + ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(), + get updateState() { return updateState }, + set updateState(value: DesktopUpdateState) { updateState = value }, + get prepareUpdate() { return prepareUpdate! }, + set prepareUpdate(value: () => Promise) { prepareUpdate = value }, + get publishUpdate() { return publishUpdate! }, + set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value }, + dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() }, openExternal: vi.fn(), + protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise) => void>(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), - mutateFailure: vi.fn<() => void>(), - disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }), + disableAllPlugins: vi.fn(async () => { + pluginsEnabled = false + return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000' + }), get preparing() { return preparing }, get prepared() { return prepared }, get hostStarted() { return hostStarted }, get navigated() { return navigated }, get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted }, + get policyBlocked() { return policyBlocked }, + get embeddedPolicy() { return embeddedPolicy }, + set embeddedPolicy(value: unknown) { embeddedPolicy = value }, nextNavigation() { navigated = deferred(); return navigated.promise }, nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, get pluginsEnabled() { return pluginsEnabled }, set pluginsEnabled(value: boolean) { pluginsEnabled = value }, + set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value }, reset() { windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() + powerMonitor.removeAllListeners() app.isPackaged = true windowFailure = undefined pluginsEnabled = false + closeWindowsOnQuit = false + prepareUpdate = undefined + publishUpdate = undefined + updateState = { phase: 'idle' } + updateCheck.mockReset().mockImplementation(async () => updateState) + updateDownload.mockReset().mockImplementation(async () => updateState) + updateInstall.mockReset().mockImplementation(async () => updateState) preparing = deferred(); prepared = deferred(); hostStarted = deferred() navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred() + policyBlocked = deferred() + embeddedPolicy = undefined }, } }) +const testAuth = vi.hoisted(() => ({ login: vi.fn<() => Promise<'returned' | 'cancelled' | 'failed'>>(), + focus: vi.fn(), dispose: vi.fn(async () => {}) })) +vi.mock('../src/policy-test-auth.ts', () => ({ DesktopPolicyTestAuth: class { + readonly login = testAuth.login + readonly focus = testAuth.focus + readonly dispose = testAuth.dispose + readonly request: typeof fetch = (input, init) => fetch(input, init) +} })) + vi.mock('electron', () => ({ app: harness.app, BrowserWindow: harness.FakeWindow, @@ -124,54 +199,100 @@ vi.mock('electron', () => ({ shell: { openExternal: harness.openExternal }, nativeTheme: { themeSource: 'system' }, ipcMain: { - on: vi.fn(), - handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) }, + on: harness.ipcOn, + handle: (channel: string, handler: InvokeHandler) => { + if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`) + harness.handlers.set(channel, handler) + }, + removeHandler: (channel: string) => { harness.handlers.delete(channel) }, }, - Menu: harness.menu, + Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, - protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, + powerMonitor: harness.powerMonitor, })) +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { ...original, readFile: vi.fn((path: Parameters[0], encoding?: 'utf8') => { + if (path === join('desktop-test-app', 'package.json')) { + return Promise.resolve(JSON.stringify({ dshDesktopAppId: 'com.deepseek.dsh', dshMandatoryUpdatePolicy: harness.embeddedPolicy })) + } + return encoding === undefined ? original.readFile(path) : original.readFile(path, encoding) + }) } +}) +vi.mock('../src/runtime-tree.ts', () => ({ readDesktopRuntime: () => ({ release: { version: '1.0.0' } }) })) vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) })) vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class { readonly applyRelease = harness.applyRelease disableAllPlugins = harness.disableAllPlugins - async mutate(_mutation: unknown, hooks: { beforeChange(): Promise; afterChange(): Promise }) { - await hooks.beforeChange() - try { - harness.mutateFailure() - harness.pluginsEnabled = false - } catch (error) { - try { await hooks.afterChange() } catch (restartError) { - throw new AggregateError([error, restartError], 'Desktop package operation and backend restart failed') - } - throw error - } - await hooks.afterChange() - } }, })) -vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost })) -vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() })) +vi.mock('../src/host-process.ts', async importOriginal => ({ + ...await importOriginal(), DesktopHostProcess: harness.FakeHost, +})) +vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class { + show(owner: { options: { modal?: boolean } }, options: unknown) { + return (owner.options.modal ? harness.dialog.showMessageBox(owner, options) + : harness.dialog.showMessageBox(options)) as Promise + } + cancel() {} + focus() {} + dispose() {} +} })) +vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class { + constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise) { + harness.prepareUpdate = beforeRestart + harness.publishUpdate = publish + } + get state() { return harness.updateState } + readonly check = harness.updateCheck + readonly download = harness.updateDownload + readonly install = harness.updateInstall + readonly dispose = vi.fn() +} })) -function invoke(channel: string, ...args: unknown[]): unknown { +function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown { const handler = harness.handlers.get(channel) if (handler === undefined) throw new Error(`missing handler ${channel}`) - return handler({ senderFrame: { url: channel === DESKTOP_IPC.boot ? 'dsh-app://app/' : 'dsh-app://shell/plugin-manager.html' } }, ...args) + if (origin === 'app') { + const sender = harness.windows[0]!.webContents + return handler({ sender, senderFrame: sender.mainFrame }, ...args) + } + return handler({ senderFrame: { url: `dsh-app://${origin}/index.html` } }, ...args) +} + +function applicationMenuItems(): MenuItemConstructorOptions[] { + const native = harness.menu.mock.calls[0]?.[0][0]?.submenu + if (Array.isArray(native)) return native + const sender = harness.windows[0]!.webContents + void harness.handlers.get(DESKTOP_IPC.windowsMenu)!({ sender, senderFrame: sender.mainFrame }, 'application', 0, 0) + return harness.menu.mock.lastCall![0] } beforeEach(() => { vi.resetModules() vi.clearAllMocks() + harness.dialog.showMessageBox.mockReset() + harness.dialog.showMessageBox.mockResolvedValue({ response: 1 }) + testAuth.login.mockReset() + testAuth.login.mockResolvedValue('cancelled') vi.useFakeTimers() harness.reset() - harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) }) + harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => { + if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 }) + harness.dialogShown.resolve() + return new Promise(() => {}) + }) vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'info').mockImplementation(() => {}) vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') - vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' }) + vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' }) vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) + vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) + vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined) }) afterEach(async () => { @@ -187,6 +308,197 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it('serves shell dialogs and their assets without forwarding them to the Host', async () => { + await readyForUpdate() + const { serveWebDocument, forwardWebRequest } = await import('../src/web-document.ts') + const handler = harness.protocolHandle.mock.calls[0]![1] + for (const file of ['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg', 'mandatory-update.html']) { + const request = new Request(`dsh-app://shell/${file}`) + await handler(request) + expect(serveWebDocument).toHaveBeenLastCalledWith(request, join('desktop-test-app', 'renderer')) + } + expect(forwardWebRequest).not.toHaveBeenCalled() + expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404) + }) + + it.each([ + ['darwin', true, 'en-US'], + ['darwin', false, 'zh-CN'], + ['win32', true, 'zh-CN'], + ['win32', false, 'en-US'], + ] as const)('offers the native About panel before other commands on %s (packaged=%s, locale=%s)', async (platform, packaged, locale) => { + vi.stubGlobal('process', { ...process, platform }) + harness.app.isPackaged = packaged + vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale) + await readyForUpdate() + const submenu = applicationMenuItems() + const options = harness.app.setAboutPanelOptions.mock.calls[0]![0] + const expected = JSON.parse(readFileSync(new URL('./expected/about-panel.json', import.meta.url), 'utf8')) as Record + expect({ menu: submenu.slice(0, 2), options: { ...options, iconPath: '' } }).toEqual(expected[locale]) + expect(options.iconPath).toBe(packaged ? join('desktop-test-resources', 'icon.png') + : join('desktop-test-app', 'resources', 'icon-windows.png')) + }) + + it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))) + const explanation = Promise.withResolvers<{ response: number }>() + const explained = Promise.withResolvers() + const login = Promise.withResolvers<'cancelled'>() + const entered = Promise.withResolvers() + harness.dialog.showMessageBox.mockImplementationOnce(() => { explained.resolve(undefined); return explanation.promise }) + testAuth.login.mockImplementationOnce(() => { entered.resolve(undefined); return login.promise }) + const checks: Promise[] = [] + try { + await import('../src/main.ts') + await explained.promise + expect(harness.hosts).toHaveLength(0) + expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() + expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({ + message: en.policyLoginRequired, buttons: [en.policyLogin, en.later], cancelId: 1, + })) + checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app'))) + expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() + expect(testAuth.login).not.toHaveBeenCalled() + explanation.resolve({ response: 0 }) + await entered.promise + checks.push(Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app'))) + expect(testAuth.focus).toHaveBeenCalled() + expect(testAuth.login).toHaveBeenCalledOnce() + expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() + login.resolve('cancelled') + await Promise.all(checks) + await vi.advanceTimersByTimeAsync(10_000) + expect(testAuth.login).toHaveBeenCalledOnce() + const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message) + expect(messages.filter(message => message === en.policyLoginRequired)).toHaveLength(1) + expect(messages).toContain('No updates available. Current version: V1.0.0') + } finally { + explanation.resolve({ response: 1 }) + login.resolve('cancelled') + await Promise.allSettled(checks) + } + }) + + it.each(['returned', 'cancelled', 'failed'] as const)('requires explicit test login and handles %s without downloading', async (outcome) => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 } + const request = vi.fn().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + vi.stubGlobal('fetch', request) + await readyForUpdate() + await vi.advanceTimersByTimeAsync(0) + expect(testAuth.login).not.toHaveBeenCalled() + expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({ message: en.policyLoginRequired })) + harness.dialog.showMessageBox.mockClear() + testAuth.login.mockImplementationOnce(async () => { + request.mockImplementation(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } })) + return outcome + }) + harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(testAuth.login).toHaveBeenCalledOnce() + expect(harness.dialog.showMessageBox.mock.calls.map(call => call.at(-1) as unknown)).toContainEqual(expect.objectContaining({ + message: en.policyLoginRequired, buttons: [en.policyLogin, en.later], + })) + expect(harness.updateDownload).not.toHaveBeenCalled() + expect(harness.updateInstall).not.toHaveBeenCalled() + const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message) + const expected = JSON.parse(readFileSync(new URL('./expected/policy-login-en.json', import.meta.url), 'utf8')) as Record + expect(messages).toEqual(expected[outcome]) + }) + + it('does not open Feishu when the user declines test login', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))) + await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValue({ response: 1 }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(testAuth.login).not.toHaveBeenCalled() + }) + + it('does not require gateway login to download an already available ordinary update', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))) + await readyForUpdate() + await vi.advanceTimersByTimeAsync(0) + harness.updateState = { phase: 'available', version: '1.0.1-nightly.1' } + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') + expect(testAuth.login).not.toHaveBeenCalled() + }) + + it('retains the same blocking window and running Host after expired test login is cancelled', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } + const request = vi.fn().mockImplementation(async () => + Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + .mockResolvedValueOnce(Response.json({ code: 40005, data: { show_content: { title: 'Update', detail: 'Required' }, + desktop_app_link: 'https://downloads.example.com/' } })) + vi.stubGlobal('fetch', request) + const host = await readyForUpdate() + await harness.policyBlocked.promise + await vi.advanceTimersByTimeAsync(1000) + const modal = harness.windows.find(window => window.options.modal)! + const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } + harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) + testAuth.login.mockResolvedValueOnce('cancelled') + await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh') + expect(testAuth.login).toHaveBeenCalledOnce() + expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({ + policy: { blocking: true, error: 'authentication-required' }, + }) + expect(modal.isDestroyed()).toBe(false) + expect(host.stop).not.toHaveBeenCalled() + expect(harness.updateDownload).not.toHaveBeenCalled() + }) + + it('persists opt-in update evidence from the real main entry without private diagnostics', async () => { + const directory = mkdtempSync(join(tmpdir(), 'dsh-main-update-journal-')) + try { + vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', directory) + await readyForUpdate() + harness.publishUpdate({ phase: 'error', failedOperation: 'download', version: '1.2.3', message: 'ENOSPC secret-url' }) + const checkUpdates = applicationMenuItems().find(item => item.label === en.checkUpdatesMenu)!.click as () => void + checkUpdates() + await vi.advanceTimersByTimeAsync(0) + for (const host of harness.hosts) host.exited.resolve() + harness.app.quit() + await harness.quitCompleted.promise + const files = readdirSync(directory) + expect(files).toHaveLength(1) + const contents = readFileSync(join(directory, files[0]!), 'utf8') + const records = contents.trim().split('\n').map(line => JSON.parse(line) as { event: string }) + expect(records.map(row => row.event)).toEqual(expect.arrayContaining(['started', 'workspace-ready', 'state', 'check-requested', 'quit-requested'])) + expect(contents).toContain('ENOSPC') + expect(contents).not.toContain('secret-url') + } finally { + // The existing teardown calls quit again; retain its journal until listeners are removed. + harness.app.removeAllListeners('before-quit') + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('accepts update IPC only from the current application top frame in the owned main window', async () => { + await readyForUpdate() + const handler = harness.handlers.get(DESKTOP_IPC.updatesStatus)! + const sender = harness.windows[0]!.webContents + expect(() => handler({ sender, senderFrame: sender.mainFrame })).not.toThrow() + for (const event of [ + { sender: {}, senderFrame: sender.mainFrame }, + { sender, senderFrame: { url: sender.mainFrame.url } }, + { sender, senderFrame: { url: 'http://127.0.0.1:40000/' } }, + ]) expect(() => handler(event)).toThrow('unowned renderer') + const original = sender.mainFrame.url + sender.mainFrame.url = 'http://127.0.0.1:40000/' + expect(() => handler({ sender, senderFrame: sender.mainFrame })).toThrow('unowned renderer') + sender.mainFrame.url = original + }) + it.each(['darwin', 'win32', 'linux'] as const)('limits native titlebar styling to macOS on %s', async (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) await import('../src/main.ts') @@ -195,6 +507,10 @@ describe('desktop main startup', () => { expect(window.urls).toEqual(['dsh-app://app/']) if (platform === 'darwin') { expect(window.options).toMatchObject({ titleBarStyle: 'hiddenInset', vibrancy: 'sidebar', backgroundColor: '#00000000' }) + } else if (platform === 'win32') { + expect(window.options).toMatchObject({ titleBarStyle: 'hidden', titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT } }) + expect(window.options).not.toHaveProperty('vibrancy') + expect(harness.menu.setApplicationMenu).toHaveBeenCalledWith(null) } else { expect(window.options).not.toHaveProperty('titleBarStyle') expect(window.options).not.toHaveProperty('vibrancy') @@ -202,6 +518,91 @@ describe('desktop main startup', () => { expect(harness.hosts).toHaveLength(0) }) + it('follows the Windows primary document language and palette without trusting other frames', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const listener = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1] + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + listener({ ...event, senderFrame: { url: 'dsh-app://app/' } }, 'zh-CN', '#ffffff', '#000000') + expect(window.setTitleBarOverlay).not.toHaveBeenCalled() + listener(event, 'zh-CN', 'rgb(249, 250, 251)', '#0f1115') + expect(window.setTitleBarOverlay).toHaveBeenCalledWith({ color: 'rgb(249, 250, 251)', symbolColor: '#0f1115' }) + window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } }) + expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: '复制', accelerator: '' }]) + listener(event, 'en', '#1b1b1c', '#f9fafb') + window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags: { canCopy: true } }) + expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, label: 'Copy', accelerator: '' }]) + window.setTitleBarOverlay.mockClear() + listener(event, 'en', 'url(file:///bad)', '#fff') + expect(window.setTitleBarOverlay).not.toHaveBeenCalled() + listener(event, {}, '#fff', '#000') + expect(window.setTitleBarOverlay).toHaveBeenLastCalledWith({ color: '#fff', symbolColor: '#000' }) + window.setTitleBarOverlay.mockClear() + window.webContents.mainFrame.url = 'dsh-app://unowned/index.html' + listener(event, 'zh-CN', '#fff', '#000') + expect(window.setTitleBarOverlay).not.toHaveBeenCalled() + expect(harness.menu.setApplicationMenu).toHaveBeenCalledExactlyOnceWith(null) + }) + + it('maps Windows caption menus to localized native commands and rejects foreign popup requests', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const appearance = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.windowsAppearance)![1] + appearance(event, 'zh-CN', '#fff', '#000') + const handler = harness.handlers.get(DESKTOP_IPC.windowsMenu)! + const foreignEvent = { ...event, sender: {} } + expect(() => handler(foreignEvent, 'application', 48, 34)).toThrow('rejected sender') + expect(() => handler(event, 'arbitrary-command', 48, 34)).toThrow('invalid popup request') + expect(() => handler(event, 'application', NaN, 34)).toThrow('invalid popup request') + const application = handler(event, 'application', 48, 34) + expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([ + '关于 DeepSeek Harness', 'separator', '检查更新…', 'separator', '退出', + ]) + expect(harness.popup.mock.lastCall![0]).toMatchObject({ window, x: 48, y: 34 }) + expect(harness.popup.mock.lastCall![0].callback).toBeTypeOf('function') + harness.popup.mock.lastCall![0].callback!() + await application + const edit = handler(event, 'edit', 104, 34) + expect(harness.menu.buildFromTemplate.mock.lastCall![0].map(item => item.label ?? item.type)).toEqual([ + '撤销', '重做', 'separator', '剪切', '复制', '粘贴', '删除', 'separator', '全选', + ]) + const commands = harness.menu.buildFromTemplate.mock.lastCall![0].filter(item => item.type !== 'separator') + for (const [index, keyCode] of ['Z', 'Y', 'X', 'C', 'V', 'Delete', 'A'].entries()) { + const click = commands[index]!.click as () => void + click() + const modifiers = keyCode === 'Delete' ? [] : ['control'] + expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 1, { type: 'keyDown', keyCode, modifiers }) + expect(window.webContents.sendInputEvent).toHaveBeenNthCalledWith(index * 2 + 2, { type: 'keyUp', keyCode, modifiers }) + } + harness.popup.mock.lastCall![0].callback!() + await edit + }) + + it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await import('../src/main.ts') + await harness.preparing.promise + const describeItem = (item: MenuItemConstructorOptions): string | undefined => + item.role ?? (item.type === 'separator' ? 'separator' : item.label) + const template = harness.menu.buildFromTemplate.mock.calls + .map(call => call[0]) + .find(items => items.some(item => item.role === 'editMenu')) + if (template === undefined) throw new Error('application menu missing') + expect(template.map(describeItem)).toEqual(platform === 'darwin' + ? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu'] + : ['Application', 'editMenu']) + const application = template[0]!.submenu as MenuItemConstructorOptions[] + expect(application.map(describeItem)).toEqual(platform === 'darwin' + ? ['about', 'separator', en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] + : ['about', 'separator', en.checkUpdatesMenu, 'separator', 'quit']) + expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() + }) + it('attaches Host socket credentials only to the owned application origin and window', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -259,15 +660,16 @@ describe('desktop main startup', () => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) }) - it('offers native editing actions on right-click and only copy for selected read-only text', async () => { + it('retains macOS native editing actions on right-click and only copy for selected read-only text', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') await import('../src/main.ts') await harness.preparing.promise const window = harness.windows[0]! const editFlags = { canUndo: true, canRedo: false, canCut: true, canCopy: true, canPaste: true, canSelectAll: true } - harness.menu.buildFromTemplate.mockClear() + harness.menu.mockClear() window.webContents.emit('context-menu', {}, { isEditable: true, selectionText: 'text', editFlags }) - expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([ + expect(harness.menu).toHaveBeenLastCalledWith([ { role: 'undo', enabled: true, accelerator: '' }, { role: 'redo', enabled: false, accelerator: '' }, { type: 'separator', accelerator: '' }, { role: 'cut', enabled: true, accelerator: '' }, { role: 'copy', enabled: true, accelerator: '' }, @@ -277,20 +679,15 @@ describe('desktop main startup', () => { expect(harness.popup).toHaveBeenCalledWith({ window }) window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: 'text', editFlags }) - expect(harness.menu.buildFromTemplate).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, accelerator: '' }]) + expect(harness.menu).toHaveBeenLastCalledWith([{ role: 'copy', enabled: true, accelerator: '' }]) - harness.menu.buildFromTemplate.mockClear() + harness.menu.mockClear() window.webContents.emit('context-menu', {}, { isEditable: false, selectionText: '', editFlags }) - expect(harness.menu.buildFromTemplate).not.toHaveBeenCalled() + expect(harness.menu).not.toHaveBeenCalled() }) it('opens message links externally while retaining same-origin application navigation', async () => { - await import('../src/main.ts') - await harness.preparing.promise - harness.prepared.resolve() - await harness.hostStarted.promise - harness.hosts[0]!.ready.resolve() - await harness.navigated.promise + await readyForUpdate() const window = harness.windows[0]! const openWindow = window.webContents.setWindowOpenHandler.mock.calls[0]![0] as (details: { url: string }) => { action: string } @@ -309,6 +706,490 @@ describe('desktop main startup', () => { expect(harness.openExternal).not.toHaveBeenCalled() }) + async function readyForUpdate() { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await harness.navigated.promise + return harness.hosts[0]! + } + + it('hides the workspace before intentional Host shutdown can look like reconnection', async () => { + const host = await readyForUpdate() + const window = harness.windows[0]! + window.show.mockClear() + window.focus.mockClear() + harness.app.quit() + expect(window.hide).toHaveBeenCalledOnce() + await host.stopping.promise + harness.app.emit('second-instance') + expect(window.show).not.toHaveBeenCalled() + expect(window.focus).not.toHaveBeenCalled() + host.exited.resolve() + await harness.quitCompleted.promise + }) + + it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => { + const host = await readyForUpdate() + const window = harness.windows[0]! + window.destroyed = true + window.hide.mockImplementation(() => { throw new TypeError('Object has been destroyed') }) + expect(() => { harness.app.quit() }).not.toThrow() + expect(window.hide).not.toHaveBeenCalled() + await host.stopping.promise + window.emit('closed') + host.exited.resolve() + await harness.quitCompleted.promise + }) + + it('shares the failed-check deadline across focus and resume, while explicit checks reset polling', async () => { + vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', '1000') + vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', '4000') + vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_JITTER', '0') + harness.updateCheck.mockResolvedValue({ phase: 'error', failedOperation: 'check', message: 'offline' }) + const host = await readyForUpdate() + await vi.advanceTimersByTimeAsync(0) + expect(harness.updateCheck).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1999) + harness.windows[0]!.emit('focus') + harness.powerMonitor.emit('resume') + expect(harness.updateCheck).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(harness.updateCheck).toHaveBeenCalledTimes(2) + harness.updateCheck.mockResolvedValue({ phase: 'idle' }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.updateCheck).toHaveBeenLastCalledWith(true) + await vi.advanceTimersByTimeAsync(1000) + expect(harness.updateCheck).toHaveBeenCalledTimes(4) + harness.app.quit() + try { + await host.stopping.promise + await vi.advanceTimersByTimeAsync(10_000) + harness.powerMonitor.emit('resume') + expect(harness.updateCheck).toHaveBeenCalledTimes(4) + expect(vi.getTimerCount()).toBe(0) + } finally { host.exited.resolve(); await harness.quitCompleted.promise } + }) + + it('blocks subsequent product operations without stopping the Host and clears only on a fresh no-force policy', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 } + vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', '{invalid environment override}') + const request = vi.fn().mockImplementation(async () => Response.json({ code: 40005, + data: { show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/' } })) + vi.stubGlobal('fetch', request) + const host = await readyForUpdate() + await harness.policyBlocked.promise + const modal = harness.windows.find(window => window.options.modal)! + expect(modal).toBeDefined() + const status = harness.handlers.get(MANDATORY_IPC.status)! + const action = harness.handlers.get(MANDATORY_IPC.action)! + const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } + expect(status(owned)).toMatchObject({ policy: { blocking: true } }) + const unowned = [ + { ...owned, sender: harness.windows[0]!.webContents }, + { ...owned, senderFrame: { url: 'dsh-app://app/index.html' } }, + { ...owned, senderFrame: { url: 'https://untrusted.example.com/' } }, + ] + for (const event of unowned) { + expect(() => status(event)).toThrow('unowned renderer') + expect(() => action(event, 'download', '1.0.1-nightly.1')).toThrow('unowned renderer') + } + expect(() => action(owned, 'open-arbitrary-url')).toThrow('invalid action') + expect(() => action(owned, 'download')).toThrow('missing confirmed version') + expect(() => action(owned, 'install', 123)).toThrow('missing confirmed version') + expect(harness.updateDownload).not.toHaveBeenCalled() + expect(harness.updateInstall).not.toHaveBeenCalled() + expect(host.stop).not.toHaveBeenCalled() + expect(harness.updateDownload).not.toHaveBeenCalled() + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + request.mockImplementationOnce(async () => Response.json({ code: 500 }, { status: 503 })) + await vi.advanceTimersByTimeAsync(10_000) + expect(modal.isDestroyed()).toBe(false) + expect(host.stop).not.toHaveBeenCalled() + request.mockImplementationOnce(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } })) + await vi.advanceTimersByTimeAsync(20_000) + expect(modal.isDestroyed()).toBe(false) + await vi.advanceTimersByTimeAsync(150) + expect(modal.isDestroyed()).toBe(true) + expect(host.stop).not.toHaveBeenCalled() + expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) + }) + + it('keeps one checking dialog open until the manual check settles, then reports the current version', async () => { + await readyForUpdate() + const checked = Promise.withResolvers() + const checking = Promise.withResolvers() + const requested = Promise.withResolvers() + harness.updateCheck.mockImplementationOnce(() => { requested.resolve(undefined); return checked.promise }) + harness.dialog.showMessageBox.mockImplementationOnce(({ signal }: { signal: AbortSignal }) => { + checking.resolve(signal) + return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) }) + }).mockImplementationOnce(() => { + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as { signal: AbortSignal }).signal.aborted).toBe(false) + return Promise.resolve({ response: 0 }) + }) + const submenu = applicationMenuItems() + const action = submenu.find(item => item.label === 'Check for Updates…') + expect(action?.click).toBeTypeOf('function') + // Electron supplies menu arguments that this callback does not consume. + Reflect.apply(action!.click!, undefined, []) + const prompt = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')) + const signal = await checking.promise + await requested.promise + expect(signal.aborted).toBe(false) + expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(1) + expect(harness.updateCheck).toHaveBeenLastCalledWith(true) + checked.resolve({ phase: 'idle' }) + await prompt + expect(signal.aborted).toBe(true) + expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({ + message: 'No updates available. Current version: V1.0.0', + })) + }) + + it('reports a manual check failure without offering a download', async () => { + await readyForUpdate() + harness.updateCheck.mockResolvedValueOnce({ phase: 'error', failedOperation: 'check', message: 'Feed unavailable' }) + harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'error', + message: 'Could not check for updates. Please try again later.', technicalDetails: 'Feed unavailable' })) + expect(harness.updateDownload).not.toHaveBeenCalled() + }) + + it('reports a stale download confirmation as a download failure', async () => { + await readyForUpdate() + harness.updateCheck.mockResolvedValueOnce({ phase: 'available', version: '1.0.1-nightly.1' }) + harness.updateDownload.mockRejectedValueOnce(new Error('desktop update: download confirmation is stale')) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'error', + message: en.updateDownloadFailed, technicalDetails: 'desktop update: download confirmation is stale' })) + }) + + it('keeps policy failures silent while an ordinary update proceeds', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', + allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => + Response.json({ code: 500 }, { status: 503 }))) + await readyForUpdate() + await vi.advanceTimersByTimeAsync(0) + harness.dialog.showMessageBox.mockClear() + harness.updateCheck.mockResolvedValueOnce({ phase: 'available', version: '1.0.1-nightly.1' }) + await invoke(DESKTOP_IPC.updatesOpen, 'app') + const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message) + expect(messages).not.toContain(en.mandatoryUnavailable) + expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + }) + + it('does not wait for a pending policy request before proceeding with an ordinary update', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', + allowedPageOrigins: ['https://downloads.example.com'] } + const policy = Promise.withResolvers() + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => policy.promise)) + harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' }) + const operation = Promise.resolve().then(async () => { + await readyForUpdate() + await invoke(DESKTOP_IPC.updatesOpen, 'app') + }) + try { + await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') }) + } finally { + policy.resolve(Response.json({ code: 500 }, { status: 503 })) + await operation + } + }) + + it('lets a confirmed mandatory policy preempt an ordinary result dialog', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', + allowedPageOrigins: ['https://downloads.example.com'] } + const policy = Promise.withResolvers() + const available = Promise.withResolvers() + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => policy.promise)) + harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' }) + harness.dialog.showMessageBox.mockImplementation(({ signal, message }: { signal?: AbortSignal; message: string }) => { + if (message !== en.updateAvailable || signal === undefined) return Promise.resolve({ response: 1 }) + available.resolve(signal) + return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) }) + }) + await readyForUpdate() + const submenu = applicationMenuItems() + const action = submenu.find(item => item.label === 'Check for Updates…') + Reflect.apply(action!.click!, undefined, []) + const operation = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')) + try { + const signal = await available.promise + policy.resolve(Response.json({ code: 40005, + data: { show_content: { title: 'Update required', detail: 'Please update' }, + desktop_app_link: 'https://downloads.example.com/' } })) + await harness.policyBlocked.promise + expect(signal.aborted).toBe(true) + await operation + expect(harness.updateDownload).not.toHaveBeenCalled() + } finally { + policy.resolve(Response.json({ code: 500 }, { status: 503 })) + await operation + } + }) + + it('queues policy authentication until the ordinary result dialog closes', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', + allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 } + const request = vi.fn() + .mockResolvedValueOnce(Response.json({ code: 0, data: { biz_code: 0, biz_data: null } })) + .mockResolvedValue(Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + vi.stubGlobal('fetch', request) + await readyForUpdate() + await vi.advanceTimersByTimeAsync(0) + const ordinaryResult = Promise.withResolvers<{ response: number }>() + const ordinaryShown = Promise.withResolvers() + const policyShown = Promise.withResolvers() + harness.dialog.showMessageBox.mockImplementation(({ message }: { message: string }) => { + if (message.startsWith('No updates available.')) { + ordinaryShown.resolve(undefined) + return ordinaryResult.promise + } + if (message === en.policyLoginRequired) policyShown.resolve(undefined) + return Promise.resolve({ response: 1 }) + }) + const operation = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app')) + await ordinaryShown.promise + expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)) + .not.toContain(en.policyLoginRequired) + ordinaryResult.resolve({ response: 0 }) + await operation + await policyShown.promise + expect(testAuth.login).not.toHaveBeenCalled() + }) + + it('downloads on the first click and opens installation confirmation only after readiness', async () => { + await readyForUpdate() + harness.updateState = { phase: 'available', version: '1.0.1-nightly.1' } + const downloading = Promise.withResolvers() + const started = Promise.withResolvers() + harness.updateDownload.mockImplementationOnce(async (version) => { + harness.updateState = { phase: 'downloading', version } + started.resolve(undefined) + return downloading.promise + }) + const action = invoke(DESKTOP_IPC.updatesOpen, 'app') + await started.promise + const repeated = invoke(DESKTOP_IPC.updatesOpen, 'app') + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + expect(harness.updateInstall).not.toHaveBeenCalled() + downloading.resolve({ phase: 'ready', version: '1.0.1-nightly.1' }) + await Promise.all([action, repeated]) + expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1') + await harness.updateCheck() + expect(harness.updateInstall).toHaveBeenCalledOnce() + }) + + it('does not lock or stop tasks when restart confirmation is dismissed', async () => { + const host = await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 }) + await expect(harness.prepareUpdate()).resolves.toBe(false) + expect(host.updateTasks.mock.calls).toEqual([['inspect']]) + expect(host.stop).not.toHaveBeenCalled() + }) + + it('rechecks admission after approval and rejects work that started during confirmation', async () => { + const host = await readyForUpdate() + host.updateTasks.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + await expect(harness.prepareUpdate()).rejects.toThrow(/New tasks/u) + expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock'], ['unlock']]) + expect(host.stop).not.toHaveBeenCalled() + }) + + it('warns about active tasks and waits for a graceful Host exit after approval', async () => { + const host = await readyForUpdate() + host.updateTasks.mockResolvedValue(true) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + await host.stopping.promise + expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Tasks are still in progress', buttons: ['Stop tasks and update', 'Update later'], + })) + expect(host.stop).toHaveBeenCalledWith(true) + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + }) + + it('unlocks admission without stopping the Host when request draining fails', async () => { + const host = await readyForUpdate() + host.updateTasks.mockResolvedValueOnce(false).mockRejectedValueOnce(new Error('task inspection timed out')) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + await expect(harness.prepareUpdate()).rejects.toThrow('task inspection timed out') + expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock'], ['unlock']]) + expect(host.stop).not.toHaveBeenCalled() + }) + + async function answerMandatory(action: 'install' | 'later') { + const modal = harness.windows.find(window => window.options.modal && !window.isDestroyed())! + const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } + await vi.waitFor(() => { + expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation') + }) + const view = harness.handlers.get(MANDATORY_IPC.status)!(event) as { confirmation: { version: string; revision: number } } + await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision) + } + + it('releases the mandatory modal when the confirmed installer quits Electron', async () => { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { + show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', + } }))) + const host = await readyForUpdate() + await harness.policyBlocked.promise + const modal = harness.windows.find(window => window.options.modal)! + const preparing = harness.prepareUpdate() + await answerMandatory('install') + await host.stopping.promise + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + harness.closeWindowsOnQuit = true + harness.app.quit() + await harness.quitCompleted.promise + expect(modal.isDestroyed()).toBe(true) + expect(harness.app.quit).toHaveBeenCalledOnce() + }) + + it.each([false, true])('restores a cleanly stopped Host after installer failure and retains mandatory blocking: %s', async (mandatory) => { + if (mandatory) { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { + show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', + } }))) + } + const host = await readyForUpdate() + if (mandatory) await harness.policyBlocked.promise + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + if (mandatory) await answerMandatory('install') + await host.stopping.promise + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + harness.dialog.showMessageBox.mockResolvedValue({ response: 1 }) + const restarted = harness.nextHostStart() + harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' }) + await restarted + const replacement = harness.hosts[1]! + replacement.url = 'http://127.0.0.1:3099/?token=replacement' + if (mandatory) replacement.updateTasks.mockResolvedValue(true) + const retry = harness.prepareUpdate() + expect(replacement.updateTasks).not.toHaveBeenCalled() + replacement.ready.resolve() + await vi.waitFor(() => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/']) }) + await expect(Promise.resolve(invoke(DESKTOP_IPC.boot))).resolves.toEqual({ injections: [], streamBaseUrl: 'http://127.0.0.1:3099' }) + if (mandatory) await answerMandatory('later') + await expect(retry).resolves.toBe(false) + expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) + expect(replacement.stop).not.toHaveBeenCalled() + if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + }) + + it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => { + if (mandatory) { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { + show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', + } }))) + } + const host = await readyForUpdate() + if (mandatory) await harness.policyBlocked.promise + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + const rejected = expect(preparing).rejects.toMatchObject(new DesktopUpdatePreparationError('stop-failed', en.updateStopFailed, 'Task teardown failed after child exit')) + if (mandatory) await answerMandatory('install') + await host.stopping.promise + host.exited.reject(new DesktopHostUncleanExitError('Task teardown failed after child exit')) + await rejected + expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock']]) + harness.dialog.showMessageBox.mockResolvedValue({ response: 1 }) + const restarted = harness.nextHostStart() + harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Task teardown failed' }) + await restarted + const replacement = harness.hosts[1]! + if (mandatory) replacement.updateTasks.mockResolvedValue(true) + const retry = harness.prepareUpdate() + expect(replacement.updateTasks).not.toHaveBeenCalled() + replacement.ready.resolve() + await vi.waitFor(() => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/']) }) + if (mandatory) await answerMandatory('later') + await expect(retry).resolves.toBe(false) + expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) + expect(replacement.stop).not.toHaveBeenCalled() + if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + }) + + it('does not replace a Host whose failed stop has not confirmed process exit', async () => { + const host = await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + const rejected = expect(preparing).rejects.toThrow('child did not exit') + await host.stopping.promise + host.exited.reject(new Error('child did not exit')) + await rejected + harness.dialog.showMessageBox.mockResolvedValue({ response: 1 }) + harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'child did not exit' }) + await expect(harness.prepareUpdate()).rejects.toThrow('Task status is unavailable') + expect(harness.hosts).toHaveLength(1) + expect(host.updateTasks.mock.calls).toEqual([['inspect'], ['lock'], ['unlock']]) + }) + + it('reports replacement startup failure without authorizing installation or retrying automatically', async () => { + const host = await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + const rejected = expect(preparing).rejects.toMatchObject(new DesktopUpdatePreparationError('stop-failed', en.updateStopFailed, 'Task teardown failed after child exit')) + await host.stopping.promise + host.exited.reject(new DesktopHostUncleanExitError('Task teardown failed after child exit')) + await rejected + harness.dialog.showMessageBox.mockResolvedValue({ response: 1 }) + const restarted = harness.nextHostStart() + harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Task teardown failed' }) + await restarted + const replacement = harness.hosts[1]! + harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) }) + replacement.ready.reject(new Error('replacement startup failed')) + replacement.exited.resolve() + await harness.dialogShown.promise + expect(harness.dialog.showMessageBox.mock.calls.some(call => + (call.at(-1) as MessageBoxOptions).detail?.includes('replacement startup failed'))).toBe(true) + await expect(harness.prepareUpdate()).rejects.toThrow('replacement startup failed') + expect(harness.hosts).toHaveLength(2) + }) + + it('opens fatal recovery when the replacement page cannot reload', async () => { + const host = await readyForUpdate() + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + await host.stopping.promise + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + const window = harness.windows[0]! + vi.spyOn(window, 'loadURL').mockRejectedValueOnce(new Error('replacement page failed to load')) + harness.dialog.showMessageBox.mockImplementation((options: MessageBoxOptions) => { + if (options.detail?.includes('replacement page failed to load')) { + harness.dialogShown.resolve() + return new Promise(() => {}) + } + return Promise.resolve({ response: 1 }) + }) + const restarted = harness.nextHostStart() + harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' }) + await restarted + harness.hosts[1]!.ready.resolve() + await harness.dialogShown.promise + expect(harness.dialog.showMessageBox.mock.calls.some(call => + (call.at(-1) as MessageBoxOptions).detail?.includes('replacement page failed to load'))).toBe(true) + }) + it('reports a window construction failure without requiring a window', async () => { const shown = Promise.withResolvers() harness.dialog.showMessageBox.mockImplementation(() => { shown.resolve(undefined); return new Promise(() => {}) }) @@ -316,7 +1197,7 @@ describe('desktop main startup', () => { await import('../src/main.ts') await shown.promise expect(harness.windows).toHaveLength(0) - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('window creation failed') + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('window creation failed') }) it('accepts Web fatal reports only from the primary application frame', async () => { @@ -332,7 +1213,7 @@ describe('desktop main startup', () => { expect(() => { handler(event, {}) }).toThrow('must be text') expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() handler(event, 'client mount failed') - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('client mount failed') + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('client mount failed') expect(window.urls).toEqual(['dsh-app://app/']) }) @@ -344,47 +1225,7 @@ describe('desktop main startup', () => { window.webContents.emit('did-fail-load', {}, -3, 'aborted', 'dsh-app://app/', true) expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() window.webContents.emit('did-fail-load', {}, -2, 'failed', 'dsh-app://app/', true) - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('Desktop page failed to load') - }) - - it('returns package-operation failures locally without opening fatal recovery', async () => { - await import('../src/main.ts') - await harness.preparing.promise - harness.prepared.resolve() - await harness.hostStarted.promise - const host = harness.hosts[0]! - host.ready.resolve() - await Promise.resolve(invoke(DESKTOP_IPC.boot)) - harness.mutateFailure.mockImplementationOnce(() => { throw new Error('package write failed') }) - host.exited.resolve() - const nextStarted = harness.nextHostStart() - const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('package write failed') - await nextStarted - harness.hosts[1]!.ready.resolve() - await failure - expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() - expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/', 'dsh-app://app/']) - }) - - it('opens fatal recovery when the Host cannot restart after a package change', async () => { - await import('../src/main.ts') - await harness.preparing.promise - harness.prepared.resolve() - await harness.hostStarted.promise - const host = harness.hosts[0]! - host.ready.resolve() - await Promise.resolve(invoke(DESKTOP_IPC.boot)) - host.exited.resolve() - const nextStarted = harness.nextHostStart() - const failure = expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('new Host failed') - await nextStarted - const replacement = harness.hosts[1]! - replacement.exited.resolve() - replacement.ready.reject(new Error('new Host failed')) - await failure - expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('new Host failed') - expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('Desktop page failed to load') }) it('offers all recovery choices when resources fail before the Host starts', async () => { @@ -392,8 +1233,8 @@ describe('desktop main startup', () => { await harness.preparing.promise harness.prepared.reject(new Error('runtime resources missing')) await harness.dialogShown.promise - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing') - expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart']) + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('runtime resources missing') + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart']) expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) }) @@ -407,7 +1248,7 @@ describe('desktop main startup', () => { harness.prepared.reject(new Error('backend also failed')) await harness.dialogShown.promise expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).not.toContain('secondary failure') + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).not.toContain('secondary failure') expect(window.urls).toEqual(['dsh-app://app/']) }) @@ -428,7 +1269,7 @@ describe('desktop main startup', () => { await import('../src/main.ts') await shown.promise expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() - expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('document missing') + expect((harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions).detail).toContain('document missing') expect(harness.hosts).toHaveLength(0) }) @@ -449,6 +1290,13 @@ describe('desktop main startup', () => { await harness.quitCompleted.promise expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1) expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0) + if (response === 2) { + expect(console.info).toHaveBeenCalledWith('Desktop profile recovery completed:', { + profilePatchBackup: 'desktop-test-profile/cordis.patch.yml.bak-1789555200000', homePatch: 'unchanged', + }) + } else { + expect(console.info).not.toHaveBeenCalled() + } expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) }) @@ -498,7 +1346,7 @@ describe('desktop main startup', () => { expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() }) - it('keeps startup errors in the existing window without a shell retry handler', async () => { + it('keeps startup errors in the existing window without a retry handler', async () => { await import('../src/main.ts') await harness.preparing.promise harness.prepared.resolve() @@ -510,7 +1358,6 @@ describe('desktop main startup', () => { expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/']) expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce() expect(harness.handlers.has('dsh-desktop:backend-retry')).toBe(false) - await expect(invoke(DESKTOP_IPC.pluginsAdd, 'example-plugin')).rejects.toThrow('The application could not start or stopped unexpectedly.') expect(harness.hosts).toHaveLength(1) }) diff --git a/apps/desktop/tests/mandatory-update-policy.spec.ts b/apps/desktop/tests/mandatory-update-policy.spec.ts new file mode 100644 index 0000000000..e43e09c814 --- /dev/null +++ b/apps/desktop/tests/mandatory-update-policy.spec.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DesktopMandatoryUpdatePolicy, desktopPolicyPage, resolveDesktopPolicyConfig, type DesktopPolicyState } from '../src/mandatory-update-policy.ts' + +const identity = { platform: 'desktop-win', arch: 'x64', version: '0.1.5-rc.1', bundledDshVersion: '0.1.5-rc.1', + bundleId: 'com.deepseek.dsh', locale: 'zh-CN' } as const +const force = { code: 40005, data: { show_content: { title: 'Update', detail: 'Required upgrade' }, + desktop_app_link: 'https://downloads.example.com/desktop?os=win' } } +const clear = { code: 0, msg: '', data: { biz_code: 0, biz_msg: '', biz_data: null } } +const deployment = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'], + intervalMs: 10_000, timeoutMs: 1_000, maxBackoffMs: 80_000, jitter: 0 } +const instances: DesktopMandatoryUpdatePolicy[] = [] + +function fixture(authentication: 'anonymous' | 'feishu-test' = 'anonymous') { + const request = vi.fn().mockResolvedValue(Response.json(clear)) + const publish = vi.fn<(state: DesktopPolicyState) => void>() + const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig({ ...deployment, authentication })!, + identity, publish, request) + instances.push(policy) + return { policy, request, publish } +} + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(async () => { + await Promise.all(instances.splice(0).map(policy => policy.dispose())) + vi.useRealTimers() +}) + +describe('mandatory update policy', () => { + it('retains a known block through test authentication expiry and rejects 422 as a policy decision', async () => { + const { policy, request } = fixture('feishu-test') + request.mockResolvedValueOnce(Response.json(force)).mockResolvedValueOnce(Response.json({ + error: { code: 'UNAUTHENTICATED', login_url: 'https://untrusted.example/' }, + }, { status: 401 })).mockResolvedValueOnce(Response.json({ detail: [] }, { status: 422 })) + await policy.check('launch') + expect(await policy.check('manual', true)).toMatchObject({ blocking: true, error: 'authentication-required' }) + expect(request.mock.calls[1]![1]).toMatchObject({ credentials: 'include', redirect: 'error' }) + expect(await policy.check('manual', true)).toMatchObject({ blocking: true, error: 'unavailable' }) + expect(await policy.check('manual', true)).toEqual({ blocking: false, checking: false }) + }) + + it.each(['anonymous', 'feishu-test'] as const)('does not infer login from HTML or unrelated 401 in %s mode', async (authentication) => { + const { policy, request } = fixture(authentication) + request.mockResolvedValueOnce(Response.json({ error: { code: 'OTHER' } }, { status: 401 })) + .mockResolvedValueOnce(new Response('login', { status: 200 })) + expect(await policy.check('launch')).toMatchObject({ error: 'unavailable' }) + expect(await policy.check('manual', true)).toMatchObject({ error: 'unavailable' }) + }) + + it('does not enable authentication from a gateway response in anonymous mode', async () => { + const { policy, request } = fixture() + request.mockResolvedValueOnce(Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })) + expect(await policy.check('launch')).toMatchObject({ error: 'unavailable' }) + }) + it('sends an anonymous independent request with installed release headers and fixed Nightly', async () => { + const { policy, request } = fixture() + await expect(policy.check('launch')).resolves.toEqual({ blocking: false, checking: false }) + const [url, options] = request.mock.calls[0]! + expect(url).toBeInstanceOf(URL) + expect((url as URL).href).toBe('https://policy.example.com/api/v0/check_client_update?scenario=launch') + expect(options).toMatchObject({ credentials: 'omit', cache: 'no-store', redirect: 'error', headers: { + 'x-client-platform': 'desktop-win', 'x-client-version': '0.1.5-rc.1', 'x-client-bundle-id': 'com.deepseek.dsh', + 'x-client-locale': 'zh-CN', 'x-client-arch': 'x64', 'x-client-update-channel': 'nightly', + 'x-client-bundled-dsh-version': '0.1.5-rc.1', + } }) + }) + + it('coalesces manual checks and honors the interval on foreground/resume', async () => { + const { policy, request } = fixture() + const pending = Promise.withResolvers() + request.mockReturnValueOnce(pending.promise) + const first = policy.check('launch') + const second = policy.check('manual', true) + expect(first).toBe(second) + await Promise.resolve() + expect(request).toHaveBeenCalledTimes(1) + pending.resolve(Response.json(clear)) + await first + await policy.check('foreground') + await policy.check('resume') + expect(request).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(10_000) + expect(request).toHaveBeenCalledTimes(2) + await policy.check('manual', true) + expect(request).toHaveBeenCalledTimes(3) + }) + + it('accepts flattened 40005 on an HTTP error and keeps server content as plain text', async () => { + const { policy, request } = fixture() + request.mockResolvedValueOnce(Response.json(force, { status: 403 })) + await expect(policy.check('launch')).resolves.toEqual({ blocking: true, checking: false, + title: 'Update', detail: 'Required upgrade', page: force.data.desktop_app_link }) + await vi.advanceTimersByTimeAsync(10_000) + expect(request).toHaveBeenCalledTimes(2) + expect(policy.state.blocking).toBe(false) + }) + + it.each([ + { code: 0, data: { biz_code: 1, biz_data: null } }, + { code: 0, data: {} }, + { code: 500 }, + ])('does not treat an invalid no-force decision as permission to unblock: %j', async (body) => { + const { policy, request } = fixture() + request.mockResolvedValueOnce(Response.json(force)).mockResolvedValueOnce(Response.json(body)) + await policy.check('launch') + expect((await policy.check('manual', true))).toMatchObject({ blocking: true, error: 'unavailable' }) + request.mockResolvedValueOnce(Response.json(clear)) + expect((await policy.check('manual', true)).blocking).toBe(false) + }) + + it('keeps a recognized block when optional copy and navigation are absent', async () => { + const { policy, request } = fixture() + request.mockResolvedValueOnce(Response.json({ code: 40005, data: { alt_app: force.data } })) + expect(await policy.check('launch')).toEqual({ blocking: true, checking: false }) + }) + + it('does not clear a block on HTTP 500 carrying a nominal success body', async () => { + const { policy, request } = fixture() + request.mockResolvedValueOnce(Response.json(force)).mockResolvedValueOnce(Response.json(clear, { status: 500 })) + await policy.check('launch') + expect(await policy.check('manual', true)).toMatchObject({ blocking: true, error: 'unavailable' }) + }) + + it('aborts a stalled request at its own deadline and retries with bounded backoff', async () => { + const { policy, request } = fixture() + request.mockImplementationOnce(async (_url, options) => new Promise((_resolve, reject) => { + options!.signal!.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + })) + const pending = policy.check('launch') + await vi.advanceTimersByTimeAsync(1000) + expect(await pending).toEqual({ blocking: false, checking: false, error: 'unavailable' }) + await vi.advanceTimersByTimeAsync(19_999) + expect(request).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(request).toHaveBeenCalledTimes(2) + }) + + it('awaits cancellation and suppresses late publications during dispose', async () => { + const { policy, request, publish } = fixture() + const entered = Promise.withResolvers() + request.mockImplementationOnce(async (_url, options) => new Promise((_resolve, reject) => { + options!.signal!.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + entered.resolve(undefined) + })) + const pending = policy.check('launch') + await entered.promise + const count = publish.mock.calls.length + await policy.dispose() + await pending + expect(publish).toHaveBeenCalledTimes(count) + await vi.advanceTimersByTimeAsync(1_000_000) + expect(request).toHaveBeenCalledTimes(1) + await expect(policy.check('manual', true)).rejects.toThrow('disposed') + }) + + it('does not start network work if disposed before the request microtask', async () => { + const { policy, request } = fixture() + const pending = policy.check('launch') + await policy.dispose() + await pending + expect(request).not.toHaveBeenCalled() + }) +}) + +describe('policy deployment and page validation', () => { + it('requires explicit production origins and confines HTTP to an explicit local policy fixture', () => { + expect(resolveDesktopPolicyConfig(undefined)).toBeUndefined() + expect(() => resolveDesktopPolicyConfig({ ...deployment, origin: 'http://127.0.0.1:19001' })).toThrow('HTTPS') + expect(resolveDesktopPolicyConfig({ ...deployment, origin: 'http://127.0.0.1:19001' }, true)!.origin).toBe('http://127.0.0.1:19001') + expect(() => resolveDesktopPolicyConfig({ ...deployment, origin: 'http://example.com' }, true)).toThrow('HTTPS') + }) + + it.each(['https://user:pass@downloads.example.com/x', 'https://downloads.example.com.evil.test', 'file:///a.exe', + 'javascript:alert(1)', 'http://downloads.example.com', 'https://downloads.example.com:9443'])('rejects a disallowed page: %s', (url) => { + expect(desktopPolicyPage(url, deployment.allowedPageOrigins)).toBeUndefined() + }) + + it.each([{ intervalMs: 0 }, { timeoutMs: 1.5 }, { maxBackoffMs: 1000 }, { jitter: 2 }, { allowedPageOrigins: [] }, + { origin: 'https://example.com/path' }, { authentication: 'feishu' }, + { authentication: 'feishu-test', origin: 'http://127.0.0.1:9000' }])('rejects invalid deployment input: %j', (change) => { + expect(() => resolveDesktopPolicyConfig({ ...deployment, ...change })).toThrow() + }) +}) diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts new file mode 100644 index 0000000000..84f9195122 --- /dev/null +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -0,0 +1,154 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import { DesktopMandatoryUpdateWindow, type MandatoryUpdateView } from '../src/mandatory-update-window.ts' +import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts' +import { resolveDesktopLocale } from '../src/locale.ts' +import type { DesktopPolicyState } from '../src/mandatory-update-policy.ts' +import type { DesktopUpdateState } from '../src/ipc.ts' + +const native = vi.hoisted(() => ({ handlers: new Map unknown>(), + open: vi.fn<(...args: unknown[]) => Promise>(), write: vi.fn(), read: vi.fn(), quit: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler), + removeHandler: (channel: string) => native.handlers.delete(channel), +}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } })) +vi.mock('../src/update-overlay.ts', () => ({ createMandatoryUpdateWindow: () => window })) + +let window: ReturnType +let ui: DesktopMandatoryUpdateWindow | undefined +function fakeWindow() { + const contents = Object.assign(new EventEmitter(), { mainFrame: { url: 'dsh-app://shell/mandatory-update.html' }, + send: vi.fn(), setWindowOpenHandler: vi.fn() }) + return Object.assign(new EventEmitter(), { webContents: contents, setMenu: vi.fn(), setTitle: vi.fn(), + loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false, + isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() }) +} +afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) +function setup() { + window = fakeWindow() + let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } + let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' } + const install = vi.fn(async () => update) + ui = new DesktopMandatoryUpdateWindow({ preload: 'owned', locale: resolveDesktopLocale('zh'), + allowedPageOrigins: ['https://downloads.example.com'], parent: () => window as unknown as BrowserWindow, + policy: () => policy, update: () => update, refresh: async () => {}, download: async () => update, install }) + ui.sync() + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const view = () => native.handlers.get(MANDATORY_IPC.status)!(event) as MandatoryUpdateView + const action = (name: string, version: string = '1.0.1-nightly.1', revision = view().confirmation?.revision) => + native.handlers.get(MANDATORY_IPC.action)!(event, name, version, revision) + return { view, action, install, policy(next: DesktopPolicyState) { policy = next; ui!.sync() }, + update(next: DesktopUpdateState) { update = next; ui!.sync() } } +} + +it('waits for a version-bound second click in the same modal and rejects obsolete or hidden responses', async () => { + const f = setup() + const pending = ui!.confirm('1.0.1-nightly.1', false) + expect(f.view().confirmation).toEqual({ version: '1.0.1-nightly.1', active: false, revision: 1 }) + expect(window.loadURL).toHaveBeenCalledOnce() + expect(f.install).not.toHaveBeenCalled() + expect(() => f.action('install', '2.0.0')).toThrow(/stale/) + expect(() => f.action('later')).toThrow(/deferral/) + await f.action('install') + expect(await pending).toBe(true) + expect(f.view().confirmation).toBeUndefined() + ui!.preparingRestart(false) + expect(f.view().restart).toBe('preparing') + ui!.preparingRestart(true) + expect(f.view().restart).toBe('stopping-tasks') + expect(window.loadURL).toHaveBeenCalledOnce() +}) + +it('defers without dismissing, and cancels pending approval on policy clearance or disposal', async () => { + const f = setup() + const deferred = ui!.confirm('1.0.1-nightly.1', true) + await f.action('later') + expect(await deferred).toBe(false) + expect(f.view().deferred).toBe(true) + expect(window.destroy).not.toHaveBeenCalled() + const cleared = ui!.confirm('1.0.1-nightly.1', false) + expect(() => f.action('install', '1.0.1-nightly.1', 1)).toThrow(/stale/) + f.policy({ blocking: false, checking: false }) + expect(await cleared).toBe(false) + ui!.preparingRestart(false) + f.policy({ blocking: true, checking: false }) + expect(f.view().restart).toBeUndefined() + expect(f.view().deferred).toBe(false) + const disposed = ui!.confirm('1.0.1-nightly.1', false) + ui!.dispose() + expect(await disposed).toBe(false) + expect(native.handlers.size).toBe(0) +}) + +it('offers copy immediately while browser opening is pending and keeps navigation failures separate', async () => { + const f = setup() + const opened = Promise.withResolvers() + native.open.mockReturnValueOnce(opened.promise) + native.read.mockResolvedValue('https://downloads.example.com/desktop') + const pending = f.action('page') + expect(f.view().navigation).toEqual({ page: 'requested' }) + await f.action('copy') + expect(f.view().navigation).toEqual({ page: 'requested', copy: 'copied' }) + opened.reject(new Error('browser rejected')) + await pending + expect(f.view().navigation).toEqual({ page: 'failed', copy: 'copied' }) + expect(f.view().error).toBeUndefined() + expect(f.view().policy.blocking).toBe(true) +}) + +it('reports clipboard failure and ignores completion after the policy destination changes', async () => { + const f = setup() + native.read.mockResolvedValue('different clipboard contents') + await f.action('page') + await f.action('copy') + expect(f.view().navigation?.copy).toBe('failed') + const pendingCopy = Promise.withResolvers() + native.write.mockReturnValueOnce(pendingCopy.promise) + const pending = f.action('copy') + f.policy({ blocking: true, checking: false, page: 'https://downloads.example.com/new' }) + pendingCopy.reject(new Error('old copy failed')) + await pending + expect(f.view().navigation).toBeUndefined() + f.policy({ blocking: true, checking: false, page: 'https://evil.example/desktop' }) + await expect(f.action('page')).rejects.toThrow(/no allowed/) + expect(native.open).toHaveBeenCalledTimes(1) +}) + +it('rejects same-URL child frames and removes quit from renderer privileges', () => { + const f = setup() + const event = { sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } } + expect(() => native.handlers.get(MANDATORY_IPC.status)!(event)).toThrow(/unowned/) + expect(() => f.action('quit')).toThrow(/invalid action/) +}) + +it('exits the application when the mandatory window is closed without clearing the policy', () => { + const f = setup() + const event = { preventDefault: vi.fn() } + window.emit('close', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(native.quit).toHaveBeenCalledOnce() + expect(f.view().policy.blocking).toBe(true) +}) + +it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => { + vi.useFakeTimers() + const f = setup() + f.policy({ blocking: false, checking: false }) + expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, + f.view()) + vi.advanceTimersByTime(100) + expect(window.destroy).not.toHaveBeenCalled() + f.policy({ blocking: true, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).not.toHaveBeenCalled() + expect(window.loadURL).toHaveBeenCalledOnce() + f.policy({ blocking: false, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).toHaveBeenCalledOnce() + expect(ui!.confirmationWindow).toBeUndefined() + f.policy({ blocking: true, checking: false }) + f.policy({ blocking: false, checking: false }) + ui!.dispose() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/apps/desktop/tests/owned-directory.spec.ts b/apps/desktop/tests/owned-directory.spec.ts new file mode 100644 index 0000000000..081e5adc76 --- /dev/null +++ b/apps/desktop/tests/owned-directory.spec.ts @@ -0,0 +1,53 @@ +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { removeOwnedDirectory } from '../src/owned-directory.ts' + +const roots: string[] = [] +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'desktop-owned-directory-')) + roots.push(root) + return root +} + +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('removes nested files and directories while preserving unknown directory-link targets', () => { + const root = fixture() + const target = join(root, 'target') + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + const owned = join(root, 'owned') + const nested = join(owned, 'node_modules', '@unknown', 'private') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'file'), 'remove') + symlinkSync(target, join(nested, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(join(root, 'missing-target'), join(owned, 'broken'), process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(owned) + expect(existsSync(owned)).toBe(false) + expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it.each([false, true])('unlinks a root directory link with missing target: %s', (missing) => { + const root = fixture() + const target = join(root, 'target') + if (!missing) { + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + } + const link = join(root, 'link') + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(link) + expect(lstatSync(link, { throwIfNoEntry: false })).toBeUndefined() + if (!missing) expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it('ignores a missing root and refuses to delete a regular-file root', () => { + const root = fixture() + expect(() => { removeOwnedDirectory(join(root, 'missing')) }).not.toThrow() + const file = join(root, 'file') + writeFileSync(file, 'preserved') + expect(() => { removeOwnedDirectory(file) }).toThrow(/not a directory/u) + expect(readFileSync(file, 'utf8')).toBe('preserved') +}) diff --git a/apps/desktop/tests/package-macos.spec.ts b/apps/desktop/tests/package-macos.spec.ts index fcbc743bbd..3b780b8b06 100644 --- a/apps/desktop/tests/package-macos.spec.ts +++ b/apps/desktop/tests/package-macos.spec.ts @@ -11,11 +11,13 @@ import { type MacOSArtifactOperations, } from '../scripts/package-macos.ts' import { desktopElectronBuilderArguments, resolveDesktopPackageTarget } from '../scripts/package-target.ts' +import { writeMacOSAppUpdateConfig } from '../scripts/macos-app-update-config.mjs' const environment = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', APPLE_KEYCHAIN_PROFILE: 'fixture-profile', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', } function barrier() { @@ -28,8 +30,11 @@ async function fixture(arch: 'arm64' | 'x64' = 'arm64') { const root = await mkdtemp(join(tmpdir(), 'desktop-parallel-notarization-')) const artifactsRoot = join(root, 'artifacts') const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') - await mkdir(appPath, { recursive: true }) + await mkdir(join(appPath, 'Contents', 'Resources'), { recursive: true }) await writeFile(join(appPath, 'payload'), 'signed content') + await writeMacOSAppUpdateConfig(join(appPath, 'Contents', 'Resources'), { + publicUrl: `https://desktop-updates.example.com/dsh-desk/feeds/mac-${arch}/`, + }, 'deepseek-harness-updater') const version = '1.2.3-alpha.1' const base = `deepseek-harness-${version}-mac-${arch}` const request = { arch, artifactsRoot, version, environment } @@ -52,7 +57,7 @@ async function fixture(arch: 'arm64' | 'x64' = 'arm64') { await writeFile(join(artifact.output, `${base}.${artifact.format}`), contents) if (artifact.format === 'zip') { await writeFile(join(artifact.output, `${base}.zip.blockmap`), 'blockmap') - await writeFile(join(artifact.output, 'alpha-mac.yml'), 'update metadata') + await writeFile(join(artifact.output, 'nightly-mac.yml'), 'update metadata') } } return { root, appPath, request, apple, build, base } @@ -101,8 +106,10 @@ describe('parallel macOS artifacts', () => { expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.dmg`), 'utf8'))) .toEqual({ payload: 'signed content', appTicket: false }) expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted') + expect(await readFile(join(f.appPath, 'Contents', 'Resources', 'app-update.yml'), 'utf8')) + .toContain(`/dsh-desk/feeds/mac-${arch}/`) expect((await readdir(f.root)).sort()).toEqual(['artifacts']) - expect(f.apple.verifySignature).toHaveBeenCalledTimes(2) + expect(f.apple.verifySignature).toHaveBeenCalledTimes(4) expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1) } finally { appAccepted.release() @@ -154,19 +161,31 @@ describe('parallel macOS artifacts', () => { } }) - it.each(['copy', 'signature', 'ticket', 'metadata'] as const)('rejects incomplete %s qualification without promoting artifacts', async (failure) => { + it.each(['copy', 'signature', 'post-signature', 'ticket', 'metadata', 'update-config', 'post-update-config'] as const)('rejects incomplete %s qualification without promoting artifacts', async (failure) => { const f = await fixture() try { + if (failure === 'update-config') { + await writeFile(join(f.appPath, 'Contents', 'Resources', 'app-update.yml'), 'provider: generic\nurl: https://wrong.example.com/\nchannel: nightly\nupdaterCacheDirName: fixture\n') + } + let signatureChecks = 0 const apple: MacOSArtifactOperations = { ...f.apple, ...(failure === 'copy' ? { copyApp: async () => { throw new Error('copy failed') } } : {}), - ...(failure === 'signature' ? { verifySignature: () => { throw new Error('signature failed') } } : {}), + ...(failure === 'signature' || failure === 'post-signature' ? { + verifySignature: () => { + signatureChecks += 1 + if (failure === 'signature' || signatureChecks > 2) throw new Error('signature failed') + }, + } : {}), ...(failure === 'ticket' ? { verifyNotarization: () => { throw new Error('ticket failed') } } : {}), } await expect(packageMacOSArtifacts(f.request, async (artifact) => { await f.build(artifact) if (failure === 'metadata' && artifact.format === 'zip') { - await writeFile(join(artifact.output, 'alpha-mac.yml'), '') + await writeFile(join(artifact.output, 'nightly-mac.yml'), '') + } + if (failure === 'post-update-config' && artifact.format === 'zip') { + await writeFile(join(artifact.appPath, 'Contents', 'Resources', 'app-update.yml'), '{}') } }, apple)).rejects.toThrow() expect(await readdir(f.root)).toEqual(['artifacts']) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts new file mode 100644 index 0000000000..bdf636ef57 --- /dev/null +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -0,0 +1,87 @@ +import { writeFileSync } from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' +import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts' +import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs' +import { prepareWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs' + +vi.mock('../scripts/windows-signing-stage.mjs', () => ({ + withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise) => operation()), +})) +vi.mock('../scripts/windows-signature-cache-directory.mjs', () => ({ + prepareWindowsSignatureCacheDirectory: vi.fn(async () => {}), + resolveWindowsSignatureCacheDirectory: vi.fn(() => 'C:\\fixture-cache'), +})) + +// Keep the real orchestration and manifest reads; this suite owns no release directories or subprocesses. +vi.mock('node:fs', async importOriginal => ({ + ...await importOriginal(), + rmSync: vi.fn(), mkdirSync: vi.fn(), writeFileSync: vi.fn(), renameSync: vi.fn(), +})) + +afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() }) + +const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', + DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } + +function supervisor(failure?: string) { + vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs') + const stages: string[] = [] + const run = { directory: 'fixture-record', finish: vi.fn(), + run: vi.fn(async (stage: string, _executable: string, _args: readonly string[], _options: { env: NodeJS.ProcessEnv }) => { + stages.push(stage) + if (stage === failure) throw new Error('stage refused') + }) } + return { run, stages } +} + +it('requires one signing preflight before building, then records only the complete release', async () => { + const { run, stages } = supervisor() + await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run) + expect(stages.slice(0, 2)).toEqual(['preflight:windows-signing', 'run build:official']) + expect(stages.filter(stage => stage === 'preflight:windows-signing')).toHaveLength(1) + expect(vi.mocked(withWindowsSigningStage).mock.calls.map(([options]) => options.stage)) + .toEqual(['preflight', 'artifacts']) + expect(run.run.mock.calls[0]![3]).toMatchObject({ env: { DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }, timeoutMs: 60_000 }) + expect(run.run.mock.calls[1]![3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(stages.indexOf('run sign:primary-runtime --dsh')).toBeGreaterThan(stages.indexOf('run prepare:dsh --defer-runtime-smoke')) + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts') + for (const call of run.run.mock.calls) { + if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) { + expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + } + } + expect(writeFileSync).toHaveBeenCalledOnce() +}) + +it('initializes shared storage only after acquiring the preflight stage lock', async () => { + const { run } = supervisor() + vi.mocked(withWindowsSigningStage).mockImplementationOnce(async (_options, operation) => { + expect(prepareWindowsSignatureCacheDirectory).not.toHaveBeenCalled() + await operation() + expect(prepareWindowsSignatureCacheDirectory).toHaveBeenCalledOnce() + }) + await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run) +}) + +it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh --defer-runtime-smoke', 'run sign:primary-runtime --dsh', + 'exec tsx scripts/smoke-packaged-runtime.ts', + 'exec electron-builder --config electron-builder.config.mjs --win --x64 --publish never']) +('never continues or records a release after %s fails', async (failure) => { + const { run, stages } = supervisor(failure) + await expect(packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run)).rejects.toThrow('stage refused') + expect(stages.at(-1)).toBe(failure) + expect(stages.filter(stage => stage === failure)).toHaveLength(1) + expect(writeFileSync).not.toHaveBeenCalled() +}) + +it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no release record', async (mode) => { + const { run, stages } = supervisor() + await packageTarget(parseDesktopPackageInvocation(['win-x64', mode], 'win32', 'x64'), environment, run) + expect(stages[0]).toBe('run build:official') + expect(stages).not.toContain('preflight:windows-signing') + expect(stages).not.toContain('run sign:primary-runtime') + expect(stages).not.toContain('run sign:primary-runtime --dsh') + expect(withWindowsSigningStage).not.toHaveBeenCalled() + for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(writeFileSync).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/packaging-run.spec.ts b/apps/desktop/tests/packaging-run.spec.ts new file mode 100644 index 0000000000..8fbf685dad --- /dev/null +++ b/apps/desktop/tests/packaging-run.spec.ts @@ -0,0 +1,96 @@ +import { mkdtemp, readFile, rm, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createPackagingRun, packagingOutputRedactor } from '../scripts/packaging-run.mjs' + +const environment = Object.fromEntries(Object.entries(process.env) + .filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$/iu.test(name))) + +describe('packaging run records', () => { + it('redacts credential values across every byte split', () => { + const secret = 'test-口令-!secret' + const bytes = Buffer.from(`before ${secret} after`) + for (let split = 0; split <= bytes.length; split++) { + let output = '' + const redactor = packagingOutputRedactor([secret], (text) => { output += text }) + redactor.write(bytes.subarray(0, split)) + redactor.write(bytes.subarray(split)) + redactor.end() + expect(output).toBe('before [REDACTED] after') + } + }) + + it('retains redacted output and prevents a later stage after a failed child', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-package-record-')) + try { + const run = createPackagingRun(root, { target: 'fixture' }) + await expect(run.run('failure', process.execPath, ['-e', "process.stdout.write(process.env.TEST_SECRET_KEY);process.stderr.write('fixture failure');process.exitCode=1"], { + cwd: root, env: { ...environment, TEST_SECRET_KEY: 'test-secret-value' }, + })).rejects.toThrow('failure failed') + await expect(run.run('not-started', process.execPath, ['--version'], { cwd: root, env: environment })).rejects.toThrow('run is blocked') + run.finish(false) + expect(await readFile(join(run.directory, 'stdout.log'), 'utf8')).toBe('[REDACTED]') + expect(await readFile(join(run.directory, 'stderr.log'), 'utf8')).toBe('fixture failure') + const events = await readFile(join(run.directory, 'events.jsonl'), 'utf8') + expect(events).toContain('stage-end') + expect(events).not.toContain('not-started') + expect(JSON.parse(await readFile(join(run.directory, 'result.json'), 'utf8'))).toMatchObject({ success: false }) + } finally { await rm(root, { recursive: true, force: true }) } + }) + + it('kills the stage and its descendant on a fatal marker before either exits voluntarily', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-package-tree-')) + try { + const run = createPackagingRun(root, {}) + await expect(run.run('fatal', process.execPath, [resolve(import.meta.dirname, 'fixtures/packaging-failure.mjs')], { + cwd: root, env: environment, + })).rejects.toThrow('fatal failed') + const descendant = JSON.parse(await readFile(join(run.directory, 'descendant.json'), 'utf8')) as { pid: number } + expect(() => process.kill(descendant.pid, 0)).toThrow() + const events = (await readFile(join(run.directory, 'events.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { type: string; fatalObserved?: boolean; terminationError?: boolean }) + expect(events.at(-1)).toMatchObject({ type: 'stage-end', fatalObserved: true, terminationError: false }) + run.finish(false) + } finally { await rm(root, { recursive: true, force: true }) } + }) + + it('fails closed when the output journal cannot be written', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-package-log-failure-')) + try { + const run = createPackagingRun(root, {}) + await rm(join(run.directory, 'stdout.log')) + await mkdir(join(run.directory, 'stdout.log')) + await expect(run.run('output', process.execPath, ['-e', "process.stdout.write('fixture');setInterval(()=>{},1000)"], { + cwd: root, env: environment, + })).rejects.toThrow('output failed') + run.finish(false) + } finally { await rm(root, { recursive: true, force: true }) } + }) + + it('records successful stages without creating a release completion record', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-package-success-')) + try { + const run = createPackagingRun(root, {}) + await run.run('version', process.execPath, ['--version'], { cwd: root, env: environment }) + run.finish(true) + expect(JSON.parse(await readFile(join(run.directory, 'result.json'), 'utf8'))).toMatchObject({ success: true }) + expect(await readFile(join(run.directory, 'events.jsonl'), 'utf8')).toContain('stage-spawn') + } finally { await rm(root, { recursive: true, force: true }) } + }) + + it('records a stage deadline separately from child exit and refuses subsequent stages', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-package-deadline-')) + try { + const run = createPackagingRun(root, {}) + await expect(run.run('deadline', process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + cwd: root, env: environment, timeoutMs: 50, + })).rejects.toThrow('deadline failed') + const events = (await readFile(join(run.directory, 'events.jsonl'), 'utf8')).trim().split('\n') + .map(line => JSON.parse(line) as { type: string; childPid?: number }) + expect(events.at(-1)).toMatchObject({ type: 'stage-end', timedOut: true, fatalObserved: true, terminationError: false }) + expect(() => process.kill(events.find(row => row.type === 'stage-spawn')!.childPid!, 0)).toThrow() + await expect(run.run('later', process.execPath, ['--version'], { cwd: root, env: environment })).rejects.toThrow('blocked') + run.finish(false) + } finally { await rm(root, { recursive: true, force: true }) } + }) +}) diff --git a/apps/desktop/tests/plugin-manager.spec.ts b/apps/desktop/tests/plugin-manager.spec.ts deleted file mode 100644 index e4a86ab397..0000000000 --- a/apps/desktop/tests/plugin-manager.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readFileSync } from 'node:fs' -import { runInContext } from 'node:vm' -import { JSDOM } from 'jsdom' -import { expect, it, vi } from 'vitest' -import { resolveDesktopLocale } from '../src/locale.ts' - -it('keeps disabled packages visible without an in-page recovery panel', async () => { - const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) - let enabled = false - const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active }) - const api = { - locale: async () => resolveDesktopLocale('en'), - plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], toggle }, - } - Object.defineProperty(dom.window, 'dshDesktop', { value: api }) - try { - runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) - const document = dom.window.document - await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin') - expect(document.querySelector('#recovery')).toBeNull() - expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"') - document.querySelector('#plugins li button')?.click() - await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]]) - await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove') - } finally { dom.window.close() } -}) - -it.each(['en', 'zh-CN'])('updates through the inline form, including the installed version (%s)', async (language) => { - const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) - const locale = resolveDesktopLocale(language) - const update = vi.fn(async () => {}) - Object.defineProperty(dom.window, 'dshDesktop', { value: { - locale: async () => locale, - plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled: true }], update }, - } }) - try { - runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) - const document = dom.window.document - await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin') - const updateButton = () => document.querySelectorAll('#plugins li button')[1]! - const form = document.querySelector('#update-form')! - const version = document.querySelector('#update-version')! - updateButton().click() - expect(form.hidden).toBe(false) - expect(version.value).toBe('1.0.0') - expect(document.activeElement).toBe(version) - expect(document.querySelector('#update-label')?.textContent).toBe(locale.messages.targetVersion.replace('{name}', 'example-plugin')) - expect(document.querySelector('#update-cancel')?.textContent).toBe(locale.messages.cancel) - if (language === 'en') { - expect({ label: document.querySelector('#update-label')?.textContent, - actions: [...form.querySelectorAll('button')].map(button => button.textContent) }).toMatchInlineSnapshot(` - { - "actions": [ - "Update", - "Cancel", - ], - "label": "Enter the target version for example-plugin", - } - `) - } - document.querySelector('#update-cancel')!.click() - expect(form.hidden).toBe(true) - expect(document.activeElement).toBe(updateButton()) - expect(update).not.toHaveBeenCalled() - for (const target of ['1.0.0', '^2.0.0']) { - updateButton().click() - version.value = target - form.requestSubmit() - await expect.poll(() => document.querySelector('#status')?.textContent).toBe(locale.messages.operationComplete) - expect(update).toHaveBeenLastCalledWith('example-plugin', target) - expect(form.hidden).toBe(true) - } - expect(update).toHaveBeenCalledTimes(2) - } finally { dom.window.close() } -}) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts deleted file mode 100644 index 9c279e41c4..0000000000 --- a/apps/desktop/tests/plugin-pnpm.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' -import { createServer } from 'node:http' -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { c } from 'tar' -import { expect, it } from 'vitest' -import { healIsolatedProfileModuleFallback, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot' -import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts' -import { resolveDesktopPaths } from '../src/paths.ts' -import { runtimeFixture, writePackage } from './runtime-fixture.ts' - -it('installs a real pnpm graph and executes scripts approved by user configuration', async () => { - // pnpm resolves its cwd natively; use the same spelling for Windows 8.3 temp paths. - const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-'))) - const server = createServer() - const archives = new Map() - try { - for (const name of ['fixture-plugin', 'fixture-script-dependency']) { - const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin' - ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } } - : { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"') - writeFileSync(join(path, 'bundle.yml'), '[]\n') - writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath}))') - const tarball = join(root, `${name}.tgz`) - await c({ file: tarball, cwd: join(path, '..'), gzip: true }, [name]) - archives.set(name, readFileSync(tarball)) - } - await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('fixture registry has no TCP address') - const origin = `http://127.0.0.1:${address.port}` - server.on('request', (request, response) => { - const name = request.url?.slice(1).replace(/\.tgz$/u, '') ?? '' - const archive = archives.get(name) - if (archive === undefined) { response.writeHead(404); response.end(); return } - if (request.url?.endsWith('.tgz')) { response.end(archive); return } - response.setHeader('content-type', 'application/json') - response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': { - name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` }, - ...(name === 'fixture-plugin' ? { dependencies: { 'fixture-script-dependency': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : { scripts: { install: 'node install.cjs' } }), - } }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } })) - }) - const dsh = join(root, 'dsh') - runtimeFixture(dsh) - const pnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') - const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) - const installAnchor = join(dsh, 'node_modules', '@deepseek-ai', 'dsh', 'package.json') - const hooks: DesktopProjectHooks = { - beforeChange: async () => {}, - afterChange: async () => { - const profile = loadProfileDirectory('desktop test', manager.paths.profile, installAnchor) - healIsolatedProfileModuleFallback({ installAnchor, profile }) - }, - } - await manager.applyRelease() - writeFileSync(join(manager.paths.profile, '.npmrc'), `registry=${origin}\n`) - writeFileSync(join(manager.paths.profile, 'pnpm-workspace.yaml'), `packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nstoreDir: ${JSON.stringify(join(root, 'store'))}\nallowBuilds:\n fixture-script-dependency: true\n`) - await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) - expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) - const builtPath = join(manager.paths.profile, 'node_modules/fixture-script-dependency/built.json') - if (!existsSync(builtPath)) { - const config = (name: string): string => execFileSync(process.execPath, [pnpm, 'config', 'get', name], { - cwd: manager.paths.profile, encoding: 'utf8', - }).trim() - throw new Error(`pnpm install script produced no output: ignoreScripts=${config('ignoreScripts')}, allowBuilds=${config('allowBuilds')}`) - } - const built = JSON.parse(readFileSync(builtPath, 'utf8')) as { node: string } - expect(realpathSync(built.node)).toBe(realpathSync(process.execPath)) - const entry = join(dsh, 'identity.mjs') - writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`) - expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('true') - await manager.mutate({ type: 'plugin-update', name: 'fixture-plugin', version: '^1.0.0' }, hooks) - expect(manager.listPlugins()[0]?.version).toBe('1.0.0') - await manager.mutate({ type: 'plugin-remove', name: 'fixture-plugin' }, hooks) - expect(manager.listPlugins()).toEqual([]) - } finally { - server.closeAllConnections() - if (server.listening) await new Promise((resolve, reject) => { - server.close((error) => { - if (error !== undefined) reject(error) - else resolve() - }) - }) - rmSync(root, { recursive: true, force: true }) - } -}, 30_000) - -it.each(['directory', 'file', 'tarball', 'plain'] as const)('installs a %s source through pnpm', async (source) => { - const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'desktop-local-pnpm-'))) - try { - const dsh = join(root, 'dsh') - runtimeFixture(dsh) - const packageDir = writePackage(join(root, 'local packages'), 'local-plugin', { - ...(source === 'plain' ? {} : { dsh: { bundle: { patch: 'missing.yml' } } }), - peerDependencies: { '@deepseek-ai/cordis': '^999.0.0' }, - }) - const tarball = join(root, 'local-plugin.tgz') - await c({ file: tarball, cwd: join(packageDir, '..'), gzip: true }, ['local-plugin']) - const spec = source === 'directory' ? packageDir : source === 'file' ? `file:${packageDir}` : tarball - const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { - node: process.execPath, pnpm: join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs'), dsh, - }) - const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} } - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec }, hooks) - expect(manager.listPlugins()).toEqual([{ name: 'local-plugin', version: '1.0.0', enabled: source !== 'plain' }]) - expect(existsSync(join(manager.paths.profile, 'node_modules/local-plugin/missing.yml'))).toBe(false) - await manager.disableAllPlugins() - expect(manager.listPlugins()[0]?.enabled).toBe(false) - await manager.mutate({ type: 'plugin-remove', name: 'local-plugin' }, hooks) - expect(manager.listPlugins()).toEqual([]) - expect(existsSync(join(packageDir, 'package.json'))).toBe(true) - } finally { - rmSync(root, { recursive: true, force: true }) - } -}, 30_000) diff --git a/apps/desktop/tests/policy-login-loading.spec.ts b/apps/desktop/tests/policy-login-loading.spec.ts new file mode 100644 index 0000000000..6baee7fed9 --- /dev/null +++ b/apps/desktop/tests/policy-login-loading.spec.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { JSDOM } from 'jsdom' +import { expect, it } from 'vitest' + +const file = join(import.meta.dirname, '../renderer/policy-login-loading.html') +const html = readFileSync(file, 'utf8') + +/** Load the packaged placeholder the way the login window does: a local document with a query. */ +function open(query = ''): Document { + const dom = new JSDOM(html, { + url: `${pathToFileURL(file).href}${query}`, + runScripts: 'dangerously', + }) + return dom.window.document +} + +it('renders the login placeholder label from the main-process query', () => { + const document = open(`?label=${encodeURIComponent('正在加载登录页面…')}`) + expect(document.getElementById('label')?.textContent).toBe('正在加载登录页面…') + const policy = document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute('content') ?? '' + // The placeholder must not be able to reach anything, so its own policy + // forbids every source but the inline style and reader. + expect(policy).toContain("default-src 'none'") + expect(policy).not.toContain('http') +}) + +it('leaves the label empty when no query supplies one', () => { + expect(open().getElementById('label')?.textContent).toBe('') +}) diff --git a/apps/desktop/tests/policy-test-auth.spec.ts b/apps/desktop/tests/policy-test-auth.spec.ts new file mode 100644 index 0000000000..517eda15ca --- /dev/null +++ b/apps/desktop/tests/policy-test-auth.spec.ts @@ -0,0 +1,177 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { DesktopPolicyTestAuth } from '../src/policy-test-auth.ts' +import { resolveDesktopLocale } from '../src/locale.ts' +import type { BrowserWindowConstructorOptions } from 'electron' + +const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>(), partition: vi.fn() })) +vi.mock('electron', () => ({ BrowserWindow: function (options: BrowserWindowConstructorOptions) { return native.create(options) }, + session: { fromPartition: native.partition } })) + +let auth: DesktopPolicyTestAuth +let browserSession: ReturnType +let window: ReturnType +const record = vi.fn() +function makeSession() { + return Object.assign(new EventEmitter(), { fetch: vi.fn(async () => Response.json({ code: 0 })), + setPermissionRequestHandler: vi.fn<(handler: ( + contents: unknown, permission: string, callback: (allowed: boolean) => void) => void) => void>(), + setPermissionCheckHandler: vi.fn<(handler: () => boolean) => void>(), + setDevicePermissionHandler: vi.fn<(handler: () => boolean) => void>(), + webRequest: { onBeforeRequest: vi.fn<(handler: (details: { resourceType: string; url: string }, + callback: (result: { cancel: boolean }) => void) => void) => void>() }, closeAllConnections: vi.fn(async () => {}), + clearStorageData: vi.fn(async () => {}), clearAuthCache: vi.fn(async () => {}) }) +} +function makeWindow() { + let destroyed = false + const instance = Object.assign(new EventEmitter(), { webContents: Object.assign(new EventEmitter(), { + setWindowOpenHandler: vi.fn<(handler: () => { action: string }) => void>(), openDevTools: vi.fn() }), + show: vi.fn(), focus: vi.fn(), setMenu: vi.fn(), loadFile: vi.fn(async () => {}), + loadURL: vi.fn(async () => {}), isDestroyed: () => destroyed, + destroy: () => { if (!destroyed) { destroyed = true; instance.emit('closed') } } }) + return instance +} +beforeEach(() => { + vi.clearAllMocks() + browserSession = makeSession(); window = makeWindow() + native.partition.mockReturnValue(browserSession); native.create.mockReturnValue(window) + auth = new DesktopPolicyTestAuth('https://policy.example.com', resolveDesktopLocale('zh'), () => undefined, record) +}) +afterEach(async () => { await auth.dispose() }) + +it('shares an isolated memory Session with policy fetches, without opening a login automatically', async () => { + expect(native.partition.mock.calls[0]![0]).toMatch(/^dsh-policy-auth-/) + expect(native.create).not.toHaveBeenCalled() + await auth.request('https://policy.example.com/api/v0/check_client_update?scenario=manual', { credentials: 'omit', redirect: 'follow' }) + expect(browserSession.fetch).toHaveBeenCalledWith('https://policy.example.com/api/v0/check_client_update?scenario=manual', + { credentials: 'include', redirect: 'error', cache: 'no-store' }) + await expect(auth.request('https://other.example.com/api/v0/check_client_update')).rejects.toThrow('disallowed') + await expect(auth.request('https://policy.example.com/other')).rejects.toThrow('disallowed') + await expect(auth.request('https://user:secret@policy.example.com/api/v0/check_client_update')).rejects.toThrow('disallowed') + expect(browserSession.fetch).toHaveBeenCalledTimes(1) +}) + +it('opens a sandboxed window on explicit action and coalesces logins without authorizing an update', async () => { + const pending = auth.login() + expect(auth.login()).toBe(pending) + expect(native.create).toHaveBeenCalledTimes(1) + expect(native.create.mock.calls[0]![0]).toMatchObject({ title: '登录测试环境', webPreferences: { + session: browserSession, nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, + webviewTag: false, devTools: true } }) + expect(native.create.mock.calls[0]![0].webPreferences?.preload).toBeUndefined() + expect(window.loadFile).toHaveBeenCalledWith('renderer/policy-login-loading.html', { query: { label: '正在加载登录页面…' } }) + // The placeholder is the first document: the remote page waits for it. + expect(window.loadURL).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(window.loadURL).toHaveBeenCalledWith('https://policy.example.com/') }) + expect(window.loadFile).toHaveBeenCalledTimes(1) + window.webContents.emit('did-navigate', {}, 'https://accounts.feishu.cn/open-apis/authen/v1/index?state=secret') + expect(window.isDestroyed()).toBe(false) + window.webContents.emit('did-navigate', {}, 'https://policy.example.com/') + expect(await pending).toBe('returned') + expect(record.mock.calls).toEqual([['opened'], ['returned']]) + expect(browserSession.fetch).not.toHaveBeenCalled() +}) + +it('opens only the Feishu login window DevTools on a single F12 keydown', () => { + void auth.login() + const preventDefault = vi.fn() + const press = (type: string, key: string, isAutoRepeat = false) => { + window.webContents.emit('before-input-event', { preventDefault }, { type, key, isAutoRepeat }) + } + press('keyDown', 'F11') + press('keyUp', 'F12') + press('keyDown', 'F12', true) + expect(window.webContents.openDevTools).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + press('keyDown', 'F12') + expect(preventDefault).toHaveBeenCalledOnce() + expect(window.webContents.openDevTools).toHaveBeenCalledExactlyOnceWith({ mode: 'detach' }) +}) + +it('shows the placeholder without letting it outlive the first remote document', async () => { + const placeholder = { resourceType: 'mainFrame', url: 'file:///app/renderer/policy-login-loading.html?label=x' } + const callback = vi.fn() + // The window's own placeholder is the one document the filter accepts locally. + browserSession.webRequest.onBeforeRequest.mock.calls[0]![0](placeholder, callback) + expect(callback).toHaveBeenCalledWith({ cancel: false }) + window.loadFile.mockRejectedValueOnce(new Error('missing renderer document')) + const pending = auth.login() + // A placeholder that cannot load must not fail the login or hold it back. + window.webContents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', placeholder.url, true) + expect(window.isDestroyed()).toBe(false) + await vi.waitFor(() => { expect(window.loadURL).toHaveBeenCalledWith('https://policy.example.com/') }) + // Entering the placeholder never covers the remote page: it is replaced by + // navigation and is never loaded again. + window.webContents.emit('did-navigate', {}, 'https://policy.example.com/') + expect(await pending).toBe('returned') + expect(window.loadFile).toHaveBeenCalledTimes(1) +}) + +it('cancels a login closed while the placeholder is still loading, without starting the remote page', async () => { + const loading = Promise.withResolvers() + window.loadFile.mockReturnValueOnce(loading.promise) + const pending = auth.login() + window.destroy() + expect(await pending).toBe('cancelled') + loading.resolve(undefined) + await Promise.resolve() + expect(window.loadURL).not.toHaveBeenCalled() +}) + +it.each(['will-navigate', 'will-redirect'])('refuses unapproved %s targets without logging OAuth data', async (eventName) => { + const pending = auth.login() + const event = { preventDefault: vi.fn() } + window.webContents.emit(eventName, event, 'https://accounts.feishu.cn.evil.example/?code=secret') + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(await pending).toBe('failed') + expect(record.mock.calls).toEqual([['opened'], ['failed']]) +}) + +it('rejects child-frame escapes, permissions, downloads, new windows and HTTP authentication', async () => { + const pending = auth.login() + const preventDefault = vi.fn(), callback = vi.fn() + browserSession.setPermissionRequestHandler.mock.calls[0]![0](null, 'camera', callback) + expect(callback).toHaveBeenCalledWith(false) + expect(browserSession.setPermissionCheckHandler.mock.calls[0]![0]()).toBe(false) + expect(browserSession.setDevicePermissionHandler.mock.calls[0]![0]()).toBe(false) + browserSession.emit('will-download', { preventDefault }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(window.webContents.setWindowOpenHandler.mock.calls[0]![0]()).toEqual({ action: 'deny' }) + window.webContents.emit('login', { preventDefault }, {}, {}, callback) + expect(callback).toHaveBeenLastCalledWith() + browserSession.webRequest.onBeforeRequest.mock.calls[0]![0]({ resourceType: 'subFrame', url: 'file:///secret' }, callback) + expect(callback).toHaveBeenLastCalledWith({ cancel: true }) + expect(await pending).toBe('failed') +}) + +it.each(['https://open.feishu.cn/', 'https://accounts.feishu.cn/', 'https://passport.feishu.cn/', + 'https://login.feishu.cn/'])('allows reviewed login documents: %s', (url) => { + const callback = vi.fn() + browserSession.webRequest.onBeforeRequest.mock.calls[0]![0]({ resourceType: 'mainFrame', url }, callback) + expect(callback).toHaveBeenCalledWith({ cancel: false }) +}) + +it('keeps manual login open until close, reports cancellation once, and permits a fresh retry', async () => { + const pending = auth.login() + window.destroy() + expect(await pending).toBe('cancelled') + window = makeWindow(); native.create.mockReturnValue(window) + const retry = auth.login() + window.webContents.emit('did-fail-load', {}, -105, 'raw URL error', 'https://accounts.feishu.cn/?state=secret', true) + expect(await retry).toBe('failed') + expect(record.mock.calls).toEqual([['opened'], ['cancelled'], ['opened'], ['failed']]) +}) + +it('awaits cleanup, closes pending login and refuses work after disposal', async () => { + const pending = auth.login() + const closed = Promise.withResolvers() + browserSession.closeAllConnections.mockReturnValueOnce(closed.promise) + const disposal = auth.dispose() + expect(await pending).toBe('cancelled') + expect(browserSession.clearStorageData).not.toHaveBeenCalled() + closed.resolve(undefined) + await disposal + expect(browserSession.clearStorageData).toHaveBeenCalledOnce() + await expect(auth.login()).resolves.toBe('cancelled') + await expect(auth.request('https://policy.example.com/api/v0/check_client_update')).rejects.toThrow('disallowed') +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index a3c42d38e1..0af35e98c5 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,5 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest' -import { DESKTOP_IPC } from '../src/ipc.ts' +import { syncWindowsAppearance } from '../src/preload-windows.ts' +import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' const electron = vi.hoisted(() => ({ contextBridge: { exposeInMainWorld: vi.fn() }, @@ -8,10 +9,30 @@ const electron = vi.hoisted(() => ({ vi.mock('electron', () => electron) vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() })) vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) +vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) -it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => { +it('limits product documents to update status and a native confirmation action', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/index.html')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + await api.updates.status() + await api.updates.open() + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([[DESKTOP_IPC.updatesStatus], [DESKTOP_IPC.updatesOpen]]) + expect(api).not.toHaveProperty('plugins') + expect(api).not.toHaveProperty('backend') + expect(api.updates).not.toHaveProperty('install') + const listener = vi.fn() + const dispose = api.updates.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void + handler({}, { visible: false }) + expect(listener).toHaveBeenCalledWith({ visible: false }) + dispose() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.updatesPresentation, handler) +}) + +it.each(['dsh-app://shell/plugin-manager.html', 'dsh-app://other/index.html', 'https://shell/startup.html', 'http://example.com/'])('exposes only the carrier marker to %s', async (url) => { vi.stubGlobal('location', new URL(url)) await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) @@ -47,3 +68,11 @@ it('exposes a directory picker only to the local application document', async () expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_DIRECTORY_PICKER__')).toBe(false) } }) + +it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://example.com/'])( + 'installs Windows appearance only for the application document (%s)', async (url) => { + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0) + }, +) diff --git a/apps/desktop/tests/preload-menu.client.spec.ts b/apps/desktop/tests/preload-menu.client.spec.ts new file mode 100644 index 0000000000..945463638f --- /dev/null +++ b/apps/desktop/tests/preload-menu.client.spec.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { installWindowsMenu } from '../src/preload-menu.ts' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const invoke = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()) +vi.mock('electron', () => ({ ipcRenderer: { invoke } })) +let menu: ReturnType | undefined + +beforeEach(() => { + const appSeat = document.createElement('div') + appSeat.dataset.shellOverlay = '' + document.body.append(appSeat) + document.documentElement.lang = 'en' + invoke.mockResolvedValue(undefined) +}) + +it('keeps caption menus absent until the application frame replaces loading', async () => { + document.body.replaceChildren() + menu = installWindowsMenu() + const loading = document.createElement('div') + loading.dataset.dshBoot = '' + document.body.append(loading) + await new Promise((resolve) => { queueMicrotask(resolve) }) + expect(document.querySelector('[data-windows-menu]')).toBeNull() + const appSeat = document.createElement('div') + appSeat.dataset.shellOverlay = '' + loading.replaceWith(appSeat) + await vi.waitFor(() => { expect(document.querySelector('[data-windows-menu]')).not.toBeNull() }) +}) + +it('does not mount menus after a loading document is disposed', async () => { + document.body.replaceChildren() + menu = installWindowsMenu() + menu.dispose() + const appSeat = document.createElement('div') + appSeat.dataset.shellOverlay = '' + document.body.append(appSeat) + await new Promise((resolve) => { queueMicrotask(resolve) }) + expect(document.querySelector('[data-windows-menu]')).toBeNull() +}) +afterEach(() => { + menu?.dispose() + menu = undefined + document.body.replaceChildren() + document.documentElement.lang = '' + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +it('localizes caption entries and removes the menu on disposal', () => { + menu = installWindowsMenu() + const host = document.querySelector('[data-windows-menu]')! + const bar = host.shadowRoot!.querySelector('[role=menubar]')! + expect(bar.outerHTML).toMatchSnapshot('english') + document.documentElement.lang = 'zh-CN' + menu.update() + expect(bar.outerHTML).toMatchSnapshot('chinese') + menu.dispose() + menu = undefined + expect(document.querySelector('[data-windows-menu]')).toBeNull() +}) + +it('opens native menus without stealing pointer focus and resets popup state when closed', async () => { + let close!: () => void + invoke.mockImplementation(() => new Promise((resolve) => { close = resolve })) + menu = installWindowsMenu() + const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')! + vi.spyOn(button, 'getBoundingClientRect').mockReturnValue(new DOMRect(48, 6, 90, 28)) + const pointer = new MouseEvent('pointerdown', { cancelable: true }) + button.dispatchEvent(pointer) + expect(pointer.defaultPrevented).toBe(true) + const mouse = new MouseEvent('mousedown', { cancelable: true }) + button.dispatchEvent(mouse) + expect(mouse.defaultPrevented).toBe(true) + button.click() + expect(invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.windowsMenu, 'application', 48, 34) + expect(button.getAttribute('aria-expanded')).toBe('true') + close() + await vi.waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) +}) + +it('moves between menu entries with arrow keys and opens the focused entry with ArrowDown', () => { + menu = installWindowsMenu() + const buttons = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelectorAll('button') + buttons[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true })) + expect(buttons[0]!.tabIndex).toBe(-1) + expect(buttons[1]!.tabIndex).toBe(0) + buttons[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })) + expect(invoke).toHaveBeenCalledWith(DESKTOP_IPC.windowsMenu, 'edit', 0, 0) +}) + +it('restores a text input and its selection before opening a keyboard menu', async () => { + const input = document.createElement('input') + input.value = 'editor text' + document.body.append(input) + menu = installWindowsMenu() + const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')! + input.focus() + input.setSelectionRange(2, 6, 'backward') + button.focus() + input.setSelectionRange(0, 0) + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })) + expect(document.activeElement).toBe(input) + expect([input.selectionStart, input.selectionEnd, input.selectionDirection]).toEqual([2, 6, 'backward']) + await vi.waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) +}) + +it('reports a failed popup request and clears the active menu', async () => { + const error = new Error('popup rejected') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + invoke.mockRejectedValue(error) + menu = installWindowsMenu() + const button = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelector('button')! + button.click() + await vi.waitFor(() => { expect(log).toHaveBeenCalledWith('Desktop caption menu failed', error) }) + expect(button.getAttribute('aria-expanded')).toBe('false') +}) + +it('restores a contenteditable selection before opening Edit with the keyboard', async () => { + const editor = document.createElement('div') + editor.contentEditable = 'true' + editor.setAttribute('contenteditable', 'true') + editor.tabIndex = 0 + editor.textContent = 'editable text' + document.body.append(editor) + menu = installWindowsMenu() + const buttons = document.querySelector('[data-windows-menu]')!.shadowRoot!.querySelectorAll('button') + editor.focus() + const range = document.createRange() + range.setStart(editor.firstChild!, 2) + range.setEnd(editor.firstChild!, 8) + document.getSelection()!.removeAllRanges() + document.getSelection()!.addRange(range) + buttons[0]!.focus() + document.getSelection()!.removeAllRanges() + buttons[0]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true })) + buttons[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })) + expect(document.activeElement).toBe(editor) + expect(document.getSelection()!.toString()).toBe('itable') + await vi.waitFor(() => { expect(buttons[1]!.getAttribute('aria-expanded')).toBe('false') }) +}) diff --git a/apps/desktop/tests/preload-windows.client.spec.ts b/apps/desktop/tests/preload-windows.client.spec.ts new file mode 100644 index 0000000000..e59b7ebbeb --- /dev/null +++ b/apps/desktop/tests/preload-windows.client.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { DESKTOP_IPC } from '../src/ipc.ts' +import { syncWindowsAppearance } from '../src/preload-windows.ts' + +const send = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ ipcRenderer: { send } })) +vi.mock('../src/preload-menu.ts', () => ({ installWindowsMenu: () => ({ update: vi.fn(), dispose: vi.fn() }) })) + +afterEach(() => { + window.dispatchEvent(new Event('pagehide')) + document.documentElement.removeAttribute('data-windows-titlebar') + document.documentElement.style.removeProperty('--dsh-windows-titlebar-height') + document.documentElement.lang = 'en' + document.body.removeAttribute('data-ds-dark-theme') + vi.restoreAllMocks() + send.mockClear() +}) + +it.each(['darwin', 'linux'] as const)('does not install Windows controls on %s', (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + syncWindowsAppearance() + expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(false) + expect(send).not.toHaveBeenCalled() +}) + +it('synchronizes live language and palette changes and stops observing a closed document', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading') + vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(() => ({ + backgroundColor: document.body.hasAttribute('data-ds-dark-theme') ? 'oklch(0.2 0 0)' : 'hsl(0 0% 100%)', + color: 'black', + }) as CSSStyleDeclaration) + const context = { + fillStyle: '', clearRect: vi.fn(), fillRect: vi.fn(), + getImageData: () => ({ data: new Uint8ClampedArray(context.fillStyle === 'black' + ? [0, 0, 0, 255] : context.fillStyle.startsWith('oklch') ? [27, 27, 28, 255] : [255, 255, 255, 255]) }), + } + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as unknown as CanvasRenderingContext2D) + document.documentElement.lang = 'en' + syncWindowsAppearance() + expect(send).not.toHaveBeenCalled() + expect(document.documentElement.hasAttribute('data-windows-titlebar')).toBe(true) + window.dispatchEvent(new Event('DOMContentLoaded')) + expect(document.documentElement.style.getPropertyValue('--dsh-windows-titlebar-height')).toBe('40px') + expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'en', 'rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 1)') + document.documentElement.lang = 'zh-CN' + document.body.setAttribute('data-ds-dark-theme', '') + await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.windowsAppearance, 'zh-CN', 'rgba(27, 27, 28, 1)', 'rgba(0, 0, 0, 1)') }) + window.dispatchEvent(new Event('pagehide')) + send.mockClear() + document.documentElement.lang = 'en' + await new Promise((resolve) => { queueMicrotask(resolve) }) + expect(send).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/prepare-package-set.spec.ts b/apps/desktop/tests/prepare-package-set.spec.ts index 1601a0c7f6..85f26a12bb 100644 --- a/apps/desktop/tests/prepare-package-set.spec.ts +++ b/apps/desktop/tests/prepare-package-set.spec.ts @@ -46,21 +46,38 @@ describe('desktop package-set selection', () => { ]) }) - it('rejects a required internal package absent from the packed release inputs', () => { + it.each([ + '@deepseek-ai/dsh-base', '@deepseek-ai/cordis', '@deepseek-ai/node-addon-system', + ])('rejects required prepared package %s absent from the packed release inputs', (dependency) => { const available = new Map([ ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { - dependencies: { '@deepseek-ai/dsh-base': '^1.0.0' }, + dependencies: { [dependency]: '^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(available)).toThrow(/unpacked package/u) expect(() => selectDesktopPackageClosure(new Map([ ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')], ]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u) }) + it('leaves independently published Office packages to npm resolution', () => { + const available = new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { + dependencies: { + '@deepseek-ai/libreoffice-kit': '0.0.1', + '@deepseek-ai/libreoffice-kit-wasm': '0.0.1', + }, + })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host')], + ]) + expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([ + '@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', + ]) + }) + it('requires the Desktop Host entry', () => { const files = [ 'package/lib/index.js', diff --git a/apps/desktop/tests/prepared-runtime-smoke.spec.ts b/apps/desktop/tests/prepared-runtime-smoke.spec.ts new file mode 100644 index 0000000000..2a2644719e --- /dev/null +++ b/apps/desktop/tests/prepared-runtime-smoke.spec.ts @@ -0,0 +1,44 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { smokePreparedRuntime } from '../scripts/smoke-prepared-runtime.ts' +import { smokeDesktopRuntime } from '../scripts/smoke-runtime.ts' +import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const { payload } = vi.hoisted(() => ({ payload: vi.fn(async (..._args: unknown[]) => ({ stdout: '' })) })) +vi.mock('node:child_process', async (importOriginal) => { + const { promisify } = await import('node:util') + return { ...await importOriginal(), + execFile: Object.assign(vi.fn(), { [promisify.custom]: payload }) } +}) +vi.mock('../scripts/smoke-runtime.ts', () => ({ smokeDesktopRuntime: vi.fn(async () => {}) })) + +const roots: string[] = [] +const hostArch = Object.getOwnPropertyDescriptor(process, 'arch')! +afterEach(() => { + Object.defineProperty(process, 'arch', hostArch) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +it('smokes an x64 target verified on an arm64 build host without revalidating against the host', async () => { + const root = mkdtempSync(join(tmpdir(), 'prepared-runtime-target-')) + roots.push(root) + const fixture = runtimeFixture(root) + const target = { platform: 'darwin' as const, arch: 'x64' } + writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name), target) + Object.defineProperty(process, 'arch', { ...hostArch, value: 'arm64' }) + await expect(verifyDesktopRuntime(root, fixture.release.version, { ...target, arch: process.arch })) + .rejects.toThrow(/incompatible/u) + const descriptor = await verifyDesktopRuntime(root, fixture.release.version, target) + const electron = join(root, 'target-electron') + const resources = join(root, 'runtime') + await smokePreparedRuntime(root, electron, resources, descriptor) + expect(payload.mock.calls[0]![0]).toBe(electron) + expect(payload.mock.calls[0]![1]).toEqual(expect.arrayContaining([root, resources])) + expect(smokeDesktopRuntime).toHaveBeenCalledWith(root, electron, descriptor, expect.any(Object), resources) + const environment = vi.mocked(smokeDesktopRuntime).mock.calls[0]![3] + expect(existsSync(environment.NARB_NATIVE_CACHE_DIR!)).toBe(false) +}) diff --git a/apps/desktop/tests/primary-runtime-signing.spec.ts b/apps/desktop/tests/primary-runtime-signing.spec.ts new file mode 100644 index 0000000000..242c93f339 --- /dev/null +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -0,0 +1,211 @@ +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { signWindowsPrimaryRuntime, signWindowsDesktopRuntime } from '../scripts/sign-primary-runtime.ts' +import { inspectWindowsRuntimeSignature, preserveWindowsRuntimeSignature, windowsRuntimeCode, verifyWindowsCode, signWindowsCode } from '../scripts/windows-runtime-signature.mjs' +import { runtimeFixture } from './runtime-fixture.ts' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { createPackagingRun } from '../scripts/packaging-run.mjs' + +const roots: string[] = [] +const thumbprint = 'A'.repeat(40) +const valid = { status: 'Valid', timestamped: true, thumbprint } +const unsigned = { status: 'NotSigned', timestamped: false, thumbprint: null } + +async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise { + const root = await mkdtemp(join(tmpdir(), 'primary-signing-')) + roots.push(root) + const pe = Buffer.alloc(128) + pe.writeUInt16LE(0x5a4d, 0) + pe.writeUInt32LE(64, 0x3c) + pe.writeUInt32LE(0x4550, 64) + for (const name of names) await writeFile(join(root, name), pe) + return root +} + +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) + +it('selects real PE code, including .node, without signing foreign native modules or data', async () => { + const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary']) + await mkdir(join(root, 'nested')) + await writeFile(join(root, 'nested', 'foreign.node'), Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + await writeFile(join(root, 'readme.txt'), 'text') + expect(await windowsRuntimeCode(root)).toEqual(['python.exe', 'runtime.node', 'extensionless', 'custom.binary'].map(name => join(root, name)).sort()) +}) + +it('refuses malformed executables and root or nested directory links', async () => { + const root = await fixture([]) + const target = await fixture([]) + const link = join(root, 'linked') + await symlink(target, link, process.platform === 'win32' ? 'junction' : 'dir') + await expect(windowsRuntimeCode(root)).rejects.toThrow('links are not signable') + await expect(windowsRuntimeCode(link)).rejects.toThrow('real directory') + await writeFile(join(target, 'broken.exe'), 'invalid') + await expect(windowsRuntimeCode(target)).rejects.toThrow('invalid PE file') + await rm(join(target, 'broken.exe')) + await writeFile(join(target, 'broken.node'), 'MZ') + await expect(windowsRuntimeCode(target)).rejects.toThrow('invalid PE file') +}) + +it('retains vendor signatures and verifies each new signature before execution', async () => { + const root = await fixture() + const signed = new Set() + const sequence: string[] = [] + const sign = vi.fn(async ({ path }: { path: string }) => { sequence.push(`sign:${path}`); signed.add(path) }) + const inspect = vi.fn(async (path: string) => { + sequence.push(`inspect:${path}`) + return path.endsWith('vendor.dll') ? { ...valid, thumbprint: 'B'.repeat(40) } : signed.has(path) ? valid : unsigned + }) + const smoke = vi.fn(() => { sequence.push('smoke') }) + await signWindowsPrimaryRuntime(root, { sign, inspect, smoke, thumbprint, record: () => {} }) + expect(sign.mock.calls.map(([input]) => input.path)).toEqual([join(root, 'a.exe'), join(root, 'b.pyd')]) + expect(sequence).toEqual([ + ...['a.exe', 'b.pyd', 'vendor.dll'].map(name => `inspect:${join(root, name)}`), + `sign:${join(root, 'a.exe')}`, `inspect:${join(root, 'a.exe')}`, + `sign:${join(root, 'b.pyd')}`, `inspect:${join(root, 'b.pyd')}`, 'smoke', + ]) + expect(smoke).toHaveBeenCalledWith(root) +}) + +it.each(['HashMismatch', 'NotTrusted', 'UnknownError'])('rejects existing %s signatures before any hardware call', async (status) => { + const root = await fixture() + const sign = vi.fn(async () => {}) + const smoke = vi.fn() + await expect(signWindowsPrimaryRuntime(root, { thumbprint, sign, smoke, record: () => {}, + inspect: async path => path.endsWith('vendor.dll') ? { ...valid, status } : unsigned })).rejects.toThrow(status) + expect(sign).not.toHaveBeenCalled() + expect(smoke).not.toHaveBeenCalled() +}) + +it('stops immediately on signer failure without retrying, signing another file or executing it', async () => { + const root = await fixture() + const sign = vi.fn(async () => { throw new Error('token refused') }) + const smoke = vi.fn() + await expect(signWindowsPrimaryRuntime(root, { thumbprint, sign, smoke, record: () => {}, inspect: async () => unsigned })) + .rejects.toThrow('token refused') + expect(sign).toHaveBeenCalledOnce() + expect(smoke).not.toHaveBeenCalled() +}) + +it.each([ + { ...valid, timestamped: false }, + { ...valid, thumbprint: 'B'.repeat(40) }, + { ...valid, status: 'HashMismatch' }, +])('refuses an unverified new signature before the next file: %j', async (invalid) => { + const root = await fixture() + let signed = false + const sign = vi.fn(async () => { signed = true }) + const smoke = vi.fn() + await expect(signWindowsPrimaryRuntime(root, { + thumbprint, sign, smoke, record: () => {}, inspect: async () => signed ? invalid : unsigned, + })) + .rejects.toThrow('signing verification failed') + expect(sign).toHaveBeenCalledOnce() + expect(smoke).not.toHaveBeenCalled() +}) + +it('stops before hardware when the audit sink fails and never reports a failed smoke as success', async () => { + const root = await fixture() + const sign = vi.fn(async () => {}) + const smoke = vi.fn() + await expect(signWindowsPrimaryRuntime(root, { thumbprint, sign, smoke, inspect: async () => unsigned, + record: () => { throw new Error('audit unavailable') } })).rejects.toThrow('audit unavailable') + expect(sign).not.toHaveBeenCalled() + const record = vi.fn() + await expect(signWindowsPrimaryRuntime(root, { thumbprint, sign, inspect: async () => valid, record, + smoke: () => { throw new Error('runtime blocked') } })).rejects.toThrow('runtime blocked') + expect(record).not.toHaveBeenCalledWith({ type: 'primary-runtime-smoke-success' }) +}) + +it('refuses an empty runtime without declaring successful validation', async () => { + const root = await fixture([]) + const smoke = vi.fn() + await expect(signWindowsPrimaryRuntime(root, { thumbprint, sign: vi.fn(), smoke, record: () => {} })).rejects.toThrow('no Windows code') + expect(smoke).not.toHaveBeenCalled() +}) + +it.skipIf(process.platform !== 'win32')('reads a Windows system signature without using signing hardware', async () => { + const signature = await inspectWindowsRuntimeSignature(join(process.env.SystemRoot!, 'System32', 'cmd.exe')) + expect(signature.status).toBe('Valid') + expect(signature.thumbprint).toMatch(/^[A-F\d]{40}$/iu) +}, 70_000) + +it('preserves only identical, valid runtime copies and records verification without signing', async () => { + const sourceRoot = await realpath(await fixture(['python.exe'])) + const destinationRoot = await realpath(await fixture(['python.exe'])) + const run = createPackagingRun(join(sourceRoot, 'records'), {}) + const inspect = vi.fn(async () => valid) + const options = { sourceRoot, destinationRoot, runDirectory: run.directory, inspect } + const path = join(destinationRoot, 'python.exe') + expect(await preserveWindowsRuntimeSignature(join(`${destinationRoot}-other`, 'python.exe'), options)).toBe(false) + expect(inspect).not.toHaveBeenCalled() + expect(await preserveWindowsRuntimeSignature(path, options)).toBe(true) + expect(await readFile(join(run.directory, 'events.jsonl'), 'utf8')).toContain('primary-runtime-copy-verified') + inspect.mockResolvedValueOnce({ ...valid, status: 'NotSigned' }) + await expect(preserveWindowsRuntimeSignature(path, options)).rejects.toThrow('copied signature is NotSigned') + await writeFile(path, 'changed executable') + await expect(preserveWindowsRuntimeSignature(path, options)).rejects.toThrow('copied executable changed') + await rm(path) + await symlink(sourceRoot, join(destinationRoot, 'linked'), process.platform === 'win32' ? 'junction' : 'dir') + await mkdir(join(sourceRoot, 'linked')) + await writeFile(join(sourceRoot, 'linked', 'python.exe'), await readFile(join(sourceRoot, 'python.exe'))) + await expect(preserveWindowsRuntimeSignature(join(destinationRoot, 'linked', 'python.exe'), options)).rejects.toThrow('linked copy') +}) + +it('rejects a newly added unsigned dependency in the final artifact audit', async () => { + const root = await fixture(['new-dependency.node', 'vendor.dll']) + await expect(verifyWindowsCode(root, async path => path.endsWith('.node') ? unsigned : valid)).rejects.toThrow('NotSigned') + await expect(verifyWindowsCode(root, async () => valid)).resolves.toBeUndefined() +}) + +it('records signed dependency bytes before smoke and rejects changes after smoke', async () => { + const root = await fixture(['dependency.node']) + runtimeFixture(root) + let signed = false + const options = { thumbprint, record: () => {}, + inspect: async () => signed ? valid : unsigned, + sign: async ({ path }: { path: string }) => { + await writeFile(path, Buffer.concat([await readFile(path), Buffer.from('signature')])) + signed = true + }, + smoke: vi.fn(async () => { await verifyDesktopRuntime(root, '1.0.0') }), + } + await signWindowsDesktopRuntime(root, '1.0.0', options) + expect(options.smoke).toHaveBeenCalledOnce() + await expect(signWindowsDesktopRuntime(root, '1.0.0', { ...options, + smoke: async () => { await writeFile(join(root, 'dependency.node'), 'mutated') }, + })).rejects.toThrow('integrity') +}) + +it('bounds public-key inspection to four files and drains a failed batch before returning', async () => { + const root = await fixture(['a.node', 'b.node', 'c.node', 'd.node', 'e.node']) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + let active = 0 + let maximum = 0 + const calls: string[] = [] + const inspect = async (path: string) => { + calls.push(path) + active += 1 + maximum = Math.max(maximum, active) + if (active === 4) started.resolve(undefined) + try { + await finish.promise + if (path.endsWith('a.node')) throw new Error('inspection failed') + return unsigned + } finally { active -= 1 } + } + const sign = vi.fn() + const pending = signWindowsCode(root, { thumbprint, inspect, sign, record: () => {} }) + const rejected = expect(pending).rejects.toThrow('inspection failed') + try { + await started.promise + expect(calls).toHaveLength(4) + expect(sign).not.toHaveBeenCalled() + } finally { finish.resolve(undefined); await rejected } + expect(maximum).toBe(4) + expect(active).toBe(0) + expect(calls).toHaveLength(4) + expect(sign).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index b2adad9116..f86e3f2f16 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -1,73 +1,45 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveDesktopPaths } from '../src/paths.ts' -import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts' +import { DesktopProjectManager } from '../src/project-manager.ts' +import { readProfilePlugins } from '@deepseek-ai/dsh-app-boot' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] -const releaseWorkers: Array<() => Promise> = [] function temporaryRoot(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-')) roots.push(root) return root } -function writeFakePnpm(root: string): string { - const path = join(root, 'pnpm.mjs') - writeFileSync(path, ` -import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -const args = process.argv.slice(2) -const project = process.cwd() -const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value)) -appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY, userconfig: process.env.NPM_CONFIG_USERCONFIG, pnpmHome: process.env.PNPM_HOME, configHome: process.env.XDG_CONFIG_HOME}) + '\\n') -if (command !== 'rebuild') { - const manifestPath = join(project, 'package.json') - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) - if (command === 'add') { - const spec = args.includes('--') ? args[args.indexOf('--') + 1] : args[args.indexOf(command) + 1] - const index = spec.lastIndexOf('@') - const name = index > 0 ? spec.slice(0, index) : spec - manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0' +function seedPlugin(manager: DesktopProjectManager): void { + const path = join(manager.paths.profile, 'package.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { + dependencies: Record + dsh: { profile: { bundles: string[] } } } - if (command === 'remove') delete manifest.dependencies[args[args.indexOf('--') + 1]] - writeFileSync(manifestPath, JSON.stringify(manifest)) - rmSync(join(project, 'node_modules'), { recursive: true, force: true }) - for (const [name, version] of Object.entries(manifest.dependencies)) { - const packageRoot = join(project, 'node_modules', name) - mkdirSync(packageRoot, { recursive: true }) - writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version, - peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}})) - writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n') - } - writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies)) + manifest.dependencies.plugin = '1.0.0' + manifest.dsh.profile.bundles.push('plugin') + writeFileSync(path, JSON.stringify(manifest)) + const directory = join(manager.paths.profile, 'node_modules/plugin') + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'plugin', version: '1.0.0', dsh: { bundle: { patch: 'bundle.yml' } } })) + writeFileSync(join(directory, 'bundle.yml'), '[]\n') } -`) - return path -} -function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides } +function plugins(manager: DesktopProjectManager) { + return readProfilePlugins({ binName: 'dsh', profileDir: manager.paths.profile, + installAnchor: join(manager.runtime.dsh, 'node_modules/@deepseek-ai/dsh/package.json') }).dependencies + .map(({ name, version, enabled }) => ({ name, version, enabled })) } function setup(): { root: string; manager: DesktopProjectManager } { const root = temporaryRoot() const dsh = join(root, 'resources', 'dsh') runtimeFixture(dsh) - return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) } + return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { dsh }) } } -function calls(root: string): { args: string[]; registry: string }[] { - const path = join(root, 'pnpm-log.jsonl') - return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : [] -} -afterEach(async () => { - const cleanups = releaseWorkers.splice(0) - const directories = roots.splice(0) - const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())) - for (const root of directories) rmSync(root, { recursive: true, force: true }) - vi.unstubAllEnvs() - const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : []) - if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed') +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) describe('desktop external plugin profile', () => { @@ -85,7 +57,7 @@ describe('desktop external plugin profile', () => { it('reuses plugin files without scanning manifests and can disable them', async () => { const { manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json') writeFileSync(manifest, '{broken') await expect(manager.applyRelease()).resolves.toBeUndefined() @@ -95,9 +67,9 @@ describe('desktop external plugin profile', () => { }) it('disables every third-party bundle without reading a broken plugin patch declaration', async () => { - const { root, manager } = setup() + const { manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml') unlinkSync(patch) await manager.disableAllPlugins() @@ -105,19 +77,22 @@ describe('desktop external plugin profile', () => { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles).not.toContain('plugin') expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) - expect(calls(root)).toHaveLength(1) await expect(manager.applyRelease()).resolves.toBeUndefined() }) - it('disables plugins before runtime initialization and preserves configuration and package files', async () => { + it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => { const { manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const patch = join(manager.paths.profile, 'cordis.patch.yml') writeFileSync(patch, ': broken') const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' }) - await uninitialized.disableAllPlugins() - expect(readFileSync(patch, 'utf8')).toBe(': broken') + const backupPath = await uninitialized.disableAllPlugins() + expect(existsSync(patch)).toBe(false) + const backups = readdirSync(manager.paths.profile).filter(name => name.startsWith('cordis.patch.yml.bak-')) + expect(backups).toHaveLength(1) + expect(backupPath).toBe(join(manager.paths.profile, backups[0]!)) + expect(readFileSync(join(manager.paths.profile, backups[0]!), 'utf8')).toBe(': broken') expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as { dependencies: Record @@ -126,11 +101,13 @@ describe('desktop external plugin profile', () => { expect(manifest.dependencies.plugin).toBe('1.0.0') expect(manifest.dsh.profile.bundles).not.toContain('plugin') expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app') + await manager.applyRelease() + expect(readFileSync(patch, 'utf8')).toContain('[]') }) it('needs no runtime or package manifest when no plugins have been installed', async () => { const { manager } = setup() - await manager.disableAllPlugins() + await expect(manager.disableAllPlugins()).resolves.toBeUndefined() expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false) expect(existsSync(manager.paths.lock)).toBe(false) }) @@ -145,47 +122,6 @@ describe('desktop external plugin profile', () => { expect(existsSync(manager.paths.lock)).toBe(false) }) - it.each(['missing', 'malformed', 'unversioned'] as const)('lists, disables, and removes a package with %s metadata', async (damage) => { - const { manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - const path = join(manager.paths.profile, 'node_modules/plugin/package.json') - if (damage === 'missing') unlinkSync(path) - else writeFileSync(path, damage === 'malformed' ? '{broken' : '{}') - expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) - await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks()) - expect(manager.listPlugins()[0]?.enabled).toBe(false) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(manager.listPlugins()).toEqual([]) - }) - - it('preserves custom profile metadata and bundle order during plugin changes', async () => { - const { manager } = setup() - await manager.applyRelease() - const path = join(manager.paths.profile, 'package.json') - writeFileSync(path, JSON.stringify({ name: 'custom', private: false, custom: true, - dependencies: {}, dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } })) - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ name: 'custom', private: false, custom: true, - dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle', 'plugin'] } } }) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ - dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } }) - }) - - it('passes user registry and pnpm configuration through to the bundled executable', async () => { - const { root, manager } = setup() - vi.stubEnv('NPM_CONFIG_REGISTRY', 'https://packages.example.test/') - vi.stubEnv('NPM_CONFIG_USERCONFIG', join(root, 'user.npmrc')) - vi.stubEnv('PNPM_HOME', join(root, 'user-pnpm')) - vi.stubEnv('XDG_CONFIG_HOME', join(root, 'user-config')) - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@next' }, hooks()) - expect(calls(root)[0]).toMatchObject({ registry: 'https://packages.example.test/', - userconfig: join(root, 'user.npmrc'), pnpmHome: join(root, 'user-pnpm'), configHome: join(root, 'user-config'), - args: ['add', '--', 'plugin@next'] }) - }) - it('initializes the same pnpm settings as a Web profile without a Desktop build allowlist', async () => { const { manager } = setup() await manager.applyRelease() @@ -209,8 +145,6 @@ describe('desktop external plugin profile', () => { writeFileSync(path, custom) await manager.applyRelease() expect(readFileSync(path, 'utf8')).toBe(custom) - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - expect(manager.listPlugins()[0]?.name).toBe('plugin') }) it('reports damaged application metadata as a reinstall failure', async () => { @@ -232,11 +166,10 @@ describe('desktop external plugin profile', () => { }) it('initializes and restarts offline without executing pnpm', async () => { - const { root, manager } = setup() + const { manager } = setup() await expect(manager.applyRelease()).resolves.toBeUndefined() await expect(manager.applyRelease()).resolves.toBeUndefined() - expect(manager.listPlugins()).toEqual([]) - expect(calls(root)).toEqual([]) + expect(plugins(manager)).toEqual([]) expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} }) }) @@ -248,7 +181,7 @@ describe('desktop external plugin profile', () => { }) it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { - const { root, manager } = setup() + const { manager } = setup() if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}') if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') @@ -257,173 +190,101 @@ describe('desktop external plugin profile', () => { const relaunched = new DesktopProjectManager(manager.paths, manager.runtime) await expect(relaunched.applyRelease()).resolves.toBeUndefined() expect(existsSync(manager.paths.profile)).toBe(true) - expect(calls(root)).toEqual([]) - }) - - it('installs plugins with pnpm script settings', async () => { - const { root, manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }]) - expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ - ['add', '--', '@scope/plugin@2.0.0'], - ]) - expect(calls(root).every(call => call.registry === process.env.NPM_CONFIG_REGISTRY)).toBe(true) - expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } }) - await expect(manager.applyRelease()).resolves.toBeUndefined() - expect(calls(root)).toHaveLength(1) - }) - - it('retains pnpm-installed host package copies instead of rejecting their dependency placement', async () => { - const { manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis@2.0.0' }, hooks()) - expect(manager.listPlugins()).toEqual([{ name: '@deepseek-ai/cordis', version: '2.0.0', enabled: false }]) - await expect(manager.applyRelease()).resolves.toBeUndefined() - await manager.mutate({ type: 'plugin-remove', name: '@deepseek-ai/cordis' }, hooks()) - }) - - it('retains disabled plugin versions through updates and enables them explicitly', async () => { - const { root, manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - await manager.disableAllPlugins() - expect(calls(root)).toHaveLength(1) - expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) - await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks()) - expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }]) - await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks()) - expect(manager.listPlugins()[0]?.enabled).toBe(true) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(manager.listPlugins()).toEqual([]) }) it('keeps plugin files and patches through a compatible release and application relocation', async () => { const { root, manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n') const nextRoot = join(root, 'relocated', 'dsh') runtimeFixture(nextRoot, '1.1.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot }) await expect(next.applyRelease()).resolves.toBeUndefined() - expect(next.listPlugins()).toEqual(manager.listPlugins()) - expect(next.dshVersion()).toBe('1.1.0') + expect(plugins(next)).toEqual(plugins(manager)) expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') - expect(calls(root)).toHaveLength(1) expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n') }) it('preserves plugin files without running pnpm when bundled Node changes', async () => { const { root, manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const dsh = join(root, 'new-node') runtimeFixture(dsh, '1.1.0', '24.18.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) await next.applyRelease() - expect(calls(root)).toHaveLength(1) - expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) + expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) }) it('allows peer version mismatches to reach Host startup and remain available for recovery', async () => { const { root, manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const dsh = join(root, 'next-major') runtimeFixture(dsh, '2.0.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) await expect(next.applyRelease()).resolves.toBeUndefined() - expect(next.dshVersion()).toBe('2.0.0') await next.disableAllPlugins() - expect(next.dshVersion()).toBe('2.0.0') - expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) }) +}) - it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => { +describe.each(['applyRelease', 'disableAllPlugins'] as const)('desktop profile lock during %s', (operation) => { + it('preserves a live owner lock and leaves the profile untouched', async () => { const { manager } = setup() - await manager.applyRelease() - let starts = 0 - await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - beforeChange: async () => { - expect(manager.listPlugins()).toEqual([]) - if (phase === 'before') throw new Error('before failed') - }, - afterChange: async () => { starts++; throw new Error('after failed') }, - }))).rejects.toThrow(`${phase} failed`) - expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }]) - expect(starts).toBe(phase === 'before' ? 0 : 1) + mkdirSync(manager.paths.profile, { recursive: true }) + const owner = `${String(process.pid)}\n` + writeFileSync(manager.paths.lock, owner) + await expect(manager[operation]()).rejects.toThrow('another profile operation is active') + expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner) + expect(readdirSync(manager.paths.profile)).toEqual(['lock']) }) - it('keeps partial package changes available for recovery after pnpm fails', async () => { - const { root, manager } = setup() - await manager.applyRelease() - const failingPnpm = join(root, 'failing.mjs') - writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`) - const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm }) - await worker.applyRelease() - let starts = 0 - await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - afterChange: async () => { starts++ }, - }))).rejects.toThrow(/pnpm exited with 1/u) - expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) - expect(starts).toBe(1) - expect(existsSync(manager.paths.lock)).toBe(false) - writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '') - await manager.applyRelease() - await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks({ afterChange: async () => { starts++ } })) - expect(starts).toBe(2) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(manager.listPlugins()).toEqual([]) + it('reclaims a stale owner lock and releases it after the operation', async () => { + const { manager } = setup() + mkdirSync(manager.paths.profile, { recursive: true }) + writeFileSync(manager.paths.lock, `${String(process.pid)}\n`) + const probe = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('process absent'), { code: 'ESRCH' }) + }) + try { + await expect(manager[operation]()).resolves.toBeUndefined() + expect(probe).toHaveBeenCalledExactlyOnceWith(process.pid, 0) + expect(existsSync(manager.paths.lock)).toBe(false) + } finally { + probe.mockRestore() + } + }) + + it.each(['invalid', '0', '-1', '9007199254740992'])('preserves a lock with invalid owner %s', async (owner) => { + const { manager } = setup() + mkdirSync(manager.paths.profile, { recursive: true }) + writeFileSync(manager.paths.lock, owner) + await expect(manager[operation]()).rejects.toThrow('another profile operation is active') + expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner) }) - it('retains both package and restart errors when the changed profile cannot start', async () => { - const { root, manager } = setup() - await manager.applyRelease() - const failingPnpm = join(root, 'failing-restart.mjs') - writeFileSync(failingPnpm, 'process.exitCode = 1') - const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm }) - await worker.applyRelease() - const afterChange = vi.fn(async () => { throw new Error('Host initialization failed') }) - const failure = await worker.mutate({ type: 'plugin-add', spec: 'missing' }, hooks({ afterChange })) - .catch((error: unknown) => error) - expect(failure).toBeInstanceOf(AggregateError) - expect((failure as AggregateError).errors.map((error: Error) => error.message)) - .toEqual([expect.stringContaining('pnpm exited with 1'), 'Host initialization failed']) - expect(afterChange).toHaveBeenCalledOnce() - expect(existsSync(manager.paths.lock)).toBe(false) + it('rejects a directory at the lock path', async () => { + const { manager } = setup() + mkdirSync(manager.paths.lock, { recursive: true }) + await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file') + expect(lstatSync(manager.paths.lock).isDirectory()).toBe(true) }) - it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => { + it('rejects a linked lock without touching its target', async () => { const { root, manager } = setup() - await manager.applyRelease() - const ready = join(root, 'ready') - const release = join(root, 'release') - const blocker = join(root, 'blocking.mjs') - writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`) - const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker }) - await worker.applyRelease() - const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - // Teardown observes failures even if the runner has abandoned the test body. - const completed = pending.then(value => ({ value }), (error: unknown) => ({ error })) - releaseWorkers.push(async () => { - writeFileSync(release, 'continue') - const outcome = await completed - if ('error' in outcome) throw outcome.error - }) + mkdirSync(manager.paths.profile, { recursive: true }) + const target = join(root, 'lock-target') + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'retain') + symlinkSync(target, manager.paths.lock, process.platform === 'win32' ? 'junction' : 'dir') try { - // Child startup shares the test budget; an aborted poll must not resume ownership assertions. - await expect.poll(() => { - signal.throwIfAborted() - return existsSync(ready) - }, { timeout: task.timeout }).toBe(true) - signal.throwIfAborted() - expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8')) - await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u) + await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file') + expect(lstatSync(manager.paths.lock).isSymbolicLink()).toBe(true) + expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('retain') } finally { - writeFileSync(release, 'continue') - await pending + unlinkSync(manager.paths.lock) } - expect(existsSync(manager.paths.lock)).toBe(false) }) }) diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index 837fd54bf1..76f5c8d95e 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -27,6 +27,8 @@ it('omits development artifacts while preserving executable modules, assets and 'node-pty/prebuilds/win32-x64/conpty.pdb', '@koromix/koffi-win32-x64/win32_x64/koffi.lib', '@mixmark-io/domino/test/entities.html', + '@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data', + '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit', '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', ] const retained = [ @@ -44,6 +46,9 @@ it('omits development artifacts while preserving executable modules, assets and '@koromix/koffi-win32-x64/win32_x64/koffi.node', '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js', '@img/sharp-win32-x64/lib/libvips-42.dll', + '@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe', + '@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json', + '@deepseek-ai/libreoffice-kit-win32-x64/licenses/MPL-2.0.txt', ] try { const runtime = runtimeFixture(source) @@ -54,7 +59,7 @@ it('omits development artifacts while preserving executable modules, assets and } cpSync(modules, join(output, 'node_modules'), { recursive: true, dereference: true, - filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows) === undefined, + filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows, 'win32-x64') === undefined, }) for (const path of removed) expect(existsSync(join(output, 'node_modules', path)), path).toBe(false) for (const path of retained) expect(readFileSync(join(output, 'node_modules', path), 'utf8'), path).toBe(`payload:${path}`) @@ -67,15 +72,28 @@ it('omits development artifacts while preserving executable modules, assets and }) it('applies package-specific rules inside scoped and nested dependency containers', () => { - expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows)).toBeDefined() - expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows)).toBeDefined() - expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows)).toBeUndefined() - expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows)).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows, 'win32-x64')).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows, 'win32-x64')).toBeUndefined() }) it('retains native prebuilds for the selected macOS architecture', () => { const mac = { platform: 'darwin' as const, arch: 'arm64' } - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac)).toBeUndefined() - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac)).toBeDefined() - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac)).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac, 'darwin-arm64')).toBeUndefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac, 'darwin-arm64')).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac, 'darwin-arm64')).toBeDefined() +}) + +it.each([ + ['linux', 'x64', 'linux-x64'], + ['linux', 'arm64', 'wasm'], + ['freebsd', 'x64', 'wasm'], +] as const)('retains only the selected Office engine for %s/%s', (platform, arch, engine) => { + for (const candidate of ['linux-x64', 'darwin-arm64', 'wasm']) { + const omitted = desktopRuntimeFileExclusion( + `@deepseek-ai/libreoffice-kit-${candidate}/prebuilds.json`, { platform, arch }, engine, + ) + expect(omitted === undefined).toBe(candidate === engine) + } }) diff --git a/apps/desktop/tests/runtime-manifests.spec.ts b/apps/desktop/tests/runtime-manifests.spec.ts new file mode 100644 index 0000000000..beb778c0df --- /dev/null +++ b/apps/desktop/tests/runtime-manifests.spec.ts @@ -0,0 +1,27 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { prepareRuntimeManifests } from '../scripts/prepare-runtime-manifests.ts' +import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +it('seals dependency metadata that remains unchanged by archive preparation', async () => { + const root = mkdtempSync(join(tmpdir(), 'runtime-manifests-')) + const fixture = runtimeFixture(root) + try { + const directory = join(root, 'node_modules/example/node_modules/nested') + mkdirSync(directory, { recursive: true }) + const manifest = join(directory, 'package.json') + writeFileSync(manifest, JSON.stringify({ name: 'nested', version: '1.0.0', + scripts: { test: 'vitest' }, keywords: ['example'], bugs: 'https://example.com', + main: 'index.js', dependencies: { runtime: '1.0.0' } })) + await expect(verifyDesktopRuntime(root, fixture.release.version)).rejects.toThrow('integrity') + await prepareRuntimeManifests(root) + expect(JSON.parse(readFileSync(manifest, 'utf8'))).toEqual({ name: 'nested', version: '1.0.0', + main: 'index.js', dependencies: { runtime: '1.0.0' } }) + writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name)) + await prepareRuntimeManifests(root) + await verifyDesktopRuntime(root, fixture.release.version) + } finally { rmSync(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/update-attention.spec.ts b/apps/desktop/tests/update-attention.spec.ts new file mode 100644 index 0000000000..fc67dd6c13 --- /dev/null +++ b/apps/desktop/tests/update-attention.spec.ts @@ -0,0 +1,81 @@ +import { EventEmitter } from 'node:events' +import type { BrowserWindow } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { DesktopUpdateAttention } from '../src/update-attention.ts' +import { resolveDesktopLocale } from '../src/locale.ts' + +const native = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + const notices: Notice[] = [] + class Notice extends EventEmitter { + static isSupported = vi.fn(() => true) + show = vi.fn() + close = vi.fn() + constructor(readonly options: unknown) { super(); notices.push(this) } + } + return { notices, Notice, dock: { bounce: vi.fn(() => 17), cancelBounce: vi.fn() } } +}) +vi.mock('electron', () => ({ Notification: native.Notice, app: { dock: native.dock } })) +let attention: DesktopUpdateAttention | undefined +afterEach(() => { attention?.reset(); native.notices.length = 0; vi.clearAllMocks(); native.Notice.isSupported.mockReturnValue(true) }) +function setup(platform: string) { + const window = Object.assign(new EventEmitter(), { focused: false, isFocused() { return this.focused }, + isDestroyed: () => false, flashFrame: vi.fn() }) + const modal = Object.assign(new EventEmitter(), { isFocused: () => false }) + const returned = vi.fn() + attention = new DesktopUpdateAttention(resolveDesktopLocale('zh'), platform) + const ready = () => { attention!.ready('1.0.1-nightly.1', window as unknown as BrowserWindow, modal as unknown as BrowserWindow, returned) } + return { window, modal, returned, ready } +} + +it.each(['win32', 'darwin'])('requests one silent reminder and returns to confirmation without installing: %s', (platform) => { + const f = setup(platform) + f.ready(); f.ready() + expect(native.notices).toHaveLength(1) + expect(native.notices[0]!.options).toEqual({ title: '更新已准备就绪', body: '返回应用确认安装并重启。', silent: true }) + if (platform === 'win32') expect(f.window.flashFrame).toHaveBeenCalledWith(true) + else expect(native.dock.bounce).toHaveBeenCalledWith('informational') + native.notices[0]!.emit('click') + expect(f.returned).toHaveBeenCalledOnce() + expect(native.notices[0]!.close).toHaveBeenCalledOnce() + f.ready() + expect(native.notices).toHaveLength(1) + expect(f.window.listenerCount('focus')).toBe(0) +}) + +it('clears on focus and ignores stale clicks, even after deferral and another background switch', () => { + const f = setup('win32') + f.ready() + const notification = native.notices[0]! + f.modal.emit('focus') + notification.emit('click') + f.ready() + expect(f.returned).not.toHaveBeenCalled() + expect(native.notices).toHaveLength(1) + expect(f.window.flashFrame).toHaveBeenLastCalledWith(false) + expect(f.modal.listenerCount('focus')).toBe(0) +}) + +it('does not notify foreground readiness and does not require notification support', () => { + const f = setup('win32') + f.window.focused = true + f.ready() + f.window.focused = false + f.ready() + expect(native.notices).toHaveLength(0) + attention!.reset() + native.Notice.isSupported.mockReturnValue(false) + f.ready() + expect(f.window.flashFrame).toHaveBeenCalledWith(true) + expect(native.notices).toHaveLength(0) +}) + +it('keeps icon attention when the system rejects a notification', () => { + const f = setup('darwin') + f.ready() + native.notices[0]!.emit('failed') + expect(native.dock.cancelBounce).not.toHaveBeenCalled() + attention!.reset() + expect(native.dock.cancelBounce).toHaveBeenCalledWith(17) + expect(f.modal.listenerCount('focus')).toBe(0) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 272ef03a1d..d600b5eecb 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -1,8 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterEach, 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' +import { DesktopUpdatePreparationError } from '../src/update-error.ts' +import { zh } from '../src/locale.ts' vi.mock('electron', () => ({ app: { isPackaged: false } })) vi.mock('electron-updater', () => ({ @@ -41,62 +44,181 @@ describe('desktop release metadata', () => { }) }) -describe('desktop update coordinator', () => { - it('installs one Electron release and restarts after download', async () => { - const states: DesktopUpdateState[] = [] - const downloadUpdate = vi.fn(async () => []) - const quitAndInstall = vi.fn() - const beforeRestart = vi.fn(async () => {}) - 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, - ) +const coordinators: InstanceType[] = [] +afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() }) - 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']) +function fixture() { + const events = new EventEmitter() + const checkForUpdates = vi.fn(async () => ({ + isUpdateAvailable: true, + updateInfo: { version: '1.1.0-rc.2' }, + })) + const downloadUpdate = vi.fn(async () => { + events.emit('download-progress', { percent: 58 }) + events.emit('download-progress', { percent: 100 }) + events.emit('update-downloaded', { version: '1.1.0-rc.2' }) + return ['verified-package'] + }) + const quitAndInstall = vi.fn() + const beforeRestart = vi.fn(async () => true) + const states: DesktopUpdateState[] = [] + const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater + const coordinator = new DesktopUpdateCoordinator( + (state) => { states.push(state); return state }, + beforeRestart, updater, () => true, () => '1.1.0-alpha.1', + ) + coordinators.push(coordinator) + return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart } +} + +describe('desktop update coordinator', () => { + it('keeps safe preparation diagnostics separate and clears them on an explicit retry', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError('stop-failed', zh.updateStopFailed, 'exit 0; shutdown acknowledged false')) + expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'error', version: '1.1.0-rc.2', + failedOperation: 'install', preparationFailure: 'stop-failed', message: zh.updateStopFailed, + technicalDetails: 'exit 0; shutdown acknowledged false' }) + expect(f.quitAndInstall).not.toHaveBeenCalled() + f.beforeRestart.mockResolvedValueOnce(false) + expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'ready', version: '1.1.0-rc.2' }) + expect(f.quitAndInstall).not.toHaveBeenCalled() }) - 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) + it('retains an asynchronous installer failure after quitAndInstall returns', async () => { + const f = fixture() + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + await f.coordinator.install('1.1.0-rc.2') + f.events.emit('error', new Error('installer could not start')) + expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'install', message: 'installer could not start' }) + }) - const checking = coordinator.check() - const installing = coordinator.install() - expect(downloadUpdate).not.toHaveBeenCalled() - checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + it('waits for platform preparation after its downloaded event and rejects stale download approval', async () => { + const f = fixture() + const prepared = Promise.withResolvers() + f.downloadUpdate.mockImplementationOnce(async () => { + f.events.emit('update-downloaded', { version: '1.1.0-rc.2' }) + return prepared.promise + }) + await f.coordinator.check() + await expect(f.coordinator.download('1.1.0-rc.1')).rejects.toThrow(/stale/u) + const downloading = f.coordinator.download('1.1.0-rc.2') + await Promise.resolve() + await Promise.resolve() + try { + expect(f.coordinator.state.phase).not.toBe('ready') + await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow(/not ready/u) + } finally { prepared.resolve(['verified']); await downloading } + expect(f.coordinator.state.phase).toBe('ready') + }) - 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() + it('consumes late updater errors until an in-flight check settles after disposal', async () => { + const f = fixture() + const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>() + f.checkForUpdates.mockImplementation(() => checked.promise) + const pending = f.coordinator.check() + await Promise.resolve() + f.coordinator.dispose() + expect(() => f.events.emit('error', new Error('late network failure'))).not.toThrow() + checked.reject(new Error('offline')) + await pending + await Promise.resolve() + expect(f.events.listenerCount('error')).toBe(0) + }) + + it('checks, downloads on demand, then requires separate installation approval', async () => { + const f = fixture() + await f.coordinator.check() + expect(f.downloadUpdate).not.toHaveBeenCalled() + await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow(/not ready/u) + await f.coordinator.download('1.1.0-rc.2') + expect(f.quitAndInstall).not.toHaveBeenCalled() + expect(f.beforeRestart).not.toHaveBeenCalled() + expect(f.coordinator.state).toEqual({ phase: 'ready', version: '1.1.0-rc.2' }) + await f.coordinator.install('1.1.0-rc.2') + expect(f.beforeRestart).toHaveBeenCalledOnce() + expect(f.quitAndInstall).toHaveBeenCalledWith(true, true) + expect(f.states.map(state => state.phase)).toEqual([ + 'available', 'downloading', 'downloading', 'verifying', 'ready', 'installing', + ]) + expect(f.updater).toMatchObject({ + autoDownload: false, autoInstallOnAppQuit: false, channel: 'nightly', + allowPrerelease: true, allowDowngrade: false, + }) + }) + + it('joins checks and downloads without retargeting a prepared release', async () => { + const f = fixture() + const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>() + f.checkForUpdates.mockImplementation(() => checked.promise) + const checking = f.coordinator.check() + const manual = f.coordinator.check(true) + const firstDownload = f.coordinator.download('1.1.0-rc.2') + const secondDownload = f.coordinator.download('1.1.0-rc.2') + checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.1.0-rc.2' } }) + await Promise.all([checking, manual, firstDownload, secondDownload]) + expect(f.checkForUpdates).toHaveBeenCalledOnce() + expect(f.downloadUpdate).toHaveBeenCalledOnce() + await f.coordinator.check(true) + expect(f.checkForUpdates).toHaveBeenCalledOnce() + await expect(f.coordinator.install('1.2.0')).rejects.toThrow(/not ready/u) + }) + + it('keeps automatic failures silent and exposes a manual caller joining the same check', async () => { + const f = fixture() + f.checkForUpdates.mockRejectedValue(new Error('offline')) + expect(await f.coordinator.check()).toMatchObject({ phase: 'error', failedOperation: 'check' }) + expect(f.states).toEqual([]) + await Promise.all([f.coordinator.check(), f.coordinator.check(true)]) + expect(f.states).toEqual([expect.objectContaining({ phase: 'error', failedOperation: 'check' })]) + expect(f.downloadUpdate).not.toHaveBeenCalled() + }) + + it.each(['1.0.0', '1.1.0-alpha.1', 'invalid'])('does not download or install inapplicable version %s', async (version) => { + const f = fixture() + f.checkForUpdates.mockResolvedValue({ isUpdateAvailable: true, updateInfo: { version } }) + await f.coordinator.check() + await expect(f.coordinator.download('1.1.0-rc.2')).rejects.toThrow(/no checked update/u) + expect(f.downloadUpdate).not.toHaveBeenCalled() + }) + + it('retains retry state after download failure and never equates 100 percent with readiness', async () => { + const f = fixture() + f.downloadUpdate.mockImplementationOnce(async () => { + f.events.emit('download-progress', { percent: 100 }) + throw new Error('signature rejected') + }) + await f.coordinator.check() + expect(await f.coordinator.download('1.1.0-rc.2')).toMatchObject({ phase: 'error', failedOperation: 'download' }) + await expect(f.coordinator.install('1.1.0-rc.2')).rejects.toThrow(/not ready/u) + await f.coordinator.download('1.1.0-rc.2') + expect(f.coordinator.state.phase).toBe('ready') + expect(f.quitAndInstall).not.toHaveBeenCalled() + }) + + it('refuses handoff after failed task preparation while retaining the prepared package', async () => { + const f = fixture() + f.beforeRestart.mockRejectedValueOnce(new Error('tasks could not stop')) + await f.coordinator.check() + await f.coordinator.download('1.1.0-rc.2') + expect(await f.coordinator.install('1.1.0-rc.2')).toMatchObject({ phase: 'error', failedOperation: 'install' }) + expect(f.quitAndInstall).not.toHaveBeenCalled() + await f.coordinator.install('1.1.0-rc.2') + expect(f.downloadUpdate).toHaveBeenCalledOnce() + expect(f.quitAndInstall).toHaveBeenCalledOnce() + }) + + it('does not publish late check completion after disposal', async () => { + const f = fixture() + const checked = Promise.withResolvers<{ isUpdateAvailable: boolean; updateInfo: { version: string } }>() + f.checkForUpdates.mockImplementation(() => checked.promise) + const pending = f.coordinator.check() + f.coordinator.dispose() + checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.1.0-rc.2' } }) + await pending + expect(f.states).toEqual([]) + expect(f.events.listenerCount('download-progress')).toBe(0) }) }) diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts new file mode 100644 index 0000000000..a163dd6e1a --- /dev/null +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -0,0 +1,193 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts' +import { resolveDesktopLocale } from '../src/locale.ts' + +const fixture = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + const handlers = new Map unknown>() + const windows: FakeWindow[] = [] + class FakeWindow extends EventEmitter { + destroyed = false + readonly webContents = Object.assign(new EventEmitter(), { + send: vi.fn(), + mainFrame: { url: 'dsh-app://shell/update-dialog.html' }, + setWindowOpenHandler: vi.fn(), + insertCSS: vi.fn(async () => 'blur'), + removeInsertedCSS: vi.fn(async () => {}), + }) + readonly focus = vi.fn() + readonly show = vi.fn() + readonly setBounds = vi.fn() + readonly loadURL = vi.fn(async () => {}) + constructor(readonly options: unknown) { super(); windows.push(this) } + getContentBounds() { return { x: 10, y: 20, width: 900, height: 650 } } + isDestroyed() { return this.destroyed } + destroy() { this.destroyed = true; this.emit('closed') } + setMenu() {} + } + return { handlers, windows, FakeWindow } +}) +vi.mock('electron', () => ({ BrowserWindow: fixture.FakeWindow, ipcMain: { + handle: (name: string, fn: (...args: unknown[]) => unknown) => fixture.handlers.set(name, fn), + removeHandler: (name: string) => fixture.handlers.delete(name), +} })) + +let dialogs: DesktopUpdateDialog | undefined +afterEach(() => { + vi.useRealTimers() + dialogs?.dispose() + dialogs = undefined + for (const window of fixture.windows) if (!window.isDestroyed()) window.destroy() + fixture.windows.length = 0 + fixture.handlers.clear() +}) + +function setup() { + const parent = new fixture.FakeWindow({}) + dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', resolveDesktopLocale('zh-CN')) + const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, { + message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }), + }) + const invoke = (channel: string, ...args: unknown[]) => { + const window = fixture.windows.at(-1)! + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + const view = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number } + return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [view.revision, ...args] : args)) + } + return { parent, show, invoke } +} + +it('accepts only a displayed choice from its own main frame and retains cancellation outside the button list', async () => { + const f = setup() + const pending = f.show() + const window = fixture.windows.at(-1)! + expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ closeLabel: '关闭', buttons: ['安装并重启'], cancelId: 1 }) + const respond = fixture.handlers.get(UPDATE_DIALOG_IPC.respond)! + expect(() => respond({ sender: {}, senderFrame: window.webContents.mainFrame }, 0)).toThrow(/unowned/) + expect(() => respond({ sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } }, 0)).toThrow(/unowned/) + for (const index of [-1, 2, '0', 0.5, NaN]) expect(() => f.invoke(UPDATE_DIALOG_IPC.respond, index)).toThrow(/invalid/) + f.invoke(UPDATE_DIALOG_IPC.respond, 1) + expect(await pending).toEqual({ response: 1, checkboxChecked: false }) + const next = f.show() + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect((await next).response).toBe(0) +}) + +it('follows the parent geometry and removes listeners when closed or replaced', async () => { + const f = setup() + const first = f.show() + const window = fixture.windows.at(-1)! + expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 }) + window.emit('ready-to-show') + expect(window.show).toHaveBeenCalledOnce() + f.parent.emit('resize') + expect(window.setBounds).toHaveBeenCalledWith(f.parent.getContentBounds()) + const next = f.show() + expect((await first).response).toBe(1) + expect(f.parent.listenerCount('resize')).toBe(1) + fixture.windows.at(-1)!.destroy() + expect((await next).response).toBe(1) + expect(f.parent.listenerCount('resize')).toBe(0) + expect(f.parent.listenerCount('move')).toBe(0) + expect(f.parent.webContents.removeInsertedCSS).toHaveBeenCalledTimes(1) +}) + +it('cancels on abort, renderer failure, disposal, or an already-closed parent', async () => { + const f = setup() + const controller = new AbortController() + const pending = f.show(controller.signal) + controller.abort() + expect((await pending).response).toBe(1) + expect((await f.show(controller.signal)).response).toBe(1) + const crashing = f.show() + fixture.windows.at(-1)!.webContents.emit('render-process-gone') + expect((await crashing).response).toBe(1) + const stopping = f.show() + dialogs!.dispose() + expect((await stopping).response).toBe(1) + expect(fixture.handlers.size).toBe(0) + const count = fixture.windows.length + expect((await f.show()).response).toBe(1) + expect(fixture.windows).toHaveLength(count) + f.parent.destroy() + expect((await f.show()).response).toBe(1) +}) + +it('denies navigation away from the owned document', async () => { + const f = setup() + const pending = f.show() + const window = fixture.windows.at(-1)! + const event = { preventDefault: vi.fn() } + window.webContents.emit('will-navigate', event, 'https://example.com') + expect(event.preventDefault).toHaveBeenCalledOnce() + window.webContents.emit('will-navigate', event, 'dsh-app://shell/update-dialog.html') + expect(event.preventDefault).toHaveBeenCalledOnce() + dialogs!.cancel() + await pending +}) + +it('supplies localized disclosure copy without putting diagnostics in the ordinary detail', async () => { + const f = setup() + const pending = dialogs!.show(f.parent as unknown as BrowserWindow, { + message: '未能安全停止任务', technicalDetails: 'exit 0; shutdown acknowledged false', + }) + expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ detail: '', + technicalDetails: 'exit 0; shutdown acknowledged false', technicalDetailsLabel: '查看技术详情' }) + dialogs!.cancel() + await pending +}) + +it('replaces content without releasing the backdrop and rejects a response from the previous prompt', async () => { + const f = setup() + const abort = new AbortController() + const first = f.show(abort.signal) + const window = fixture.windows.at(-1)! + const old = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number } + const next = f.show() + expect((await first).response).toBe(1) + expect(fixture.windows).toHaveLength(2) + expect(window.loadURL).toHaveBeenCalledOnce() + expect(window.webContents.send.mock.calls).toEqual([[UPDATE_DIALOG_IPC.changed, f.invoke(UPDATE_DIALOG_IPC.status)]]) + expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled() + abort.abort() + expect(() => fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!({ + sender: window.webContents, senderFrame: window.webContents.mainFrame, + }, old.revision, 0)).toThrow(/stale/) + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + expect((await next).response).toBe(0) +}) + +it('fades out before releasing the backdrop and cancels pending removal when another prompt opens', async () => { + vi.useFakeTimers() + const f = setup() + const first = f.show() + const window = fixture.windows.at(-1)! + dialogs!.cancel() + await first + expect(window.isDestroyed()).toBe(false) + expect(window.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null) + const next = f.show() + await vi.advanceTimersByTimeAsync(150) + expect(window.isDestroyed()).toBe(false) + dialogs!.cancel() + await next + await vi.advanceTimersByTimeAsync(150) + expect(window.isDestroyed()).toBe(true) + expect(f.parent.listenerCount('focus')).toBe(0) + expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0) +}) + +it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redirects focus without a native sheet', async () => { + const f = setup() + const pending = f.show() + const window = fixture.windows.at(-1)! + const event = { preventDefault: vi.fn() } + f.parent.webContents.emit('before-input-event', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + f.parent.emit('focus') + expect(window.focus).toHaveBeenCalledTimes(2) + dialogs!.dispose() + await pending + expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0) +}) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts new file mode 100644 index 0000000000..ad92f2df8f --- /dev/null +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -0,0 +1,217 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { JSDOM } from 'jsdom' +import { expect, it, onTestFinished, vi } from 'vitest' +import { resolveDesktopLocale } from '../src/locale.ts' +import type { UpdateDialogApi, UpdateDialogView } from '../src/update-dialog.ts' +import type { MandatoryUpdateApi, MandatoryUpdateView } from '../src/mandatory-update-window.ts' + +function page(name: string) { + const dom = new JSDOM(readFileSync(new URL(`../renderer/${name}.html`, import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + onTestFinished(() => { + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + dom.window.close() + }) + const document = dom.window.document + const element = (id: string): HTMLElement => { + const result = document.getElementById(id) + if (result === null) throw new Error(`Missing update element: ${id}`) + return result + } + const run = (): void => { runInContext(readFileSync(new URL(`../renderer/${name}.js`, import.meta.url), 'utf8'), dom.getInternalVMContext()) } + return { dom, document, element, run } +} + +it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyboard-accessible: %s', async (language) => { + const p = page('update-dialog') + const locale = resolveDesktopLocale(language) + const state: UpdateDialogView = { revision: 1, locale: locale.id, title: locale.messages.updateFailedTitle, + message: locale.messages.updateStopFailed, detail: '', buttons: [locale.messages.updateAcknowledge], cancelId: 0, + closeLabel: locale.messages.updateClose, technicalDetailsLabel: locale.messages.updateTechnicalDetails, + technicalDetails: '\nexit 0; shutdown acknowledged false' } + const respond = vi.fn(async () => {}) + let publish!: (view: UpdateDialogView | null) => void + const api: UpdateDialogApi = { status: async () => state, respond, subscribe: (listener) => { publish = listener; return () => {} } } + Object.defineProperty(p.dom.window, 'dshUpdateDialog', { value: api }) + p.run() + await expect.poll(() => p.element('dialog').hidden).toBe(false) + const disclosure = p.element('technical-details') as HTMLDetailsElement + expect(disclosure.hidden).toBe(false) + expect(disclosure.open).toBe(false) + expect(p.element('title').textContent).toBe(locale.messages.updateStopFailed) + expect(p.element('detail').textContent).toBe('') + expect(p.element('technical-details-content').childElementCount).toBe(0) + expect(p.element('technical-details-content').textContent).toBe(state.technicalDetails) + const tab = () => p.document.dispatchEvent(new p.dom.window.KeyboardEvent('keydown', { key: 'Tab', cancelable: true })) + tab() + expect(p.document.activeElement?.id).toBe('close') + tab() + expect(p.document.activeElement?.id).toBe('technical-details-label') + p.element('technical-details-label').click() + expect(disclosure.open).toBe(true) + tab() + expect(p.document.activeElement?.id).toBe('technical-details-content') + p.element('technical-details-label').click() + expect(disclosure.open).toBe(false) + expect(respond).not.toHaveBeenCalled() + p.document.dispatchEvent(new p.dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true })) + expect(respond).toHaveBeenCalledWith(1, 0) + const backdrop = p.document.body + const dialog = p.element('dialog') + dialog.scrollTop = 170 + publish({ ...state, revision: 2, message: locale.messages.updateChecking, technicalDetails: '', buttons: ['OK'] }) + expect(p.document.body).toBe(backdrop) + expect(p.element('dialog')).toBe(dialog) + expect(dialog.scrollTop).toBe(0) + expect(backdrop.classList.contains('visible')).toBe(true) + expect(p.element('actions').childElementCount).toBe(1) + expect(disclosure.open).toBe(false) + expect(p.document.activeElement).toBe(dialog) + dialog.scrollTop = 80 + publish(state) + expect(dialog.scrollTop).toBe(80) + expect(p.element('title').textContent).toBe(locale.messages.updateChecking) + publish(null) + expect(backdrop.classList.contains('visible')).toBe(false) +}) + +it('keeps mandatory diagnostics expandable without clearing the block or authorizing installation', async () => { + const p = page('mandatory-update') + const locale = resolveDesktopLocale('zh-CN') + const initial: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false }, + update: { phase: 'error', failedOperation: 'install', preparationFailure: 'stop-failed', version: '0.1.6-nightly.1', + message: 'different shell locale', technicalDetails: 'exit 0; shutdown acknowledged false' } } + const action = vi.fn(async () => {}) + let publish!: (view: MandatoryUpdateView) => void + const unsubscribe = vi.fn() + const api: MandatoryUpdateApi = { status: async () => initial, action, + subscribe: (listener) => { publish = listener; return unsubscribe } } + Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: api }) + p.run() + await expect.poll(() => p.element('error').textContent).toBe(locale.messages.updateStopFailed) + const disclosure = p.element('technical-details') as HTMLDetailsElement + expect(disclosure.open).toBe(false) + expect(disclosure.hidden).toBe(false) + expect([p.element('error').textContent, p.element('technical-details-label').textContent, + p.element('update').textContent]).toMatchInlineSnapshot(` + [ + "未能安全停止任务,更新未安装。请稍后重试。", + "查看技术详情", + "重试更新", + ] + `) + p.element('technical-details-label').click() + expect(disclosure.open).toBe(true) + publish(initial) + expect(disclosure.open).toBe(true) + const escape = new p.dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true }) + p.document.dispatchEvent(escape) + expect(escape.defaultPrevented).toBe(true) + expect(action).not.toHaveBeenCalled() + publish({ ...initial, update: { ...initial.update, technicalDetails: '' } }) + expect(disclosure.open).toBe(false) + expect(p.element('technical-details-content').childElementCount).toBe(0) + publish({ ...initial, error: locale.messages.mandatoryPageFailed }) + expect(disclosure.hidden).toBe(false) + expect(p.element('technical-details-content').textContent).toBe(initial.update.technicalDetails) + publish({ ...initial, update: { phase: 'ready', version: '0.1.6-nightly.1' } }) + expect(disclosure.hidden).toBe(true) + expect(p.element('error').hidden).toBe(true) + expect(p.document.getElementById('quit')).toBeNull() + expect(p.document.body.classList.contains('visible')).toBe(true) + publish({ ...initial, policy: { blocking: false, checking: false } }) + expect(p.document.body.classList.contains('visible')).toBe(false) + publish(initial) + expect(p.document.body.classList.contains('visible')).toBe(true) + p.dom.window.dispatchEvent(new p.dom.window.Event('pagehide')) + expect(unsubscribe).toHaveBeenCalledOnce() +}) + +function mandatoryPage(update: MandatoryUpdateView['update']) { + const p = page('mandatory-update') + const initial: MandatoryUpdateView = { locale: resolveDesktopLocale('zh'), deferred: false, + policy: { blocking: true, checking: false, title: '需要更新', page: 'https://downloads.example.com/desktop' }, update } + const action = vi.fn(async () => {}) + let publish!: (view: MandatoryUpdateView) => void + Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: { + status: async () => initial, action, subscribe: (listener: typeof publish) => { publish = listener; return () => {} }, + } satisfies MandatoryUpdateApi }) + p.run() + return { ...p, initial, action, publish: (view: MandatoryUpdateView) => { publish(view) } } +} + +it('uses client copy and retry when optional policy fields are absent', async () => { + const p = mandatoryPage({ phase: 'idle' }) + p.publish({ ...p.initial, policy: { blocking: true, checking: false } }) + const messages = p.initial.locale.messages + expect(p.element('title').textContent).toBe(messages.mandatoryTitle) + expect(p.element('detail').textContent).toBe(messages.mandatoryDetail) + expect(p.element('error').textContent).toBe(messages.mandatoryNoRelease) + expect(p.element('refresh').hidden).toBe(false) + expect(p.element('page').hidden).toBe(true) +}) + +it('uses the same modal for download, verification, inspected confirmation and task-aware restart', async () => { + const p = mandatoryPage({ phase: 'available', version: '1.0.1-nightly.1' }) + await expect.poll(() => p.element('update').textContent).toBe('下载更新') + const modal = p.document.querySelector('main') + const pending = Promise.withResolvers() + p.action.mockReturnValueOnce(pending.promise) + p.element('update').focus() + p.element('update').click() + p.publish({ ...p.initial, update: { phase: 'verifying', version: '1.0.1-nightly.1' } }) + expect(p.element('status').textContent).toBe('正在校验更新文件…') + expect(p.element('update').hidden).toBe(true) + p.publish({ ...p.initial, update: { phase: 'installing', version: '1.0.1-nightly.1' }, + confirmation: { active: false, version: '1.0.1-nightly.1', revision: 1 } }) + expect(p.document.activeElement?.id).not.toBe('update') + expect(p.element('update').textContent).toBe('安装并重启') + expect((p.element('update') as HTMLButtonElement).disabled).toBe(false) + expect(p.element('page').hidden).toBe(true) + expect(p.element('refresh').hidden).toBe(true) + expect(p.element('manual-copy').hidden).toBe(true) + const repeat = new p.dom.window.KeyboardEvent('keydown', { key: 'Enter', repeat: true, cancelable: true }) + p.document.dispatchEvent(repeat) + expect(repeat.defaultPrevented).toBe(true) + expect(p.action).toHaveBeenCalledTimes(1) + p.element('update').dispatchEvent(new p.dom.window.MouseEvent('click', { detail: 2, bubbles: true })) + expect(p.action).toHaveBeenCalledTimes(1) + p.element('update').click() + expect(p.action).toHaveBeenLastCalledWith('install', '1.0.1-nightly.1', 1) + pending.resolve(undefined) + p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'preparing' }) + expect(p.element('detail').textContent).toBe('应用即将重启,请稍候。') + p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'stopping-tasks' }) + expect(p.element('detail').textContent).toBe('正在安全结束应用中的任务。') + expect(p.document.querySelector('main')).toBe(modal) +}) + +it('reveals the complete selectable address only after copy failure, without replacing updater diagnostics', async () => { + const p = mandatoryPage({ phase: 'error', failedOperation: 'download', message: 'HASH_MISMATCH' }) + await expect.poll(() => p.element('error').textContent).toBe('更新文件下载或准备失败,请重试。') + const originalError = p.element('error').textContent + p.publish({ ...p.initial, navigation: { page: 'requested' } }) + expect(p.element('browser-message').textContent).toBe('若页面未打开,可') + expect(p.element('manual-copy').hidden).toBe(true) + p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'failed' } }) + expect(p.element('manual-copy').hidden).toBe(false) + expect((p.element('address') as HTMLTextAreaElement).value).toBe(p.initial.policy.page) + expect((p.element('address') as HTMLTextAreaElement).readOnly).toBe(true) + expect(p.element('error').textContent).toBe(originalError) + expect(p.element('technical-details-content').textContent).toBe('HASH_MISMATCH') + expect(p.element('copy-message').textContent).toBe('复制失败,请手动选择下方地址复制。') + p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'copied' } }) + expect(p.element('manual-copy').hidden).toBe(true) + expect((p.element('address') as HTMLTextAreaElement).value).toBe('') + expect(p.element('copy').textContent).toBe('已复制链接') + expect(p.action).not.toHaveBeenCalled() +}) + +it('renders server markup literally in a dedicated safety case', async () => { + const p = mandatoryPage({ phase: 'available', version: '1.0.1-nightly.1' }) + await expect.poll(() => p.element('update').textContent).toBe('下载更新') + p.publish({ ...p.initial, policy: { ...p.initial.policy, title: '请更新', detail: '' } }) + expect(p.element('title').textContent).toBe('请更新') + expect(p.element('title').childElementCount).toBe(0) + expect(p.element('detail').childElementCount).toBe(0) +}) diff --git a/apps/desktop/tests/update-http-executor.spec.ts b/apps/desktop/tests/update-http-executor.spec.ts new file mode 100644 index 0000000000..6fb46d0f74 --- /dev/null +++ b/apps/desktop/tests/update-http-executor.spec.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientRequest } from 'electron' +import { DesktopUpdateHttpExecutor } from '../src/update-http-executor.ts' + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +function fixture() { + const request = Object.assign(new EventEmitter(), { abort: vi.fn(() => { request.emit('abort') }) }) + const reject = vi.fn() + new DesktopUpdateHttpExecutor(1000).addErrorAndTimeoutHandlers(request as unknown as ClientRequest, reject) + return { request, reject } +} + +describe('Electron updater inactivity deadline', () => { + it('aborts a request that never receives headers without waiting for a Node socket event', async () => { + const { request, reject } = fixture() + await vi.advanceTimersByTimeAsync(1000) + expect(reject).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ code: 'ETIMEDOUT' })) + expect(request.abort).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('refreshes the deadline on actual response bytes rather than timing out a long active download', async () => { + const { request, reject } = fixture() + const response = new EventEmitter() + request.emit('response', response) + for (let index = 0; index < 4; index++) { + await vi.advanceTimersByTimeAsync(900) + response.emit('data', Buffer.from('data')) + } + expect(reject).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1000) + expect(reject).toHaveBeenCalledOnce() + expect(response.listenerCount('data')).toBe(0) + }) + + it.each(['abort', 'error'])('removes the deadline on request %s', async (event) => { + const { request, reject } = fixture() + request.emit(event, new Error('transport failure')) + const count = reject.mock.calls.length + await vi.advanceTimersByTimeAsync(10_000) + expect(request.abort).not.toHaveBeenCalled() + expect(reject).toHaveBeenCalledTimes(count) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps waiting for headers after Electron closes the request writable stream', async () => { + const { request, reject } = fixture() + request.emit('finish') + request.emit('close') + await vi.advanceTimersByTimeAsync(1000) + expect(reject).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ code: 'ETIMEDOUT' })) + }) + + it('does not leave a deadline running while completed bytes undergo verification', async () => { + const { request, reject } = fixture() + const response = new EventEmitter() + request.emit('response', response) + response.emit('end') + await vi.advanceTimersByTimeAsync(10_000) + expect(reject).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([0, 999, 1.5, NaN, 2_147_483_648])('rejects invalid inactivity configuration: %s', (value) => { + expect(() => new DesktopUpdateHttpExecutor(value)).toThrow('idle timeout') + }) +}) diff --git a/apps/desktop/tests/update-journal.spec.ts b/apps/desktop/tests/update-journal.spec.ts new file mode 100644 index 0000000000..e6344c333f --- /dev/null +++ b/apps/desktop/tests/update-journal.spec.ts @@ -0,0 +1,53 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { DesktopUpdateJournal, desktopUpdateJournalState } from '../src/update-journal.ts' + +describe('desktop qualification update journal', () => { + it('keeps both versions in separate files in the shared evidence directory', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-update-journal-')) + try { + const old = new DesktopUpdateJournal(directory, '1.0.0-nightly.1') + old.action('download-requested') + old.state({ phase: 'downloading', version: '1.0.0-nightly.2', percent: 42.1 }) + old.state({ phase: 'downloading', version: '1.0.0-nightly.2', percent: 42.9 }) + old.state({ phase: 'error', version: '1.0.0-nightly.2', failedOperation: 'download', message: 'ETIMEDOUT private-data' }) + old.action('download-requested') + old.state({ phase: 'ready', version: '1.0.0-nightly.2' }) + old.action('install-confirmed') + old.action('quit-requested') + const next = new DesktopUpdateJournal(directory, '1.0.0-nightly.2') + next.action('workspace-ready') + const first = (await readFile(old.path, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as Record) + const second = (await readFile(next.path, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as Record) + expect(first.map(row => row.sequence)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(first[3]).toMatchObject({ failedOperation: 'download', errorCode: 'ETIMEDOUT' }) + expect(second).toMatchObject([{ event: 'started', version: '1.0.0-nightly.2' }, { event: 'workspace-ready' }]) + expect(JSON.stringify(first)).not.toContain('private-data') + expect(next.path).not.toBe(old.path) + } finally { await rm(directory, { recursive: true, force: true }) } + }) + + it('drops detailed errors and unexpected fields instead of trying to redact arbitrary text', () => { + const state = { phase: 'error' as const, failedOperation: 'download' as const, version: '1.2.3', + message: 'https://user:password@example.test/file?token=secret', technicalDetails: 'password', extra: 'secret' } + expect(desktopUpdateJournalState(state)).toEqual({ phase: 'error', targetVersion: '1.2.3', + failedOperation: 'download', errorCode: 'UNCLASSIFIED' }) + expect(desktopUpdateJournalState({ phase: 'idle' })).toEqual({ phase: 'idle' }) + expect(desktopUpdateJournalState({ phase: 'downloading' })).toEqual({ phase: 'downloading' }) + expect(desktopUpdateJournalState({ phase: 'error' })).toEqual({ phase: 'error', errorCode: 'UNCLASSIFIED' }) + expect(desktopUpdateJournalState({ phase: 'verifying', version: '1.2.3', percent: 100 })).toEqual({ phase: 'verifying', targetVersion: '1.2.3' }) + }) + + it('refuses relative evidence paths and reports storage failure', async () => { + expect(() => new DesktopUpdateJournal('relative-logs', '1.0.0')).toThrow('must be absolute') + const directory = await mkdtemp(join(tmpdir(), 'dsh-update-journal-failure-')) + try { + const journal = new DesktopUpdateJournal(directory, '1.0.0') + await rm(journal.path) + await rm(directory, { recursive: true, force: true }) + expect(() => { journal.action('download-requested') }).toThrow() + } finally { await rm(directory, { recursive: true, force: true }) } + }) +}) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts new file mode 100644 index 0000000000..97bb5cc266 --- /dev/null +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -0,0 +1,52 @@ +import { EventEmitter } from 'node:events' +import { expect, it, vi } from 'vitest' +import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' +import { createMandatoryUpdateWindow } from '../src/update-overlay.ts' + +const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) +vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) + +it('gives the Windows mandatory modal native move, resize, and maximize controls', () => { + const window = Object.assign(new EventEmitter(), { + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), isDestroyed: () => false, + }) + native.create.mockReturnValue(window) + const parent = {} as BrowserWindow + expect(createMandatoryUpdateWindow(parent, 'owned', 'Update required', 'win32')).toBe(window) + expect(native.create).toHaveBeenCalledWith(expect.objectContaining({ + parent, modal: true, show: false, title: 'Update required', + movable: true, resizable: true, maximizable: true, + minWidth: 480, minHeight: 360, + })) + const options = native.create.mock.calls[0]![0] + expect(options.webPreferences).toMatchObject({ preload: 'owned', sandbox: true, nodeIntegration: false }) + expect(options).not.toHaveProperty('frame', false) + window.emit('ready-to-show') + expect(window.show).toHaveBeenCalledOnce() + expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce() +}) + +it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const parent = Object.assign(new EventEmitter(), { + getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + isDestroyed: () => false, + }) + const window = Object.assign(new EventEmitter(), { + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false, + }) + native.create.mockReturnValue(window) + try { + createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin') + expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) + const event = { preventDefault: vi.fn() } + parent.webContents.emit('before-input-event', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + window.emit('closed') + expect(parent.listenerCount('focus')).toBe(0) + expect(parent.webContents.listenerCount('before-input-event')).toBe(0) + } finally { platform.mockRestore() } +}) diff --git a/apps/desktop/tests/update-presentation.spec.ts b/apps/desktop/tests/update-presentation.spec.ts new file mode 100644 index 0000000000..70ed16a168 --- /dev/null +++ b/apps/desktop/tests/update-presentation.spec.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest' +import { desktopUpdateErrorSummary, presentDesktopUpdate } from '../src/update-presentation.ts' +import { en, zh } from '../src/locale.ts' +import type { DesktopUpdateState, DshDesktopProductApi } from '../src/ipc.ts' +import type { DesktopUpdateBridge } from '../../../packages/client/ui-settings-general/src/client/desktop-update-bridge.ts' + +it('keeps the Electron preload assignable to the product consumer', () => { + const assign = (bridge: DshDesktopProductApi['updates']): DesktopUpdateBridge => bridge + expect(assign).toBeTypeOf('function') +}) + +it('records visible ordinary-update states without authorizing downloads or installation', async () => { + const phases: DesktopUpdateState['phase'][] = ['idle', 'available', 'downloading', 'verifying', 'ready', 'installing', 'error'] + const states = phases.map(phase => presentDesktopUpdate({ phase, version: '0.1.5-rc.2', percent: 58 })) + await expect(JSON.stringify(states, null, 2) + '\n').toMatchFileSnapshot('./expected/update-status-zh.json') +}) + +it('keeps raw diagnostics out of error tooltips in both locales', () => { + for (const messages of [en, zh]) { + for (const failedOperation of ['check', 'download', 'install'] as const) { + const state: DesktopUpdateState = { phase: 'error', failedOperation, + message: 'net::ERR_CONNECTION_CLOSED\nHeaders: private\n' + 'at internal/path\n'.repeat(1000) } + const presentation = presentDesktopUpdate(state) + expect(presentation.failure).toBe(`${failedOperation}-network`) + expect(JSON.stringify(presentation)).not.toContain('Headers') + expect(JSON.stringify(presentation)).not.toContain('internal/path') + } + expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'check', message: '404 NoSuchKey' }, messages)) + .toBe(messages.updateCheckFailed) + expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'download' }, messages)) + .toBe(messages.updateDownloadFailed) + expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure: 'stop-failed', + message: 'translated or changed copy', technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateStopFailed) + for (const [preparationFailure, expected] of [ + ['tasks-changed', messages.updateTasksChanged], ['tasks-unavailable', messages.updateTasksUnavailable], + ] as const) { + expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure, + message: 'different locale' }, messages)).toBe(expected) + } + expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', message: messages.updateStopFailed }, messages)) + .toBe(messages.updateInstallFailed) + expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', message: 'internal failure', + technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateInstallFailed) + } +}) diff --git a/apps/desktop/tests/update-schedule.spec.ts b/apps/desktop/tests/update-schedule.spec.ts new file mode 100644 index 0000000000..9d481f21fc --- /dev/null +++ b/apps/desktop/tests/update-schedule.spec.ts @@ -0,0 +1,186 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppUpdater } from 'electron-updater' +import type { DesktopUpdateState } from '../src/ipc.ts' +import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from '../src/update-schedule.ts' + +vi.mock('electron', () => ({ app: { isPackaged: false } })) +vi.mock('electron-updater', () => ({ default: { autoUpdater: {} } })) +const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts') + +const cleanup: (() => void)[] = [] +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { + for (const dispose of cleanup.splice(0)) dispose() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +function fixture(jitter = 0, random = () => 0.5) { + const checkForUpdates = vi.fn(async () => ({ isUpdateAvailable: false, updateInfo: { version: '1.0.0' } })) + const events = new EventEmitter() + const downloadUpdate = vi.fn(async () => { + events.emit('update-downloaded', { version: '1.1.0-nightly.1' }) + return ['verified'] + }) + const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall: vi.fn() }) as unknown as AppUpdater + const states: DesktopUpdateState[] = [] + const coordinator = new DesktopUpdateCoordinator((state) => { states.push(state); return state }, async () => true, + updater, () => true, () => '1.0.0') + const schedule = new DesktopUpdateSchedule(coordinator, { intervalMs: 10_000, maxBackoffMs: 40_000, jitter }, random) + cleanup.push(() => { schedule.dispose(); coordinator.dispose() }) + return { schedule, coordinator, states, checkForUpdates, downloadUpdate } +} + +describe('ordinary update polling', () => { + it('checks immediately, doubles failed delays up to the cap, and resets after success', async () => { + const f = fixture() + f.checkForUpdates.mockRejectedValue(new Error('offline')) + await f.schedule.check() + expect(f.checkForUpdates).toHaveBeenCalledTimes(1) + for (const [delay, calls] of [[20_000, 2], [40_000, 3], [40_000, 4]] as const) { + await vi.advanceTimersByTimeAsync(delay - 1) + await f.schedule.check() + expect(f.checkForUpdates).toHaveBeenCalledTimes(calls - 1) + await vi.advanceTimersByTimeAsync(1) + expect(f.checkForUpdates).toHaveBeenCalledTimes(calls) + } + expect(f.states).toEqual([]) + f.checkForUpdates.mockResolvedValue({ isUpdateAvailable: false, updateInfo: { version: '1.0.0' } }) + await f.schedule.check(true) + await vi.advanceTimersByTimeAsync(9_999) + expect(f.checkForUpdates).toHaveBeenCalledTimes(5) + await vi.advanceTimersByTimeAsync(1) + expect(f.checkForUpdates).toHaveBeenCalledTimes(6) + expect(f.downloadUpdate).not.toHaveBeenCalled() + }) + + it.each([[0, 8_000, 32_000], [0.5, 10_000, 36_000], [0.9999, 12_000, 39_999]])( + 'samples jitter %s without exceeding the final backoff cap', async (sample, healthyDelay, cappedDelay) => { + const f = fixture(0.2, () => sample) + await f.schedule.check() + await vi.advanceTimersByTimeAsync(healthyDelay - 1) + expect(f.checkForUpdates).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(f.checkForUpdates).toHaveBeenCalledTimes(2) + f.checkForUpdates.mockRejectedValue(new Error('offline')) + await f.schedule.check(true) + await f.schedule.check(true) + await vi.advanceTimersByTimeAsync(cappedDelay - 1) + expect(f.checkForUpdates).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1) + expect(f.checkForUpdates).toHaveBeenCalledTimes(5) + }, + ) + + it('starts the delay at completion and joins a manual caller without hiding its failure', async () => { + const f = fixture() + const pending = Promise.withResolvers>>() + f.checkForUpdates.mockImplementationOnce(() => pending.promise) + const automatic = f.schedule.check() + const manual = f.schedule.check(true) + try { + await vi.advanceTimersByTimeAsync(100_000) + expect(f.checkForUpdates).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } finally { + pending.reject(new Error('offline')) + await Promise.all([automatic, manual]) + } + expect(f.states).toEqual([expect.objectContaining({ phase: 'error', failedOperation: 'check' })]) + await vi.advanceTimersByTimeAsync(19_999) + expect(f.checkForUpdates).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(f.checkForUpdates).toHaveBeenCalledTimes(2) + }) + + it('ignores wall-clock changes and lets policy arrival bypass the automatic deadline silently', async () => { + const f = fixture() + await f.schedule.check() + vi.setSystemTime(Date.now() + 86_400_000) + await f.schedule.check() + expect(f.checkForUpdates).toHaveBeenCalledOnce() + f.checkForUpdates.mockRejectedValue(new Error('offline')) + await f.schedule.check(false, true) + expect(f.states).toEqual([{ phase: 'idle' }]) + vi.setSystemTime(Date.now() - 172_800_000) + await vi.advanceTimersByTimeAsync(20_000) + expect(f.checkForUpdates).toHaveBeenCalledTimes(3) + }) + + it('never downloads automatically or retargets a downloaded version during polling', async () => { + const f = fixture() + f.checkForUpdates.mockResolvedValue({ isUpdateAvailable: true, updateInfo: { version: '1.1.0-nightly.1' } }) + await f.schedule.check() + await vi.advanceTimersByTimeAsync(10_000) + expect(f.downloadUpdate).not.toHaveBeenCalled() + await f.coordinator.download('1.1.0-nightly.1') + await vi.advanceTimersByTimeAsync(50_000) + await f.schedule.check(true) + expect(f.checkForUpdates).toHaveBeenCalledTimes(2) + expect(f.downloadUpdate).toHaveBeenCalledOnce() + expect(f.coordinator.state).toEqual({ phase: 'ready', version: '1.1.0-nightly.1' }) + }) + + it('retains a failed download for user retry without automatic transfers', async () => { + const f = fixture() + f.checkForUpdates.mockResolvedValue({ isUpdateAvailable: true, updateInfo: { version: '1.1.0-nightly.1' } }) + await f.schedule.check() + f.downloadUpdate.mockRejectedValue(new Error('disk full')) + await f.coordinator.download('1.1.0-nightly.1') + await vi.advanceTimersByTimeAsync(50_000) + expect(f.checkForUpdates).toHaveBeenCalledOnce() + expect(f.downloadUpdate).toHaveBeenCalledOnce() + expect(f.coordinator.state).toMatchObject({ phase: 'error', failedOperation: 'download' }) + }) + + it('clears its timer and does not rearm after a pending check settles during disposal', async () => { + const f = fixture() + await f.schedule.check() + expect(vi.getTimerCount()).toBe(1) + const pending = Promise.withResolvers>>() + const started = Promise.withResolvers() + f.checkForUpdates.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) + const checking = f.schedule.check(true) + try { + await started.promise + f.schedule.dispose() + f.coordinator.dispose() + await expect(f.schedule.check(true)).rejects.toThrow('disposed') + } finally { + pending.resolve({ isUpdateAvailable: false, updateInfo: { version: '1.0.0' } }) + await checking + } + expect(vi.getTimerCount()).toBe(0) + expect(f.states).toEqual([{ phase: 'idle' }]) + await vi.advanceTimersByTimeAsync(100_000) + expect(f.checkForUpdates).toHaveBeenCalledTimes(2) + }) +}) + +describe('ordinary update polling disposal', () => { + it('does not start queued network work after disposal', async () => { + const f = fixture() + const pending = f.schedule.check() + f.schedule.dispose() + await expect(pending).rejects.toThrow('disposed') + expect(f.checkForUpdates).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) + +describe('ordinary update polling configuration', () => { + it('resolves defaults and supports an explicit interval longer than the default backoff cap', () => { + expect(resolveDesktopUpdateScheduleConfig({})).toEqual({ intervalMs: 600_000, maxBackoffMs: 3_600_000, jitter: 0.2 }) + expect(resolveDesktopUpdateScheduleConfig({ DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS: '7200000' })) + .toEqual({ intervalMs: 7_200_000, maxBackoffMs: 7_200_000, jitter: 0.2 }) + }) + + it.each([ + ['INTERVAL_MS', '0'], ['INTERVAL_MS', '1.5'], ['INTERVAL_MS', '2147483648'], ['INTERVAL_MS', ''], + ['MAX_BACKOFF_MS', '1000'], ['MAX_BACKOFF_MS', 'Infinity'], + ['JITTER', '-0.1'], ['JITTER', '1.1'], ['JITTER', 'NaN'], + ])('rejects invalid %s=%s at configuration load', (suffix, value) => { + expect(() => resolveDesktopUpdateScheduleConfig({ [`DSH_DESKTOP_UPDATE_CHECK_${suffix}`]: value })).toThrow() + }) +}) diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts new file mode 100644 index 0000000000..9a6a93f0a8 --- /dev/null +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -0,0 +1,145 @@ +import { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveCredentialUploadEnvironment } from '../scripts/upload-target.ts' + +const execute = promisify(execFile) +const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', import.meta.url)) +const roots: string[] = [] +const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` + +async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-failure', deployment = 'production') { + const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-')) + roots.push(root) + const credentialPath = join(root, 'credential.clixml') + let entry = launcher + let uploadArguments = '' + if (mode === 'upload' || mode === 'upload-failure') { + const scripts = join(root, 'apps/desktop/scripts') + await mkdir(scripts, { recursive: true }) + entry = join(scripts, 'upload-with-credentials.ps1') + await copyFile(launcher, entry) + await symlink(fileURLToPath(new URL('../../../node_modules', import.meta.url)), join(root, 'node_modules'), 'junction') + // Replace only the child uploader: this fixture must never contact real release storage. + await writeFile(join(scripts, 'upload-target.ts'), ` + import assert from 'node:assert/strict' + assert.equal(process.env.DOWNLOAD_PROD_COS_SECRET_ID, 'fixture-id') + assert.equal(process.env.DOWNLOAD_PROD_COS_SECRET_KEY, 'fixture-secret') + assert.equal(process.env.DOWNLOAD_PROD_COS_BUCKET, 'fixture-bucket') + assert.equal(process.env.DOWNLOAD_TEST_COS_SECRET_KEY, undefined) + assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined) + assert.equal(process.env.NODE_OPTIONS, undefined) + assert.equal(process.argv[2], 'win-x64') + assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket']) + console.log('fixture-id fixture-secret') + console.error('private service details fixture-secret') + process.exit(${mode === 'upload-failure' ? 17 : 0}) + `) + uploadArguments = ' -Upload -Target win-x64 -Bucket fixture-bucket' + } + const setup = mode === 'missing' ? '' : ` + [pscustomobject]@{ + SecretId = ${mode === 'plaintext' ? "'fixture-id'" : "ConvertTo-SecureString 'fixture-id' -AsPlainText -Force"} + SecretKey = ConvertTo-SecureString '${mode === 'blank' ? ' ' : 'fixture-secret'}' -AsPlainText -Force + } | Export-Clixml -LiteralPath ${quote(credentialPath)} + ` + const script = ` + $ErrorActionPreference = 'Stop' + ${setup} + $env:DOWNLOAD_PROD_COS_SECRET_ID = 'parent-sentinel' + $env:DOWNLOAD_TEST_COS_SECRET_KEY = 'unrelated-test-key' + $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = 'unrelated-signing-pin' + $env:NODE_OPTIONS = '--require=missing-preload-must-not-run' + $global:LASTEXITCODE = 0 + & ${quote(entry)} -CredentialFile ${quote(credentialPath)} -Environment ${quote(deployment)}${uploadArguments} + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:DOWNLOAD_PROD_COS_SECRET_ID -ne 'parent-sentinel') { throw 'Parent environment changed' } + Write-Output 'parent environment unchanged' + ` + // The child creates its own DPAPI file; real runner credentials never enter the fixture. + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => + !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_DESKTOP_WINDOWS_|^APPLE_|^CSC_/iu.test(name), + )) + const pathKey = Object.keys(env).find(name => name.toLowerCase() === 'path') ?? 'PATH' + env[pathKey] = `${dirname(process.execPath)}${delimiter}${env[pathKey] ?? ''}` + try { + const result = await execute('pwsh', [ + '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64'), + ], { env, timeout: 30_000 }) + return { code: 0, output: result.stdout + result.stderr } + } + catch (error) { + const result = error as Error & { code: number | string; stdout: string; stderr: string; killed?: boolean } + expect(result.killed).not.toBe(true) + expect(typeof result.code).toBe('number') + return { code: result.code, output: result.stdout + result.stderr } + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async root => rm(root, { recursive: true, force: true }))) +}) + +describe('credential launcher destination', () => { + const fileEnvironment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-bucket', DOWNLOAD_TEST_COS_SECRET_ID: 'stale-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'stale-key', + } + const injectedEnvironment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_COS_BUCKET: 'test-bucket', + DOWNLOAD_TEST_COS_SECRET_ID: 'decrypted-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'decrypted-key', + } + + it('uses decrypted credentials only for the matching packaged deployment and bucket', () => { + expect(resolveCredentialUploadEnvironment(fileEnvironment, injectedEnvironment, 'test', 'test-bucket', 'win-x64')) + .toMatchObject({ DOWNLOAD_TEST_COS_SECRET_ID: 'decrypted-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'decrypted-key' }) + expect(() => resolveCredentialUploadEnvironment(fileEnvironment, injectedEnvironment, 'production', 'test-bucket', 'win-x64')) + .toThrow(/differs from the packaged release destination/u) + expect(() => resolveCredentialUploadEnvironment(fileEnvironment, injectedEnvironment, 'test', 'other-bucket', 'win-x64')) + .toThrow(/differs from the packaged release destination/u) + }) + + it('rejects child credentials that do not match the explicit launcher selection', () => { + expect(() => resolveCredentialUploadEnvironment(fileEnvironment, { ...injectedEnvironment, + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }, 'test', 'test-bucket', 'win-x64')).toThrow(/differs from its explicit arguments/u) + expect(() => resolveCredentialUploadEnvironment(fileEnvironment, { ...injectedEnvironment, + DOWNLOAD_TEST_COS_SECRET_KEY: '', + }, 'test', 'test-bucket', 'win-x64')).toThrow(/DOWNLOAD_TEST_COS_SECRET_KEY/u) + }) +}) + +// DPAPI's user-and-machine encryption is Windows-only. +describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () => { + it.each(['production', 'test'])('checks %s credentials without uploading or changing the parent', async (deployment) => { + const result = await check('valid', deployment) + expect(result.code, result.output).toBe(0) + const expected = await readFile(new URL('./expected/upload-credentials-check.txt', import.meta.url), 'utf8') + expect(result.output.replaceAll('\r\n', '\n')).toBe(expected) + expect(result.output).not.toContain('fixture-id') + expect(result.output).not.toContain('fixture-secret') + expect(result.output).not.toContain('uploading') + }) + + it.each(['plaintext', 'blank', 'missing'] as const)('rejects %s credentials without printing values', async (mode) => { + const result = await check(mode) + expect(result.code).toBe(1) + expect(result.output).toContain('desktop credentials: failed;') + expect(result.output).not.toContain('fixture-id') + expect(result.output).not.toContain('fixture-secret') + expect(result.output).not.toContain('child environment verified') + }) + + it.each(['upload', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { + const result = await check(mode) + expect(result.code, result.output).toBe(mode === 'upload' ? 0 : 1) + expect(result.output).toContain('[REDACTED] [REDACTED]') + expect(result.output).not.toContain('fixture-id') + expect(result.output).not.toContain('fixture-secret') + expect(result.output).not.toContain('private service details') + }) +}) diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts new file mode 100644 index 0000000000..fc45a38ef9 --- /dev/null +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -0,0 +1,185 @@ +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { Stats } from 'node:fs' +import { Arch, Packager, type BeforePackContext, type Configuration } from 'app-builder-lib' +import { WinPackager } from 'app-builder-lib/out/winPackager.js' +import { getFileMatchers } from 'app-builder-lib/out/fileMatcher.js' +import { readAsar } from 'app-builder-lib/out/asar/asar.js' +import { afterEach, expect, it, vi } from 'vitest' +import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from '../scripts/windows-asar-unpack.mjs' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' + +vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({ + ...await importOriginal(), + createWindowsTokenSigner: () => async () => {}, + installWindowsNsisBootstrapSigner: () => {}, + resolveWindowsUpdatePublisher: () => 'Fixture Publisher', +})) +vi.mock('../scripts/windows-runtime-signature.mjs', async importOriginal => ({ + ...await importOriginal(), + signWindowsCode: async () => {}, +})) +// Vite's root-relative IDs also resolve mocked build outputs on a clean checkout. +vi.mock('/apps/desktop/lib/types/runtime-tree.js', () => ({ verifyDesktopRuntime: async () => {} })) +vi.mock('/apps/desktop/lib/types/mandatory-update-policy.js', async () => import('../src/mandatory-update-policy.ts')) +vi.mock('node:crypto', async importOriginal => ({ + ...await importOriginal(), + X509Certificate: class { fingerprint = 'AA:BB' }, +})) + +const require = createRequire(import.meta.url) +// The locked builder omits declarations for its AsarPackager implementation. +const { AsarPackager } = require('app-builder-lib/out/asar/asarUtil.js') as { + AsarPackager: new (packager: object, configuration: object) => { + pack(files: { src: string; destination: string; files: string[]; metadata: Map }[]): Promise + } +} +const roots: string[] = [] +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +async function fixture(external: boolean) { + const root = await mkdtemp(join(tmpdir(), 'windows-asar-')) + roots.push(root) + const appDir = join(root, 'app') + const source = external ? join(root, 'private', 'dsh') : join(appDir, '.desktop-build', 'dsh') + const resources = join(root, 'output', 'resources') + await mkdir(resources, { recursive: true }) + await mkdir(appDir, { recursive: true }) + const pe = Buffer.alloc(128) + pe.writeUInt16LE(0x5a4d, 0) + pe.writeUInt32LE(64, 0x3c) + pe.writeUInt32LE(0x4550, 64) + const names = ['extensionless', 'custom.binary', 'native.node', 'a[0]{x,y}+(z)@!#.binary', '${arch}.binary'] + for (const name of names) { + const file = join(source, 'node_modules', 'foo', name) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, pe) + } + await writeFile(join(source, 'node_modules', 'foo', 'companion.json'), '{}') + await writeFile(join(source, 'node_modules', 'foo', '$xarchy.binary'), 'neighbor') + const config = { + files: [{ from: source, to: 'dsh', filter: ['**/*'] }, + { from: join(source, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }], + asarUnpack: ['**/*.node'], + } satisfies Configuration + const info = new Packager({ projectDir: appDir }) + Object.defineProperties(info, { + appDir: { value: appDir }, config: { value: config }, + metadata: { value: { name: 'fixture', version: '1.0.0', description: 'Fixture' } }, + framework: { value: { name: 'electron' } }, + }) + vi.spyOn(info, 'disposeOnBuildFinish').mockImplementation((cleanup) => { cleanups.push(cleanup) }) + const context: BeforePackContext = { packager: new WinPackager(info), arch: Arch.x64, + appOutDir: dirname(resources), outDir: dirname(resources), electronPlatformName: 'win32', targets: [] } + return { root, appDir, source, resources, config, context, names } +} + +async function packageFixture(input: Awaited>) { + const destination = join(input.resources, 'app') + const matchers = getFileMatchers(input.config, 'asarUnpack', destination, { + defaultSrc: input.appDir, globalOutDir: dirname(input.resources), customBuildOptions: {}, + macroExpander: value => value.replaceAll('${arch}', 'x64'), + })! + const mapping = input.config.files[0]! + if (typeof mapping === 'string' || mapping.from === undefined) throw new Error('missing source mapping') + const files: string[] = [] + const metadata = new Map() + async function visit(directory: string): Promise { + for (const name of await readdir(directory)) { + const file = join(directory, name) + const stat = await lstat(file) + if (stat.isDirectory()) await visit(file) + else { files.push(file); metadata.set(file, stat) } + } + } + await visit(mapping.from) + await new AsarPackager({ info: { getWorkspaceRoot: async () => input.root } }, { + defaultDestination: destination, resourcePath: input.resources, options: {}, unpackPattern: matchers[0]!.createFilter(), + }).pack([{ src: mapping.from, destination: join(destination, 'dsh'), files, metadata }]) +} + +it.each([false, true])('unpacks PE files through builder source patterns (external=%s)', async (external) => { + const input = await fixture(external) + const files = await prepareWindowsAsarUnpack(input.context, input.source) + await packageFixture(input) + await verifyWindowsAsarUnpack(input.source, input.resources, files) + const archive = await readAsar(join(input.resources, 'app.asar')) + expect(archive.getFile(join('dsh', 'node_modules', 'foo', 'companion.json')).unpacked).not.toBe(true) + expect(archive.getFile(join('dsh', 'node_modules', 'foo', '$xarchy.binary')).unpacked).toBe(true) + expect(files).toEqual(input.names.map(name => join('node_modules', 'foo', name)).sort()) + const first = input.config.files[0]! + const nested = input.config.files[1]! + if (typeof first === 'string' || typeof nested === 'string') throw new Error('missing source mappings') + expect(first.from === input.source).toBe(!external) + expect(nested.from).toBe(join(first.from, 'node_modules')) + expect(cleanups.length).toBe(external ? 1 : 0) + if (external) { + await cleanups.pop()!() + await expect(lstat(first.from)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(join(input.source, files[0]!))).toHaveLength(128) + } +}) + +it('rejects an inline PE even when a neighboring unpacked copy exists', async () => { + const input = await fixture(false) + const files = await prepareWindowsAsarUnpack(input.context, input.source) + input.config.asarUnpack = ['**/*.node'] + await packageFixture(input) + const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, await readFile(join(input.source, files[0]!))) + await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow('PE must be unpacked') +}) + +it.each(['changed', 'missing'] as const)('rejects %s unpacked PE bytes', async (state) => { + const input = await fixture(false) + const files = await prepareWindowsAsarUnpack(input.context, input.source) + await packageFixture(input) + const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!) + if (state === 'changed') await writeFile(path, 'changed') + else await rm(path) + await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow(state === 'changed' ? 'PE bytes changed' : 'ENOENT') +}) + +it('registers external staging cleanup before a configuration rewrite fails', async () => { + const input = await fixture(true) + const files = input.config.files + // The source still passes PE discovery; a destination mapping setter fails after staging is copied. + Object.defineProperty(input.config, 'files', { get: () => files, set: () => { throw new Error('configuration rejected') } }) + await expect(prepareWindowsAsarUnpack(input.context, input.source)).rejects.toThrow('configuration rejected') + const staging = await readdir(join(input.appDir, '.desktop-build')) + expect(staging).toHaveLength(1) + expect(cleanups).toHaveLength(1) + await cleanups.pop()!() + expect(await readdir(join(input.appDir, '.desktop-build'))).toEqual([]) +}) + +it.each([true, false])('validates the real builder hook for unsigned=%s', async (unsigned) => { + const input = await fixture(true) + const certificate = join(input.root, 'certificate.cer') + await writeFile(certificate, 'fixture public certificate') + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0', + DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + }, 'win32', 'x64', input.source) + // Qualification replaces files after creating the base configuration; builder owns a separate config object. + input.config.asarUnpack = [...config.asarUnpack] + await config.beforePack(input.context) + await packageFixture(input) + await config.afterPack(input.context) + if (!unsigned) await config.afterSign(input.context) + const file = join(input.resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'foo', 'custom.binary') + await writeFile(file, 'changed after packaging') + if (unsigned) await expect(config.afterPack(input.context)).rejects.toThrow('PE bytes changed') + else { + await config.afterPack(input.context) + await expect(config.afterSign(input.context)).rejects.toThrow('PE bytes changed') + } +}) diff --git a/apps/desktop/tests/windows-installer-presentation.cpp b/apps/desktop/tests/windows-installer-presentation.cpp new file mode 100644 index 0000000000..4900a2e089 --- /dev/null +++ b/apps/desktop/tests/windows-installer-presentation.cpp @@ -0,0 +1,32 @@ +#define WIN32_LEAN_AND_MEAN +#define UNICODE +#include + +extern "C" __declspec(dllimport) BOOL __cdecl InstallerPresentWelcome(HWND window); + +static bool Above(HWND upper, HWND lower) { + for (HWND current = GetWindow(lower, GW_HWNDPREV); current; + current = GetWindow(current, GW_HWNDPREV)) { + if (current == upper) return true; + } + return false; +} + +int wmain() { + HINSTANCE instance = GetModuleHandleW(nullptr); + HWND installer = CreateWindowExW(0, L"STATIC", L"Installer", WS_POPUP, + 100, 100, 300, 300, nullptr, nullptr, instance, nullptr); + HWND other = CreateWindowExW(0, L"STATIC", L"Other", WS_POPUP, + 120, 120, 300, 300, nullptr, nullptr, instance, nullptr); + if (!installer || !other) return 1; + ShowWindow(installer, SW_SHOW); + ShowWindow(other, SW_SHOW); + if (!Above(other, installer)) return 2; + if (!InstallerPresentWelcome(installer)) return 3; + if (!Above(installer, other)) return 4; + if (GetWindowLongPtrW(installer, GWL_EXSTYLE) & WS_EX_TOPMOST) return 5; + if (GetPropW(installer, L"HarnessInstaller.Presented") != reinterpret_cast(1)) return 6; + DestroyWindow(other); + DestroyWindow(installer); + return 0; +} diff --git a/apps/desktop/tests/windows-installer-smoke.ps1 b/apps/desktop/tests/windows-installer-smoke.ps1 index b44aca7bb5..b4e6ff9e6c 100644 --- a/apps/desktop/tests/windows-installer-smoke.ps1 +++ b/apps/desktop/tests/windows-installer-smoke.ps1 @@ -43,6 +43,11 @@ function Start-Setup([string]$Theme, [string]$Path = $installPath) { Start-Sleep -Milliseconds 5 } while ($timer.Elapsed.TotalSeconds -lt 30) if ($window -eq [IntPtr]::Zero) { throw 'Installer welcome window did not appear' } + $timer.Restart() + while ([InstallerCapture]::GetProp($window, 'HarnessInstaller.Presented') -eq [IntPtr]::Zero) { + if ($process.HasExited -or $timer.Elapsed.TotalSeconds -gt 10) { throw 'Installer welcome window was not presented' } + Start-Sleep -Milliseconds 10 + } [InstallerCapture]::Reveal($window) $languages = @($localizedCopy.Keys | Where-Object { [InstallerCapture]::FindButton($process.Id, $localizedCopy[$_].INSTALLER_INSTALL) -ne [IntPtr]::Zero @@ -127,6 +132,7 @@ function Run-Silent([string]$Arguments, [int]$Code) { } try { $process = Start-Setup light + $results.Add('welcome-presented-on-first-show') $window = [InstallerCapture]::Find($process.Id) [void][InstallerCapture]::Save($window, (Join-Path $OutputDirectory 'light-welcome.png')) Click-Control $process $copy.INSTALLER_CHOOSE_PATH diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts index 991a34d17c..53113a2b36 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -1,9 +1,11 @@ import { execFile } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' 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 { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { createPackagingRun } from '../scripts/packaging-run.mjs' import { buildWindowsSigningEnvironment, createRedactedWindowsSigningError, @@ -15,12 +17,23 @@ import { vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, execFile: vi.fn() } + const { promisify } = await import('node:util') + const mock = vi.fn() + // Node's execFile promisifier returns both output streams; generic promisify drops stderr. + Object.defineProperty(mock, promisify.custom, { value: (...args: unknown[]) => new Promise((resolve, reject) => { + mock(...args, (error: Error | null, stdout: string, stderr: string) => { + if (error) reject(error) + else resolve({ stdout, stderr }) + }) + }) }) + return { ...actual, execFile: mock } }) -vi.mock('node:crypto', () => ({ +vi.mock('node:crypto', async importOriginal => ({ + ...await importOriginal(), X509Certificate: class { readonly ca = false + readonly fingerprint = 'A'.repeat(40) readonly keyUsage = ['1.3.6.1.5.5.7.3.3'] constructor(contents: Buffer) { @@ -35,6 +48,107 @@ const CERTIFICATE_FILE = 'C:\\release\\server.cer' const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd') describe('Windows token signing', () => { + it.each(['invalid-primary', 'timestamp-exhausted', 'timestamp-recovered', 'normalization-corrupt'] as const)( + 'keeps hardware protection separate from %s', async (mode) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sign-completion-')) + const certificateFile = join(root, 'server.cer') + const signTool = join(root, 'signtool.exe') + const path = join(root, 'application.exe') + const stateDirectory = join(root, 'state') + await writeFile(certificateFile, 'code-signing-certificate-fixture') + await writeFile(signTool, 'fixture') + await writeFile(path, 'input') + const run = createPackagingRun(join(root, 'records'), {}) + let hardware = 0 + let timestamps = 0 + vi.mocked(execFile).mockImplementation((...args: unknown[]) => { + const command = args[0] as string + const argv = args[1] as string[] + const options = args[2] as { env: NodeJS.ProcessEnv } + const callback = args.at(-1) as (error: Error | null, stdout?: string, stderr?: string) => void + if (argv[0] === '/d') { + hardware++ + expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(true) + writeFileSync(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!, 'signed:input') + callback(null, '', '') + } else { + expect(options.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + expect(options.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER).toBeUndefined() + if (command === 'powershell.exe') { + const timestamped = readFileSync(options.env.DSH_RUNTIME_VERIFY_FILE!, 'utf8').endsWith(':timestamp') + if (!timestamped) expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(true) + callback(null, JSON.stringify({ status: mode === 'invalid-primary' ? 'NotTrusted' : 'Valid', timestamped, thumbprint: 'A'.repeat(40) }), '') + } else if (argv[0] === 'remove') { + const target = argv.at(-1)! + const content = readFileSync(target, 'utf8') + if (mode === 'normalization-corrupt') writeFileSync(target, 'unexpected modification') + else writeFileSync(target, content.replace(':timestamp', '')) + if (mode === 'normalization-corrupt' || !content.endsWith(':timestamp')) { + callback(Object.assign(new Error('normalization warning'), { code: 2 })) + } else callback(null, '', '') + } else { + expect(argv[0]).toBe('timestamp') + expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(false) + const target = argv.at(-1)! + expect(readFileSync(target, 'utf8')).toBe('signed:input') + timestamps++ + if (mode === 'timestamp-exhausted' || timestamps === 1) { + writeFileSync(target, 'failed partial output') + callback(Object.assign(new Error('timestamp unavailable'), { code: 1, stderr: 'timestamp unavailable' })) + } else { + writeFileSync(target, 'signed:input:timestamp') + callback(null, '', '') + } + } + } + return undefined as unknown as ReturnType + }) + try { + const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container', + runDirectory: run.directory, stateDirectory }) + const task = { path, hash: 'sha256', isNest: false } + if (mode === 'timestamp-recovered') { + await sign(task) + expect(await readFile(path, 'utf8')).toBe('signed:input:timestamp') + expect(timestamps).toBe(2) + } else { + const results = await Promise.allSettled([sign(task), sign(task)]) + expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']) + expect(await readFile(path, 'utf8')).toBe('input') + expect(timestamps).toBe(mode === 'timestamp-exhausted' ? 3 : 0) + expect(existsSync(join(run.directory, 'fatal.json'))).toBe(true) + } + expect(hardware).toBe(1) + expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(mode === 'invalid-primary') + } finally { + vi.mocked(execFile).mockReset() + await rm(root, { recursive: true, force: true }) + } + }, 15_000, + ) + it('preserves verified copies without hardware and rejects the entire queue after preservation failure', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-copy-signature-')) + try { + const certificateFile = join(directory, 'server.cer') + const signTool = join(directory, 'signtool.exe') + await writeFile(certificateFile, 'code-signing-certificate-fixture') + await writeFile(signTool, 'fixture') + const preserveSignature = vi.fn(async () => true) + const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', + keyContainer: 'fixture-container', preserveSignature }) + const task = { path: join(directory, 'copy.exe'), hash: 'sha256', isNest: false } + await sign(task) + expect(execFile).not.toHaveBeenCalled() + preserveSignature.mockRejectedValueOnce(new Error('copy changed')) + const results = await Promise.allSettled([sign(task), sign(task), sign(task)]) + expect(results.map(result => result.status)).toEqual(['rejected', 'rejected', 'rejected']) + expect(preserveSignature).toHaveBeenCalledTimes(2) + expect(execFile).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + it('stops concurrent and subsequent signing tasks after a PIN failure', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-pin-failure-')) try { @@ -46,8 +160,10 @@ describe('Windows token signing', () => { await writeFile(path, 'fixture') validateDesktopPackageEnvironment({ DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool, DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container', + DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: 'C:\\fixture\\signature-cache', }, { platform: 'win32', arch: 'x64' }) expect(execFile).not.toHaveBeenCalled() vi.mocked(execFile).mockImplementationOnce((...args: unknown[]) => { @@ -55,12 +171,22 @@ describe('Windows token signing', () => { callback(Object.assign(new Error('signing failed'), { stderr: 'SignTool Error: No private key is available.', code: 1 })) return undefined as unknown as ReturnType }) - const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container' }) + const run = createPackagingRun(join(directory, 'records'), {}) + const options = { certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container', + runDirectory: run.directory, stateDirectory: join(directory, 'state') } + const sign = createWindowsTokenSigner(options) const task = { path, hash: 'sha256', isNest: false } const results = await Promise.allSettled([sign(task), sign(task), sign(task)]) expect(results.map(result => result.status)).toEqual(['rejected', 'rejected', 'rejected']) await expect(sign(task)).rejects.toThrow('No private key is available.') + const laterRun = createPackagingRun(join(directory, 'records'), {}) + await expect(createWindowsTokenSigner({ ...options, runDirectory: laterRun.directory })(task)).rejects.toThrow('interlock unavailable') expect(execFile).toHaveBeenCalledTimes(1) + const records = await readFile(join(run.directory, 'events.jsonl'), 'utf8') + expect(records).toContain('sign-start') + expect(records).toContain('sign-failure') + expect(records).not.toContain('fixture-pin') + expect(await readFile(join(options.stateDirectory, 'attempt.json'), 'utf8')).toContain(run.directory.replaceAll('\\', '\\\\')) } finally { vi.mocked(execFile).mockReset() await rm(directory, { recursive: true, force: true }) @@ -111,7 +237,7 @@ describe('Windows token signing', () => { 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') + expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% "%targetFile%"\r\n') }) it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => { @@ -161,6 +287,9 @@ describe('Windows token signing', () => { hash: 'sha1', isNest: false, })).rejects.toThrow(/requires SHA-256/u) + await expect(createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container' })({ + path: 'C:\\release\\setup.exe', hash: 'sha256', isNest: true, + })).rejects.toThrow('does not support appended signatures') } finally { await rm(directory, { recursive: true }) diff --git a/apps/desktop/tests/windows-signature-batch.spec.ts b/apps/desktop/tests/windows-signature-batch.spec.ts new file mode 100644 index 0000000000..6e6ebe602f --- /dev/null +++ b/apps/desktop/tests/windows-signature-batch.spec.ts @@ -0,0 +1,50 @@ +/** Parser fault checks; real-file equivalence is measured separately. */ +import { it as test } from 'vitest' +import assert from 'node:assert/strict' +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseSignatureRows, inspectSignaturesBatched } from '../scripts/windows-signature-batch.mjs' +import { inspectWindowsRuntimeSignature, signWindowsCode, verifyWindowsCode } from '../scripts/windows-runtime-signature.mjs' + +const files = ['one.node', 'two.dll'] +const rows = files.map(path => ({ path, status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) })) +const output = (input: readonly unknown[]): string => input.map(value => JSON.stringify(value)).join('\n') + +test('each row retains its own signature status', () => { + const input = [rows[0], { ...rows[1], status: 'NotSigned', timestamped: false, thumbprint: null }] + const result = parseSignatureRows(output(input), '', files) + assert.deepEqual(result.map(signature => signature.status), ['Valid', 'NotSigned']) +}) + +for (const [name, input] of [ + ['missing', [rows[0]]], ['duplicate', [rows[0], rows[0]]], ['reordered', [...rows].reverse()], + ['extra', [...rows, rows[0]]], ['null', [rows[0], null]], + ['bad timestamp', [rows[0], { ...rows[1], timestamped: 'true' }]], + ['bad certificate', [rows[0], { ...rows[1], thumbprint: 'wrong' }]], +] as const) test(`${name} output is rejected`, () => { + assert.throws(() => parseSignatureRows(output(input), '', files)) +}) + +test('stderr rejects otherwise complete output', () => { + assert.throws(() => parseSignatureRows(output(rows), 'warning', files)) +}) +test('empty input launches no verifier and returns no rows', async () => { + assert.deepEqual(await inspectSignaturesBatched([]), []) +}) + +test.skipIf(process.platform !== 'win32')('Unicode paths retain the same Windows trust result as individual inspection', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'signature-batch-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + const path = join(root, "验证 ' 文件.exe") + await copyFile(join(process.env.SystemRoot!, 'System32', 'cmd.exe'), path) + const expected = await inspectWindowsRuntimeSignature(path) + assert.equal(expected.status, 'Valid') + assert.deepEqual(await inspectSignaturesBatched([path]), [expected]) + await signWindowsCode(root, { + thumbprint: 'A'.repeat(40), + sign: async () => { throw new Error('vendor-signed files must not access signing hardware') }, + record: () => {}, + }) + await verifyWindowsCode(root) +}) diff --git a/apps/desktop/tests/windows-signature-cache-directory.spec.ts b/apps/desktop/tests/windows-signature-cache-directory.spec.ts new file mode 100644 index 0000000000..d910624f18 --- /dev/null +++ b/apps/desktop/tests/windows-signature-cache-directory.spec.ts @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm, symlink } from 'node:fs/promises' +import { join } from 'node:path' +import { homedir } from 'node:os' +import { promisify } from 'node:util' +import { expect, it, vi } from 'vitest' +import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs' + +it.skipIf(process.platform !== 'win32')('resolves an account cache independently of checkout and LocalAppData virtualization', () => { + expect(resolveWindowsSignatureCacheDirectory({ LOCALAPPDATA: 'C:\\Users\\build\\AppData\\Local' })) + .toBe(join(homedir(), '.dsh-desktop-signing', 'signature-cache', 'v1')) +}) + +it('accepts an explicit local cache directory', () => { + expect(resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: 'D:/private-cache' })) + .toBe('D:\\private-cache') +}) + +it.skipIf(process.platform !== 'win32')('prepares storage when the inherited process execution policy is Restricted', async (t) => { + const root = await mkdtemp(join(import.meta.dirname, 'directory-test-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + vi.stubEnv('PSExecutionPolicyPreference', 'Restricted') + t.onTestFinished(() => { vi.unstubAllEnvs() }) + await prepareWindowsSignatureCacheDirectory(join(root, 'private')) + expect(process.env.PSExecutionPolicyPreference).toBe('Restricted') +}) + +it.each(['', 'cache', 'C:cache', '\\cache', '\\\\server\\share', '\\\\?\\C:\\cache', 'C:\\cache:stream']) +('rejects unsupported cache path %j', (path) => { + expect(() => resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: path })).toThrow(/absolute local/u) +}) + +it.skipIf(process.platform !== 'win32')('creates private storage and rejects linked or publicly readable cache roots', async (t) => { + const root = await mkdtemp(join(import.meta.dirname, 'directory-test-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + const cache = join(root, 'private') + await prepareWindowsSignatureCacheDirectory(cache) + await prepareWindowsSignatureCacheDirectory(cache) + const link = join(root, 'linked') + await symlink(cache, link, 'junction') + await expect(prepareWindowsSignatureCacheDirectory(join(link, 'child'))).rejects.toThrow(/reparse/u) + await promisify(execFile)('icacls.exe', [cache, '/grant', '*S-1-1-0:(OI)(CI)R'], { windowsHide: true }) + await expect(prepareWindowsSignatureCacheDirectory(cache)).rejects.toThrow(/permissions/u) +}) diff --git a/apps/desktop/tests/windows-signature-cache.spec.ts b/apps/desktop/tests/windows-signature-cache.spec.ts new file mode 100644 index 0000000000..975feb6fbe --- /dev/null +++ b/apps/desktop/tests/windows-signature-cache.spec.ts @@ -0,0 +1,253 @@ +/** Fault checks for the exploratory cache; these never call signing hardware. */ +import { it as test, type TestContext } from 'vitest' +import assert from 'node:assert/strict' +import { mkdtemp, readFile, readdir, rm, writeFile, mkdir, symlink } from 'node:fs/promises' +import { join } from 'node:path' +import { createCachedSigner, maintainSignatureCache, migrateSignatureCache, signatureCacheIdentity } from '../scripts/windows-signature-cache.mjs' + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(import.meta.dirname, 'cache-test-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'module.node') + await writeFile(path, 'original') + const cache = join(root, 'cache') + const state = { hardwareCalls: 0, events: [] as object[] } + const thumbprint = 'A'.repeat(40) + const options: import('../scripts/windows-signature-cache.mjs').WindowsSignatureCacheOptions = { + root: cache, identity: 'test-policy', thumbprint, + sign: async ({ path }) => { state.hardwareCalls++; await writeFile(path, `signed:${await readFile(path, 'utf8')}`) }, + inspect: async path => ({ status: (await readFile(path, 'utf8')).startsWith('signed:') ? 'Valid' : 'NotSigned', timestamped: true, thumbprint }), + record: event => state.events.push(event), + } + const request = { path, hash: 'sha256', isNest: false } + return { root, path, cache, state, options, request } +} + +test('unchanged bytes restored in another file avoid another hardware call', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const second = join(f.root, 'other.node') + await writeFile(second, 'original') + await createCachedSigner(f.options)({ ...f.request, path: second }) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(await readFile(second, 'utf8'), 'signed:original') + assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-hit').length, 1) +}) + +test('cache summaries distinguish avoided signatures from actual signing attempts', async (t) => { + const f = await fixture(t) + const signer = createCachedSigner(f.options) + await signer(f.request) + await writeFile(f.path, 'original') + await signer(f.request) + const { verificationMs, restoreMs, signingMs, ...summary } = signer.summary() + assert.deepEqual(summary, { root: f.cache, identity: f.options.identity, hits: 1, misses: 1, + published: 1, retained: 0, signingCalls: 1, avoidedSigningCalls: 1, validationFailures: 0 }) + assert(verificationMs >= 0 && restoreMs >= 0 && signingMs >= 0) + assert.equal(f.state.hardwareCalls, summary.signingCalls) + assert.equal(await readFile(f.path, 'utf8'), 'signed:original') +}) + +test('restores cached signatures into deeply nested dependency paths', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const directory = join(f.root, 'dependency'.repeat(12)) + await mkdir(directory) + const path = join(directory, 'module.node') + await writeFile(path, 'original') + await createCachedSigner(f.options)({ ...f.request, path }) + assert.equal(await readFile(path, 'utf8'), 'signed:original') + assert.equal(f.state.hardwareCalls, 1) + assert.deepEqual(await readdir(directory), ['module.node']) +}) + +test('different content or signing policy misses the cache', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'changed') + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + await createCachedSigner({ ...f.options, identity: 'changed-policy' })(f.request) + assert.equal(f.state.hardwareCalls, 3) +}) + +for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + const entry = (await readdir(f.cache)).find(name => !name.startsWith('.')) + assert(entry) + await writeFile(join(f.cache, entry, corrupt), 'corrupt') + const signer = createCachedSigner(f.options) + await assert.rejects(signer(f.request)) + assert.equal(signer.summary().validationFailures, 1) + assert.equal(signer.summary().avoidedSigningCalls, 0) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(await readFile(f.path, 'utf8'), 'original') +}) + +for (const signature of [ + { status: 'NotTrusted', timestamped: true, thumbprint: 'A'.repeat(40) }, + { status: 'Valid', timestamped: false, thumbprint: 'A'.repeat(40) }, + { status: 'Valid', timestamped: true, thumbprint: 'B'.repeat(40) }, +]) test(`cache refuses signature ${JSON.stringify(signature)}`, async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + await assert.rejects(createCachedSigner({ ...f.options, inspect: async () => signature })(f.request), /invalid cached signature/u) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(await readFile(f.path, 'utf8'), 'original') +}) + +test('hardware failure rejects queued work without publishing an entry or retrying', async (t) => { + const f = await fixture(t) + const signer = createCachedSigner({ ...f.options, sign: async () => { f.state.hardwareCalls++; throw new Error('hardware failed') } }) + const results = await Promise.allSettled([signer(f.request), signer(f.request)]) + assert(results.every(result => result.status === 'rejected')) + assert.equal(f.state.hardwareCalls, 1) + assert.deepEqual(await readdir(f.cache), []) + assert.equal(signer.summary().signingCalls, 1) + assert.equal(signer.summary().validationFailures, 0) +}) + +test('a failed new signature verification never publishes a cache entry', async (t) => { + const f = await fixture(t) + await assert.rejects(createCachedSigner({ ...f.options, inspect: async () => ({ status: 'NotTrusted', timestamped: false, thumbprint: null }) })(f.request), /new signature verification failed/u) + assert.deepEqual(await readdir(f.cache), []) +}) + +test('an incomplete entry is rejected without hardware access', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const entry = (await readdir(f.cache))[0] + assert(entry) + await rm(join(f.cache, entry, 'record.json')) + await writeFile(f.path, 'original') + await assert.rejects(createCachedSigner(f.options)(f.request)) + assert.equal(f.state.hardwareCalls, 1) +}) + +test('certificate and toolchain content invalidate policy identity independently of paths', async (t) => { + const f = await fixture(t) + const certificate = join(f.root, 'certificate.cer') + const command = join(f.root, 'sign.cmd') + await writeFile(certificate, 'certificate A') + await writeFile(command, 'signing policy A') + const first = await signatureCacheIdentity([certificate, command]) + const relocated = join(f.root, 'relocated.cer') + await writeFile(relocated, 'certificate A') + assert.equal(await signatureCacheIdentity([relocated, command]), first) + await writeFile(certificate, 'certificate B') + assert.notEqual(await signatureCacheIdentity([certificate, command]), first) + await writeFile(command, 'signing policy B') + assert.notEqual(await signatureCacheIdentity([relocated, command]), first) +}) + +test('two writers publish one complete entry without overwriting another writer', async (t) => { + const f = await fixture(t) + const second = join(f.root, 'second.node') + await writeFile(second, 'original') + const entered = Promise.withResolvers() + let arrivals = 0 + const options: import('../scripts/windows-signature-cache.mjs').WindowsSignatureCacheOptions = { ...f.options, sign: async (request) => { + if (++arrivals === 2) entered.resolve(undefined) + await entered.promise + await f.options.sign(request) + } } + const results = await Promise.allSettled([ + createCachedSigner(options)(f.request), + createCachedSigner(options)({ ...f.request, path: second }), + ]) + for (const result of results) if (result.status === 'rejected') throw result.reason + assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-published').length, 1) + assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-retained').length, 1) + assert.equal((await readdir(f.cache)).length, 1) + await writeFile(f.path, 'original') + await createCachedSigner(f.options)(f.request) + assert.equal(f.state.hardwareCalls, 2) + assert.equal(await readFile(f.path, 'utf8'), 'signed:original') +}) + +test('linked cache directories cannot redirect cache restoration', async (t) => { + const f = await fixture(t) + const elsewhere = join(f.root, 'elsewhere') + await mkdir(elsewhere) + await symlink(elsewhere, f.cache, process.platform === 'win32' ? 'junction' : 'dir') + await assert.rejects(createCachedSigner(f.options)(f.request), /unlinked directory/u) + assert.equal(f.state.hardwareCalls, 0) +}) + +test('a target changed during verification is never replaced with stale cache bytes', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + const inspect = async (candidate: string) => { await writeFile(f.path, 'new input'); return f.options.inspect(candidate) } + await assert.rejects(createCachedSigner({ ...f.options, inspect })(f.request), /target changed/u) + assert.equal(await readFile(f.path, 'utf8'), 'new input') + assert.equal(f.state.hardwareCalls, 1) + assert(!(await readdir(f.root)).some(name => name.startsWith('.signature-restore-'))) +}) + +test('migration preserves different valid timestamp bytes at the same destination key', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const destination = join(f.root, 'shared') + await writeFile(f.path, 'original') + await createCachedSigner({ ...f.options, root: destination, + sign: async ({ path }) => writeFile(path, 'signed:original:another-timestamp'), + })(f.request) + const key = (await readdir(f.cache))[0]! + const sourceRecord = await readFile(join(f.cache, key, 'record.json'), 'utf8') + const counts = await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }) + assert.deepEqual(counts, { published: 0, retained: 1, skipped: 0 }) + assert.equal(await readFile(join(destination, key, 'payload'), 'utf8'), 'signed:original:another-timestamp') + assert.equal(await readFile(join(f.cache, key, 'record.json'), 'utf8'), sourceRecord) +}) + +test('migration copies complete entries and skips staging directories without reading them', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await mkdir(join(f.cache, '.publish-incomplete')) + const destination = join(f.root, 'shared') + assert.deepEqual(await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }), + { published: 1, retained: 0, skipped: 1 }) + await writeFile(f.path, 'original') + await createCachedSigner({ ...f.options, root: destination })(f.request) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(await readFile(f.path, 'utf8'), 'signed:original') + assert.equal((await readdir(destination)).length, 1) +}) + +test('migration refuses a corrupt existing destination instead of replacing it', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const destination = join(f.root, 'shared') + await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }) + const key = (await readdir(destination))[0]! + await writeFile(join(destination, key, 'payload'), 'corrupt') + await assert.rejects(migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }), /corrupt/u) + assert.equal(await readFile(join(destination, key, 'payload'), 'utf8'), 'corrupt') +}) + +test('maintenance counts and clears complete entries while preserving incomplete staging', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await mkdir(join(f.cache, '.publish-incomplete')) + const usage = await maintainSignatureCache(f.cache) + assert.equal(usage.entries, 1) + assert(usage.bytes > 0) + assert.equal(usage.incomplete, 1) + assert.deepEqual(await maintainSignatureCache(f.cache, true), usage) + assert.deepEqual(await readdir(f.cache), ['.publish-incomplete']) + assert.equal(await readFile(f.path, 'utf8'), 'signed:original') +}) + +test('maintenance refuses unexpected entry contents without deleting them', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const key = (await readdir(f.cache))[0]! + const unexpected = join(f.cache, key, 'unrecognized') + await writeFile(unexpected, 'keep') + await assert.rejects(maintainSignatureCache(f.cache, true), /unexpected files/u) + assert.equal(await readFile(unexpected, 'utf8'), 'keep') +}) diff --git a/apps/desktop/tests/windows-signing-preflight.spec.ts b/apps/desktop/tests/windows-signing-preflight.spec.ts new file mode 100644 index 0000000000..e560654e80 --- /dev/null +++ b/apps/desktop/tests/windows-signing-preflight.spec.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { createPackagingRun } from '../scripts/packaging-run.mjs' +import { preflightWindowsSigning } from '../scripts/windows-signing-preflight.ts' +import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' + +vi.mock('node:crypto', async importOriginal => ({ + ...await importOriginal(), + X509Certificate: class { + ca = false + keyUsage = ['1.3.6.1.5.5.7.3.3'] + fingerprint = 'A'.repeat(40) + validFrom = 'Jan 1 2000 GMT' + validTo = 'Jan 1 2100 GMT' + }, +})) + +const roots: string[] = [] +afterEach(async () => { + vi.useRealTimers() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +it.each(['1999-01-01', '2101-01-01'])('rejects a certificate outside its validity period at %s', async (date) => { + const options = await fixture() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date(date)) + await expect(preflightWindowsSigning(options)).rejects.toThrow('currently valid') + expect(options.compile).not.toHaveBeenCalled() + expect(options.sign).not.toHaveBeenCalled() +}) + +it.skipIf(process.platform !== 'win32')('compiles a real unsigned probe without executing it or accessing a token', async () => { + const { compile: _compile, ...options } = await fixture() + options.environment.SystemRoot = process.env.SystemRoot! + options.inspect.mockImplementation(async (path: string) => options.sign.mock.calls.length === 0 + ? inspectWindowsRuntimeSignature(path) + : { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) }) + await preflightWindowsSigning(options) + const bytes = await readFile(join(options.runDirectory, 'signing-preflight/probe.exe')) + expect(bytes.subarray(0, 2).toString()).toBe('MZ') + expect(bytes.length).toBeLessThan(16 * 1024) + expect(options.sign).toHaveBeenCalledOnce() +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'signing-preflight-')) + roots.push(root) + const run = createPackagingRun(join(root, 'records'), {}) + const compiler = join(root, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe') + await mkdir(dirname(compiler), { recursive: true }) + await writeFile(compiler, 'inert compiler') + const environment = { SystemRoot: root, + DSH_DESKTOP_WINDOWS_CER_FILE: join(root, 'public.cer'), + DSH_DESKTOP_WINDOWS_SIGNTOOL: join(root, 'signtool.exe'), + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } + await writeFile(environment.DSH_DESKTOP_WINDOWS_CER_FILE, 'inert certificate') + await writeFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL, 'inert signer') + const sequence: string[] = [] + const compile = vi.fn(async (_compiler: string, _source: string, output: string) => { + sequence.push('compile'); await writeFile(output, 'inert unsigned probe') + }) + const sign = vi.fn(async () => { sequence.push('sign') }) + const inspect = vi.fn(async (_path: string) => { + sequence.push('inspect') + return sign.mock.calls.length === 0 + ? { status: 'NotSigned', timestamped: false, thumbprint: null } + : { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) } + }) + return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect } +} + +it('compiles one new probe and verifies one production-format signature before success', async () => { + const options = await fixture() + await preflightWindowsSigning(options) + expect(options.sequence).toEqual(['compile', 'inspect', 'sign', 'inspect']) + expect(options.sign).toHaveBeenCalledExactlyOnceWith({ path: join(options.runDirectory, 'signing-preflight/probe.exe'), hash: 'sha256', isNest: false }) + const events = await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8') + expect(events).toContain('signing-preflight-success') + expect(events).toContain('"probeExecuted":false') + expect(events).not.toContain('fixture-pin') +}) + +it.each(['interlock', 'fatal', 'compiler', 'certificate', 'signTool', 'pin', 'audit'] as const) +('rejects static %s failure without compiling or signing', async (failure) => { + const options = await fixture() + if (failure === 'interlock') { + await mkdir(options.stateDirectory) + await writeFile(join(options.stateDirectory, 'attempt.json'), 'retained attempt') + } else if (failure === 'fatal') await writeFile(join(options.runDirectory, 'fatal.json'), 'failed') + else if (failure === 'compiler') await rm(options.compiler) + else if (failure === 'certificate') await rm(options.environment.DSH_DESKTOP_WINDOWS_CER_FILE) + else if (failure === 'signTool') await rm(options.environment.DSH_DESKTOP_WINDOWS_SIGNTOOL) + else if (failure === 'pin') options.environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN = '' + else { + await rm(join(options.runDirectory, 'events.jsonl')) + await mkdir(join(options.runDirectory, 'events.jsonl')) + } + await expect(preflightWindowsSigning(options)).rejects.toThrow() + expect(options.compile).not.toHaveBeenCalled() + expect(options.sign).not.toHaveBeenCalled() + if (failure === 'interlock') expect(await readFile(join(options.stateDirectory, 'attempt.json'), 'utf8')).toBe('retained attempt') +}) + +it.each(['compile', 'unexpected-signature', 'sign', 'timestamp', 'publisher', 'verification'] as const) +('stops on %s failure without retry or successful evidence', async (failure) => { + const options = await fixture() + if (failure === 'compile') options.compile.mockRejectedValueOnce(new Error('compiler refused')) + else if (failure === 'unexpected-signature') options.inspect.mockResolvedValueOnce({ status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) }) + else if (failure === 'sign') options.sign.mockRejectedValueOnce(new Error('token refused')) + else options.inspect.mockResolvedValueOnce({ status: 'NotSigned', timestamped: false, thumbprint: null }) + .mockResolvedValueOnce({ status: failure === 'verification' ? 'HashMismatch' : 'Valid', + timestamped: failure !== 'timestamp', thumbprint: (failure === 'publisher' ? 'B' : 'A').repeat(40) }) + await expect(preflightWindowsSigning(options)).rejects.toThrow() + expect(options.sign).toHaveBeenCalledTimes(failure === 'compile' || failure === 'unexpected-signature' ? 0 : 1) + expect(await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8')).not.toContain('signing-preflight-success') +}) diff --git a/apps/desktop/tests/windows-signing-stage.spec.ts b/apps/desktop/tests/windows-signing-stage.spec.ts new file mode 100644 index 0000000000..a205681781 --- /dev/null +++ b/apps/desktop/tests/windows-signing-stage.spec.ts @@ -0,0 +1,134 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, it, type TestContext } from 'vitest' +import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs' + +const test = it.skipIf(process.platform !== 'win32') + +interface StageChild { + process: ChildProcess + events: string[] + closed: Promise<{ code: number | null; signal: NodeJS.Signals | null }> + wait: (event: string) => Promise +} + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(import.meta.dirname, 'stage-test-')) + const children: StageChild[] = [] + t.onTestFinished(async () => { + for (const item of children) { + if (item.process.exitCode === null && item.process.signalCode === null) item.process.kill() + await item.closed + } + await rm(root, { recursive: true, force: true }) + }) + function child(input: string, mode = 'sign'): StageChild { + const process = fork(join(import.meta.dirname, 'fixtures/windows-signing-stage.mjs'), [root, input, mode], { + execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }) + const events: string[] = [] + let stderr = '' + process.stderr!.on('data', (chunk) => { stderr += String(chunk) }) + const waiters = new Map void>() + process.on('message', (message: { type: string }) => { events.push(message.type); waiters.get(message.type)?.() }) + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + process.once('close', (code, signal) => { resolve({ code, signal }) }) + }) + const failed = new Promise((_resolve, reject) => { process.once('error', reject) }) + const item = { process, events, closed, + wait: (event: string) => Promise.race([ + events.includes(event) ? Promise.resolve() : new Promise(resolve => waiters.set(event, resolve)), + closed.then((result) => { throw new Error(`fixture exited before ${event}: ${JSON.stringify(result)} ${stderr}`) }), + failed, + ]), + } + children.push(item) + return item + } + const first = join(root, 'first.node') + const second = join(root, 'second.node') + await writeFile(first, 'original') + await writeFile(second, 'original') + return { root, first, second, child } +} + +test('independent signing stages wait and recheck the cache before accessing hardware', async (t) => { + const f = await fixture(t) + const first = f.child(f.first) + await first.wait('entered') + const second = f.child(f.second) + await second.wait('signing-stage-wait') + expect(second.events).not.toContain('entered') + first.process.send('release') + expect(await first.closed).toEqual({ code: 0, signal: null }) + await second.wait('entered') + second.process.send('release') + expect(await second.closed).toEqual({ code: 0, signal: null }) + expect(await readFile(join(f.root, 'hardware-calls'), 'utf8')).toBe('call\n') + expect(await readFile(f.second, 'utf8')).toBe('signed:original') + expect(second.events).toContain('signature-cache-hit') +}) + +test('an interrupted holder releases only the stage lock and retains hardware failure evidence', async (t) => { + const f = await fixture(t) + const first = f.child(f.first) + await first.wait('entered') + const evidence = join(f.root, 'state/attempt.json') + await writeFile(evidence, 'retained hardware attempt') + first.process.kill() + await first.closed + await withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'inspect', record: () => {} }, async () => { + expect(await readFile(evidence, 'utf8')).toBe('retained hardware attempt') + }) +}) + +test('a failed stage releases its handle before another process enters', async (t) => { + const f = await fixture(t) + await expect(withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'failed', record: () => {} }, async () => { + throw new Error('stage failed') + })).rejects.toThrow('stage failed') + const next = f.child(f.first) + await next.wait('entered') + next.process.send('release') + expect(await next.closed).toEqual({ code: 0, signal: null }) +}) + +test('accepts a state directory whose spelling differs only in Windows path case', async (t) => { + const f = await fixture(t) + let entered = false + await withWindowsSigningStage({ stateDirectory: join(f.root, 'state').toUpperCase(), stage: 'case', record: () => {} }, async () => { + entered = true + }) + expect(entered).toBe(true) +}) + +test('a cancelled waiter never enters and does not release another process lock', async (t) => { + const f = await fixture(t) + const holder = f.child(f.first) + await holder.wait('entered') + const controller = new AbortController() + let entered = false + await expect(withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'cancelled', signal: controller.signal, + record: (event) => { if ('type' in event && event.type === 'signing-stage-wait') controller.abort() }, + }, async () => { entered = true })).rejects.toThrow() + expect(entered).toBe(false) + holder.process.send('release') + expect(await holder.closed).toEqual({ code: 0, signal: null }) +}) + +test('cache clearing waits until another process finishes signing and publishing', async (t) => { + const f = await fixture(t) + const signer = f.child(f.first) + await signer.wait('entered') + const cleaner = f.child(f.second, 'clear') + await cleaner.wait('signing-stage-wait') + expect(cleaner.events).not.toContain('entered') + signer.process.send('release') + expect(await signer.closed).toEqual({ code: 0, signal: null }) + await cleaner.wait('entered') + cleaner.process.send('release') + expect(await cleaner.closed).toEqual({ code: 0, signal: null }) + expect(await readdir(join(f.root, 'cache'))).toEqual([]) + expect(await readFile(f.first, 'utf8')).toBe('signed:original') +}) diff --git a/apps/desktop/tests/windows-signing-state.spec.ts b/apps/desktop/tests/windows-signing-state.spec.ts new file mode 100644 index 0000000000..643b4d6bab --- /dev/null +++ b/apps/desktop/tests/windows-signing-state.spec.ts @@ -0,0 +1,53 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createPackagingRun } from '../scripts/packaging-run.mjs' +import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs' + +describe('persistent Windows signing interlock', () => { + it('admits one independent process and retains its slot after that process exits', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-signing-interlock-')) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$/iu.test(name))) + const stateDirectory = join(root, 'state') + const acquire = async () => { + const run = createPackagingRun(root, {}) + const child = spawn(process.execPath, [resolve(import.meta.dirname, 'fixtures/signing-interlock.mjs'), run.directory, stateDirectory], { + env, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], + }) + let output = '' + let error: Error | undefined + child.stdout.on('data', (data: Buffer) => { output += data.toString() }) + child.once('error', (value) => { error = value }) + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done) => { + child.once('close', (code, signal) =>{ done({ code, signal }) }) + }) + expect(error).toBeUndefined() + expect(result.signal).toBeNull() + return { ...result, output } + } + try { + const results = await Promise.all([acquire(), acquire()]) + expect(results.map(result => result.code).sort()).toEqual([0, 1]) + expect(results.map(result => result.output).sort()).toEqual(['acquired', 'blocked']) + expect(await acquire()).toMatchObject({ code: 1, output: 'blocked' }) + } finally { await rm(root, { recursive: true, force: true }) } + }) + + it('releases only successful attempts and records both operations before completion', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-signing-success-')) + try { + const run = createPackagingRun(root, {}) + const options = { runDirectory: run.directory, stateDirectory: join(root, 'state'), target: 'fixture.exe' } + beginWindowsSigningAttempt(options).success() + beginWindowsSigningAttempt(options).success() + const events = (await readFile(join(run.directory, 'events.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { type: string }) + expect(events.map(event => event.type)).toEqual(['sign-start', 'sign-success', 'sign-start', 'sign-success']) + } finally { await rm(root, { recursive: true, force: true }) } + }) + + it('refuses hardware entry without an existing supervised run', () => { + expect(() => beginWindowsSigningAttempt({ runDirectory: 'relative-missing-run', target: 'fixture.exe' })).toThrow('supervised packaging run') + }) +}) diff --git a/apps/desktop/tests/windows-timestamp.spec.ts b/apps/desktop/tests/windows-timestamp.spec.ts new file mode 100644 index 0000000000..71e1cbc911 --- /dev/null +++ b/apps/desktop/tests/windows-timestamp.spec.ts @@ -0,0 +1,139 @@ +/** Timestamp retries use fresh signed inputs and cannot publish partial or substituted files. */ +import { it, expect, type TestContext } from 'vitest' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { completeWindowsSignature, type WindowsTimestampOptions } from '../scripts/windows-timestamp.mjs' + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'dsh-timestamp-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'input.exe') + const evidenceDirectory = join(root, 'evidence') + await mkdir(evidenceDirectory) + await writeFile(path, 'input') + const state = { hardware: 0, requests: [] as string[], events: [] as object[] } + const options: WindowsTimestampOptions = { + evidenceDirectory, thumbprint: 'A'.repeat(40), + sign: async (file) => { state.hardware++; await writeFile(file, 'signed:input') }, + timestamp: async (file) => { state.requests.push(await readFile(file, 'utf8')); await writeFile(file, 'signed:input:timestamp') }, + normalize: async (file) => { await writeFile(file, (await readFile(file, 'utf8')).replace(':timestamp', '')) }, + inspect: async file => ({ status: 'Valid', timestamped: (await readFile(file, 'utf8')).endsWith(':timestamp'), thumbprint: 'A'.repeat(40) }), + record: (event) => { state.events.push(event) }, + wait: async () => {}, + } + return { root, path, options, state } +} + +it('a failed timestamp cannot contaminate the next input or repeat hardware signing', async (t) => { + const f = await fixture(t) + const timestamp = f.options.timestamp + let requests = 0 + f.options.timestamp = async (path) => { + if (++requests === 1) { + expect(await readFile(path, 'utf8')).toBe('signed:input') + await writeFile(path, 'damaged by failed timestamp') + throw Object.assign(new Error('timestamp failed'), { code: 1 }) + } + await timestamp(path) + } + await completeWindowsSignature(f.path, f.options) + expect(f.state.hardware).toBe(1) + expect(requests).toBe(2) + expect(f.state.requests).toEqual(['signed:input']) + expect(await readFile(f.path, 'utf8')).toBe('signed:input:timestamp') + expect((await readdir(f.root)).filter(name => name.startsWith('.complete-signature-'))).toEqual([]) +}) + +it('exhausted timestamp attempts retain evidence and leave the original untouched', async (t) => { + const f = await fixture(t) + let requests = 0 + f.options.timestamp = async () => { requests++; throw Object.assign(new Error('offline'), { code: 1 }) } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('offline') + expect(requests).toBe(3) + expect(f.state.hardware).toBe(1) + expect(await readFile(f.path, 'utf8')).toBe('input') + expect(await readdir(f.options.evidenceDirectory)).toHaveLength(1) + expect((await readdir(f.root)).filter(name => name.startsWith('.complete-signature-'))).toEqual([]) +}) + +it('retains staging when failure evidence cannot be stored', async (t) => { + const f = await fixture(t) + f.options.evidenceDirectory = f.path + let staging = '' + f.options.sign = async (path) => { staging = dirname(path); throw new Error('hardware failed') } + t.onTestFinished(async () => { if (staging) await rm(staging, { recursive: true, force: true }) }) + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('evidence could not be retained') + expect(await readdir(staging)).toEqual(['raw.exe']) + expect(await readFile(f.path, 'utf8')).toBe('input') +}) + +it('uses short tool paths for deeply nested targets and publishes the completed bytes', async (t) => { + const f = await fixture(t) + const directory = join(f.root, 'nested'.repeat(20), 'dependency'.repeat(12)) + await mkdir(directory, { recursive: true }) + const path = join(directory, 'native-module.node') + await writeFile(path, 'input') + function checked(operation: (file: string) => Promise) { + return async (file: string) => { + expect(file.length).toBeLessThan(260) + expect(file.endsWith('.node')).toBe(true) + expect(dirname(file)).not.toBe(directory) + return operation(file) + } + } + f.options.sign = checked(f.options.sign) + f.options.timestamp = checked(f.options.timestamp) + f.options.normalize = checked(f.options.normalize) + f.options.inspect = checked(f.options.inspect) + await completeWindowsSignature(path, f.options) + expect(await readFile(path, 'utf8')).toBe('signed:input:timestamp') + expect(await readdir(directory)).toEqual(['native-module.node']) +}) + +for (const failure of [{ code: 'ENOENT' }, { code: 1, killed: true }, { code: 1, signal: 'SIGTERM' }]) { + it(`does not retry uncertain timestamp process failure ${JSON.stringify(failure)}`, async (t) => { + const f = await fixture(t) + let requests = 0 + f.options.timestamp = async () => { requests++; throw Object.assign(new Error('process failure'), failure) } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('process failure') + expect(requests).toBe(1) + expect(await readFile(f.path, 'utf8')).toBe('input') + }) +} + +for (const signature of [ + { status: 'Valid', timestamped: false, thumbprint: 'A'.repeat(40) }, + { status: 'Valid', timestamped: true, thumbprint: 'B'.repeat(40) }, + { status: 'HashMismatch', timestamped: true, thumbprint: 'A'.repeat(40) }, +]) it(`rejects successful commands with an invalid final signature ${JSON.stringify(signature)}`, async (t) => { + const f = await fixture(t) + f.options.inspect = async () => signature + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('verification failed') + expect(f.state.requests).toHaveLength(1) + expect(await readFile(f.path, 'utf8')).toBe('input') +}) + +it('rejects changed contents even when the substituted file has the expected valid certificate', async (t) => { + const f = await fixture(t) + f.options.timestamp = async (path) => { await writeFile(path, 'signed:different-input:timestamp') } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('signing content changed') + expect(await readFile(f.path, 'utf8')).toBe('input') +}) + +it('does not overwrite an input changed while its signature is being completed', async (t) => { + const f = await fixture(t) + const timestamp = f.options.timestamp + f.options.timestamp = async (path) => { await timestamp(path); await writeFile(f.path, 'new input') } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('input changed before publication') + expect(await readFile(f.path, 'utf8')).toBe('new input') +}) + +it('does not retry hardware failures or request timestamps after them', async (t) => { + const f = await fixture(t) + f.options.sign = async () => { f.state.hardware++; throw new Error('hardware failed') } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('hardware failed') + expect(f.state.hardware).toBe(1) + expect(f.state.requests).toHaveLength(0) + expect(await readFile(f.path, 'utf8')).toBe('input') +}) diff --git a/apps/desktop/tests/windows-update-publisher.spec.ts b/apps/desktop/tests/windows-update-publisher.spec.ts new file mode 100644 index 0000000000..8967f67b87 --- /dev/null +++ b/apps/desktop/tests/windows-update-publisher.spec.ts @@ -0,0 +1,97 @@ +import { createRequire } from 'node:module' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { resolveWindowsUpdatePublisher } from '../scripts/windows-sign.mjs' + +vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({ + ...await importOriginal(), + installWindowsNsisBootstrapSigner: vi.fn(), +})) + +vi.mock('node:crypto', async importOriginal => ({ + ...await importOriginal(), + X509Certificate: class { + readonly ca = false + readonly keyUsage = ['1.3.6.1.5.5.7.3.3'] + constructor(private readonly contents: Buffer) {} + toLegacyObject(): { subject: unknown } { + return { subject: JSON.parse(this.contents.toString('utf8')) as unknown } + } + }, +})) + +const require = createRequire(import.meta.url) +// Exercise the installed builder's metadata derivation without acquiring a signing device. +const { WindowsSignToolManager } = require('app-builder-lib/out/codeSign/windowsSignToolManager.js') as { + WindowsSignToolManager: new (packager: { + platformSpecificBuildOptions: unknown + getCscLink: () => undefined + }) => { computedPublisherName: { value: Promise } } +} +const builderRequire = createRequire(require.resolve('app-builder-lib/package.json')) +const { parseDn } = builderRequire('builder-util-runtime') as { parseDn: (value: string) => Map } + +async function withCertificate(subject: unknown, action: (file: string, signTool: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-update-publisher-')) + try { + const certificate = join(directory, 'publisher.cer') + const signTool = join(directory, 'signtool.exe') + await writeFile(certificate, JSON.stringify(subject)) + await writeFile(signTool, 'inert signing-tool fixture') + await action(certificate, signTool) + } + finally { + await rm(directory, { recursive: true, force: true }) + } +} + +describe('Windows update publisher', () => { + beforeAll(() => { + vi.stubEnv('DSH_DESKTOP_APP_ID', 'com.example.publisher-test') + vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'https://policy.example.com') + vi.stubEnv('DSH_DESKTOP_TARGET_PLATFORM', 'win32') + vi.stubEnv('DSH_DESKTOP_UNSIGNED', '1') + }) + afterAll(() => vi.unstubAllEnvs()) + + it('preserves organization identity and escapes DN delimiters using the updater parser', async () => { + const subject = { CN: '测试 "Publisher", Inc;+\\', O: ' Leading=org ', C: 'CN', ST: 'State' } + await withCertificate(subject, async (file) => { + expect(Object.fromEntries(parseDn(resolveWindowsUpdatePublisher(file)))) + .toEqual({ CN: subject.CN, O: subject.O, C: subject.C }) + }) + }) + + it.each([ + { CN: '', O: 'Company', C: 'CN' }, + { CN: 'Publisher', C: 'CN' }, + { CN: 'Publisher', O: 'Company', C: ['CN', 'US'] }, + ])('rejects missing or ambiguous publisher identity: %j', async (subject) => { + await withCertificate(subject, async (file) => { + expect(() => resolveWindowsUpdatePublisher(file)).toThrow(/requires one nonempty/u) + }) + }) + + it.each([true, false])('supplies publisher metadata with explicit Windows target = %s', async (explicitTarget) => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + await withCertificate({ CN: 'Publisher', O: 'Company', C: 'CN' }, async (file, signTool) => { + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.publisher-test', + DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com', + ...(explicitTarget ? { DSH_DESKTOP_TARGET_PLATFORM: 'win32' } : {}), + DSH_DESKTOP_WINDOWS_CER_FILE: file, + DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool, + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'test-container', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'test-pin', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }, 'win32', 'x64') + expect(config.win.forceCodeSigning).toBe(true) + expect(typeof config.win.signtoolOptions.sign).toBe('function') + const manager = new WindowsSignToolManager({ platformSpecificBuildOptions: config.win, getCscLink: () => undefined }) + expect(await manager.computedPublisherName.value).toEqual(['CN=Publisher,O=Company,C=CN']) + }) + }) +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index d34cf1bf26..aed896b427 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,7 +12,7 @@ export default defineConfig([ clean: false, deps: { neverBundle: ['electron'] }, }, - ...(['preload', 'preload-app'] as const).map(name => ({ + ...(['preload-app', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. entry: { [name]: `lib/types/${name}.js` }, outDir: 'lib', diff --git a/apps/web/package.json b/apps/web/package.json index acdb74244a..07823f0a29 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 2b2ba0e135..b1c57c60e7 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -73,7 +73,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const currentMenuBox = await currentMenu.boundingBox() expect(currentTriggerBox).not.toBeNull() expect(currentMenuBox).not.toBeNull() - expect(Math.abs(currentMenuBox!.width - 218)).toBeLessThan(1) + expect(Math.abs(currentMenuBox!.width - 144)).toBeLessThan(1) expect(currentMenuBox!.width).toBeGreaterThan(currentTriggerBox!.width) expect(Math.abs(currentTriggerBox!.y - currentMenuBox!.y - currentMenuBox!.height - 4)).toBeLessThan(1) @@ -183,7 +183,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await menu.waitFor() await captureAutoReviewState(page, 'experimental-narrow-menu') const menuBox = (await menu.boundingBox())! - expect(menuBox.width).toBeGreaterThanOrEqual(218) + expect(menuBox.width).toBeGreaterThanOrEqual(144) expect(menuBox.width).toBeLessThanOrEqual(360) expect(menuBox.x).toBeGreaterThanOrEqual(0) expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(420) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 7e069b2465..88ae171e85 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -254,7 +254,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { // new-session screen with the self-referential preset staged, and the // blank session the flow produces composes from it on the host. await dialog.waitFor({ state: 'detached', timeout: 10_000 }) - await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) await expect.poll(async () => { const response = await scaffold.hostFetch('/api/session/list', { method: 'POST', diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index b3192c3655..ac4e484db0 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,4 +1,4 @@ -// Keyless assembled-browser coverage for the opt-in Agent Teams Web profiles +// Keyless assembled-browser coverage for the opt-in Agent Teams bundle // over the real Host Typert Remote flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -18,11 +18,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) -const HOST_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) -const WEB_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/cordis.patch.yml', import.meta.url)) +const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) const INSTALL_ANCHORS = [ fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/package.json', import.meta.url)), - fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/package.json', import.meta.url)), ] const MODE = webSnapshotMode() @@ -33,11 +31,8 @@ function profileEntries(path: string): unknown[] { } describe('Agent Teams panel overlay', () => { - it('matches the shipped Host and Web profile layers', () => { - expect(profileEntries(OVERLAY)).toEqual([ - ...profileEntries(HOST_PATCH), - ...profileEntries(WEB_PATCH), - ]) + it('matches the shipped Agent Teams bundle', () => { + expect(profileEntries(OVERLAY)).toEqual(profileEntries(TEAM_PATCH)) }) }) diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml index cb6dd2decd..20c49c23a1 100644 --- a/apps/web/tests/agent-team-panel.overlay.yml +++ b/apps/web/tests/agent-team-panel.overlay.yml @@ -1,5 +1,4 @@ -# Equivalent to the two explicit Agent Teams profile layers over the scaffold's -# stable base and Web bundles. +# Equivalent to the Agent Teams bundle over the stable base and Web bundles. - id: tool-subagent-control disabled: true @@ -28,6 +27,5 @@ freshProvider: spawn forkProvider: fork -- insert: - id: ui-agent-team name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 69739dc7a7..36eab3cb42 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -71,7 +71,14 @@ interface ControlBaseline { interface CapturedFixture { readonly sessionList: { readonly ok: true; readonly value: { readonly items: readonly SessionSummary[] } } - readonly settingsDescribe: unknown + readonly settingsDescribe: { + readonly ok: true + readonly value: { + readonly writable: boolean + readonly hasDocument: boolean + readonly namespaces: readonly unknown[] + } + } readonly credentialsDescribe: unknown readonly modelCatalog: unknown readonly agentPresets: unknown @@ -90,6 +97,8 @@ interface CapturedFixture { } export interface AssembledRemoteOptions { + /** Supply an enabled Host preference for diagnostic View scenarios. */ + readonly developerTools?: boolean /** Return the fixture's image-dimension admission error from Session prompt. */ readonly rejectPrompt?: boolean } @@ -131,7 +140,16 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass const mock = RemoteMock.create().load(remoteDefaultResponses) mock.load({ unary: { - 'settings/describe': structuredClone(fixture.settingsDescribe), + 'settings/describe': options.developerTools === true + ? ok({ + ...fixture.settingsDescribe.value, + namespaces: [...fixture.settingsDescribe.value.namespaces, { + ns: 'ui-developer-tools', + schema: { type: 'object', dict: { enabled: { type: 'boolean' } } }, + value: { enabled: true }, applies: 'live', secrets: [], revision: 0, + }], + }) + : structuredClone(fixture.settingsDescribe), 'credentials/describe': structuredClone(fixture.credentialsDescribe), 'session/modelCatalog': structuredClone(fixture.modelCatalog), 'agentPresets/list': structuredClone(fixture.agentPresets), diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index 17a741dac4..46115eff44 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -91,6 +91,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() within(waitingRow).getByText('Waiting for answer') + within(waitingRow).getByText('Answer') // Opening a session reaches chat content through the RemoteMock transport. fireEvent.click(waitingTitle) @@ -103,7 +104,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn for (let index = 0; index < 3; index += 1) { fireEvent.click(await screen.findByRole('button', { name: 'Skip' })) } - fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })) + const allowOnce = await screen.findByRole('button', { name: 'Allow once' }) + await waitFor(() => { + within(waitingRow).getByText('Waiting for approval') + within(waitingRow).getByText('Approval') + }) + fireEvent.click(allowOnce) // The fixture mirrors all three token-meter projections, so the assembled // ContextMeter reaches its composition panel instead of only the occupancy diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 08045544fc..fd85c388f8 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -51,6 +51,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1))) } scaffold = await launchWebScaffold({ + developerTools: false, compareReplaySession: true, extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)), ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }), @@ -106,6 +107,18 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n') const card = page.locator('[data-changed-files]') + expect(await card.count()).toBe(0) + const header = page.locator('header').filter({ has: page.locator('[data-conversation-header-leading]') }) + expect(await header.getByRole('tablist').count()).toBe(0) + const compactHeight = await header.evaluate(element => element.getBoundingClientRect().height) + expect(compactHeight).toBeLessThan(60) + await page.getByRole('button', { name: '设置', exact: true }).click() + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByRole('switch', { name: '开发者工具' }).click() + await expect.poll(() => settings.getByRole('switch', { name: '开发者工具' }).getAttribute('aria-checked')).toBe('true') + await settings.getByRole('button', { name: '关闭', exact: true }).click() + await header.getByRole('tablist').waitFor({ state: 'visible' }) + expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBeGreaterThan(compactHeight) await card.waitFor({ state: 'visible' }) expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1) expect(await card.getByRole('listitem').count()).toBe(3) @@ -144,6 +157,43 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。']) expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。']) + // Constrain the recorded three-row diff to exercise unequal horizontal ranges and classic scrollbars. + const scrollLayout = await page.addStyleTag({ content: ` + [data-changes-review] { height: 120px !important; } + [data-diff-side] { scrollbar-width: auto; } + [data-diff-side]::-webkit-scrollbar { width: 16px; height: 16px; } + [data-diff-side="left"] { width: 65px; } + ` }) + try { + const left = review.locator('[data-diff-side="left"]') + const right = review.locator('[data-diff-side="right"]') + const metrics = (side: typeof left) => side.evaluate(element => ({ + x: element.scrollLeft, y: element.scrollTop, + maxX: element.scrollWidth - element.clientWidth, + maxY: element.scrollHeight - element.clientHeight, + bottom: element.lastElementChild!.getBoundingClientRect().bottom, + })) + const initialLeft = await metrics(left) + const initialRight = await metrics(right) + expect(initialLeft.maxX).toBeGreaterThan(0) + expect(initialRight.maxX).toBe(0) + expect(initialLeft.maxY).toBeGreaterThan(0) + expect(initialLeft.maxY).toBe(initialRight.maxY) + await left.evaluate((element) => { element.scrollLeft = element.scrollWidth }) + await expect.poll(async () => (await metrics(left)).x).toBe(initialLeft.maxX) + await left.evaluate((element) => { element.scrollTop = element.scrollHeight }) + await expect.poll(async () => (await metrics(right)).y).toBe(initialLeft.maxY) + // Two frames allow the browser-generated peer event to run before checking the source. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))) + expect((await metrics(left)).x).toBe(initialLeft.maxX) + expect((await metrics(left)).y).toBe(initialLeft.maxY) + expect((await metrics(left)).bottom).toBeCloseTo((await metrics(right)).bottom, 1) + await right.evaluate((element) => { element.scrollTop = 0 }) + await expect.poll(async () => (await metrics(left)).y).toBe(0) + expect((await metrics(left)).x).toBe(initialLeft.maxX) + } finally { + await scrollLayout.evaluate(element => element.parentNode!.removeChild(element)) + } await review.getByRole('button', { name: '自动换行' }).click() await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' }) await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。']) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 17e639814c..33c8046779 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -198,35 +198,37 @@ describe('web e2e: long Chat interaction contract', () => { const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' }) await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 }) - // The whole-log outline offers every fixture turn before any paging, with - // the live tail mark current. const marks = turnNavigation.getByRole('button') - await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS) - expect(await marks.last().getAttribute('aria-current')).toBe('true') + await expect.poll(() => marks.last().getAttribute('aria-current'), { timeout: 15_000 }).toBe('true') + expect(await marks.count()).toBeLessThan(FIXTURE_TURNS) // The oldest turn is an unloaded mark whose outline preview already // carries both the prompt and the settled response. const firstTurnButton = turnNavigation .getByRole('button', { name: 'Load and jump to turn 1', exact: true }) + const railScroller = turnNavigation.locator('[class*="scroller"]') + await railScroller.hover() + await page.mouse.wheel(0, -FIXTURE_TURNS * 10) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) await firstTurnButton.focus() const preview = page.getByRole('tooltip') await preview.waitFor({ state: 'visible', timeout: 5_000 }) - expect(await preview.textContent()).toContain(FIXTURE.markers.user(1)) + await expect.poll(() => preview.textContent(), { timeout: 5_000 }).toContain(FIXTURE.markers.user(1)) expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1)) - const firstTurnPosition = await firstTurnButton.evaluate(button => ( - button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? '' + const markPitch = () => turnNavigation.getByRole('button').evaluateAll(buttons => ( + buttons[1]!.getBoundingClientRect().top - buttons[0]!.getBoundingClientRect().top )) - expect(firstTurnPosition).toBe('0px') + expect(await markPitch()).toBe(10) const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true }) - const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / }) - const loadedBefore = await loadedMarks.count() + const loadedRows = page.locator('[data-chat-flow-key]') + const loadedBefore = await loadedRows.count() await loadEarlier.click() - // Paging converts marks to their loaded form without moving the - // fixed-pitch ladder. - await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore) - expect(await firstTurnButton.evaluate(button => ( - button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? '' - ))).toBe(firstTurnPosition) + await expect.poll(() => loadedRows.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore) + await railScroller.hover() + await page.mouse.wheel(0, -FIXTURE_TURNS * 10) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) + await firstTurnButton.waitFor({ state: 'visible' }) + expect(await markPitch()).toBe(10) // Activating the still-unloaded oldest mark pages the rest in and lands // on the turn's own row. await firstTurnButton.focus() diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 4299bf542c..af9ebc875f 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -587,7 +587,7 @@ describe('web e2e: long Chat scroll contract', () => { }) }, 180_000) - it.skipIf(MODE === 'record')('offers every outline turn on the rail and jumps to an unloaded one', async () => { + it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => { await withScrollWorld({ failureShot: 'web-e2e-turn-rail-jump', seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }], @@ -595,31 +595,41 @@ describe('web e2e: long Chat scroll contract', () => { await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns)) await expectBottom(world.page) - // The whole-log outline reaches the rail before any paging: one mark - // per fixture turn, the oldest still in its load-and-jump form. const rail = world.page.getByRole('navigation', { name: 'Turn navigation' }) - await expect.poll(() => rail.getByRole('button').count(), { timeout: 15_000 }) - .toBe(HISTORY_FIXTURE.turns) + const latest = rail.getByRole('button', { name: `Jump to turn ${String(HISTORY_FIXTURE.turns)}`, exact: true }) + await expect.poll(() => latest.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true') + expect(await rail.getByRole('button').count()).toBeLessThan(HISTORY_FIXTURE.turns) const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true }) - expect(await firstUnloaded.count()).toBe(1) - // Fixed pitch: the ladder keeps its natural height, scrolls inside the - // frame, and (following the active tail mark) fades its upper end. - expect(await rail.evaluate(nav => nav.style.getPropertyValue('--turn-natural-height'))) - .toBe(`${String((HISTORY_FIXTURE.turns - 1) * 10 + 12)}px`) + expect(await firstUnloaded.count()).toBe(0) const railScroller = rail.locator('[class*="scroller"]') await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true) await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1) + const bodyBeforeRailScroll = await scrollGeometry(world.page) + await railScroller.hover() + await world.page.mouse.wheel(0, -HISTORY_FIXTURE.turns * 10) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) + await firstUnloaded.waitFor({ state: 'visible' }) + expect((await scrollGeometry(world.page)).scrollTop).toBe(bodyBeforeRailScroll.scrollTop) - // Activate the unloaded mark by keyboard: pointer input belongs to the - // rail frame, while each mark is the keyboard/AT destination. Focus - // first shows the outline-backed preview: prompt and settled response - // both travel ahead of the events. const beforeRows = await loadedFlowRows(world.page) await firstUnloaded.focus() const tooltip = world.page.getByRole('tooltip') await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1) - expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1)) + await expect.poll(() => tooltip.textContent(), { timeout: 5_000 }).toContain(HISTORY_FIXTURE.markers.user(1)) expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1)) + const previewId = await tooltip.getAttribute('id') + const hoveredMark = () => rail.evaluate(element => element.querySelector('button:hover')?.dataset.index ?? null) + await expect.poll(hoveredMark).not.toBeNull() + const hoveredBefore = await hoveredMark() + await world.page.mouse.wheel(0, 80) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(80) + await expect.poll(hoveredMark).toBe(String(Number(hoveredBefore) + 8)) + await nextPaint(world.page) + expect(await firstUnloaded.evaluate(element => document.activeElement === element)).toBe(true) + expect(await firstUnloaded.getAttribute('aria-describedby')).toBe(previewId) + expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1)) + await world.page.mouse.wheel(0, -80) + await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0) const transcriptLayers = await tooltip.evaluate((preview) => { const railSlot = preview.closest('nav')?.parentElement const codeBanner = document.querySelector('.md-code-block > :first-child') @@ -752,7 +762,7 @@ describe('web e2e: long Chat scroll contract', () => { }) }, 180_000) - it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => { + it.skipIf(MODE === 'record')('keeps composer resizing on the correct scroll owner across reopened Sessions', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-restore-composer', seeds: [ @@ -780,7 +790,6 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() await nextPaint(world.page) await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE) - const narrowSessionAnchor = await visibleFlowAnchor(world.page) await openSeed( world.page, @@ -791,9 +800,9 @@ describe('web e2e: long Chat scroll contract', () => { world.page, RESTORE_FIXTURE_A, ) - await expectSameFlowTop(world.page, narrowSessionAnchor) const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + await backToBottom.waitFor({ timeout: 15_000 }) await backToBottom.evaluate((button) => { if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element') button.click() diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 869bfb30e7..21e4f78bae 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -2,7 +2,8 @@ // in one settled keyless turn — the regression anchor for unifying link // styles. One fixture turn produces: // - prose: Markdown link, reference-style link, mailto link, inline-code URL, -// produced-file mention, plus inert contrasts (ambiguous basename, unwritten +// produced-file mention, a known-site link carrying that site's own leading +// mark, plus inert contrasts (ambiguous basename, unwritten // file, command code, URL-with-flags code, javascript: destination, // footnote superscript, remote image, fenced code block with its copy chrome) // - artifacts: seven produced files (chips overflow into the "+N" remainder @@ -49,12 +50,16 @@ const DONE = 'LINK_GALLERY_DONE' const GALLERY_TIME = Date.UTC(2026, 0, 15, 12) const GUIDE_URL = 'https://docs.example.test/guide' +const HTTP_URL = 'http://docs.example.test/plain' const API_URL = 'https://docs.example.test/api' const RELEASES_URL = 'https://docs.example.test/releases' const MAILTO_URL = 'mailto:owner@example.test' const SOURCE_URL = 'https://docs.example.test/links' const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec' const FETCH_URL = 'https://docs.example.test/tokens' +// A mapped host, so the prose pins that the leading glyph is the site's mark +// rather than the globe the unmapped docs host keeps. +const REPO_URL = 'https://github.com/example/link-gallery' /** One-part text content for a built message. */ function text(value: string): { type: 'text'; text: string }[] { @@ -256,6 +261,10 @@ function galleryFixture(imageUrl: string): string { `Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], ` + `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`, '', + `Preview: [plain HTTP](${HTTP_URL}).`, + '', + `Upstream: [the repository](${REPO_URL}).`, + '', `Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`, '', 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.', @@ -309,6 +318,10 @@ describe('web e2e: clickable links gallery', () => { await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ + contentType: 'text/html', + body: `

    ${new URL(route.request().url()).pathname}

    `, + })) await page.clock.setFixedTime(GALLERY_TIME + 60_000) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -336,6 +349,7 @@ describe('web e2e: clickable links gallery', () => { const markdown = page.locator('[class*="markdown"]') await expect.poll(() => markdown.locator(`a[href="${GUIDE_URL}"]`).count(), { timeout: 10_000 }).toBe(1) expect(await markdown.locator(`a[href="${RELEASES_URL}"]`).count()).toBe(1) + expect(await markdown.locator(`a[href="${HTTP_URL}"]`).count()).toBe(1) expect(await markdown.locator(`a[href="${MAILTO_URL}"]`).count()).toBe(1) const inlineCodeLink = markdown.locator(`code a[href="${API_URL}"]`) expect(await inlineCodeLink.count()).toBe(1) @@ -418,6 +432,13 @@ describe('web e2e: clickable links gallery', () => { expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none') expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1) } + // A mapped host leads with its own mark; the unmapped docs host keeps the + // globe in the same seat. + const repoLink = markdown.locator(`a[href="${REPO_URL}"]`) + expect(await repoLink.count()).toBe(1) + const repoMark = await repoLink.locator('svg path').first().getAttribute('d') + const globeMark = await guideLink.locator('svg path').first().getAttribute('d') + expect(repoMark).not.toBe(globeMark) await guideLink.hover() expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline') expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted') @@ -426,5 +447,12 @@ describe('web e2e: clickable links gallery', () => { expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted') // The excluded grey affordance: tool-row file links keep their own color. expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE) + + // Ordinary message HTTP(S) links delegate to the right Sidebar Browser. + await guideLink.click() + const browserAddress = page.locator('[data-rightbar-col]').getByRole('textbox', { name: 'Enter an HTTP(S) address' }) + await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) + await markdown.locator(`a[href="${HTTP_URL}"]`).click() + await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL) }, 90_000) }) diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index fe5751ca11..3b321032de 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -15,8 +15,7 @@ async function openInventory(page: Page, url: string) { await page.goto(url, { waitUntil: 'load' }) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '插件', exact: true }).click() - await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() + await dialog.getByRole('button', { name: '内置插件', exact: true }).click() await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor() return dialog } diff --git a/apps/web/tests/context-meter.e2e.ts b/apps/web/tests/context-meter.e2e.ts new file mode 100644 index 0000000000..8eab8e868e --- /dev/null +++ b/apps/web/tests/context-meter.e2e.ts @@ -0,0 +1,86 @@ +/** Context details stay inside the viewport with and without Chat's statistics contribution. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { describe, expect, it } from 'vitest' +import { fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const NO_CHAT = fileURLToPath(new URL('./fixtures/context-meter-no-chat.patch.yml', import.meta.url)) + +describe.skipIf(webSnapshotMode() === 'record')('web e2e: context details placement', () => { + it.each([true, false])('keeps details visible across viewport changes (Chat statistics: %s)', async (withStats) => { + const fixture = await selectedSessionFixture(FIXTURE, false) + const scaffold = await launchWebScaffold({ + replayFixture: fixture, + compareReplaySession: false, + paceMs: 5, + ...withStats ? {} : { extraOverlayPath: NO_CHAT }, + }) + try { + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const card = page.locator('[data-composer-card]') + expect(await card.evaluate(element => + element.parentElement!.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom, + )).toBe(0) + const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8')) + expect(prompts).toHaveLength(1) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(prompts[0]!) + await input.press('Enter') + await settled + const trigger = page.getByRole('button', { name: /% of context used$/ }) + await trigger.waitFor() + expect(await trigger.textContent()).toMatch(/^\d+%$/) + expect(await page.getByRole('button', { name: /tok · Cache hit/ }).count()).toBe(withStats ? 1 : 0) + expect(await card.evaluate((element) => { + const dock = element.nextElementSibling! + return { + above: getComputedStyle(dock).paddingTop, + below: getComputedStyle(element.parentElement!).paddingBottom, + } + })).toEqual({ above: '4px', below: '4px' }) + await trigger.click() + const panel = page.getByRole('dialog', { name: 'of context used', exact: true }) + await panel.waitFor() + for (const width of [390, 800, 1280]) { + await page.setViewportSize({ width, height: 900 }) + await page.locator('[data-sidebar-collapsed="true"]').waitFor({ + state: width <= 800 ? 'attached' : 'detached', + }) + await page.evaluate(async () => { + await Promise.all(document.getAnimations() + .filter(animation => animation.effect?.getComputedTiming().iterations !== Infinity) + .map(animation => animation.finished.catch(() => undefined))) + }) + await expect.poll(async () => { + const rect = await panel.boundingBox() + return rect !== null && rect.x >= 12 && rect.x + rect.width <= width - 12 + && rect.y >= 12 && rect.y + rect.height <= 888 + }).toBe(true) + } + await panel.getByText('System prompt', { exact: true }).click() + expect(await panel.isVisible()).toBe(true) + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'hidden' }) + await trigger.click() + await panel.waitFor() + await page.mouse.click(1260, 400) + await panel.waitFor({ state: 'hidden' }) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } + }) +}) diff --git a/apps/web/tests/desktop-updates.e2e.ts b/apps/web/tests/desktop-updates.e2e.ts new file mode 100644 index 0000000000..1e30848020 --- /dev/null +++ b/apps/web/tests/desktop-updates.e2e.ts @@ -0,0 +1,146 @@ +/** Built workspace chrome with a controlled Desktop carrier; no Electron or installer is exercised. */ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { describe, expect, it } from 'vitest' +import { presentDesktopUpdate } from '../../desktop/src/update-presentation.ts' +import { en } from '../../desktop/src/locale.ts' +import { launchWebScaffold, watchConsole } from './scaffold.ts' + +// Mirrors the preload's presentation-only API; importing Client projects would mix compiler faces. +type Presentation = ReturnType +interface CarrierFixture { + publish(state: Presentation): void + opens: number + listeners: Set<(state: Presentation) => void> +} +type FixtureWindow = Window & typeof globalThis & { updateFixture: CarrierFixture } + +describe('web e2e: Desktop update workspace chrome', () => { + it.each(['zh-CN', 'en-US'])('renders update states and routes explicit actions in %s', async (locale) => { + const scaffold = await launchWebScaffold({}) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale }) + await page.addInitScript(() => { + let current: Presentation = { phase: 'idle' } + const listeners = new Set<(state: Presentation) => void>() + const fixture: CarrierFixture = { + opens: 0, listeners, + publish(state) { current = state; for (const listener of listeners) listener(state) }, + } + Object.assign(window, { + updateFixture: fixture, + dshDesktop: { protocolVersion: 1, updates: { + status: async () => current, + open: async () => { fixture.opens += 1 }, + subscribe(listener: (state: Presentation) => void) { listeners.add(listener); return () => listeners.delete(listener) }, + } }, + }) + }) + const tripwire = watchConsole(page) + const evidenceRoot = fileURLToPath(new URL('../../desktop/.desktop-build/qualification/', import.meta.url)) + await mkdir(evidenceRoot, { recursive: true }) + const evidence = await mkdtemp(join(evidenceRoot, `workspace-updates-${locale}-`)) + try { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]') + await expect.poll(() => page.evaluate(() => (window as FixtureWindow).updateFixture.listeners.size)).toBe(1) + const availableLabel = locale === 'zh-CN' ? '新版本' : 'Update' + const retryLabel = locale === 'zh-CN' ? '重试更新' : 'Retry update' + const errorDetail = locale === 'zh-CN' ? '下载更新失败,请重试。' : 'Could not download the update. Please try again.' + const readyLabel = locale === 'zh-CN' ? '安装并重启' : 'Install and Restart' + const version = '0.1.5-nightly.20260911' + // The carrier classification deliberately uses English shell copy; Web copy follows its own locale. + const available = presentDesktopUpdate({ phase: 'available', version }, en) + const publish = async (state: Presentation) => page.evaluate((value) => { + (window as FixtureWindow).updateFixture.publish(value) + }, state) + const opens = async () => page.evaluate(() => (window as FixtureWindow).updateFixture.opens) + await publish(available) + const update = page.getByRole('button', { name: availableLabel, exact: true }) + await update.waitFor() + expect(await opens()).toBe(0) + const geometry = await update.boundingBox() + expect(geometry).not.toBeNull() + expect(geometry!.x).toBeLessThan(350) + expect(geometry!.y).toBeGreaterThan(650) + await page.screenshot({ path: join(evidence, 'available.png') }) + await update.click() + await expect.poll(opens).toBe(1) + + const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }, en) + await publish(progress) + const downloading = page.getByRole('button', { name: '58%…', exact: true }) + await downloading.waitFor() + expect(await downloading.getAttribute('aria-disabled')).toBe('true') + expect(await downloading.locator('svg').count()).toBe(1) + // Force dispatch past Playwright's aria-disabled guard to verify the product's click guard. + await downloading.click({ force: true }) + expect(await opens()).toBe(1) + await page.screenshot({ path: join(evidence, 'downloading.png') }) + + const collapse = locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar' + const expand = locale === 'zh-CN' ? '打开侧边栏' : 'Open sidebar' + await page.getByRole('button', { name: collapse, exact: true }).click() + const toggle = page.getByRole('button', { name: expand, exact: true }) + const badge = toggle.getByRole('img', { name: '58%…', exact: true }) + await badge.waitFor() + await expect.poll(() => downloading.count()).toBe(0) + expect(await page.getByRole('img', { name: '58%…', exact: true }).count()).toBe(1) + const blue = await badge.evaluate(element => getComputedStyle(element).backgroundColor) + await page.screenshot({ path: join(evidence, 'collapsed.png') }) + + const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }, en) + await publish(error) + const errorBadge = toggle.getByRole('img', { name: retryLabel, exact: true }) + await errorBadge.waitFor() + expect(await errorBadge.getAttribute('data-error')).toBe('true') + const red = await errorBadge.evaluate(element => getComputedStyle(element).backgroundColor) + expect(red).not.toBe(blue) + await errorBadge.hover() + const tooltip = page.getByRole('tooltip', { name: errorDetail, exact: true }) + await expect.poll(() => tooltip.evaluateAll(elements => elements.map(element => getComputedStyle(element).opacity))).toEqual(['1']) + await page.screenshot({ path: join(evidence, 'collapsed-error.png') }) + await toggle.click() + const retry = page.getByRole('button', { name: retryLabel, exact: true }) + await retry.waitFor() + await expect.poll(() => errorBadge.count()).toBe(0) + // The expanding ancestor can move the button after hover's element-only stability check. + await retry.evaluate(async (element) => { + const movements = document.getAnimations().filter(animation => animation.effect instanceof KeyframeEffect + && animation.effect.target instanceof Element && animation.effect.target.contains(element) + && animation.effect.getComputedTiming().endTime !== Infinity) + await Promise.all(movements.map(animation => animation.finished)) + }) + await retry.hover() + await expect.poll(() => tooltip.evaluateAll(elements => elements.map(element => getComputedStyle(element).opacity))).toEqual(['1']) + await page.screenshot({ path: join(evidence, 'retry.png') }) + await retry.click() + await expect.poll(opens).toBe(2) + + await publish(presentDesktopUpdate({ phase: 'ready', version }, en)) + const ready = page.getByRole('button', { name: readyLabel, exact: true }) + await ready.waitFor() + expect(await ready.getAttribute('aria-disabled')).toBe('false') + expect(await opens()).toBe(2) + await page.screenshot({ path: join(evidence, 'ready.png') }) + await ready.click() + await expect.poll(opens).toBe(3) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + expect(await page.evaluate(() => (window as FixtureWindow).updateFixture.listeners.size)).toBe(1) + await writeFile(join(evidence, 'result.json'), JSON.stringify({ locale, geometry, blue, red, + passed: true, explicitActions: 3, carrier: 'substituted', host: 'real Web composition', + electron: false, installerExecuted: false }, null, 2) + '\n') + console.log(`Desktop workspace chrome evidence: ${evidence}`) + } catch (error) { + await page.screenshot({ path: join(evidence, 'failure.png') }).catch(() => undefined) + throw error + } + } finally { await browser.close() } + } finally { await scaffold.close() } + }) +}) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 6f6fb83f10..0f807e3b91 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -379,9 +379,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') expect(await paneSnapshot(page)).toEqual(retainedB) await open() - await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true') + await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('false') expect(await paneSnapshot(page)).toEqual(retainedB) - await checkpoint('B restored: normal mode and Files directory state') + await checkpoint('B restored: normal mode and collapsed Files directory') await close() await select(original, 'LIGHTHOUSE') await expect.poll(() => columns(page)).toEqual(normalColumns) diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts new file mode 100644 index 0000000000..16c0e6500a --- /dev/null +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -0,0 +1,29 @@ +/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +it('persists developer tools in the Host settings document and restores the accepted choice', async () => { + const scaffold = await launchWebScaffold({ developerTools: false }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const toggle = page.getByRole('switch', { name: 'Developer tools' }) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:') + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true') + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false') +}) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 00fcf849e0..b92bb7cb94 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -1,12 +1,15 @@ -/** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +/** Keyless document-preview smoke through a real Session, Files tab, shipped renderers, and the default-application controls. */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { nativeFileManager } from '@deepseek-ai/dsh-native-command' +import { delimiter, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' -import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { realOfficeBytes } from './office-fixture.ts' +import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -18,6 +21,8 @@ const PAGE_LINES = 64 const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() +/** The stubbed opener runs as a POSIX script; Windows and WSL keep their real file associations out of the lane. */ +const STUB_OPENER = nativeFileManager() !== 'explorer' const TINY_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -29,6 +34,24 @@ async function successShot(page: Page, name: string): Promise { await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true }) } +/** Exercise native browser selection and copy, including the text overlay's canvas alignment. */ +async function copyPdfText(page: Page, preview: Locator, expected: string): Promise { + const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first() + await text.waitFor({ state: 'visible' }) + await expect.poll(() => text.evaluate(node => getComputedStyle(node).userSelect)).toBe('text') + await text.click({ clickCount: 3 }) + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(expected) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.readText()).trim())).toBe(expected) + await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => { + const canvas = node.querySelector('canvas')!.getBoundingClientRect() + const layer = node.querySelector('.textLayer')!.getBoundingClientRect() + return Math.max(Math.abs(layer.width - canvas.width), Math.abs(layer.height - canvas.height), + Math.abs(layer.left - canvas.left), Math.abs(layer.top - canvas.top)) + })).toBeLessThan(1) +} + /** Trigger the document owner's native scroll handler after a real first page overflows. */ async function scrollForNextPage(body: Locator): Promise { await body.evaluate((node) => { @@ -51,16 +74,47 @@ async function canvasColor(canvas: Locator): Promise { }) } +/** Select a workspace file through the Files tab and wait for its preview identity. */ +async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise { + await filesTab.click() + await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click() + await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) +} + describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType let outsideRoot: string | undefined + let nativeRoot: string | undefined + let openLog = '' + const opened = async (): Promise> => + (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH }) + if (STUB_OPENER) { + // Exercise the built Host through its actual OS command, replacing only the desktop application. + nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-native-')) + openLog = join(nativeRoot, 'opened.jsonl') + await writeFile(openLog, '') + const command = process.platform === 'darwin' ? 'open' : 'xdg-open' + await writeFile(join(nativeRoot, command), `#!/usr/bin/env node +const fs = require('node:fs'); +const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n'); +`, { mode: 0o700 }) + vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) + } + // The Open In rows carry the default-application controls; the SSH marker + // keeps the host's application catalog empty, so the Session-header split + // button stays off every platform while the pinned desktop serves the file controls. + scaffold = await launchWebScaffold({ + developerTools: false, replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: [PAGING_PATCH, fileURLToPath(new URL('./fixtures/native-open-on.patch.yml', import.meta.url))], + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), + }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -75,7 +129,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () try { await scaffold?.close() } finally { + vi.unstubAllEnvs() if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true }) + if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true }) } } }) @@ -85,17 +141,30 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await mkdir(SHOT_DIR, { recursive: true }) await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`) }) + expect(await page.locator('[data-slot="conversation.hero.agentPreset"] button').count()).toBe(0) + expect(scaffold.ctx.settings.get('ui-developer-tools')).toEqual({ enabled: false }) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await page.getByRole('tablist').count()).toBe(0) const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd if (cwd === undefined) throw new Error('settled Session has no workspace cwd') if (outsideRoot === undefined) throw new Error('outside fixture directory is unavailable') const outsideScript = join(outsideRoot, 'outside.js') const outsideReference = relative(cwd, outsideScript).replace(/\\/g, '/') + let previewNetworkRequests = 0 + await page.route('https://preview.invalid/developer-tools.png', async (route) => { + previewNetworkRequests += 1 + await route.fulfill({ status: 200, contentType: 'image/png', body: TINY_PNG }) + }) + const blockedRequests: string[] = [] + await page.route('https://blocked-preview.invalid/**', async (route) => { + blockedRequests.push(route.request().url()) + await route.fulfill({ status: 200, contentType: 'text/html', body: 'ESCAPED' }) + }) const markdownText = [ '# Markdown smoke', '', 'Rendered from the workspace.', '', ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(), @@ -106,12 +175,24 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () 'const tail = "CODE_TAIL";', ] await Promise.all([ + writeFile(join(cwd, 'hostile.html'), ` + + +

    Static adversarial preview

    + Plain link +
    + SVG link + + x +
    +

    Static

    + + LinkSVG + `)) + const parsed = new DOMParser().parseFromString(html, 'text/html') + expect(parsed.head.firstElementChild?.getAttribute('http-equiv')).toBe('Content-Security-Policy') + expect(parsed.querySelector('meta')?.getAttribute('content')).toContain("connect-src 'none'") + expect(parsed.querySelectorAll('script, iframe, object, embed, base, a[href], a[xlink\\:href]')).toHaveLength(0) + expect(parsed.querySelectorAll('meta[http-equiv]')).toHaveLength(1) + expect(parsed.querySelector('p')?.textContent).toBe('Static') + expect(parsed.querySelector('style')?.textContent).toContain('color: red') + expect(parsed.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AA==') +}) + +it('preserves document styling and removes navigation inside nested templates and SVG animation', () => { + const html = createBasicHtmlDocument(new TextEncoder().encode(` + +
    + + `)) + const parsed = new DOMParser().parseFromString(html, 'text/html') + expect(parsed.documentElement.lang).toBe('zh') + expect(parsed.documentElement.className).toBe('dark') + expect(parsed.body.style.margin).toBe('0px') + expect(parsed.body.dir).toBe('rtl') + expect(parsed.querySelectorAll('noscript, link, set, animate')).toHaveLength(0) + const nested = parsed.querySelector('template')!.content.querySelector('template')!.content + expect(nested.querySelector('a')!.hasAttribute('href')).toBe(false) + expect(nested.querySelector('iframe')).toBeNull() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts new file mode 100644 index 0000000000..05e14300b5 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts @@ -0,0 +1,29 @@ +/** Host configuration supplies bounded Office reuse settings to browser pages. */ +import { Context } from '@deepseek-ai/cordis' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { expect, it, onTestFinished } from 'vitest' +import { Config } from '../src/config.ts' +import * as host from '../src/index.ts' + +it('exposes only Client reuse limits on the Host entry', () => { + expect(host.Config).toBe(Config) + expect(Config({})).toEqual({ office: { + maxCachedEntries: 8, maxCachedBytes: 64 * 1024 * 1024, maxPending: 8, maxReaders: 32, + } }) + for (const key of ['maxCachedEntries', 'maxCachedBytes']) expect(() => Config({ office: { [key]: 0 } })).toThrow() +}) + +it('embeds YAML cache settings in browser pages and withdraws them on disposal', async () => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const config = Config({ office: { maxCachedEntries: 2 } }) + const fiber = ctx.plugin(host, config) + await fiber.await() + const rows: IndexInjection[] = [] + ctx.emit('webserver/index-inject', rows) + expect(rows).toEqual([{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config }]) + await fiber.dispose() + const after: IndexInjection[] = [] + ctx.emit('webserver/index-inject', after) + expect(after).toEqual([]) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts new file mode 100644 index 0000000000..209f8f8970 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts @@ -0,0 +1,31 @@ +import { spawnSync } from 'node:child_process' +import { expect, it } from 'vitest' +import { documentFileBytes } from '../src/client/rpc.ts' + +const source = { absolutePath: '/report.pdf', version: 'v1', offset: 0, eof: true, missingFonts: ['Missing Serif'] } + +it('preserves every byte value, source metadata, font notices, padding, and empty input', () => { + for (const size of [0, 1, 2, 256, 257]) { + const input = Uint8Array.from({ length: size }, (_, index) => index % 256) + expect(documentFileBytes({ ...source, data: Buffer.from(input).toString('base64') })) + .toEqual({ ...source, data: input }) + } + expect(() => documentFileBytes({ ...source, data: '!invalid!' })).toThrow() +}) + +it('decodes a large response within a bounded JavaScript heap', () => { + // Typed buffers fit this heap; expanding the binary string into JS elements does not. + const decoder = new URL('../src/client/rpc.ts', import.meta.url).href + const child = spawnSync(process.execPath, ['--max-old-space-size=96', '--import', 'tsx/esm', '--input-type=module', '-e', ` + import { documentFileBytes } from ${JSON.stringify(decoder)} + const input = Buffer.alloc(12 * 1024 * 1024) + for (let index = 0; index < input.length; index++) input[index] = index % 256 + const output = documentFileBytes({ ...${JSON.stringify(source)}, data: input.toString('base64') }).data + if (!Buffer.from(output).equals(input)) throw new Error('Decoded PDF bytes differ') + process.stdout.write(String(output.length)) + `], { encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024, env: { ...process.env, NODE_OPTIONS: '' } }) + expect(child.error).toBeUndefined() + expect(child.signal, child.stderr).toBeNull() + expect(child.status, child.stderr).toBe(0) + expect(child.stdout).toBe(String(12 * 1024 * 1024)) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index adc99e5c51..d225f3cbd5 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom /** Document extension registration and dispatch through the production Sidebar and Slot renderer. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -36,6 +37,7 @@ afterEach(async () => { async function boot() { const rt = await SlotTestRuntime.create() runtime = rt + rt.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never) rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) const locale = new LocaleRuntime(rt.ctx) rt.ctx.provide('locale', locale) @@ -45,6 +47,7 @@ async function boot() { 'conversation.session.header.corner': { kind: 'single', scope: 'session' }, }) await rt.sessions.add({ id: SESSION }) + await rt.sessions.retainFor(rt.ctx, SESSION, { source: 'mainView' }).ready await rt.mount({ inject: [...resourcesInject], apply: resourcesApply }) const read = vi.fn().mockImplementation(async (_sessionId, _path, range) => ({ ok: true, @@ -54,8 +57,7 @@ async function boot() { ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true }, }) const workspaceFiles = { read, readAll: bytes } - rt.ctx.provide('remote', { workspaceFiles } as never) - rt.ctx.provide('remote.workspaceFiles', workspaceFiles as never) + rt.remote.provideNamespaces({ workspaceFiles }) rt.ctx.effect(() => rt.ctx.resources.register({ protocol: 'file', open: async function* (_address, { signal }) { @@ -83,7 +85,7 @@ async function boot() { data-renderer={id} data-renderer-tab={tab.id} data-renderer-path={resource.value?.absolutePath} data-renderer-version={resource.value?.version} > - {props.content.kind === 'text' ? props.content.text : new TextDecoder().decode(props.content.data)} + {props.content.kind === 'text' ? props.content.text : props.content.kind === 'bytes' ? new TextDecoder().decode(props.content.data) : 'renderer'} ) }, diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx index 0fa5189b44..0b4a14fb4c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx @@ -7,8 +7,11 @@ import { TextPreview } from '../src/client/TextPreview.tsx' import type { TextPreviewProps } from '../src/client/TextPreview.tsx' import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' -import { PLAIN_BODY_ID } from '../src/client/text/index.ts' -import { ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts' +import { textBodyDefinition, PLAIN_BODY_ID } from '../src/client/text/index.ts' +import { pdfBodyDefinition } from '../src/client/pdf/index.ts' +import { imageBodyDefinition } from '../src/client/image/index.ts' +import { htmlBodyDefinition } from '../src/client/html/index.ts' +import { documentSlots, ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts' afterEach(cleanup) @@ -24,12 +27,56 @@ function codeProps(h: ReturnType): TextPreviewProps { return { ...props, useDocumentPreviews: selector => selector([definition]), - // This adapter only receives the concrete document slot, not an arbitrary generic key. - renderSlot: (_key, owner) => } t={key => key} />, + renderSlot: documentSlots((_key, owner) => } t={key => key} />), } } describe('document toolbar', () => { + it.each([ + ['report.doc', false], ['sheet.xls', false], ['slides.ppt', false], + ['report.docx', false], ['sheet.xlsx', false], ['slides.pptx', false], + ['report.pdf', false], ['image.png', false], ['image.SVG', true], ['page.html', true], + ])('offers plain text for %s only when supported', async (path, supportsText) => { + const h = harness() + const props = h.props() + const info = props.useTabInfo() + const definitions = [ + textBodyDefinition(() => 'Plain text'), pdfBodyDefinition(() => 'PDF'), + imageBodyDefinition(() => 'Image'), htmlBodyDefinition(() => 'HTML'), + { ...binary, extensions: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'], binaryExtensions: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'] }, + ] + h.bytes.mockResolvedValue({ ok: true, value: { + absolutePath: path, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true, + } }) + if (!supportsText) h.instance.actions.selected(TAB_ID, PLAIN_BODY_ID) + const view = render( ({ ...info, tab: { ...info.tab, contentId: `dsh-resource://file/session/s-1/${path}` } })} + useDocumentPreviews={selector => selector(definitions)} + renderSlot={() => null} + />) + await settle() + const picker = view.queryByRole('button', { name: 'openWith' }) + expect(picker !== null).toBe(supportsText) + expect(h.read).not.toHaveBeenCalled() + expect(h.bytes).toHaveBeenCalledTimes(1) + if (picker !== null) { + fireEvent.click(picker) + expect(screen.getByRole('menuitem', { name: 'Plain text' })).toBeDefined() + } else { + expect(view.container.textContent).not.toContain('Plain text') + expect(view.queryByRole('button', { name: 'wrap.aria' })).toBeNull() + } + }) + + it('shows no implementation picker when plain text is the only viewer', async () => { + const h = harness({ 1: page(1, ['held'], true) }) + const view = render() + await settle() + expect(view.queryByRole('button', { name: 'openWith' })).toBeNull() + expect(view.container.textContent).toContain('held') + }) + it('keeps Reading above the first code page and reload, retaining code during pagination', async () => { const h = harness() const first = Promise.withResolvers>>() @@ -97,35 +144,51 @@ describe('document toolbar', () => { it('shows the shared loading indicator until a complete read settles', async () => { const h = harness() const pending = Promise.withResolvers>>() - const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } } + const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true } } onTestFinished(async () => { h.controller.abort() pending.resolve(result) await pending.promise }) h.bytes.mockReturnValueOnce(pending.promise) - const renderSlot = vi.fn(() => null) + const renderSlot = vi.fn((_key: string) => null) + const documentRenders = (): number => renderSlot.mock.calls.filter(call => call[0] === 'sidebar.right.tab.document').length const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot } const view = render() expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true) expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading') expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status')) - expect(renderSlot).not.toHaveBeenCalled() + expect(documentRenders()).toBe(0) await act(async () => { pending.resolve(result) await pending.promise }) - expect(renderSlot).toHaveBeenCalled() + expect(documentRenders()).toBeGreaterThan(0) expect(view.queryByRole('status')).toBeNull() }) - it('retries and refreshes complete content and uses the read result path before metadata arrives', async () => { + it('mounts a renderer-owned body before content is available and withholds the previous pages', async () => { + const h = harness({ 1: page(1, ['previous reader content'], true) }) + const view = render() + await settle() + const renderSlot = vi.fn(() => null) + view.rerender( selector([{ ...binary, loading: 'renderer' }])} renderSlot={renderSlot} />) + expect(view.container.textContent).not.toContain('previous reader content') + expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.document', expect.objectContaining({ + content: expect.objectContaining({ kind: 'renderer' }) as unknown, + }), expect.any(Object)) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined() + expect(h.bytes).not.toHaveBeenCalled() + }) + + it('retries and refreshes complete content when metadata lookup fails', async () => { const h = harness() - const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } } + const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true } } const read = h.bytes.mockResolvedValueOnce({ - ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }), + ok: false, error: new RemoteError('gateway/internal', 'socket closed', {}), }).mockResolvedValue(result) - h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined }) + h.useResource.mockReturnValue({ status: 'failed', value: undefined, + failure: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }) }) const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), } @@ -141,7 +204,7 @@ describe('document toolbar', () => { fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!) await settle() expect(read).toHaveBeenCalledTimes(3) - expect(read).toHaveBeenLastCalledWith(FILE, h.controller.signal) + expect(read).toHaveBeenLastCalledWith(FILE, expect.any(AbortSignal)) h.controller.abort() }) @@ -160,7 +223,7 @@ describe('document toolbar', () => { it('drops the plain-text fallback for a declared binary suffix and hides the viewer control entirely', async () => { const h = harness() - h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/photo.png', version: 'v1', offset: 0, data: btoa('x'), bytes: 1, eof: true } }) + h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/photo.png', version: 'v1', offset: 0, data: new TextEncoder().encode('x'), bytes: 1, eof: true } }) const image: DocumentPreviewDefinition = { id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete', } @@ -185,7 +248,7 @@ describe('document toolbar', () => { it('keeps the plain-text fallback in the picker for a non-binary suffix of the same viewer', async () => { const h = harness() - h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/logo.svg', version: 'v1', offset: 0, data: btoa(''), bytes: 6, eof: true } }) + h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/logo.svg', version: 'v1', offset: 0, data: new TextEncoder().encode(''), bytes: 6, eof: true } }) const image: DocumentPreviewDefinition = { id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete', } @@ -211,15 +274,10 @@ describe('document toolbar', () => { it('dismisses the implementation picker with Escape without changing the selected implementation', async () => { const h = harness({ 1: page(1, ['held'], true) }) - const base = h.props() - const code: DocumentPreviewDefinition = { - id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true, - } - const plain: DocumentPreviewDefinition = { - id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true, - } - const props: TextPreviewProps = { ...base, useDocumentPreviews: selector => selector([code, plain]) } - const view = render() + const props = codeProps(h) + const view = render( selector([ + ...props.useDocumentPreviews(value => value), textBodyDefinition(() => 'viewer.text'), + ])} />) await settle() fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!) expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined() @@ -245,6 +303,11 @@ describe('document toolbar', () => { expect(view.container.querySelector('[data-textpreview-path]')).not.toBeNull() expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull() expect(view.container.querySelector('[data-textpreview-tool="reload"]')).toBeNull() + // Both handoff seats receive the file once its Host path is known. + const header = view.container.querySelector('[data-textpreview-path]')?.parentElement + expect(header?.querySelector('[data-slot="sidebar.right.tab.document.actions"]')?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + const empty = view.container.querySelector('[data-textpreview-unsupported]') + expect(empty?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) expect(h.read).not.toHaveBeenCalled() expect(h.bytes).not.toHaveBeenCalled() h.controller.abort() diff --git a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts index 08ac54b9d2..9a728c0844 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' -import type { WorkspaceFileBytes, WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types' +import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types' import { textFace } from '../src/client/face.ts' import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage } from '../src/client/rpc.ts' import { hostFileOf } from '../src/client/rpc.ts' @@ -81,7 +81,7 @@ function bench(sessionId = 'other-session' as SessionId) { pending.push({ offset, ...deferred }) return deferred.promise }) - const whole = readQueue() + const whole = readQueue() let sequence = 0 const bytes = vi.fn(() => whole.request(++sequence)) const controller = new AbortController() @@ -105,10 +105,7 @@ function bench(sessionId = 'other-session' as SessionId) { } return { instance, read, face, forget, settle, bytes, controller, - settleAll: (result: RemoteResult, key?: number) => whole.settle(result.ok - ? { ok: true, value: { ...result.value, data: btoa(String.fromCharCode(...result.value.data)) } } - : result, key), - settleAllWire: whole.settle, + settleAll: whole.settle, outstandingAll: whole.outstanding, outstanding: () => pending.map(call => call.offset), tab: () => instance.getSnapshot().byTab[TAB_1], @@ -216,11 +213,11 @@ describe('textFace', () => { await settle(page(1, ['A'], true, 'v2')) expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: true }) }) - it('loads native complete bytes with only the tab signal', async () => { + it('loads native complete bytes with cancellable read lifetime', async () => { const { face, read, bytes, settleAll, tab, controller } = bench() const result = complete() face.loadAll(TAB_1, FILE, controller.signal) - expect(bytes).toHaveBeenCalledExactlyOnceWith(FILE, controller.signal) + expect(bytes).toHaveBeenCalledExactlyOnceWith(FILE, expect.any(AbortSignal)) expect(read).not.toHaveBeenCalled() expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: true, pages: {}, failure: undefined }) expect(tab()?.complete).toBeUndefined() @@ -240,20 +237,6 @@ describe('textFace', () => { expect(tab()).toMatchObject({ loading: false, failure: undefined, complete: complete().value }) }) - it('records malformed complete-byte wire data as a failed read', async () => { - const { face, settleAllWire, tab, controller } = bench() - face.loadAll(TAB_1, FILE, controller.signal) - await settleAllWire({ - ok: true, - value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: '!!!', eof: true, bytes: 3 }, - }) - expect(tab()).toMatchObject({ - mode: 'bytes-complete', loading: false, version: undefined, - failure: { code: 'gateway/internal', message: 'document file byte response has malformed base64 data' }, - }) - expect(tab()?.complete).toBeUndefined() - }) - it('reloads complete bytes, discarding the old result and preserving the view', async () => { const { instance, face, bytes, settleAll, tab, controller } = bench() face.loadAll(TAB_1, FILE, controller.signal) @@ -264,7 +247,7 @@ describe('textFace', () => { instance.actions.navigated(TAB_1, 3) face.reloadAll(TAB_1, FILE, controller.signal) expect(bytes).toHaveBeenCalledTimes(2) - expect(bytes).toHaveBeenLastCalledWith(FILE, controller.signal) + expect(bytes).toHaveBeenLastCalledWith(FILE, expect.any(AbortSignal)) expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: true, version: undefined, eof: false, pages: {} }) expect(tab()?.complete).toBeUndefined() const result = complete('v2', new Uint8Array([2, 3, 255])) @@ -406,11 +389,36 @@ describe('textFace', () => { await second.settle(page(1, ['second'], true)) first.face.loadAll(TAB_1, firstFile, first.controller.signal) second.face.reloadAll(TAB_1, secondFile, second.controller.signal) - expect(first.bytes).toHaveBeenCalledExactlyOnceWith(firstFile, first.controller.signal) - expect(second.bytes).toHaveBeenCalledExactlyOnceWith(secondFile, second.controller.signal) + expect(first.bytes).toHaveBeenCalledExactlyOnceWith(firstFile, expect.any(AbortSignal)) + expect(second.bytes).toHaveBeenCalledExactlyOnceWith(secondFile, expect.any(AbortSignal)) await first.settleAll(complete('v1')) await second.settleAll(complete('v2')) expect(first.tab()?.version).toBe('v1') expect(second.tab()?.version).toBe('v2') }) }) + +it.each([new Error('invalid bytes'), 'invalid bytes'])('reports an active complete-read decoding failure: %s', async (failure) => { + const instance = createTextStore().create() + const controller = new AbortController() + const read = vi.fn().mockRejectedValue(failure) + try { + textFace(vi.fn(), read)(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal) + await Promise.resolve() + expect(instance.getSnapshot().byTab[TAB_1]?.failure).toMatchObject({ code: 'gateway/internal', message: 'invalid bytes' }) + } finally { controller.abort() } +}) + +it('ignores a complete-read rejection after renderer-owned loading takes over', async () => { + const instance = createTextStore().create() + const controller = new AbortController() + const pending = Promise.withResolvers>>() + const face = textFace(vi.fn(), () => pending.promise)(SESSION, instance.actions) + try { + face.loadAll(TAB_1, FILE, controller.signal) + face.prepareRenderer(TAB_1, controller.signal, 'office') + pending.reject(new Error('retired')) + await pending.promise.catch(() => {}) + expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBeUndefined() + } finally { controller.abort() } +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts index c43804d8ef..134cf3e822 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts @@ -6,7 +6,7 @@ import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' // The namespace declaration `TranslateNS<'sidebarDocumentPreview'>` resolves against. import type {} from '../src/client/index.ts' -import { failureLine } from '../src/client/failure-line.ts' +import { emptyFailureRecourse, failureLine } from '../src/client/failure-line.ts' /** Key-echoing translate that also shows its parameters, so a formatted value is visible. */ const t: TranslateNS<'sidebarDocumentPreview'> = (key, params) => @@ -33,3 +33,20 @@ describe('failureLine', () => { expect(failureLine(t, failure('gateway/internal', {}, 'socket closed'))).toBe('error.unavailable(message=socket closed)') }) }) + +describe('emptyFailureRecourse', () => { + it('hands a readable file the preview cannot render to the Host', () => { + expect(emptyFailureRecourse(failure('workspace-file/not-text'))).toBe('open') + expect(emptyFailureRecourse(failure('workspace-file/too-large', { limit: 512 }))).toBe('open') + }) + + it('offers nothing for a path with nothing to show or open', () => { + expect(emptyFailureRecourse(failure('workspace-file/not-found'))).toBe('none') + expect(emptyFailureRecourse(failure('workspace-file/not-regular-file'))).toBe('none') + }) + + it('retries carrier and unclassified failures', () => { + expect(emptyFailureRecourse(failure('gateway/internal'))).toBe('retry') + expect(emptyFailureRecourse(failure('someday/a-new-code'))).toBe('retry') + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts index 200626232c..bbc9c2dea6 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts @@ -8,6 +8,7 @@ * not the slot runtime. */ import { onTestFinished, vi } from 'vitest' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { Mock } from 'vitest' import { act } from '@testing-library/react' import { createElement, useSyncExternalStore } from 'react' @@ -26,6 +27,15 @@ import { TextBody } from '../src/client/text/TextBody.tsx' import { textBodyDefinition } from '../src/client/text/index.ts' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot'] + +/** Preserve the body-slot callback used by component fixtures. */ +export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { + return (name: string, owner: unknown, options?: unknown) => name === 'sidebar.right.tab.document' + ? body(name, owner as Parameters[1], options as Parameters[2]) + : null +} + export const TAB_ID = 'tab-1' as TabId export const SESSION = 's-1' as SessionId /** The path relative to the session's workspace root, as the Host receives it. */ @@ -122,9 +132,17 @@ export function harness(script: Record> onTestFinished(() => { controller.abort() }) const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() } const definitions = [textBodyDefinition(() => t('viewer.text'))] - const renderSlot: TextPreviewProps['renderSlot'] = (_key, owner, opts) => createElement(TextBody, { - ...owner, useTabInfo: opts.hookContext, sessionId: SESSION, useResource, - } as unknown as DocumentPreviewProps) + // The document seat renders the plain body; the file-handoff seats render a + // marker carrying what the owner handed them. + const renderSlot: TextPreviewProps['renderSlot'] = (key: string, owner: unknown, opts?: { hookContext?: unknown }) => + key === 'sidebar.right.tab.document' + ? createElement(TextBody, { + ...owner as object, useTabInfo: opts?.hookContext, sessionId: SESSION, useResource, + } as unknown as DocumentPreviewProps) + : key === 'sidebar.right.tab.document.action' ? null : createElement('div', { + 'data-slot': key, + 'data-slot-path': (owner as { absolutePath: string }).absolutePath, + }) const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({ useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, @@ -142,7 +160,7 @@ export function harness(script: Record> actions: instance.actions, loadPage: face.loadPage, reloadPages: face.reloadPages, - loadAll: face.loadAll, + prepareRenderer: face.prepareRenderer, loadAll: face.loadAll, reloadAll: face.reloadAll, useDocumentPreviews: () => definitions, renderSlot, diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml b/packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml new file mode 100644 index 0000000000..78eef38eb0 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml @@ -0,0 +1,4 @@ +- id: ui-sidebar-documentpreview + config: + office: + maxCachedEntries: 2 diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts index 06cd2f7729..bcf14b5c6a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts @@ -1,4 +1,5 @@ /** HTML metadata and keyed slot contributions share one identity and unwind with their fiber. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' @@ -21,7 +22,7 @@ afterEach(async () => { await dispose?.(); dispose = undefined }) describe('HTML registration', () => { it('claims HTML suffixes as a builtin complete-byte renderer without wrap', () => { const title = vi.fn(() => 'localized HTML') - expect(htmlBodyDefinition(title)).toEqual({ + expect(htmlBodyDefinition(title)).toMatchObject({ id: HTML_BODY_ID, extensions: ['html', 'htm'], priority: 'builtin', title, loading: 'bytes-complete', wrap: false, }) expect(title).not.toHaveBeenCalled() @@ -30,6 +31,7 @@ describe('HTML registration', () => { it('registers its dictionary and matching keyed body, and removes all contributions on disposal', async () => { const ctx = new Context() + ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never) // No Session or Tab services are mounted; the global callback must use its file address. const registry = new DocumentPreviewRegistry() const dictionaries = new Map() @@ -46,7 +48,7 @@ describe('HTML registration', () => { bind: () => (key: keyof typeof en) => en[key], register: (name: string, value: unknown) => { dictionaries.set(name, value); return () => { dictionaries.delete(name) } }, } as never) - const fiber = ctx.plugin({ apply }) + const fiber = ctx.plugin({ inject: ['settingsScope'], apply }) dispose = async () => { await fiber.dispose() } await fiber.await() expect(registry.candidates('INDEX.HTM').map(entry => entry.id)).toEqual([HTML_BODY_ID]) diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx index 78eca6a5f2..d5c36bbb4e 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx @@ -34,6 +34,7 @@ afterEach(() => { function props(text = '

    hello

    '): HtmlBodyProps { const signal = new AbortController().signal return { + useInteractivePreview: select => select(true), resourceAddress: 'dsh-resource://file/session/html/index.html', content: { kind: 'bytes', data: utf8(text) }, wrap: false, @@ -48,6 +49,28 @@ function props(text = '

    hello

    '): HtmlBodyProps { const utf8 = (text: string): Uint8Array => new TextEncoder().encode(text) describe('HtmlBody', () => { + it('renders static HTML without reading related files, running scripts or retaining an advanced frame', async () => { + const initial = props('

    Preview

    Static content

    ') + const basic = { ...initial, useInteractivePreview: ((select: (enabled: boolean) => unknown) => select(false)) as HtmlBodyProps['useInteractivePreview'] } + const view = render() + const frame = screen.getByTitle(en.frame) + expect(frame.getAttribute('sandbox')).toBe('') + expect(frame.getAttribute('srcdoc')).toContain("default-src 'none'") + expect(frame.getAttribute('srcdoc')).not.toContain('Advanced

    ') + view.rerender() + const advanced = await screen.findByTitle(en.frame) + expect(advanced).not.toBe(frame) + expect(advanced.getAttribute('sandbox')).toBe('allow-scripts') + view.rerender() + expect(advanced.isConnected).toBe(false) + expect(revoke).toHaveBeenCalledOnce() + view.rerender() + expect(screen.getByRole('alert').textContent).toBe(en.failed) + }) + it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => { const initial = props() const view = render() diff --git a/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts index af99142277..79aad979b3 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts @@ -13,7 +13,7 @@ describe('image registration', () => { it('claims common image suffixes as a builtin complete-byte renderer without wrap', () => { const title = vi.fn(() => 'localized image') const definition = imageBodyDefinition(title) - expect(definition).toEqual({ + expect(definition).toMatchObject({ id: IMAGE_BODY_ID, extensions: IMAGE_EXTENSIONS, binaryExtensions: BINARY_IMAGE_EXTENSIONS, diff --git a/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx index 7ffc7aa826..aaf45a8a59 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx @@ -12,5 +12,6 @@ describe('LoadingIndicator', () => { const status = getByRole('status') expect(status.textContent).toBe('') expect(status.getAttribute('aria-label')).toBe('Reading…') + expect(status.querySelector('[data-state="ongoing"]')).not.toBeNull() }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx index 582fdb8220..4e328a1977 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx @@ -5,6 +5,7 @@ import { act } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { documentTabInfoFactory } from '../src/client/document/contract.ts' import { apply, MARKDOWN_BODY_ID, markdownDefinition } from '../src/client/markdown/index.ts' @@ -42,7 +43,10 @@ describe('Markdown implementation registration', () => { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) locale.setLocale('en') - await runtime.sessions.add({ id: 'markdown-registration' }) + const sessionId = SessionId('markdown-registration') + await runtime.sessions.add({ id: sessionId }) + const reference = runtime.sessions.retainFor(runtime.ctx, sessionId) + await reference.ready const feature = await runtime.mount({ inject: ['slots', 'locale', 'documentPreviews'], apply }) expect(previews.getSnapshot().map(definition => definition.id)).toEqual([MARKDOWN_BODY_ID]) const useTabInfo = vi.fn(() => { throw new Error('Markdown rendering does not need tab actions') }) @@ -50,12 +54,20 @@ describe('Markdown implementation registration', () => { 'sidebar.right.tab.document': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } }, }, - }, ({ renderSlot }) => renderSlot('sidebar.right.tab.document', { - resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md', - content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true }, - wrap: false, - scrollportRef: vi.fn(), - }, { entryKey: MARKDOWN_BODY_ID, hookContext: useTabInfo, fallback: })) + }, ({ renderSlot, SessionProvider }) => ( + + {renderSlot('sidebar.right.tab.document', { + resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md', + content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true }, + wrap: false, + scrollportRef: vi.fn(), + }, { + entryKey: MARKDOWN_BODY_ID, + hookContext: useTabInfo, + fallback: , + })} + + )) const view = runtime.renderRoot() expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined() expect(view.getByRole('button', { name: 'Copy' })).toBeDefined() diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts new file mode 100644 index 0000000000..0dc71e9cd1 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts @@ -0,0 +1,395 @@ +/** Controlled conversion completions pin shared cancellation, freshness, and bounded binary reuse. */ +import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionFile } from '../src/client/rpc.ts' +import type { ReadOfficeDocument } from '../src/client/office/cache.ts' +import { OfficePreviewCache } from '../src/client/office/cache.ts' + +const file = { sessionId: 's1' as SessionId, path: 'report.docx' } +const result = (version = 'v1', text = 'pdf!', generation = 'renderer'): Awaited> => ({ + ok: true, value: { absolutePath: '/report.docx', version, offset: 0, eof: true, missingFonts: [], data: new TextEncoder().encode(text), generation: (generation as OfficeToPdfGeneration) }, +}) +function harness(entries = 2, bytes = 32, pending = 8, readers = 32) { + const stat = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } }) + const convert = vi.fn().mockResolvedValue(result()) + const generation = vi.fn().mockResolvedValue({ ok: true, value: ('renderer' as OfficeToPdfGeneration) }) + const cache = new OfficePreviewCache(stat, convert, entries, bytes, pending, readers, generation, () => new Error('busy')) + return { stat, convert, cache, generation, read: (signal = new AbortController().signal) => cache.read(file, signal) } +} + +it('shares a pending conversion between readers and reauthorizes cached reads', async () => { + const h = harness() + const started = Promise.withResolvers() + const completed = Promise.withResolvers>>() + h.convert.mockImplementation((_file, signal) => { started.resolve(signal); return completed.promise }) + const background = new AbortController() + const first = h.read(background.signal) + const aborted = expect(first).rejects.toMatchObject({ name: 'AbortError' }) + const conversionSignal = await started.promise + const second = h.read() + await vi.waitFor(() => { expect(h.stat).toHaveBeenCalledTimes(2) }) + background.abort() + await aborted + expect(conversionSignal.aborted).toBe(false) + completed.resolve(result()) + expect(await second).toEqual(result()) + expect(await h.read()).toEqual(result()) + expect(h.convert).toHaveBeenCalledOnce() + expect(h.stat).toHaveBeenCalledTimes(3) + await h.cache.dispose() +}) + +it('rejects stale or unauthorized reuse and never caches a declared conversion failure', async () => { + const h = harness() + await h.read() + h.stat.mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v2' } }) + const failure = { ok: false, error: { message: 'failed' } } as Awaited> + h.convert.mockResolvedValueOnce(failure).mockResolvedValue(result('v2')) + expect(await h.read()).toEqual(failure) + expect(await h.read()).toEqual(result('v2')) + h.stat.mockResolvedValue(failure) + expect(await h.read()).toEqual(failure) + expect(h.convert).toHaveBeenCalledTimes(3) + await h.cache.dispose() +}) + +it('discards a cancelled conversion that finishes late without replacing a newer result', async () => { + const h = harness() + const started = Promise.withResolvers() + const late = Promise.withResolvers>>() + h.convert.mockImplementationOnce(() => { started.resolve(undefined); return late.promise }) + const caller = new AbortController() + const pending = h.read(caller.signal) + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + await started.promise + caller.abort() + await rejected + h.convert.mockResolvedValue(result('v1', 'new')) + await h.read() + late.resolve(result('v1', 'old')) + await late.promise + expect(await h.read()).toEqual(result('v1', 'new')) + await h.cache.dispose() +}) + +it('bounds retained entries and byte storage, and separates Session authorization scopes', async () => { + const h = harness(1, 8) + await h.read() + await h.cache.read({ ...file, sessionId: 's2' as SessionId }, new AbortController().signal) + await h.read() + expect(h.convert).toHaveBeenCalledTimes(3) + h.stat.mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'large' } }) + h.convert.mockResolvedValue(result('large', 'x'.repeat(10))) + await h.read() + await h.read() + expect(h.convert).toHaveBeenCalledTimes(5) + await h.cache.dispose() +}) + +it('does not retain a conversion whose source changed during conversion', async () => { + const h = harness() + h.convert.mockResolvedValue(result('v2')) + await h.read() + await h.read() + expect(h.convert).toHaveBeenCalledTimes(2) + await h.cache.dispose() +}) + +it('reuses the borrowed binary PDF at the exact byte budget without copying', async () => { + const h = harness(2, 4) + const converted = result() + h.convert.mockResolvedValue(converted) + try { + expect(await h.read()).toBe(converted) + const cached = await h.read() + expect(cached).toBe(converted) + if (!cached.ok || !converted.ok) throw new Error('Expected PDF bytes') + expect(cached.value.data).toBe(converted.value.data) + expect(cached.value.data.byteLength).toBe(4) + expect(h.convert).toHaveBeenCalledOnce() + } finally { await h.cache.dispose() } +}) + +it('does not retain a different canonical source even when its version token matches', async () => { + const h = harness() + h.convert.mockResolvedValue({ ok: true, value: { + absolutePath: '/replacement.docx', version: 'v1', offset: 0, eof: true, missingFonts: [], generation: 'renderer' as OfficeToPdfGeneration, data: new Uint8Array([1]), + } }) + try { + await h.read() + await h.read() + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { await h.cache.dispose() } +}) + +it('cancels before source authorization and after a delayed stat without starting conversion', async () => { + const h = harness() + try { + await expect(h.read(AbortSignal.abort())).rejects.toMatchObject({ name: 'AbortError' }) + expect(h.stat).not.toHaveBeenCalled() + const metadata = Promise.withResolvers>>() + h.stat.mockReturnValue(metadata.promise) + const caller = new AbortController() + const pending = h.read(caller.signal) + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + caller.abort() + metadata.resolve({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } }) + await rejected + expect(h.convert).not.toHaveBeenCalled() + } finally { await h.cache.dispose() } +}) + +it.each([new Error('transport'), 'transport'])('retries rejected conversions (%s) and waits for cancelled Host work when disposed', async (failure) => { + const h = harness() + const started = Promise.withResolvers() + const completed = Promise.withResolvers>>() + try { + h.convert.mockRejectedValueOnce(failure) + await expect(h.read()).rejects.toMatchObject(failure instanceof Error ? { message: 'transport' } : { + message: 'Office preview failed', cause: failure, + }) + h.convert.mockImplementation((_file, signal) => { started.resolve(signal); return completed.promise }) + const pending = h.read() + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await started.promise + let disposed = false + const disposing = h.cache.dispose().then(() => { disposed = true }) + await rejected + expect(signal.aborted).toBe(true) + expect(disposed).toBe(false) + completed.resolve(result()) + await disposing + await expect(h.read()).rejects.toMatchObject({ name: 'AbortError' }) + } finally { + completed.resolve(result()) + await h.cache.dispose() + } +}) + +it('preserves an AbortSignal cancellation reason while waiting for the shared conversion to settle', async () => { + const h = harness() + const caller = new AbortController() + const started = Promise.withResolvers() + const completion = Promise.withResolvers>>() + h.convert.mockImplementation(() => { started.resolve(undefined); return completion.promise }) + const pending = h.read(caller.signal) + const rejected = expect(pending).rejects.toMatchObject({ message: 'Office preview cancelled', cause: 'left document' }) + try { + await started.promise + caller.abort('left document') + await rejected + } finally { + completion.resolve(result()) + await h.cache.dispose() + } +}) + +it('clears Client reuse when the Host renderer generation changes', async () => { + const h = harness() + try { + await h.read() + h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) }) + h.convert.mockResolvedValue(result('v1', 'pdf!', 'replacement')) + await h.read() + expect(h.convert).toHaveBeenCalledTimes(2) + await h.read() + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { await h.cache.dispose() } +}) + +it('bounds metadata readers and unsettled conversion RPCs including cancellation teardown', async () => { + const h = harness(2, 32, 1, 2) + const entered = Promise.withResolvers(), completed = Promise.withResolvers>>() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise }) + const caller = new AbortController() + const first = expect(h.read(caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + await entered.promise + caller.abort() + await first + try { + await expect(h.read()).rejects.toThrow('busy') + expect(h.convert).toHaveBeenCalledOnce() + } finally { completed.resolve(result()); await h.cache.dispose() } +}) + +it('sends foreground intent to the Host when joining an in-flight prewarm', async () => { + const h = harness() + const entered = Promise.withResolvers(), completed = Promise.withResolvers>>() + h.convert.mockImplementation(() => { entered.resolve(undefined); return completed.promise }) + const prewarm = h.cache.read(file, new AbortController().signal, 'background') + await entered.promise + const foreground = h.cache.read(file, new AbortController().signal, 'foreground') + try { + await vi.waitFor(() => { expect(h.convert).toHaveBeenCalledTimes(2) }) + expect(h.convert.mock.calls.map(call => call[2])).toEqual(['background', 'foreground']) + } finally { completed.resolve(result()); await Promise.all([prewarm, foreground]); await h.cache.dispose() } +}) + +it('counts unresolved renderer metadata against the reader limit and joins it on disposal', async () => { + const h = harness(2, 32, 8, 1) + const completed = Promise.withResolvers<{ ok: true; value: ReturnType }>() + h.generation.mockReturnValueOnce(completed.promise) + const first = expect(h.read()).rejects.toMatchObject({ name: 'AbortError' }) + await expect(h.read()).rejects.toThrow('busy') + expect(h.generation).toHaveBeenCalledOnce() + let disposed = false + const closing = h.cache.dispose().then(() => { disposed = true }) + expect(disposed).toBe(false) + completed.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) }) + await first + await closing + expect(h.stat).not.toHaveBeenCalled() +}) + +it('passes renderer discovery failures through before source authorization and allows retry', async () => { + const h = harness() + const failure = { ok: false, error: new RemoteError('document-render/failed', 'Renderer unavailable', { reason: 'unavailable' }) } + try { + h.generation.mockResolvedValueOnce(failure) + expect(await h.read()).toBe(failure) + expect(h.stat).not.toHaveBeenCalled() + expect(h.convert).not.toHaveBeenCalled() + expect(await h.read()).toEqual(result()) + expect(h.convert).toHaveBeenCalledOnce() + } finally { await h.cache.dispose() } +}) + +it('restarts a stale generation query without invalidating the current renderer', async () => { + const h = harness() + const current = { ok: true as const, value: ('current' as OfficeToPdfGeneration) } + const stale = { ok: true as const, value: ('stale' as OfficeToPdfGeneration) } + const converted = result('v1', 'pdf!', 'current') + h.convert.mockResolvedValue(converted) + const firstQuery = Promise.withResolvers() + const middleQuery = Promise.withResolvers() + h.generation.mockReturnValueOnce(firstQuery.promise).mockReturnValueOnce(middleQuery.promise).mockResolvedValue(current) + const earlier = h.read() + const outdated = h.read() + const restarted = expect(outdated).resolves.toEqual(converted) + try { + expect(await h.read()).toEqual(converted) + firstQuery.resolve(current) + expect(await earlier).toEqual(converted) + middleQuery.resolve(stale) + await restarted + expect(await h.read()).toEqual(converted) + expect(h.stat).toHaveBeenCalledTimes(4) + expect(h.convert).toHaveBeenCalledOnce() + } finally { + firstQuery.resolve(current) + middleQuery.resolve(stale) + await h.cache.dispose() + } +}) + +it('restarts a pending conversion when a newer renderer generation is accepted', async () => { + const h = harness() + const entered = Promise.withResolvers() + const completed = Promise.withResolvers>>() + h.convert.mockImplementationOnce((_file, signal) => { + signal.addEventListener('abort', () => { completed.reject(signal.reason) }, { once: true }) + entered.resolve(signal) + return completed.promise + }) + const previous = h.read() + const converted = result('v1', 'pdf!', 'replacement') + const restarted = expect(previous).resolves.toEqual(converted) + try { + const signal = await entered.promise + h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) }) + h.convert.mockResolvedValue(converted) + expect(await h.read()).toEqual(converted) + await restarted + expect(signal.aborted).toBe(true) + expect(h.convert).toHaveBeenCalledTimes(2) + await h.read() + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { + completed.resolve(result()) + await h.cache.dispose() + } +}) + +it('reauthorizes source metadata that completes after the renderer generation changes', async () => { + const h = harness() + const entered = Promise.withResolvers() + const metadata = { ok: true, value: { absolutePath: '/report.docx', version: 'v1' } } + const completed = Promise.withResolvers() + h.stat.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise }) + const previous = h.read() + const converted = result('v1', 'pdf!', 'replacement') + const restarted = expect(previous).resolves.toEqual(converted) + try { + await entered.promise + h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) }) + h.convert.mockResolvedValue(converted) + expect(await h.read()).toEqual(converted) + completed.resolve(metadata) + await restarted + expect(h.convert).toHaveBeenCalledOnce() + } finally { + completed.resolve(metadata) + await h.cache.dispose() + } +}) + +it.each([[1, 32], [8, 1]])('leaves foreground admission available when pending/readers are %i/%i', async (pending, readers) => { + const h = harness(2, 32, pending, readers) + try { + await expect(h.cache.read(file, new AbortController().signal, 'background')).rejects.toThrow('busy') + expect(h.convert).not.toHaveBeenCalled() + expect(await h.read()).toEqual(result()) + } finally { await h.cache.dispose() } +}) + +it.each([[2, 32], [8, 2]])('reserves the final pending/reader slot for foreground promotion with %i/%i', async (pending, readers) => { + const h = harness(2, 32, pending, readers) + h.stat.mockImplementation(async (requested: SessionFile) => ({ ok: true, value: { absolutePath: `/${requested.path}`, version: 'v1' } })) + const entered = Promise.withResolvers(), complete = Promise.withResolvers>>() + h.convert.mockImplementation(() => { entered.resolve(undefined); return complete.promise }) + const prewarm = h.cache.read(file, new AbortController().signal, 'background') + await entered.promise + let foreground: ReturnType | undefined + try { + await expect(h.cache.read({ ...file, path: 'other.docx' }, new AbortController().signal, 'background')).rejects.toThrow('busy') + foreground = h.read() + await vi.waitFor(() => { expect(h.convert).toHaveBeenCalledTimes(2) }) + expect(h.convert.mock.calls.map(call => call[2])).toEqual(['background', 'foreground']) + } finally { complete.resolve(result()); await Promise.all([prewarm, foreground]); await h.cache.dispose() } +}) + +it('reports repeated generation supersession through the localized capacity failure', async () => { + const h = harness() + const first = Promise.withResolvers<{ ok: true; value: ReturnType }>() + const retry = Promise.withResolvers<{ ok: true; value: ReturnType }>() + h.generation.mockReturnValueOnce(first.promise) + const work = expect(h.read()).rejects.toThrow('busy') + try { + await h.read() + h.generation.mockReturnValueOnce(retry.promise) + first.resolve({ ok: true, value: ('obsolete' as OfficeToPdfGeneration) }) + await vi.waitFor(() => { expect(h.generation).toHaveBeenCalledTimes(3) }) + h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) }) + await h.read() + retry.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) }) + await work + expect(h.generation).toHaveBeenCalledTimes(4) + } finally { + first.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) }) + retry.resolve({ ok: true, value: ('replacement' as OfficeToPdfGeneration) }) + await h.cache.dispose() + } +}) + +it('does not retain a PDF returned by a generation newer than the preceding metadata query', async () => { + const h = harness() + const converted = result('v1', 'pdf!', 'replacement') + h.convert.mockResolvedValue(converted) + try { + expect(await h.read()).toBe(converted) + expect(await h.read()).toBe(converted) + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { await h.cache.dispose() } +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx new file mode 100644 index 0000000000..169aaf0aa6 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { FontNotice, type FontNoticeProps } from '../src/client/office/FontNotice.tsx' +import { en, zh } from '../src/client/office/locales.ts' + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { + observe(): void {} + disconnect(): void {} + }) +}) +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + +function props(overrides: Partial = {}): FontNoticeProps { + return { + fonts: ['Consolas', 'Missing Serif'], + t: makeTranslate(en), ...overrides, + } +} + +it('opens details, restores focus on Escape or close, and dismisses outside without stealing focus', () => { + render() + const more = screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) }) + fireEvent.click(more) + const dialog = screen.getByRole('dialog', { name: en.missingFontsTitle }) + expect(dialog.textContent).toContain('Consolas') + expect(dialog.textContent).toContain('Missing Serif') + expect(document.activeElement).toBe(dialog) + fireEvent.keyDown(dialog, { key: 'ArrowDown' }) + expect(screen.getByRole('dialog')).toBe(dialog) + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(more) + fireEvent.click(more) + fireEvent.click(screen.getByRole('button', { name: en.closeDetails })) + expect(screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })).toBe(more) + expect(screen.queryByRole('status')).toBeNull() + expect(document.activeElement).toBe(more) + fireEvent.click(more) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(more) + fireEvent.click(more) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it('keeps details open while focus moves inside them and closes when focus leaves the notice', () => { + render(<>) + const more = screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) }) + fireEvent.click(more) + const dialog = screen.getByRole('dialog') + fireEvent.blur(dialog, { relatedTarget: screen.getByRole('button', { name: en.closeDetails }) }) + expect(screen.getByRole('dialog')).toBe(dialog) + fireEvent.blur(dialog, { relatedTarget: more }) + expect(screen.getByRole('dialog')).toBe(dialog) + fireEvent.blur(dialog, { relatedTarget: screen.getByRole('button', { name: 'Other action' }) }) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it('shows a warning without automatically opening details or remembering that they were viewed', () => { + const view = render() + const warning = screen.getByRole('button') + expect(warning.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(warning) + fireEvent.click(screen.getByRole('button', { name: en.closeDetails })) + expect(screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })).toBe(warning) + view.unmount() + render() + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it('has localized copy and does not reserve a notice for fonts that are available', () => { + const view = render() + fireEvent.click(screen.getByRole('button', { name: makeTranslate(zh)('viewMissingFonts', { count: 2 }) })) + expect(screen.getByRole('dialog', { name: zh.missingFontsTitle })).toBeDefined() + view.rerender() + expect(view.container.childElementCount).toBe(0) + expect(screen.queryByRole('dialog')).toBeNull() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts new file mode 100644 index 0000000000..22bd4da75b --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -0,0 +1,272 @@ +/** Authorized Host PDFs remain binary through Client reuse and disposal. */ +import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types' +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-office-to-pdf/remote' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' +import { apply } from '../src/client/office/index.ts' +import { Config } from '../src/config.ts' +import { OfficeBody, type OfficeBodyInjected } from '../src/client/office/OfficeBody.tsx' +import type { OfficeStore } from '../src/client/office/store.ts' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { en, zh } from '../src/client/office/locales.ts' +import { en as documentEn } from '../src/client/locales.ts' + +const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' } +const generation = ('renderer' as OfficeToPdfGeneration) +const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: 'JVBERg==' } +const pdf = new Uint8Array([37, 80, 68, 70]) +const converted = { ok: true as const, value: { ...source, generation, missingFonts: ['Missing Serif'] } } + +it('retains Office view state across remounts and releases it on tab close or plugin disposal', async () => { + const h = await harness() + const first = 'first' as TabId + const second = 'second' as TabId + const closed = new AbortController() + const retained = new AbortController() + try { + h.instance.actions.loading(first, 1) + h.injected.retainTab(first, closed.signal) + h.injected.retainTab(first, closed.signal) + expect(h.instance.getSnapshot().byTab[first]).toBeDefined() + closed.abort() + expect(h.instance.getSnapshot().byTab[first]).toBeUndefined() + h.instance.actions.loading(first, 2) + h.injected.retainTab(first, closed.signal) + expect(h.instance.getSnapshot().byTab[first]).toBeUndefined() + h.instance.actions.loading(second, 1) + h.injected.retainTab(second, retained.signal) + const message = documentEn['error.unavailable'].replace('{message}', 'conversion stopped') + expect(h.injected.describeFailure(new RemoteError('gateway/internal', 'conversion stopped', {}))).toBe(message) + expect(h.injected.describeFailure({ message: 'conversion stopped' })).toBe(message) + await h.close() + expect(h.instance.getSnapshot().byTab[second]).toBeUndefined() + } finally { closed.abort(); retained.abort(); await h.close() } +}) + +async function harness(config: Partial = {}, missing?: 'remote' | 'render' | 'files') { + const ctx = new Context() + const registry = new DocumentPreviewRegistry() + const removeLocale = vi.fn() + const locale = { register: vi.fn(() => removeLocale), bind: (name: string) => name === 'sidebarDocumentPreview' ? makeTranslate(documentEn) : makeTranslate(en) } + const render = vi.fn().mockResolvedValue(converted) + const rendererGeneration = vi.fn().mockResolvedValue({ ok: true, value: generation }) + const stat = vi.fn().mockResolvedValue({ ok: true, value: source }) + const readBytes = vi.fn().mockResolvedValue({ ok: true, value: source }) + const removeNotice = vi.fn() + const recorded: { options: { name: string; store: OfficeStore; inject: (id: SessionId, actions: ReturnType['actions']) => OfficeBodyInjected }; component: unknown }[] = [] + const register = vi.fn((options: typeof recorded[number]['options'], component: unknown) => { recorded.push({ options, component }); return removeNotice }) + ctx.provide('documentPreviews', registry) + ctx.provide('slots', { inject: (_name: string, effect: () => () => void) => effect(), register } as never) + ctx.provide('locale', locale as never) + if (missing !== 'remote') { + ctx.provide('remote', { workspaceFiles: { stat, readBytes }, officeToPdf: { render, generation: rendererGeneration } } as never) + if (missing !== 'files') ctx.provide('remote.workspaceFiles', { stat, readBytes } as never) + if (missing !== 'render') ctx.provide('remote.officeToPdf', { render, generation: rendererGeneration } as never) + } + const fiber = ctx.plugin({ apply: (scope: Context) => { + apply(scope, Config({ office: config }).office) + } }) + await fiber.await() + const entry = recorded.find(entry => entry.component === OfficeBody)!.options + const instance = entry.store.create() + const injected = entry.inject(file.sessionId, instance.actions) + return { ctx, registry, instance, injected, recorded, locale, removeLocale, render, rendererGeneration, + stat, readBytes, register, removeNotice, + read: (signal = new AbortController().signal, path = file.path) => injected.read({ ...file, path }, signal), + close: () => fiber.dispose(), + } +} + +it.each(['remote', 'render', 'files'] as const)('keeps Office registration and guidance when %s is absent', async (missing) => { + const h = await harness(undefined, missing) + try { + expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en }) + for (const path of ['a.DOC', 'b.DOCX', 'c.XLS', 'd.xlsx', 'e.PPT', 'f.pptx']) { + expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) + expect(h.registry.candidates(path)[0]!.title()).toBe(en.title) + expect(h.registry.candidates(path)[0]!.loading).toBe('renderer') + expect(h.registry.candidates(path)[0]).not.toHaveProperty('read') + await expect(h.read(undefined, path)).rejects.toThrow(en.unavailable) + } + expect(h.render).not.toHaveBeenCalled() + } finally { await h.close() } + expect(h.registry.getSnapshot()).toEqual([]) + expect(h.removeLocale).toHaveBeenCalledOnce() + expect(h.register).toHaveBeenCalledWith(expect.objectContaining({ + name: 'sidebar.right.tab.document', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', locale: 'sidebarOffice', + }), OfficeBody) + expect(h.removeNotice).toHaveBeenCalledTimes(3) +}) + +it('requests a Host PDF with source identity and borrows the same binary cache result', async () => { + const h = await harness({ maxCachedBytes: pdf.byteLength }) + try { + const result = await h.read() + expect(h.render).toHaveBeenCalledExactlyOnceWith(file.sessionId, file.path, 'foreground', expect.any(AbortSignal)) + expect(result).toEqual({ ok: true, value: { ...source, data: pdf, generation, missingFonts: ['Missing Serif'] } }) + expect(await h.read()).toBe(result) + expect(h.stat).toHaveBeenCalledTimes(2) + expect(h.readBytes).toHaveBeenCalledTimes(2) + expect(h.readBytes).toHaveBeenCalledWith(file.sessionId, file.path, { offset: 0, length: 1 }, expect.any(AbortSignal)) + expect(h.render).toHaveBeenCalledOnce() + } finally { await h.close() } +}) + +it.each([ + ['rendererGeneration', 'gateway/invocation-unavailable'], ['rendererGeneration', 'gateway/service-unavailable'], + ['render', 'gateway/invocation-unavailable'], ['render', 'gateway/service-unavailable'], +] as const)('shows configuration guidance when %s returns %s', async (method, code) => { + const h = await harness() + try { + const endpoint = method === 'render' ? 'officeToPdf/render' : 'officeToPdf/generation' + const failure = new RemoteError(code, 'Office provider is unavailable.', { endpoint }) + h[method].mockResolvedValueOnce({ ok: false, error: failure }) + await expect(h.read()).rejects.toMatchObject({ message: en.unavailable, cause: failure }) + expect((await h.read()).ok).toBe(true) + } finally { await h.close() } +}) + +it('shows configuration guidance when the Host exposes no Office HTTP endpoint', async () => { + const h = await harness() + try { + const failure = new RemoteError('gateway/internal', 'transport failure for /api/officeToPdf/generation: HTTP 404', {}) + h.rendererGeneration.mockResolvedValueOnce({ ok: false, error: failure }) + await expect(h.read()).rejects.toMatchObject({ message: en.unavailable, cause: failure }) + expect(h.readBytes).not.toHaveBeenCalled() + expect(h.render).not.toHaveBeenCalled() + } finally { await h.close() } +}) + +it('refuses Client cached bytes when metadata still succeeds but the read probe fails', async () => { + const h = await harness() + try { + await h.read() + const failure = new Error('read denied') + h.readBytes.mockRejectedValueOnce(failure) + await expect(h.read()).rejects.toBe(failure) + expect(h.render).toHaveBeenCalledOnce() + expect(h.stat).toHaveBeenCalledOnce() + } finally { await h.close() } +}) + +it('returns a declared authorization failure without consulting metadata or cached bytes', async () => { + const h = await harness() + try { + await h.read() + const denied = { ok: false as const, error: new RemoteError('workspace-file/not-found', 'File missing', { path: file.path }) } + h.readBytes.mockResolvedValueOnce(denied) + expect(await h.read()).toBe(denied) + expect(h.stat).toHaveBeenCalledOnce() + expect(h.render).toHaveBeenCalledOnce() + } finally { await h.close() } +}) + +it('localizes Client capacity rejection while another reader waits for source metadata', async () => { + const h = await harness({ maxReaders: 1 }) + const entered = Promise.withResolvers() + const completed = Promise.withResolvers>>() + h.stat.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise }) + const first = h.read() + try { + await entered.promise + await expect(h.read()).rejects.toThrow(en.busy) + expect(h.stat).toHaveBeenCalledOnce() + expect(h.render).not.toHaveBeenCalled() + completed.resolve({ ok: true, value: source }) + expect((await first).ok).toBe(true) + } finally { + completed.resolve({ ok: true, value: source }) + await first + await h.close() + } +}) + +it('refuses a source replaced during the Client authorization probe', async () => { + const h = await harness() + try { + h.stat.mockResolvedValueOnce({ ok: true, value: { ...source, version: 'v2' } }) + await expect(h.read()).rejects.toThrow(en.changed) + expect(h.render).not.toHaveBeenCalled() + } finally { await h.close() } +}) + +it('passes source authorization failures through and rechecks permission before reuse', async () => { + const h = await harness() + try { + const missing = { ok: false as const, error: new RemoteError('workspace-file/not-found', 'File missing', { path: file.path }) } + h.render.mockResolvedValueOnce(missing) + expect(await h.read()).toBe(missing) + await h.read() + h.stat.mockResolvedValueOnce(missing) + expect(await h.read()).toBe(missing) + expect(h.render).toHaveBeenCalledTimes(2) + } finally { await h.close() } +}) + +it.each([ + ['input-too-large', 'tooLarge'], ['output-too-large', 'tooLarge'], ['invalid-document', 'invalid'], + ['unsupported-format', 'invalid'], ['invalid-output', 'failed'], ['timeout', 'timeout'], + ['unavailable', 'unavailable'], ['failed', 'failed'], ['busy', 'busy'], ['source-changed', 'changed'], +] as const)('localizes %s Host conversion failures and allows retry', async (code, key) => { + const h = await harness() + try { + const failure = new RemoteError('document-render/failed', 'Office conversion failed.', { reason: code }) + h.render.mockResolvedValueOnce({ ok: false, error: failure }) + await expect(h.read()).rejects.toMatchObject({ message: en[key], cause: failure }) + expect((await h.read()).ok).toBe(true) + } finally { await h.close() } +}) + +it('cancels the Host request on disposal and waits for its completion', async () => { + const h = await harness() + const completed = Promise.withResolvers() + const started = Promise.withResolvers() + h.render.mockImplementation((_session, _path, _priority, signal) => { started.resolve(signal!); return completed.promise }) + const pending = h.read() + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await started.promise + let disposed = false + const closing = h.close().then(() => { disposed = true }) + try { + await rejected + expect(signal.aborted).toBe(true) + expect(disposed).toBe(false) + } finally { completed.resolve(converted); await closing } +}) + +it('replaces cached PDFs on connection reset', async () => { + const h = await harness() + try { + await h.read() + await h.ctx.parallel('connection/reset') + await h.read() + expect(h.render).toHaveBeenCalledTimes(2) + } finally { await h.close() } +}) + +it('joins a retired request during reset without retaining its late PDF in the new cache', async () => { + const h = await harness() + const completed = Promise.withResolvers() + const started = Promise.withResolvers() + h.render.mockImplementationOnce((_session, _path, _priority, signal) => { started.resolve(signal!); return completed.promise }) + const pending = h.read() + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await started.promise + const resetting = h.ctx.parallel('connection/reset') + try { + await rejected + expect(signal.aborted).toBe(true) + await h.read() + expect(h.render).toHaveBeenCalledTimes(2) + let disposed = false + const closing = h.close().then(() => { disposed = true }) + expect(disposed).toBe(false) + completed.resolve(converted) + await resetting + await closing + } finally { completed.resolve(converted); await resetting; await h.close() } +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx index 4330f6a936..a39f99a865 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx @@ -8,8 +8,11 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { PdfDocument } from '../src/client/pdf/document.ts' import type { renderPdfPage } from '../src/client/pdf/document.ts' import type { openPdf } from '../src/client/pdf/runtime.ts' +import type { PageViewport, PDFPageProxy } from 'pdfjs-dist' const engine = vi.hoisted(() => ({ open: vi.fn(), render: vi.fn() })) +const overlay = vi.hoisted(() => ({ create: vi.fn(), render: vi.fn(), cancel: vi.fn() })) +vi.mock('../src/client/pdf/text.ts', () => ({ pdfTextRenderer: (host: HTMLDivElement) => { overlay.create(host); return overlay.render } })) vi.mock('../src/client/pdf/runtime.ts', () => ({ openPdf: engine.open })) vi.mock('../src/client/pdf/document.ts', () => ({ renderPdfPage: engine.render })) import { PdfBody, type PdfBodyProps } from '../src/client/pdf/pdf.tsx' @@ -31,6 +34,9 @@ beforeEach(() => { return { document: deferred.promise, dispose } }) engine.render.mockReset().mockResolvedValue({ width: 100, height: 100 }) + overlay.create.mockReset() + overlay.render.mockReset() + overlay.cancel.mockReset() }) afterEach(() => { @@ -101,6 +107,7 @@ describe('PDF body', () => { const view = render() expect(screen.getByRole('status').getAttribute('aria-label')).toBe('Reading…') expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true) + expect(screen.getByRole('status').querySelector('[data-state="ongoing"]')).not.toBeNull() await act(async () => { loads[0]!.deferred.resolve(documentOf()) }) await act(async () => {}) expect(view.container.querySelector('[role="toolbar"]')).toBeNull() @@ -123,6 +130,25 @@ describe('PDF body', () => { expect(engine.render.mock.calls.every(([document]) => document === latest)).toBe(true) }) + it('mounts selectable text with the page and cancels it when the body unmounts', async () => { + const pdfPage = { pageNumber: 1 } as PDFPageProxy + const viewport = { width: 100, height: 100, scale: 1 } as PageViewport + overlay.render.mockReturnValue({ promise: Promise.resolve(), cancel: overlay.cancel }) + engine.render.mockImplementation(async (_document, _number, _canvas, _signal, _ratio, renderText) => { + await renderText!(pdfPage, viewport).promise + return { width: viewport.width, height: viewport.height } + }) + const h = harness() + const view = render() + await act(async () => { loads[0]!.deferred.resolve(documentOf(1)) }) + expect(overlay.create).toHaveBeenCalledExactlyOnceWith(view.container.querySelector('[data-pdf-text]')) + expect(overlay.render).toHaveBeenCalledExactlyOnceWith(pdfPage, viewport) + expect(screen.getByRole('img', { name: 'PDF page 1' })).toBeDefined() + expect(overlay.cancel).not.toHaveBeenCalled() + view.unmount() + expect(overlay.cancel).toHaveBeenCalledOnce() + }) + it('shows localized load errors and retries without replacing the file resource', async () => { const h = harness() render() diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-document.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-document.client.spec.ts index 8f4b7c3617..2caccd5336 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-document.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-document.client.spec.ts @@ -5,6 +5,33 @@ import type { PDFPageProxy } from 'pdfjs-dist' import { renderPdfPage } from '../src/client/pdf/document.ts' describe('PDF canvas rendering', () => { + it('joins canvas cancellation before cleanup when text rendering fails', async () => { + const rendered = Promise.withResolvers() + const textStarted = Promise.withResolvers() + const textFailed = Promise.withResolvers() + const cleanup = vi.fn() + const cancelText = vi.fn() + const cancelCanvas = vi.fn() + const page = { + getViewport: () => ({ width: 100, height: 80 }), + render: () => ({ promise: rendered.promise, cancel: cancelCanvas }), cleanup, + } as unknown as PDFPageProxy + const pending = renderPdfPage({ numPages: 1, getPage: async () => page }, 1, + document.createElement('canvas'), new AbortController().signal, 1, () => { + textStarted.resolve(undefined) + return { promise: textFailed.promise, cancel: cancelText } + }) + const failure = expect(pending).rejects.toThrow('text failed') + await textStarted.promise + textFailed.reject(new Error('text failed')) + await vi.waitFor(() => { expect(cancelText).toHaveBeenCalledOnce() }) + expect(cancelCanvas).toHaveBeenCalledOnce() + expect(cleanup).not.toHaveBeenCalled() + rendered.resolve(undefined) + await failure + expect(cleanup).toHaveBeenCalledOnce() + }) + it('cancels when the owner aborts during the library render call, before the abort listener attaches', async () => { const controller = new AbortController() const cancel = vi.fn() diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts index 66e7613679..56fd2ac09c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts @@ -1,17 +1,48 @@ -/** Deterministic two-page PDF with red and blue vector rectangles and an explicit xref table. */ +/** Deterministic two-page PDF with colored rectangles, selectable text, and an explicit xref table. */ -/** @returns complete PDF bytes; no clocks, external fonts, images, or network references. */ -export function pdfFixture(): Uint8Array { +/** + * @param userUnit - Page-coordinate unit size as a multiple of 1/72 inch. + * @param rotation - Clockwise page rotation in degrees. + * @returns complete PDF bytes; no clocks, external fonts, images, or network references. + */ +export function pdfFixture(userUnit = 1, rotation = 0): Uint8Array { const streams = ['0.9 0.1 0.1 rg 10 10 100 80 re f', '0.1 0.1 0.9 rg 10 10 100 80 re f'] + .map(stream => `${stream}\nBT /F1 8 Tf 0 0 0 rg 10 92 Td (Selectable PDF text) Tj ET`) const objects = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>', - '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 100] /Resources << >> /Contents 4 0 R >>', + `<< /Type /Page /Parent 2 0 R /UserUnit ${userUnit} /Rotate ${rotation} /MediaBox [0 0 120 100] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>`, `<< /Length ${streams[0]!.length} >>\nstream\n${streams[0]}\nendstream`, - '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 100] /Resources << >> /Contents 6 0 R >>', + `<< /Type /Page /Parent 2 0 R /UserUnit ${userUnit} /Rotate ${rotation} /MediaBox [0 0 120 100] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>`, `<< /Length ${streams[1]!.length} >>\nstream\n${streams[1]}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', ] - let text = '%PDF-1.4\n' + return pdfObjects(objects) +} + +/** @returns a journal-like page with table cells, spaced text fragments, and blank regions for drag selection. */ +export function selectionPdfFixture(): Uint8Array { + const lines: Array<[number, number, string]> = [ + [40, 460, 'JOURNAL'], [40, 415, 'TODAY'], + [40, 385, '*'], [40, 355, '*'], [40, 325, '*'], + [40, 275, 'THREE TASKS'], [50, 245, 'NUMBER'], [180, 245, 'PRIORITY'], [310, 245, 'DONE'], + [50, 215, '1'], [180, 215, 'HIGH / MEDIUM / LOW'], [310, 215, '[ ]'], + [50, 185, '2'], [180, 185, 'HIGH / MEDIUM / LOW'], [310, 185, '[ ]'], + [50, 155, '3'], [180, 155, 'HIGH / MEDIUM / LOW'], [310, 155, '[ ]'], + [40, 105, 'REFLECTION'], [40, 65, '*'], [40, 35, 'AFTER TABLE'], + ] + const stream = lines.map(([x, y, value]) => `BT /F1 10 Tf ${x} ${y} Td (${value}) Tj ET`).join('\n') + return pdfObjects([ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]) +} + +function pdfObjects(objects: readonly string[]): Uint8Array { + let text = '%PDF-1.7\n' const offsets = [0] objects.forEach((object, index) => { offsets.push(text.length) diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-text.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-text.client.spec.ts new file mode 100644 index 0000000000..df52124d7d --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-text.client.spec.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +/** Text overlay resources follow page ownership; resize only aligns selection with the existing canvas. */ +import type { PageViewport, PDFPageProxy } from 'pdfjs-dist' +import { afterEach, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ render: vi.fn(), cancel: vi.fn(), create: vi.fn() })) +vi.mock('pdfjs-dist', () => ({})) +vi.mock('pdfjs-dist/web/pdf_viewer.mjs', () => ({ + TextLayerBuilder: class { + div = document.createElement('div') + constructor(options: unknown) { api.create(options) } + render = api.render + cancel = api.cancel + }, +})) +import { pdfTextRenderer } from '../src/client/pdf/text.ts' + +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +it('aligns selectable text to the displayed width and removes its observer and DOM on cancellation', async () => { + let resize: ResizeObserverCallback | undefined + const observe = vi.fn() + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { resize = callback } + observe = observe + disconnect = disconnect + }) + api.render.mockResolvedValue(undefined) + const host = document.createElement('div') + let width = 200 + vi.spyOn(host, 'getBoundingClientRect').mockImplementation(() => ({ width }) as DOMRect) + const viewport = { width: 400, scale: 4 / 3, userUnit: 1 } as PageViewport + const text = new ReadableStream() + const page = { streamTextContent: () => text } as PDFPageProxy + const task = pdfTextRenderer(host)(page, viewport) + await task.promise + expect(api.create).toHaveBeenCalledWith({ pdfPage: page }) + expect(api.render).toHaveBeenCalledWith({ viewport }) + expect(observe).toHaveBeenCalledWith(host) + expect((host.firstElementChild as HTMLElement).style.scale).toBe('0.5') + width = 100 + resize!([], {} as ResizeObserver) + expect((host.firstElementChild as HTMLElement).style.scale).toBe('0.25') + task.cancel() + expect(disconnect).toHaveBeenCalledOnce() + expect(api.cancel).toHaveBeenCalledOnce() + expect(host.childElementCount).toBe(0) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx new file mode 100644 index 0000000000..660ddc643e --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx @@ -0,0 +1,243 @@ +// @vitest-environment jsdom +/** Renderer-owned loads retain displayed versions and retire work on reload, replacement, and close. */ +import { useEffect, useState, useSyncExternalStore } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots' +import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types' +import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts' +import type { DocumentContent } from '../src/client/document/contract.ts' +import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx' +import { OfficeFontAction, type OfficeFontActionProps } from '../src/client/office/OfficeFontAction.tsx' +import { OfficeBody, type OfficeBodyProps } from '../src/client/office/OfficeBody.tsx' +import { createOfficeStore, type OfficeState } from '../src/client/office/store.ts' +import type { ReadOfficeDocument } from '../src/client/office/cache.ts' +import { en } from '../src/client/office/locales.ts' +import { harness, ABSOLUTE_PATH, TAB_ID, settle } from './fixtures.client.ts' + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) +}) +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + +it('lets a non-Office renderer load content, report its version, and reload through the shared toolbar', async () => { + const h = harness() + const custom: DocumentPreviewDefinition = { + id: 'custom-viewer', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Custom', loading: 'renderer', + } + const read = vi.fn<(signal: AbortSignal) => Promise<{ text: string; version: string }>>() + .mockResolvedValueOnce({ text: 'Custom content v1', version: 'v1' }) + .mockResolvedValueOnce({ text: 'Custom content v2', version: 'v2' }) + function CustomBody({ content }: { content: DocumentContent }) { + const [file, setFile] = useState<{ text: string; version: string; revision: number }>() + const request = content.kind === 'renderer' ? content : undefined + const revision = request?.revision + const displayed = file?.revision === revision ? file : undefined + useEffect(() => { + if (revision === undefined) return + const controller = new AbortController() + void read(controller.signal).then((file) => { + if (controller.signal.aborted) return + setFile({ ...file, revision }) + }) + return () => { controller.abort() } + }, [revision]) + useEffect(() => { if (displayed !== undefined) request?.loaded(displayed.version) }, [displayed, request?.loaded]) + return

    {displayed?.text ?? 'Loading custom content'}

    + } + const renderSlot: TextPreviewProps['renderSlot'] = (name: string, input: unknown) => { + if (name !== 'sidebar.right.tab.document') return null + const owner = input as OwnerOf<'sidebar.right.tab.document'> + return + } + const useDocumentPreviews: TextPreviewProps['useDocumentPreviews'] = selector => selector([custom]) + const view = render() + expect(read).toHaveBeenCalledTimes(1) + expect(await screen.findByText('Custom content v1')).toBeTruthy() + expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1') + h.setVersion('v2') + view.rerender() + expect(screen.getByText('changed')).toBeTruthy() + expect(read).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'reloadNow' })) + expect(await screen.findByText('Custom content v2')).toBeTruthy() + expect(read).toHaveBeenCalledTimes(2) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2') + expect(screen.queryByText('changed')).toBeNull() + expect(h.read).not.toHaveBeenCalled() + expect(h.bytes).not.toHaveBeenCalled() + expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined() +}) + +const definition: DocumentPreviewDefinition = { + id: 'office', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Office', loading: 'renderer', +} +type Result = Awaited> +const result = (version = 'v1', missingFonts: readonly string[] = []): Result => ({ ok: true, value: { + absolutePath: ABSOLUTE_PATH, version, data: new TextEncoder().encode(`PDF ${version}`), + offset: 0, eof: true, missingFonts, generation: 'engine' as OfficeToPdfGeneration, +} }) + +function setup() { + const h = harness() + const office = createOfficeStore().create() + const pending: { signal: AbortSignal; deferred: ReturnType> }[] = [] + const read = vi.fn().mockImplementation((_file, signal) => { + const deferred = Promise.withResolvers() + pending.push({ signal, deferred }) + return deferred.promise + }) + const retained = new Set() + const retainTab: OfficeBodyProps['retainTab'] = (tab, signal) => { + if (retained.has(signal)) return + retained.add(signal) + signal.addEventListener('abort', () => { office.actions.forget(tab) }, { once: true }) + } + const subscribe = (listener: () => void) => office.subscribe(listener) + const snapshot = () => office.getSnapshot() + function useOffice(selector: (state: OfficeState) => T): T { + return selector(useSyncExternalStore(subscribe, snapshot)) + } + const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message + let request: Extract | undefined + const slots: TextPreviewProps['renderSlot'] = (key: string, input: unknown, options?: { hookContext?: unknown }) => { + if (key === 'sidebar.right.tab.document.action') { + return , useTabInfo: options?.hookContext, useStore: useOffice, + actions: office.actions, t: makeTranslate(en) } as unknown as OfficeFontActionProps} /> + } + if (key !== 'sidebar.right.tab.document') return null + const owner = input as OwnerOf<'sidebar.right.tab.document'> + if (owner.content.kind !== 'renderer') return

    Raw bytes

    + request = owner.content + // The component fixture supplies the standard seats used by Office; the real slot binding is exercised by the browser scenario. + const props = { ...h.props(), ...owner, useTabInfo: options?.hookContext, useStore: useOffice, + actions: office.actions, read, retainTab, describeFailure, + t: makeTranslate(en), renderSlot: (_name: string, child: { content: DocumentContent }) => ( +

    {child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}

    + ), + } as unknown as OfficeBodyProps + return + } + function View({ renderer = true }: { renderer?: boolean }) { + return selector([renderer ? definition : { ...definition, id: 'raw', loading: 'bytes-complete' }])} /> + } + onTestFinished(async () => { + h.controller.abort() + for (const { deferred } of pending) deferred.resolve(result()) + await Promise.allSettled(pending.map(item => item.deferred.promise)) + }) + return { h, office, pending, read, View, request: () => request! } +} + +it('loads without reading raw bytes, retains content across remounts, and reloads only after a source-change action', async () => { + const h = setup() + let mounted = render() + expect(screen.getByRole('status').getAttribute('aria-label')).toBe(en.loading) + expect(h.h.bytes).not.toHaveBeenCalled() + await act(async () => { h.pending[0]!.deferred.resolve(result()) }) + expect(screen.getByText('PDF v1')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1') + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined() + mounted.unmount() + mounted = render() + expect(screen.getByText('PDF v1')).toBeTruthy() + expect(h.read).toHaveBeenCalledTimes(1) + h.h.setVersion('v2') + mounted.rerender() + expect(screen.getByText('changed')).toBeTruthy() + expect(h.read).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'reloadNow' })) + expect(h.read).toHaveBeenCalledTimes(2) + await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) + expect(screen.queryByText('changed')).toBeNull() + expect(screen.getByText('PDF v2')).toBeTruthy() +}) + +it('aborts a superseded load and rejects its late bytes and version report', async () => { + const h = setup() + render() + const previous = h.request() + fireEvent.click(screen.getByRole('button', { name: 'reload' })) + expect(h.pending[0]!.signal.aborted).toBe(true) + await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) + await act(async () => { h.pending[0]!.deferred.resolve(result('v1')); previous.loaded('v1') }) + expect(screen.getByText('PDF v2')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2') +}) + +it.each(['replace', 'close', 'hide'] as const)('retires pending conversion on %s and ignores a late rejection', async (transition) => { + const h = setup() + h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } }) + const mounted = render() + if (transition === 'replace') mounted.rerender() + else if (transition === 'close') act(() => { h.h.controller.abort() }) + else mounted.unmount() + expect(h.pending[0]!.signal.aborted).toBe(true) + await act(async () => { h.pending[0]!.deferred.reject(new Error('late error')) }) + if (transition === 'close') { + expect(h.office.getSnapshot().byTab[TAB_ID]).toBeUndefined() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined() + } else expect(h.office.getSnapshot().byTab[TAB_ID]?.failure).toBeUndefined() + if (transition === 'replace') { + await settle() + expect(screen.getByText('Raw bytes')).toBeTruthy() + } +}) + +it.each(['declared', 'exception', 'foreign'] as const)('shows %s conversion failures and retries only while the resource is available', async (kind) => { + const h = setup() + const mounted = render() + await act(async () => { + if (kind === 'declared') h.pending[0]!.deferred.resolve({ ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }) }) + else h.pending[0]!.deferred.reject(kind === 'exception' ? new Error('Conversion failed') : 'Conversion failed') + }) + expect(screen.getByText(kind === 'declared' ? 'Missing file' : 'Conversion failed')).toBeTruthy() + const resource = h.h.useResource() + h.h.useResource.mockReturnValue({ status: 'none', value: undefined, failure: undefined }) + mounted.rerender() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + expect(h.read).toHaveBeenCalledTimes(1) + expect(screen.getByText(kind === 'declared' ? 'Missing file' : 'Conversion failed')).toBeTruthy() + h.h.useResource.mockReturnValue(resource) + mounted.rerender() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + expect(h.read).toHaveBeenCalledTimes(2) + await act(async () => { h.pending[1]!.deferred.resolve(result()) }) + expect(screen.getByText('PDF v1')).toBeTruthy() + expect(h.h.bytes).not.toHaveBeenCalled() +}) + +it('starts no conversion when a body receives ordinary shared content', () => { + const h = setup() + const props = { ...h.h.props(), content: { kind: 'bytes', data: new Uint8Array() }, + useStore: () => undefined, read: h.read, retainTab: vi.fn(), describeFailure: vi.fn(), + } as unknown as OfficeBodyProps + const view = render() + expect(view.container.childElementCount).toBe(0) + expect(h.read).not.toHaveBeenCalled() +}) + +it('shows the current revision’s missing fonts in the toolbar and clears stale details during reload', async () => { + const h = setup() + const view = render() + expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() + await act(async () => { h.pending[0]!.deferred.resolve(result('v1', ['Missing Serif'])) }) + const warning = screen.getByRole('button', { name: 'Missing fonts: 1. Click to view.' }) + fireEvent.click(warning) + expect(screen.getByRole('dialog').textContent).toContain('Missing Serif') + fireEvent.click(screen.getByRole('button', { name: 'reload' })) + expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() + await act(async () => { h.pending[1]!.deferred.resolve(result('v2', ['Missing Sans'])) }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Missing fonts: 1. Click to view.' })) + expect(screen.getByRole('dialog').textContent).toContain('Missing Sans') + expect(screen.getByRole('dialog').textContent).not.toContain('Missing Serif') + h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } }) + view.rerender() + await settle() + expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts index 89f8f8521c..42055d9c31 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts @@ -74,7 +74,7 @@ describe('text store', () => { instance.actions.navigated(TAB_1, 3) instance.actions.reset(TAB_1) expect(instance.getSnapshot().byTab[TAB_1]).toEqual({ - ...fresh(), scrollTop: 120, wrap: false, revision: 3, + ...fresh(), loadRevision: 1, scrollTop: 120, wrap: false, revision: 3, }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index 733bc6decc..81b53f15bb 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -20,7 +20,7 @@ import { CodeBody } from '../src/client/code/CodeBody.tsx' import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts' import { TextBody } from '../src/client/text/TextBody.tsx' import { PLAIN_BODY_ID } from '../src/client/text/index.ts' -import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts' +import { documentSlots, ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts' const LINE_HEIGHT = 20 @@ -88,7 +88,7 @@ function codeProps(h: ReturnType, navigation: { params?: unknown return { ...props, useDocumentPreviews: selector => selector([definition]), - renderSlot: (_key, owner) => } t={key => key} />, + renderSlot: documentSlots((_key, owner) => } t={key => key} />), } } @@ -253,20 +253,59 @@ describe('TextPreview — pages', () => { }) it('says why a page failed and retries the same page', async () => { + const h = harness({ 1: failure('gateway/internal', { path: PATH }) }) + const view = render() + await settle() + const failed = view.container.querySelector('[data-textpreview-failed]') + expect(failed?.getAttribute('data-textpreview-failed')).toBe('gateway/internal') + expect(view.container.textContent).toContain('error.unavailable(message=boom)') + // Nothing read yet: the failure stands as the body, under the file's type sheet. + expect(failed?.querySelector('svg')).not.toBeNull() + expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull() + h.script(1, page(1, ['one'], true)) + click(view.container, '[data-textpreview-retry]') + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal) + expect(lines(view.container)).toEqual(['one\n']) + expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull() + }) + + it('hands a readable file it cannot render to the unpreviewable seat instead of Retry', async () => { const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) const view = render() await settle() const failed = view.container.querySelector('[data-textpreview-failed]') expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text') expect(view.container.textContent).toContain('error.notText') - // Nothing read yet: the failure stands as the body, under the file's type sheet. - expect(failed?.querySelector('svg')).not.toBeNull() - expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() - h.script(1, page(1, ['one'], true)) - click(view.container, '[data-textpreview-retry]') + expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull() + const seat = failed?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]') + expect(seat?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + // The header's own handoff seat receives the same file. + const actions = view.container.querySelector('[data-slot="sidebar.right.tab.document.actions"]') + expect(actions?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + }) + + it('offers neither Retry nor a handoff for a path with nothing to show', async () => { + const h = harness({ 1: failure('workspace-file/not-found', { path: PATH }) }) + const view = render() await settle() - expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal) - expect(lines(view.container)).toEqual(['one\n']) + expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-found') + expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull() + }) + + it('withholds the handoff seats until the file reports a Host path', async () => { + const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) + h.setVersion(undefined) + const view = render() + await settle() + expect(view.container.querySelector('[data-textpreview-failed]')).not.toBeNull() + expect(view.container.querySelector('[data-slot]')).toBeNull() + h.script(1, page(1, ['reloaded'], true)) + click(view.container, '[data-textpreview-tool="reload"]') + await settle() + expect(h.read).toHaveBeenCalledTimes(2) expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull() }) @@ -429,7 +468,7 @@ describe('TextPreview — navigation and view', () => { it('rebinds scrolling when the selected Slot body is replaced without changing the renderer id', async () => { const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) const code = codeProps(h, { revision: 1 }) - const fallback: TextPreviewProps = { ...code, renderSlot: () =>
    } + const fallback: TextPreviewProps = { ...code, renderSlot: documentSlots(() =>
    ) } const view = render() await settle() const outer = body(view.container) @@ -465,12 +504,12 @@ describe('TextPreview — navigation and view', () => { const props: TextPreviewProps = { ...base, useDocumentPreviews: selector => selector(definitions), - renderSlot: (_key, owner, opts) => { + renderSlot: documentSlots((_key, owner, opts) => { const documentOwner = owner as unknown as OwnerOf<'sidebar.right.tab.document'> if (opts.entryKey === 'code') return key} /> if (opts.entryKey === PLAIN_BODY_ID) return return
    - }, + }), } const view = render() await settle() diff --git a/packages/client/ui-sidebar-documentpreview/tsconfig.client.json b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json new file mode 100644 index 0000000000..c02235dfc2 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json @@ -0,0 +1,72 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src/client", + "src/config.ts", + "src/css-modules.d.ts" + ], + "references": [ + { + "path": "../../api/gateway/tsconfig.client.json" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale" + }, + { + "path": "../resources" + }, + { + "path": "../store" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-sidebar-right" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../api/workspace-files/tsconfig.client.json" + }, + { + "path": "../../util/workspace-path" + }, + { + "path": "../connection/tsconfig.client.json" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../document/office-to-pdf" + }, + { + "path": "../ui-settings" + } + ] +} diff --git a/packages/client/ui-sidebar-documentpreview/tsconfig.host.json b/packages/client/ui-sidebar-documentpreview/tsconfig.host.json new file mode 100644 index 0000000000..b47a7b37ef --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tsconfig.host.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/config.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../host/webserver" + } + ] +} diff --git a/packages/client/ui-sidebar-documentpreview/tsconfig.json b/packages/client/ui-sidebar-documentpreview/tsconfig.json index 8ce23ffc8e..2eca820546 100644 --- a/packages/client/ui-sidebar-documentpreview/tsconfig.json +++ b/packages/client/ui-sidebar-documentpreview/tsconfig.json @@ -1,57 +1,11 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../api/gateway/tsconfig.client.json" + "path": "./tsconfig.host.json" }, { - "path": "../../../vendor/cordis" - }, - { - "path": "../../api/remotes/tsconfig.client.json" - }, - { - "path": "../../core/session" - }, - { - "path": "../locale" - }, - { - "path": "../resources" - }, - { - "path": "../store" - }, - { - "path": "../ui-dockkit" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../ui-renderer" - }, - { - "path": "../ui-session" - }, - { - "path": "../ui-sidebar-right" - }, - { - "path": "../ui-slots" - }, - { - "path": "../../api/workspace-files/tsconfig.client.json" - }, - { - "path": "../../util/workspace-path" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index 3c0c62be27..10768f4300 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-files", "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index 7a8c70f501..9d3fb42f48 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -16,7 +16,7 @@ import clsx from 'clsx' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { - FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType, + FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, classifyFileType, } from '@deepseek-ai/dsh-client-ui-primitives' import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' @@ -116,7 +116,7 @@ function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirect return (
  • {expanded &&
    } @@ -243,7 +243,7 @@ export function FilesBody({ data-files-reload onClick={reload} > - +
  • {/* jscpd:ignore-end */} diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 041ac5c789..4de8cd03f3 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: a2ad8e17e58e04f10efb97236951cd172c654f95 -README.zh.md: 5ce03a0f712aa4ca8ef29404ff1a75467d2ec317 +README.md: dbe09becc8f5f322720c21f89e5042a1f423e6f0 +README.zh.md: 357f9366298dcc4313068d78f73105691d82c129 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index a2ad8e17e5..dbe09becc8 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -43,6 +43,8 @@ Normal and fullscreen presentations share the same content tree, so switching do | `push` (default) | Panel width: the conversation makes room | In the track; its left edge and the conversation's right edge travel together, on the frame's own curve | | `fullscreen` | Retains the wide-screen normal track; automatic narrow-screen fullscreen takes no track | Covers the entire viewport | +In Windows Electron, `html[data-windows-titlebar]` keeps fullscreen panels below the caption and to the right of the expanded sidebar. The frame's `--dsh-windows-sidebar-width` supplies that inset and maximum width; `--dsh-windows-content-radius` supplies the top-left radius. Only fullscreen panels clip overflow for that corner. + The seat reports presentation through `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()`; the frame does not inject this package. Switching fullscreen on a wide viewport leaves the center width unchanged, and the width handle appears only in expanded normal mode. Independent floating panels and `float`/`dock` operations remain available. The panel has no header row. Its two controls — the presentation switch and the collapse button — ride the kit's chrome seat at the far end of the top-right pane's tab strip, so the strip is the panel's whole top edge. Each strip reads, left to right: the tab capsules with close controls where allowed, the add control (drawn only while that pane holds no guide tab; it opens the guide there through `ctx.sidebarRight.openTab`), the pane's split control, and in the top-right pane the two panel controls. Only the chips give way in a narrow pane; the controls after them never shrink or clip. @@ -88,6 +90,8 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. + `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 5ce03a0f71..357f936629 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -43,6 +43,8 @@ kind: "package-reference" | `push`(默认) | 面板宽度:会话区让出空间 | 在轨道内;它的左缘与会话区的右缘沿框架自己的曲线一起移动 | | `fullscreen` | 保留宽屏普通轨道;窄屏自动全屏不占轨道 | 覆盖整个窗口 | +在 Windows Electron 中,`html[data-windows-titlebar]` 让全屏面板保持在顶栏下方及展开侧栏的右侧。框架的 `--dsh-windows-sidebar-width` 提供该缩进及最大宽度;`--dsh-windows-content-radius` 提供左上圆角。仅全屏面板为该圆角裁剪溢出内容。 + 席位通过 `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()` 报告呈现,框架不注入本包。宽屏切换全屏不改变中栏宽度;宽度拖拽区只在普通展开态显示。独立浮窗及 `float`/`dock` 操作保持可用。 面板没有标题行。它的两个控件——形态切换与折叠按钮——搭在套件 chrome 席位上,位于右上格 tab 条的最末端,因此 tab 条就是面板的整条上边。每条 tab 条从左到右读作:在允许时带关闭按钮的 tab 胶囊,添加控件(只在该格没有引导 tab 时绘制;它通过 `ctx.sidebarRight.openTab` 在该格打开引导页),该格的分栏控件,以及右上格里的两个面板控件。窄格里只有 chip 让位;其后的控件从不收缩或被裁切。 @@ -88,6 +90,8 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的资源 tab 被聚焦,不限所在分栏,除非 `revealIfOpened: false`;页 tab 始终只在目标分栏内去重,不受该选项影响;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 +`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 + `close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 2057a9dcae..a62d0a9aaf 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-right", "description": "Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-right/src/client/contract/params.ts b/packages/client/ui-sidebar-right/src/client/contract/params.ts index 6b29656839..bc9eca6dd9 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/params.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/params.ts @@ -5,7 +5,7 @@ * resource type — the segment after `dsh-resource://` — and filled by the * package that owns that type (the `file` provider adds `file: { line?: number }`); * `SidebarRightTabParamsMap` is keyed by tab kind and filled by a page type that - * takes parameters (neither shipped page does). Values are JSON-shaped by + * takes parameters (the Browser page adds `browser: { url?: string }`). Values are JSON-shaped by * convention; nothing validates them at run time, because caller and body meet * at a typed same-process boundary. A body narrows `navigation.params` by the * scheme and type of `navigation.address`. diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index da421aa39f..6095752b27 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -113,6 +113,8 @@ export interface SidebarRightTabNavigation { export interface SidebarRightTabPlacement { /** Land a new tab in this pane instead. */ readonly paneId?: PaneId + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean /** Resource tabs reveal existing content by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */ diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 682b81c69b..9fb5aaf413 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -115,6 +115,8 @@ export interface SidebarRightBinding { export interface SidebarRightPlacement { /** Land a new tab in this pane instead of the active docked one. */ readonly paneId?: PaneId + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean /** Take this tab's place — its pane and its strip slot — and close it in the same step. */ readonly replaceTab?: TabId /** @@ -378,15 +380,30 @@ export class SidebarRightController implements ISidebarRight { placement: SidebarRightPlacement, params: SidebarRightNavigationParams, ): void { + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + ?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined) + const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout) + const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane] + const preferNewPane = placement.preferNewPane === true + && placement.replaceTab === undefined + && surface !== undefined + && target?.kind === 'pane' + && target.host === 'dock' + && target.tabs.length > 0 + && canSplit(surface.layout) + && dockPaneIds(surface.layout).length < 2 + && this.binding?.sessionId === sessionId + && this.binding.canSplitPane(target.id) const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, title: claim.title, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...preferNewPane ? { preferNewPane: true } : {}, ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) } - const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout + const layout = surface?.layout const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] const revealed = layout === undefined || placement.revealIfOpened === false ? undefined : findContentTab(layout, claim.contentId, claim.kind) diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx index 7d9b2d276e..a89486d27c 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx @@ -14,7 +14,7 @@ * on the other edge. */ import type { ReactNode } from 'react' -import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { createSidebarRightStore } from '../stores.ts' @@ -41,7 +41,7 @@ export function ExpandButton({ sessionId, useStore, actions, t }: ExpandButtonPr data-sidebar-right-expand onClick={() => { actions.setExpanded(sessionId, true) }} > - + ) diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index 269a9ca6cc..9afba72843 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -59,6 +59,16 @@ -webkit-app-region: no-drag; } +/* Windows keeps caption controls above fullscreen file and terminal panels. */ +:global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] { + overflow: hidden; + left: var(--dsh-windows-sidebar-width); + max-width: calc(100% - var(--dsh-windows-sidebar-width)); + top: var(--dsh-windows-titlebar-height); + border-radius: var(--dsh-windows-content-radius) 0 0 0; + corner-shape: round; +} + /* macOS fullscreen: the panel reaches the window's top-left corner, where the hiddenInset traffic lights (x 16..~68) float over the first pane's tab strip. Widen that strip's start inset (dockkit publishes the variable); diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 476134c47d..1a517f4684 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -30,7 +30,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { ReactNode, RefObject } from 'react' import { createPortal } from 'react-dom' -import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' @@ -225,28 +225,21 @@ function titlesFor(panel: PanelProps): TabRenderer { return tab => } -/** Expand-to-viewport glyph: four frame corners (figma extract). */ +/** Expand-to-viewport glyph from the shared product artwork. */ function FullscreenGlyph(): ReactNode { return ( ) } -/** Restore-from-fullscreen glyph: two corners drawn inward (figma extract). */ +/** Restore-from-fullscreen glyph from the shared product artwork. */ function ExitFullscreenGlyph(): ReactNode { return ( ) } @@ -279,7 +272,7 @@ function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick data-sidebar-right-toggle onClick={() => { actions.toggleExpanded(sessionId) }} > - + diff --git a/packages/client/ui-sidebar-right/src/client/stores.ts b/packages/client/ui-sidebar-right/src/client/stores.ts index 6778152500..9478980de6 100644 --- a/packages/client/ui-sidebar-right/src/client/stores.ts +++ b/packages/client/ui-sidebar-right/src/client/stores.ts @@ -32,7 +32,7 @@ import type { DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord, } from '@deepseek-ai/dsh-client-ui-dockkit' import { - activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane, getPane, + activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findContentTab, findPaneContentTab, findTabPane, getPane, planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward, } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -87,6 +87,8 @@ export interface OpenContentIntent { readonly title: string /** Land a new tab in this pane. */ readonly paneId?: PaneId + /** Split the target pane and put new content alone in the new pane. */ + readonly preferNewPane?: boolean /** Take this tab's pane and slot, and close it in the same entry. */ readonly replaceTab?: TabId /** Resource tabs reveal an existing identity by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ @@ -319,18 +321,30 @@ export function createSidebarRightStore( // cross-pane reveal is for resources only. const page = contentId === pageAddress(kind) const held = page ? panePage(state, paneId ?? activeDockPaneId(state), kind) : undefined - const planned = held !== undefined - ? { ops: [{ type: 'focusTab' as const, tabId: held }], tabId: held } - : planOpenContent(state, mint, { - kind, - contentId, - title, - ...paneId === undefined ? {} : { paneId }, - ...index === undefined ? {} : { index }, - ...page - ? { revealIfOpened: false } - : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, + const revealed = page + ? held + : intent.revealIfOpened === false ? undefined : findContentTab(state, contentId, kind) + let openedInNewPane: TabId | undefined + const split = intent.preferNewPane === true && replace === undefined && revealed === undefined + ? planSplitPane(state, mint, paneId, (id) => { + openedInNewPane = id + return { id, kind, contentId, title } }) + : [] + const planned = revealed !== undefined + ? { ops: [{ type: 'focusTab' as const, tabId: revealed }], tabId: revealed } + : openedInNewPane !== undefined + ? { ops: split, tabId: openedInNewPane } + : planOpenContent(state, mint, { + kind, + contentId, + title, + ...paneId === undefined ? {} : { paneId }, + ...index === undefined ? {} : { index }, + ...page + ? { revealIfOpened: false } + : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, + }) ops.push(...planned.ops) if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace }) settled(planned.tabId) diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index 53b259584e..8dfe2b3ba0 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -158,6 +158,7 @@ export class TabDomain { ? { replaceTab: tabId } : held.paneId === undefined ? {} : { paneId: held.paneId }, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...placement.preferNewPane === undefined ? {} : { preferNewPane: placement.preferNewPane }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, }) const held: Held = { diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css index eb4676a3e2..07f68cee8f 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css @@ -56,7 +56,7 @@ font: inherit; text-align: left; background: var(--dsw-alias-bg-layer-1); - border: 0.5px solid var(--dsw-alias-border-l4); + border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 24px; cursor: pointer; } @@ -95,7 +95,7 @@ .entryTitle { overflow: hidden; - font-size: 15px; + font-size: 14px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx index 3c763ce95a..573c476f2d 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx @@ -18,8 +18,8 @@ import css from './GuideBody.module.css' export function CompassGlyph({ size = 16, className }: IconProps): ReactNode { return ( ) } @@ -35,19 +35,8 @@ export function CompassGlyph({ size = 16, className }: IconProps): ReactNode { export function CubeGlyph({ size = 16, className }: IconProps): ReactNode { return ( ) } diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index d493ed88ce..8a5db55190 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -72,6 +72,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) { 'conversation.session.header.corner': { kind: 'single', scope: 'session' }, }) await runtime.sessions.add({ id: SESSION }) + let reference = runtime.sessions.retainFor(runtime.ctx, SESSION, { source: 'mainView' }) const feature = await runtime.mount({ inject: [...inject], apply }) const bodies = new Map() const titles = new Map() @@ -100,14 +101,22 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) { runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title) }) const view = runtime.renderSlot('rightbar', { width: 420, viewportWidth, canShow }) - const instance = runtime.storeOf('rightbar.session', SESSION) as ReturnType['create']> + const instance = runtime.storeOf('rightbar.session', reference) as ReturnType['create']> const controller = runtime.ctx.sidebarRight const layout = () => instance.getSnapshot().bySession[SESSION]!.layout const open = (name = 'a.txt', options?: Parameters[1]) => { act(() => { controller.openResource(`dsh-resource://file/session/s-test/${name}`, options) }) return controller.active()! } - return { runtime, feature, controller, instance, actions: instance.actions, layout, open, frame, pin, bodies, titles, hooks, view } + const selectSession = (id: SessionId): void => { + const next = runtime.sessions.retainFor(runtime.ctx, id, { source: 'mainView' }) + reference.release() + reference = next + } + return { + runtime, feature, controller, instance, actions: instance.actions, layout, + open, selectSession, frame, pin, bodies, titles, hooks, view, + } } function element(container: HTMLElement, selector: string): HTMLElement { @@ -347,6 +356,7 @@ describe('RightbarSeat fullscreen entry', () => { else if (change === 'push') fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) else if (change === 'session') { await h.runtime.sessions.add({ id: OTHER }) + act(() => { h.selectSession(OTHER) }) act(() => { h.controller.openResource('dsh-resource://file/session/s-other/b.txt') }) } else await h.runtime.dispose() const openCalls = [...h.frame.openRightbar.mock.calls] @@ -430,6 +440,7 @@ describe('slot-owned useTabInfo', () => { await h.runtime.sessions.add({ id: OTHER }) expect(h.instance.getSnapshot()).toBe(stored) expect(info.tab.signal.aborted).toBe(false) + act(() => { h.selectSession(OTHER) }) act(() => { h.controller.openResource('dsh-resource://file/session/s-other/other.txt', { params: { line: 9 } }) }) const otherTab = h.controller.active()! expect(otherTab.id).toBe(own.id) @@ -442,7 +453,6 @@ describe('slot-owned useTabInfo', () => { act(() => { info.tab.actions.close() }) expect(info.tab.signal.aborted).toBe(true) expect(otherInfo.tab.signal.aborted).toBe(false) - await h.runtime.sessions.setCurrent(SESSION) const remaining = h.bodies.get(h.controller.active()!.id)! expect(remaining.tab.navigation.revision).toBe(1) await h.feature.dispose() diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 35ffc47444..889497fd2a 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -169,6 +169,39 @@ describe('SidebarRightController — opening', () => { expect(findTabPane(layout(), tabOf('a.txt')).id).toBe(left) }) + it('opens new content alone in a preferred pane and falls back to the current pane at the limit', () => { + const { controller, publish, layout, tabOf, entries, expand } = harness() + expand() + publish() + const before = entries() + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + const a = tabOf('a.txt') + const pane = findTabPane(layout(), a) + expect(dockPaneIds(layout())).toHaveLength(2) + expect(pane.tabs).toEqual([a]) + expect(entries()).toBe(before + 1) + + publish() + controller.openResource('dsh-resource://file/session/s-test/b.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(2) + expect(findTabPane(layout(), tabOf('b.txt')).id).toBe(pane.id) + }) + + it('falls back when the pane is too narrow and reveals existing content without splitting', () => { + const { controller, publish, layout, tabOf, room, expand } = harness() + expand() + publish() + room.allowed = false + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(1) + publish() + + room.allowed = true + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(1) + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt')) + }) + it('takes the replaced tab\'s pane and slot, closes it, and records one entry', () => { const { controller, publish, layout, tabOf, entries } = harness() publish() @@ -462,6 +495,22 @@ describe('SidebarRightController — a tab\'s own actions', () => { const A_TXT = 'dsh-resource://file/session/s-test/a.txt' const B_TXT = 'dsh-resource://file/session/s-test/b.txt' + it('opens into an adopted store before that store has created its session surface', () => { + const { controller, adopt, instance, titles } = harness() + const releaseOwn = adopt(SESSION, instance) + const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) + const releaseBinding = controller.bind({ + sessionId: OTHER, + actions: other.actions, + surfaces: other.getSnapshot().bySession, + canSplitPane: () => true, + }) + controller.openResourceIn(SESSION, A_TXT) + expect(titles()).toContain('a.txt') + releaseBinding() + releaseOwn() + }) + it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => { const { controller, adopt, instance, publish, layout, expand } = harness() const releaseOwn = adopt(SESSION, instance) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index 793bda8083..7c9a10c46e 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -195,16 +195,19 @@ describe('TabDomain — a tab\'s own actions', () => { expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) - it('passes revealIfOpened through, so a tab may open a second copy beside itself', () => { + it('passes placement options through, so a tab may prefer a new pane and open a second copy', () => { const { domain, navigator, controller, current } = harness() const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) domain.sync(SESSION, current()) const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) - tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { revealIfOpened: false }) + tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { + preferNewPane: true, + revealIfOpened: false, + }) expect(navigator.openResourceIn).toHaveBeenLastCalledWith( SESSION, 'dsh-resource://file/session/s-one/a.txt', - { paneId: current().rootId, revealIfOpened: false }, + { paneId: current().rootId, preferNewPane: true, revealIfOpened: false }, ) }) }) diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json index 1d80ce2d79..d9a9fa48ac 100644 --- a/packages/client/ui-sidebar-terminal/package.json +++ b/packages/client/ui-sidebar-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal", "description": "Interactive shell tabs for the right Sidebar", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css index 3380810d55..1926b98b1c 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css @@ -1,8 +1,9 @@ -.entry { box-sizing: border-box; display: flex; align-items: stretch; width: 100%; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: 24px; background: var(--dsw-alias-bg-layer-1); } +.entry { box-sizing: border-box; display: flex; align-items: stretch; width: 100%; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 24px; background: var(--dsw-alias-bg-layer-1); } .main { flex: 1; justify-content: flex-start; gap: 14px; min-width: 0; height: auto; min-height: 56px; padding: 14px 20px; border-radius: 24px 0 0 24px; text-align: left; } .icon { flex: none; } .text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } -.title { overflow: hidden; color: var(--dsw-alias-label-primary); font-size: 15px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } +.title { overflow: hidden; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } .description { overflow: hidden; color: var(--dsw-alias-label-caption); font-size: 13px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } .trigger { align-self: stretch; flex: none; height: auto; width: 44px; padding: 0; border-radius: 0 24px 24px 0; } +.trigger svg { width: 18px; height: 18px; color: var(--dsw-alias-label-tertiary); } .menu { display: flex; align-self: stretch; flex: none; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx index 68fb351686..4011f17de7 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx @@ -1,6 +1,6 @@ /** Shell launch menu owned by the terminal provider's guide entry. */ import { useEffect, useState, type ReactNode } from 'react' -import { Button, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -67,7 +67,7 @@ export function TerminalGuide({ title, description, kind, useTabInfo, loadShells }} anchor={} />
    diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx index df5525a08d..4c0fa3d11c 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -8,7 +8,8 @@ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' */ export function TerminalIcon(): ReactNode { return } @@ -19,7 +20,7 @@ export function TerminalIcon(): ReactNode { */ export function TerminalGuideIcon({ size = 26, className }: IconProps): ReactNode { return } diff --git a/packages/client/ui-sidebar-terminal/src/client/terminal.tsx b/packages/client/ui-sidebar-terminal/src/client/terminal.tsx index 9a64861bb3..1e3cd760d3 100644 --- a/packages/client/ui-sidebar-terminal/src/client/terminal.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/terminal.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' -import { Button, IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconPlusOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -29,7 +29,7 @@ export function TerminalBody({ useTabInfo, useTerminal, useTheme, view, t }: Ter useEffect(() => model.mount(), [model]) if (state === undefined) return null const newTerminal = diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx index fb1f61aab4..ec7bcd46ca 100644 --- a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -13,7 +13,7 @@ afterEach(cleanup) type CleanupProps = Parameters[0] const unusedHook = (): never => { throw new Error('Terminal cleanup does not consume global hooks') } const standard: Omit = { - useSessions: unusedHook, useSessionPendingInteraction: unusedHook, usePanelInfo: unusedHook, + useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, usePanelInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, } diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 45249bfbad..74a544bbbe 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 783ef070442922ec4c0b56143b1131a4f381aa11 -README.zh.md: 4bd5e822b41a1b9a305a4605cfa6e0529962d232 +README.md: 513e9d3151aba64b640475c3cd8c2f993df077db +README.zh.md: 69050dc8c530bf4c794d42113f2679fe41891e5f diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 783ef07044..513e9d3151 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -37,11 +37,15 @@ Plugins add an icon component to the root-scoped `sidebar.panellist` list with a ### Collapse behavior +The top expand button hosts the optional, non-interactive `sidebar.toggle.badge` slot while collapsed. Its occupant supplies status and tooltip content without adding another action or changing the button's navigation behavior. + During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. +On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. + ### macOS desktop -Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the conversation header's `conversation.session.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). +Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The resident header remains mounted without a selected Session. The package registers `HeaderLeadingControls` into the conversation header's root-scoped `conversation.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). ### Scrollbars diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 4bd5e822b4..69050dc8c5 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -37,11 +37,15 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### 折叠行为 +侧栏收起时,顶部展开按钮承载可选、不可交互的 `sidebar.toggle.badge` slot。占用方提供状态和提示内容,不增加操作,也不改变按钮的导航行为。 + 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。 +在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。 + ### macOS 桌面 -在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。本包向会话头部的 `conversation.session.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 +在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。未选中会话时,常驻头部仍保持挂载。本包向会话头部的根作用域 `conversation.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 ### 滚动条 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index b62b246018..cb417bfdee 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx index 77a98caa63..e4af1e420d 100644 --- a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx +++ b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -1,6 +1,6 @@ -/** macOS-desktop conversation-header controls for the fully hidden sidebar. */ +/** macOS-desktop resident-header controls for the fully hidden sidebar. */ import { - IconNewChatOutline16, IconPanelLeftOutline16, isDarwinDesktop, Tooltip, + IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the conversation header slot declarations. @@ -8,14 +8,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SidebarRootInjected } from './contract/slots.ts' import css from './HeaderLeadingControls.module.css' -/** Full props of the conversation-header leading occupant. */ +/** Full props of the resident-header leading occupant. */ export type HeaderLeadingControlsProps = - PropsRuntime<'conversation.session.header.leading'> + PropsRuntime<'conversation.header.leading'> & InjectFace & PropsLocale<'sidebar'> /** - * Sidebar-open and New Session controls in the conversation header's leading + * Sidebar-open and New Session controls in the resident header's leading * seat. On macOS desktop a collapsed sidebar hides entirely (no rail), taking * both controls off screen; this occupant puts them back beside the traffic * lights. Mounted whenever the platform matches; visibility rides the @@ -35,7 +35,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, t }: Header aria-label={t('toggle.open')} onClick={() => { toggleSidebar() }} > - + @@ -45,7 +45,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, t }: Header aria-label={t('session.new.label')} onClick={() => { startSession() }} > - +
    diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 68d85c63ec..1309dd2322 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -35,6 +35,89 @@ padding: 18px 10px 6px; } +/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */ +:global([data-windows-titlebar]) .root .logoRow { + height: 40px; + margin: 0; + padding: 0; +} + +:global([data-windows-titlebar]) .toggle { + position: fixed; + top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2); + left: 12px; + z-index: 30; + -webkit-app-region: no-drag; +} + +:global([data-windows-titlebar]) .root.collapsed .logoRow { + height: 0; +} + +:global([data-windows-titlebar]) .root:not(.collapsed) .brand { + padding-left: 4px; +} + +:global([data-windows-titlebar]) .brandIdentity { + transform: translateY(1px); +} + +:global([data-windows-titlebar]) .brandMark { + transform: translateX(1px); +} + +:global([data-windows-titlebar]) .brandName { + font-weight: 400; +} + +:global([data-windows-titlebar]) .root.collapsed { + padding: 0; +} + +:global([data-windows-titlebar]) .root:not(.collapsed) .newSession { + margin-top: 8px; + margin-left: 0; +} + +:global([data-windows-titlebar]) .collapsed .panelList, +:global([data-windows-titlebar]) .collapsed .regionArea, +:global([data-windows-titlebar]) .collapsed .footArea { + display: none; +} + +:global(html[data-windows-titlebar]:has([data-sidebar-collapsed='true'])) { + --dsh-windows-menu-start: 84px; +} + +:global([data-windows-titlebar]) .collapsed .newSession { + position: fixed; + top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2); + left: 48px; + margin: 0; + padding: 0; + border: none; + z-index: 30; + -webkit-app-region: no-drag; +} + +:global([data-windows-titlebar]) .railIn .iconButton, +:global([data-windows-titlebar]) .railIn .newSession { + animation: none; +} + +:global([data-windows-titlebar]) .collapsed .toggle, +:global([data-windows-titlebar]) .collapsed .newSession { + width: 28px; + height: 28px; + border-radius: 50%; + corner-shape: round; + color: var(--dsw-alias-label-secondary); +} + +:global([data-windows-titlebar]) .collapsed .toggle .panelIcon { + display: inline; +} + /* Scrollbars in the column are a pointer affordance: the shell adds this class whenever the pointer is not inside (SidebarRoot.tsx owns the linger), and rebinding ui-theme's indirection pair to `transparent` takes the thumb @@ -210,6 +293,7 @@ } .iconButton { + position: relative; flex: none; display: inline-flex; align-items: center; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index cc9152ed62..ea362a51f2 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -19,7 +19,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - FishLogo, IconNewChatOutline16, IconPanelLeftOutline16, isDarwinDesktop, Tooltip, + FishLogo, IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { @@ -105,7 +105,8 @@ export function SidebarRoot({ const timer = window.setTimeout(() => { setSettled(true) }, COLLAPSE_SETTLE_MS) return () => { window.clearTimeout(timer) } }, [collapsed]) - const wide = !collapsed || !settled + const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar') + const wide = windowsTitlebar ? !collapsed : !collapsed || !settled // Freeze the content at its expanded width while it fades out (collapsed // && wide): the sliding column then clips it instead of reflowing it. The @@ -175,13 +176,14 @@ export function SidebarRoot({ aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')} onClick={() => { toggleSidebar() }} > - {!wide && ( + {!wide && !windowsTitlebar && ( )} {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */} - + + {!wide && renderSlot('sidebar.toggle.badge', {})} ) @@ -243,7 +245,7 @@ export function SidebarRoot({ aria-label={t('session.new.label')} onClick={() => { startSession() }} > - + {wide && {t('session.new')}} diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index df2933430e..d94b5935ed 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -14,6 +14,8 @@ import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Non-interactive notification inside the collapsed sidebar expand button. */ + 'sidebar.toggle.badge': { kind: 'single'; scope: 'root'; owner: Record } /** * Brand mark rendered in the expanded brand row and collapsed rail. * Declared by this package's `sidebar` entry; deployments may replace @@ -136,6 +138,7 @@ export type SidebarRootComponentProps = & PropsRenderSlots< | 'sidebar.brand.mark' | 'sidebar.brand.name' + | 'sidebar.toggle.badge' | 'sidebar.panellist' | 'sidebar.workspaces' | 'sidebar.settings' diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index a768048c44..f1e3a38ce1 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -77,6 +77,7 @@ export function apply(ctx: ClientContext): void { children: { 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, 'sidebar.brand.name': { kind: 'single', scope: 'root' }, + 'sidebar.toggle.badge': { kind: 'single', scope: 'root' }, 'sidebar.panellist': { kind: 'list', scope: 'root' }, 'sidebar.workspaces': { kind: 'single', scope: 'root' }, 'sidebar.settings': { kind: 'single', scope: 'root' }, @@ -88,8 +89,8 @@ export function apply(ctx: ClientContext): void { // Session controls move into the conversation header's leading seat; the // occupant reuses the shell's injected actions and shows itself purely // through CSS against the AppFrame's data-sidebar-collapsed attribute. - ctx.slots.inject('conversation.session.header.leading', () => ctx.slots.register({ - name: 'conversation.session.header.leading', + ctx.slots.inject('conversation.header.leading', () => ctx.slots.register({ + name: 'conversation.header.leading', locale: NS, inject: injectProps, }, HeaderLeadingControls)) diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index ba3e4d9540..ce6cdec05b 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -1,5 +1,221 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`sidebar shell snapshots > renders Windows caption controls in expanded and collapsed states > windows collapsed 1`] = ` +
    +