diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index af337114a0..c07b56468c 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: 9fdb4039b525bbc4051ab3c8058b117e4de5f2f4 +README.zh.md: 2d7e07b963d3c4d77da9148b011e1478fa2ae5da diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 8bf1adf875..9fdb4039b5 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -43,9 +43,9 @@ Once sealed, every archived triplet is permanently frozen. Do not edit, translat ## 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..2d7e07b963 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.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/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml new file mode 100644 index 0000000000..4fac2496cb --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-09-content-sized-diagram-previews.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-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 2ee3fa8226..926441caeb 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -364,6 +364,9 @@ "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30", "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371", "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1", + "architecture/2026-09-09-desktop-in-place-profile.i18n.yaml": "sha256:08543070a32503373d38ca98b7ce96ee60c972302534030c5ba380f19e04fa00", + "architecture/2026-09-09-desktop-in-place-profile.md": "sha256:82bdcad48207c5aecbc9695717aaaa4c8cca90c82ca58884bfa653b60f192290", + "architecture/2026-09-09-desktop-in-place-profile.zh.md": "sha256:f105636c9160c64b12bd1822c8ec81ee744ba27c0b0ab6eeed870561ccd81ce7", "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", @@ -745,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", 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-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-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index d5073475ba..2ccfc07ac8 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: 834a26e55f87e5db54d57f06fec592e4fdb9f61c +2026-09-05-sidebar-tab-types-and-navigation.zh.md: 9884db9eef49162e8883c390dd71bdbf9d45489f 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..834a26e55f 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 @@ -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..9884db9eef 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 @@ -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-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-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/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 859e17061e..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: 63ec81991f3e396075fe4ed09a8ddaa87c865862 -2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e +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 63ec81991f..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,13 +10,13 @@ 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. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction 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 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 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 05dbad8006..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,13 +10,13 @@ Status: implemented ## Decision -Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。 +Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。 -首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。 +首次报告在等待对话框前取得展示权。后续报告保留在日志中。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 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-desktop-policy-login-loading.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml new file mode 100644 index 0000000000..b25a4b72b4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.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-policy-login-loading.md +2026-09-16-desktop-policy-login-loading.md: 26fd635c6cc5b92cad8011919382a11497d2e835 +2026-09-16-desktop-policy-login-loading.zh.md: 16de25323afc971b3142d5359c769df50c08800d diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md new file mode 100644 index 0000000000..26fd635c6c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md @@ -0,0 +1,37 @@ +# Agent Note: Show a local document while the policy login page loads + +Status: implemented + +English | [中文](2026-09-16-desktop-policy-login-loading.zh.md) + +## Problem + +The test-deployment login window ([mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md)) navigated straight to the policy origin. Until that document committed and painted, the window was blank, and the remote page can take seconds to appear. A user pressed "Sign in with Feishu" and saw an empty window with no sign that anything was happening — while the window had to stay closable and must never cover a page that had become interactive. + +## Decision + +[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts) loads [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) as the window's first document and requests the policy origin only after that document has committed. The placeholder is a packaged, self-contained file whose only text is the label the main process passes through `loadFile`'s query, so the copy stays in the shell locale dictionary (`policyLoginLoading`). The first committed remote document replaces it. + +The placeholder never coexists with the remote page, so there is no removal step and no timer: Chromium keeps the placeholder frame until the remote document's first frame is ready, and from then on the third-party page owns the window. A redirect chain keeps the placeholder until the last document commits. The window is created visible and closable as before. + +Two places recognize the placeholder's own load by file name and nothing else: the Session's `onBeforeRequest` filter, which cancels document requests outside the policy and Feishu origins, and the `did-fail-load` handler, which fails the login on a main-frame error. The `will-navigate` and `will-redirect` guards still require an allowed HTTPS origin, so the remote page cannot steer the window back to a local file. A placeholder that cannot load is not a login failure: the login request starts anyway and the window is blank exactly as it was before this change. + +## Alternatives considered + +**Overlay the window with a `WebContentsView` (or `BrowserView`) and remove it when the page is ready.** It would show feedback even earlier and can be removed at any chosen signal, but it needs a second renderer, a position that follows window resizes, and an explicit removal point. Every available removal signal is either late (`did-finish-load` still waits for subresources) or a guess (`dom-ready` can precede the first paint), and a view left in place covers an interactive page. + +**Inject an overlay element into the login page with a preload script.** The one approach the decision has to keep out: it puts a shell-owned DOM layer inside a third-party page, and it would give that page a preload bridge the isolated Session deliberately withholds (`webPreferences.preload` is unset). + +**Keep the window hidden until the remote page finishes loading.** It removes the blank flash by removing the feedback, and it delays the user's first view of the page until every subresource completes. + +**Show a native splash window, or the loading text in the window title.** A second window adds a focus and lifetime problem for a two-second wait, and a title is not visible feedback inside the window. + +**Reveal the page with a fixed delay.** A timer cannot distinguish a slow origin from a fast one, and it either covers a ready page or uncovers a blank one. + +## Consequences + +The login window gives immediate local feedback that cannot depend on the network: the placeholder's CSP sets `default-src 'none'`, so the document has no origin, font, image, or connection to reach. Because the placeholder is the window's document rather than a layer, nothing about it can survive into the third-party page, and a slow subresource on the login page cannot extend it. + +The placeholder shares the login Session, so its request passes through the same document filter; the filter's exemption is name-scoped and does not widen the allowed-origin check for navigation. The window title remains the shell-owned login title (the renderer prevents `page-title-updated`), so the placeholder contributes no copy of its own. + +[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts) covers the order (placeholder first, remote page only after it settles), the filter's acceptance of the placeholder, a failed placeholder load that must not fail the login, and a window closed while the placeholder is loading that never starts the remote page. [policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts) loads the packaged document under jsdom and checks that it renders the label it is given, leaves the label empty without one, and forbids every network source. The placeholder's rendered frames and the first-paint timing of the substituted login page remain unverified against a real login window. diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md new file mode 100644 index 0000000000..16de25323a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 策略登录页加载期间显示本地文档 + +Status: implemented + +[English](2026-09-16-desktop-policy-login-loading.md) | 中文 + +## 问题 + +测试部署的登录窗口([强制更新客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md))直接导航到策略 origin。在该文档提交并绘制之前,窗口是空白的,而远端页面可能需要数秒才出现。用户点击“通过飞书登录”后只看到一个空窗口,没有任何正在进行的迹象——同时窗口必须保持可关闭,也绝不能遮挡已经可交互的页面。 + +## 决策 + +[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts)把 [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) 作为窗口的首个文档载入,并在该文档提交之后才请求策略 origin。占位页是随包发布的、自包含的文件,其中唯一的文本是主进程通过 `loadFile` 的 query 传入的文案,因此文案仍由 shell 本地化字典(`policyLoginLoading`)拥有。首个提交的远端文档会替换它。 + +占位页从不与远端页面共存,因此没有移除步骤,也没有计时器:Chromium 会一直保留占位帧,直到远端文档的首帧就绪;此后窗口归第三方页面所有。重定向链会把占位页保持到最后一个文档提交。窗口仍与之前一样在创建时即显示且可关闭。 + +只有两处按文件名识别占位页自身的加载,不识别其他任何东西:Session 的 `onBeforeRequest` 过滤器(它会取消策略与飞书 origin 之外的文档请求)以及 `did-fail-load` 处理器(主框架出错时让登录失败)。`will-navigate` 与 `will-redirect` 守卫仍然要求允许的 HTTPS origin,因此远端页面无法把窗口引回本地文件。占位页无法载入不算登录失败:登录请求照常开始,窗口与本次改动之前一样保持空白。 + +## 考虑过的替代方案 + +**用 `WebContentsView`(或 `BrowserView`)覆盖窗口,并在页面就绪时移除。** 它能更早显示反馈,也可以在任选信号处移除,但需要第二个渲染器、一个随窗口尺寸变化的位置,以及一个明确的移除时机。可用的移除信号要么偏晚(`did-finish-load` 仍会等待子资源),要么只是猜测(`dom-ready` 可能早于首帧绘制);而一旦忘记移除,视图就会遮挡可交互的页面。 + +**通过 preload 脚本向登录页注入覆盖元素。** 这是决策必须排除的做法:它把 shell 拥有的 DOM 层放进第三方页面,并且会给该页面一个隔离 Session 刻意不提供的 preload 桥(`webPreferences.preload` 未设置)。 + +**在远端页面加载完成前保持窗口隐藏。** 它通过取消反馈来消除空白闪烁,并把用户第一次看到页面的时间推迟到所有子资源完成之后。 + +**使用原生启动窗口,或把加载文本放进窗口标题。** 为一个两秒的等待增加第二个窗口会带来焦点与生命周期问题,而标题不是窗口内的可见反馈。 + +**用固定延时揭示页面。** 计时器无法区分慢 origin 与快 origin,结果要么遮挡已就绪的页面,要么在空白页面上取消遮挡。 + +## 结果 + +登录窗口给出不依赖网络的即时本地反馈:占位页的 CSP 设置了 `default-src 'none'`,因此该文档没有任何 origin、字体、图片或连接可用。由于占位页是窗口的文档而不是覆盖层,它的任何部分都不会残留到第三方页面中,登录页上缓慢的子资源也无法延长它的存在。 + +占位页共享登录 Session,因此它的请求会经过同一个文档过滤器;该过滤器的豁免按文件名限定,不会放宽导航所用的允许 origin 检查。窗口标题仍是 shell 拥有的登录标题(渲染器阻止了 `page-title-updated`),因此占位页不贡献自己的文案。 + +[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts)覆盖顺序(先占位页,只有它稳定后才请求远端页面)、过滤器对占位页的接受、占位页载入失败不得导致登录失败,以及占位页加载期间关闭窗口后绝不启动远端页面。[policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts)在 jsdom 下载入随包发布的文档,检查它渲染传入的文案、在没有文案时保持为空,并禁止一切网络来源。占位页的实际渲染帧与替代登录页的首帧时机仍未在真实登录窗口上验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml new file mode 100644 index 0000000000..7079d4ec79 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.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-nested-tooltip-suppression.md +2026-09-16-nested-tooltip-suppression.md: 1b06a3a1a4f754aa78e821ee8a1479dab19873ff +2026-09-16-nested-tooltip-suppression.zh.md: 96063f78b281deb3561045bb73fcffa64a2c1ef8 diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md new file mode 100644 index 0000000000..1b06a3a1a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md @@ -0,0 +1,35 @@ +# Agent Note: Suppress an enclosing tooltip while a nested one is shown + +Status: implemented + +English | [中文](2026-09-16-nested-tooltip-suppression.zh.md) + +## Problem + +The collapsed sidebar renders its update badge through the `sidebar.toggle.badge` slot inside the expand button, and both the button and the badge attach a `Tooltip`. Hovering the badge showed two bubbles at once — the version notice and "Open sidebar" — because the badge is a DOM descendant of the button: the button's hover state stays true for as long as the pointer rests on the badge, and each tooltip correctly follows its own anchor. + +## Decision + +[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) provides a suppression setter through an internal `TooltipSuppression` context wrapped around its cloned anchor and its bubble. A nested tooltip consumes the nearest setter and announces whenever its own bubble becomes visible. An enclosing tooltip that receives a suppression claim keeps its hover/focus triggers and its measured position but does not render its bubble, so the enclosing bubble returns in the same commit that the nested one disappears. + +The nested tooltip announces synchronously inside `show()` and through a `visible` effect. The synchronous call keeps a nested pair that becomes visible in one React commit from painting both bubbles for a frame; the effect releases the claim on every other path that hides a bubble, including unmount and a `disabled` flip. + +No component in `ui-sidebar` or `ui-settings-general` changed: the badge already renders inside the toggle's anchor, so the shared primitive resolves the overlap for any future nested pair as well. + +## Alternatives considered + +**Disable the toggle tooltip whenever an update exists.** The simplest guard, and wrong: it removes "Open sidebar" from ordinary rail hovers for the whole time an update is available, including hovers that never touch the badge. + +**Disable the toggle tooltip while the pointer is over the badge.** Hover-scoped, but `Tooltip` clears its hover and focus triggers when it is disabled, and no new `mouseenter` fires on the button while the pointer moves from the badge onto the button's own area. The enclosing bubble would stay gone until the pointer left and re-entered the button. + +**Stop the badge's `mouseenter` from reaching the button.** It only helps a pointer that enters the badge directly; a pointer that enters the button first and then moves onto the badge already has the enclosing bubble visible, because the enclosing hover never ended. + +**Render the badge outside the toggle's anchor.** The slot is declared inside the toggle button, and the badge must sit on the button's corner; moving the anchor out of the button would either detach the bubble from the control or change the slot's declared placement. + +## Consequences + +The rule is a property of tooltip nesting, not of the update badge: any tooltip whose anchor contains another tooltip now yields the bubble to the innermost visible one. A tooltip with no nested tooltip is unaffected — it consumes a null context and never announces. + +The enclosing tooltip's bubble is withdrawn only while a descendant is visible; its trigger state survives, so no re-entry is needed to restore it. Suppression does not change what the anchors do: the button still toggles the sidebar and the badge remains a non-interactive marker. + +[Tooltip tests](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx) cover withdrawal, restoration when the nested anchor is left for the enclosing one with a real `relatedTarget`, and release when a shown nested tooltip unmounts. The [sidebar shell test](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx) covers the product wiring: the rail badge replaces the toggle bubble after the toggle's own hover delay has already elapsed. diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md new file mode 100644 index 0000000000..96063f78b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 嵌套提示显示时抑制外层提示 + +Status: implemented + +[English](2026-09-16-nested-tooltip-suppression.md) | 中文 + +## 问题 + +侧边栏收起时,更新蓝点通过 `sidebar.toggle.badge` 插槽渲染在展开按钮内部,而按钮和蓝点各自挂了一个 `Tooltip`。hover 蓝点会同时出现两个气泡——版本提示和“打开侧边栏”——因为蓝点是按钮的 DOM 后代:只要指针停在蓝点上,按钮的 hover 状态就保持为真,而两个 tooltip 都在正确地跟随各自的 anchor。 + +## 决策 + +[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx)通过内部 `TooltipSuppression` context 提供一个抑制 setter,并把该 context 包在克隆后的 anchor 与气泡外层。嵌套的 tooltip 消费最近的 setter,并在自己的气泡可见时上报。收到抑制声明后,外层 tooltip 保留 hover/focus 触发状态与已测量的位置,但不渲染自己的气泡,因此嵌套气泡消失的同一提交里外层气泡就会回来。 + +嵌套 tooltip 既在 `show()` 中同步上报,也通过 `visible` effect 上报。同步调用避免在同一 React 提交中同时可见的一对工具提示多绘制一帧的两个气泡;effect 则在其他所有隐藏气泡的路径上释放抑制,包括卸载和 `disabled` 翻转。 + +`ui-sidebar` 与 `ui-settings-general` 中的组件无需改动:蓝点本就渲染在 toggle 的 anchor 内,因此共享 primitive 也顺带解决了未来任何嵌套组合。 + +## 考虑过的替代方案 + +**只要存在更新就禁用 toggle 的 tooltip。** 这是最简单的防护,但也是错的:它会在更新可用的整个期间移除普通 rail hover 的“打开侧边栏”,包括根本不会碰到蓝点的 hover。 + +**指针位于蓝点上时禁用 toggle 的 tooltip。** 作用域正确,但 `Tooltip` 被禁用时会清空自己的 hover 与 focus 触发状态,而指针从蓝点移到按钮自身区域时按钮不会再触发新的 `mouseenter`。外层气泡会一直消失,直到指针离开按钮并重新进入。 + +**阻止蓝点的 `mouseenter` 传播到按钮。** 这只能帮助直接从蓝点进入的指针;先进入按钮再移到蓝点上的指针,其外层气泡早已可见,因为外层的 hover 从未结束过。 + +**把蓝点渲染到 toggle 的 anchor 之外。** 该插槽声明在 toggle 按钮内部,蓝点必须落在按钮角上;把 anchor 移出按钮要么让气泡脱离控件,要么改变插槽声明的放置位置。 + +## 结果 + +这条规则是 tooltip 嵌套的性质,而不是更新蓝点的性质:任何 anchor 内含另一个 tooltip 的 tooltip 现在都会把气泡让给最内层可见的那个。没有嵌套 tooltip 的 tooltip 不受影响——它消费到的是 null context,永远不会上报。 + +外层 tooltip 仅在子级可见期间收回气泡;它的触发状态得以保留,因此不需要重新进入即可恢复。抑制不改变 anchor 的行为:按钮仍然切换侧边栏,蓝点仍然是非交互标记。 + +[Tooltip 测试](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx)覆盖收回、用真实 `relatedTarget` 从嵌套 anchor 移到外层 anchor 时的恢复,以及已显示的嵌套 tooltip 卸载时的释放。[侧边栏 shell 测试](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx)覆盖产品接线:在 toggle 自身的 hover 延迟已经过去之后,rail 蓝点气泡仍替换掉 toggle 气泡。 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-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-16-present-delayed-windows-installer.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.i18n.yaml new file mode 100644 index 0000000000..f8d7bd6adf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-16-present-delayed-windows-installer.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-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/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index cd51630220..1ef5c7c433 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: fd2ff912a83f2535e33472e2b5755b9d059ba674 +2026-07-27-web-subagent-conversations.zh.md: 0124ce4143d65fe79dc0f5c66a42058984f5182e 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..fd2ff912a8 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. 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..0124ce4143 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 授权。 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-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-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index c5ffd3e8e7..4ed53fa01a 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: d3bb7d173450d4c27af4b5b65e16f33cc285605a +2026-09-14-desktop-primary-runtime.zh.md: 2df6effdccc09a488e322bfe6f144e18b101369c 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..d3bb7d1734 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 a supervised primary-runtime signing stage. PE inspection excludes foreign-platform Node addons and refuses directory links. 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; no failure permits a retry or later stage. Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. + 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,8 @@ 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. +**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. + ## 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..2df6effdcc 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 检查排除其他平台的 Node 插件,并拒绝目录链接。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;任何失败都不允许重试或继续后续阶段。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。 + 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,8 @@ Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0 **独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。 +**仅签名解释器或跳过本机 smoke。** Windows 代码完整性也检查 DLL 和 Python 扩展。启动程序有签名不能让未签名扩展被加载,跳过执行会隐藏安装后不可用的依赖。保留有效上游签名既减少硬件操作,也保留上游归属信息。 + ## Consequences 应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。 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-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 index 90b1137dc7..d85ac2b2b2 100644 --- 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 @@ -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-16-windows-desktop-titlebar.md -2026-09-16-windows-desktop-titlebar.md: 679e24b9e5b27e1f7dfce86ef35fd3d472651c37 -2026-09-16-windows-desktop-titlebar.zh.md: 6cc3c1ce35d150b8211caf5bcf350607f9b173fa +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 index 679e24b9e5..17fd2aac30 100644 --- a/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md +++ b/.agents/notes/implemented/feature/2026-09-16-windows-desktop-titlebar.md @@ -12,11 +12,11 @@ Windows needs a compact caption that keeps sidebar navigation available when the 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. Ctrl+, retains the Desktop Plugins shortcut. 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. +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 Desktop package management; the runtime plugin panel cannot replace the independent Desktop Plugins window. 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. +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. @@ -26,4 +26,4 @@ The caption remains available above fullscreen file panels, and collapsed naviga ## Testing -Desktop startup and preload tests cover platform isolation, language changes, palette messages, sender validation, native command mapping, menu lifecycle, and 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, plugin-window opening, caption navigation, and fullscreen clearance. Ordinary-browser verification checks the absence of desktop controls and retention of its sidebar rail. +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 index 6cc3c1ce35..05903d8696 100644 --- 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 @@ -12,11 +12,11 @@ Windows 需要紧凑的顶栏,在侧栏隐藏或文件铺满内容区时仍保 Windows 主窗口组合 Electron 隐藏标题栏与原生窗口按钮覆盖层。只有本地应用 preload 发布 `data-windows-titlebar`;共享布局、侧栏和全屏面板规则均要求该标记。原生顶栏颜色与右键菜单语言跟随应用文档。渲染器消息仅接受主窗口的主框架来源。 -Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。Ctrl+, 保留桌面插件快捷键。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。 +Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root 中挂载本地化“应用”和“编辑”顶栏入口,主进程为通过校验的请求打开原生弹出菜单。“应用”与其他平台共用命令模板;“编辑”保留 Windows 原生命令集合,并显式提供由语言字典管理的标签。顶栏导航复用现有侧栏回调,不另建导航存储,收起态的新建会话控件位于侧栏开关和菜单之间。[macOS 顶栏决策](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)仍独立适用于 macOS。 ## Alternatives considered -按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和 Desktop 包管理;运行时插件面板不能替代独立的桌面插件窗口。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。 +按 Alt 显示的原生菜单行额外占用纵向空间,且依赖键盘惯例才能发现。完全移除命令会丢失手动更新和编辑命令。插件管理使用主应用的“插件”页面。顶栏入口保留这些操作而不增加菜单行。原生弹出菜单保留命令执行与键盘导航,无需在 Web 组件中重复编辑器操作。 自定义 HTML 窗口按钮会让应用承担原生顶栏交互。原生覆盖层保留这些按钮,同时允许应用内容显示在旁边。全局 CSS 修改会影响 Web 和 macOS;由 preload 持有的标记将呈现限制在 Windows 主窗口。 @@ -26,4 +26,4 @@ Windows 移除独立的原生菜单行。应用 preload 在隔离的 shadow root ## Testing -桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、插件窗口打开、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。 +桌面启动与 preload 测试覆盖平台隔离、语言变更、调色板消息、来源校验、原生命令映射、菜单生命周期和桌面插件快捷键的缺失。菜单与侧栏本地快照覆盖本地化入口及展开、收起控件;布局测试覆盖零宽度收起。窗口验证覆盖原生弹出菜单、顶栏导航和全屏避让。普通浏览器验证检查桌面控件缺失及侧栏轨道保留。 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-web-sidebar-session-title-hover-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml new file mode 100644 index 0000000000..896278e3d6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.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-web-sidebar-session-title-hover-scroll.md +2026-09-17-web-sidebar-session-title-hover-scroll.md: 07f802f2c64aa40bb13ba679cda2007112b58d69 +2026-09-17-web-sidebar-session-title-hover-scroll.zh.md: aa08bac5018304a0e25e93062f7cc45ca415799e diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md new file mode 100644 index 0000000000..07f802f2c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md @@ -0,0 +1,41 @@ +# Agent Note: Web sidebar Session title hover scroll + +Status: implemented + +English | [中文](2026-09-17-web-sidebar-session-title-hover-scroll.zh.md) + +## Problem + +A Session title wider than its sidebar row is clipped with an ellipsis, so the part that distinguishes two related Sessions is the part that disappears. Fork makes it visible: the child's persisted title is the parent's plus an increment, so a long parent title leaves the `(1)` off the row's right edge. Opening the hover card shows the full title, but the card needs a dwell, covers neighboring rows, and is not the comparison the user is trying to make. + +## Decision + +A Session row reveals its clipped title in place while the pointer rests on the row. `SessionNodeItem` writes `title.scrollLeft = scrollWidth - clientWidth` on pointer enter and returns it with `scrollTo({ left: 0, behavior: 'instant' })` on pointer leave; the title element keeps `overflow: hidden`, so nothing reflows, no scrollbar appears, and the page never scrolls. Entering glides because the title carries `scroll-behavior: smooth` (the file's reduced-motion block restores `auto`), so the handler owns no timer or animation loop. Returning is deliberately one step: a smooth return would travel under the resting ellipsis and the row's narrowing cell for its whole duration. + +While the pointer is on the row, `text-overflow` switches from `ellipsis` to `clip`: a scrolled `overflow: hidden` box still paints the ellipsis at its right edge, which would cover exactly the characters the scroll just revealed (verified in Chromium before the rule was written). That rule sits behind `@media (hover: hover)`, because a touch tap latches `:hover` on the row after the title has already returned, and an unguarded rule would leave the tapped row hard-clipped at its start. + +The reveal belongs to the Session row, not to a single cell: entering anywhere on the row starts it, and it ends only when the pointer leaves the row. Workspace rows and search results keep their ellipsis behavior — their titles distinguish nothing by a trailing token — and only the Session title carries the scroll behavior. + +The hovered row is also the wider cell: its trailing relative-time label yields to the row menu, so the hovered `clientWidth` is larger than the resting one and the hovered scroll maximum is smaller. A handler that read the resting width would compute a larger delta that the browser clamps to the hovered maximum, reaching the same far edge; the browser scenario therefore polls the live extent instead of the value the handler read. + +## Alternatives considered + +**A moving inner span or a duplicated-copy marquee.** Rejected: `text-overflow: ellipsis` ellipsizes text directly inside the clipping block, so moving the text requires a nested element whose own clipping (or the duplicate copy) moves with it; the resting ellipsis, the existing DOM, and the accessible text would all change. Scrolling the existing element keeps every one of them. + +**A rAF or CSS-loop marquee.** Rejected: a continuous animation keeps moving while the user reads and needs per-row lifetime management. One scroll to the far edge and one back is the whole reveal. + +**`scrollTo({ behavior: 'smooth' })` for both moves.** Rejected: it needs a `matchMedia` probe for reduced motion plus a capability branch, and it would keep the return animating under the resting ellipsis. The stylesheet supplies the entering glide, and the leaving move asks for `instant` explicitly. + +**A handler-owned clip state cleared on `scrollend`.** Considered for the return transient; rejected because returning in one step removes the transient without a second state, an event listener, or a browser capability the jsdom lane lacks. + +**`direction: rtl` on hover.** Rejected: it jumps instead of scrolling and reorders punctuation inside mixed-direction titles. + +**Leaving the reveal to the hover card.** Rejected: the card opens only after its dwell and covers neighboring rows, so it cannot support comparing two adjacent rows. + +## Consequences + +A long title still occupies exactly one row line; the scroll range is measured from the element's own box, so a width change (the sidebar resizing, or the hovered row's own cell change) is picked up on the next pointer enter, and the instant return leaves the resting ellipsis and the text consistent whenever the pointer leaves. Revealing depends on a pointer resting on the row, so touch and keyboard users still rely on the hover card and the Rename dialog for the full title; removing the reveal leaves an ordinary ellipsized row. + +## Testing + +`packages/client/ui-workspace/tests/rows.client.spec.tsx` pins the scroll position written on pointer enter, the explicit instant return, and that a title that fits stays at its start. `packages/client/ui-workspace/tests/browser-styles.client.spec.ts` pins the stylesheet contract for the hovered row. `apps/web/tests/sidebar-title-hover-scroll.e2e.ts` hovers a seeded clipped row in the assembled application and asserts that the title really overflows, reaches its own live extent under the pointer, stops ellipsizing, returns to its start within one step, and that the computed scroll behavior is `smooth` in the default media state and `auto` under reduced motion. diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md new file mode 100644 index 0000000000..aa08bac501 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Web 侧栏 Session 标题悬停滚动 + +Status: implemented + +[English](2026-09-17-web-sidebar-session-title-hover-scroll.md) | 中文 + +## 问题 + +标题宽于侧栏行时会被省略号裁切,于是区分两个相关 Session 的那一部分恰好消失。Fork 会让这件事暴露出来:子会话持久化标题是父标题加一个递增序号,父标题一长,`(1)` 就落到行的右边界之外。悬停卡片能显示完整标题,但它需要停留等待、会盖住相邻行,而且用户想做的比较并不是看卡片。 + +## 决策 + +指针停在 Session 行上时,该行就地显示被裁切的标题。`SessionNodeItem` 在指针进入时写入 `title.scrollLeft = scrollWidth - clientWidth`,在指针离开时用 `scrollTo({ left: 0, behavior: 'instant' })` 一次性回到起点;标题元素保持 `overflow: hidden`,因此不会重排、不会出现滚动条,页面本身也不滚动。进入时之所以平滑,是因为标题带有 `scroll-behavior: smooth`(本文件的减少动效块恢复为 `auto`),处理函数因此不持有定时器或动画循环。返回被有意做成一步到位:平滑返回会让静止态省略号和收窄后的格子在整个返回过程中一直覆盖着文字。 + +指针停留在行上期间,`text-overflow` 由 `ellipsis` 切换为 `clip`:已滚动的 `overflow: hidden` 盒子仍会在右边界绘制省略号,而那正是刚刚被滚动显示出来的字符(写这条规则前已在 Chromium 中验证)。这条规则位于 `@media (hover: hover)` 之内,因为触屏点按会在标题已经返回之后仍让该行的 `:hover` 保持钉住,不设保护就会让被点按的行停在起点且硬裁切、失去省略号。 + +该行为属于 Session 行,而不是某一格:指针进入行的任意位置都会开始,只有离开该行才结束。Workspace 行与搜索结果保留省略号行为——它们的标题没有靠尾部记号区分的含义——只有 Session 标题带有滚动行为。 + +悬停中的行同时也是更宽的格子:行尾的相对时间标签会让位给行操作菜单,因此悬停时的 `clientWidth` 比静止时更大,可滚动上限更小。若处理函数读到的是静止宽度,它会算出一个更大的位移,浏览器会把它夹到悬停状态的上限,最终同样到达远端;因此浏览器场景断言的是实时到达范围,而不是处理函数读到的数值。 + +## 备选方案 + +**移动的内层元素或复制副本的跑马灯。** 否决:`text-overflow: ellipsis` 只对裁切块内直接包含的文本生效,因此移动文本需要嵌套元素,而它自身的裁切(或复制副本)会随之移动;静止态的省略号、现有 DOM 和可访问文本都会改变。滚动现有元素则三者都保留。 + +**rAF 或 CSS 循环跑马灯。** 否决:持续动画会在用户阅读时继续移动,并且需要逐行管理生命周期。滚到远端再滚回一次,就是这个揭示所需的全部动作。 + +**两个方向都用 `scrollTo({ behavior: 'smooth' })`。** 否决:它需要一个 `matchMedia` 探测来处理减少动效,还需要一个能力分支,并且会让返回过程在静止态省略号之下持续动画。进入的平滑由样式表提供,离开则显式要求 `instant`。 + +**由处理函数持有裁切状态并在 `scrollend` 时清除。** 曾用于消除返回过程的瞬态;否决,因为一步返回直接消除该瞬态,无需第二个状态、事件监听或 jsdom 车道缺少的浏览器能力。 + +**悬停时使用 `direction: rtl`。** 否决:它是跳变而非滚动,并且会重排混合方向标题中的标点。 + +**把揭示交给悬停卡片。** 否决:卡片要等停留时间结束才出现,而且会盖住相邻行,无法支持并排比较两行。 + +## 后果 + +长标题仍然只占一行;滚动范围按元素自身盒宽测量,因此宽度变化(侧栏缩放,或悬停行自身的格子变化)会在下一次指针进入时被重新读取,而一步返回保证指针离开时静止态省略号与文字始终一致。揭示依赖指针停在行上,所以触屏与键盘用户仍需通过悬停卡片和重命名对话框获取完整标题;去掉该行为后,这一行只是普通的省略号行。 + +## 测试 + +`packages/client/ui-workspace/tests/rows.client.spec.tsx` 钉住指针进入时写入的滚动位置、显式的一步返回,以及能放下的标题停留在起点。`packages/client/ui-workspace/tests/browser-styles.client.spec.ts` 钉住悬停行的样式表契约。`apps/web/tests/sidebar-title-hover-scroll.e2e.ts` 在组装后的应用中悬停一条被裁切的种子会话行,断言标题确实溢出、到达指针下的实时范围、停止省略、一步回到起点,并断言默认媒体状态下计算出的滚动行为为 `smooth`、减少动效下为 `auto`。 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-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/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml new file mode 100644 index 0000000000..5c2380b3d6 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.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/simplification/2026-09-17-plugin-inventory-phase-dot.md +2026-09-17-plugin-inventory-phase-dot.md: e90d49b6a3d2d8e03f662b3de531ef180d350cad +2026-09-17-plugin-inventory-phase-dot.zh.md: d344ede75c7e0dd77a0d57720e0c1366f22e75b8 diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md new file mode 100644 index 0000000000..e90d49b6a3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md @@ -0,0 +1,35 @@ +# Agent Note: The plugin inventory reserves its status dot for phases the tag cannot state + +Status: implemented + +English | [中文](2026-09-17-plugin-inventory-phase-dot.zh.md) + +## Problem + +The Settings plugin list marks every Loader entry with an enablement tag (`已启用`, `已停用`, `条件启用`, `预设中启用`, `启动失败`). It also drew a colored root-fiber status dot on enabled rows whose fiber was live. For the common `active` phase the dot and the tag stated the same thing twice on one collapsed card, while `pending`, `loading` and `unloading` — the phases the tag has no words for — were where the dot carried its own information. + +## Decision + +`PluginInventorySettingsTab` renders the status dot only for `pending`, `loading` and `unloading`. `active` and `failed` render no dot: a settled enabled row shows its enablement tag alone, and a failed row has the failure tag and card treatment. `PHASE_DOT_STATES` lists exactly the dotted phases, and `showsPhaseDot` narrows a phase to that union. + +Row enablement, tag tones, grouping, search and list behavior are unchanged, as are the Loader lifecycle and persistence. The expanded card still lists the phase under 运行状态 / Status, so the runtime phase stays readable without the collapsed dot. + +## Alternatives considered + +**Remove the dot for every phase.** Simpler to state, but it drops the only collapsed-state signal that a plugin is still loading or unloading — the one state the tag cannot express. + +**Keep the `active` dot on preset rows only.** The preset group is where the duplication was first noticed, but the global group renders the same tag-and-dot pair. A per-group exception would make one rendering rule depend on which group a row sits in, and would leave the duplicate in the global group. + +**Fold the phase into the enablement tag.** The tag reports the config fact the Host publishes as `enabled`; adding a runtime phase would make one control carry two lifecycles with different owners. + +## Consequences + +- A collapsed card no longer shows that an enabled entry's fiber is currently `active`. A reader who needs the phase expands the row, whose Status fact keeps it. That is the information the change deliberately gives up. +- Rows mid-transition keep their dot, its animation and its `role="img"` name, so the accessible name still says `等待依赖`, `加载中` or `卸载中`. +- Known gap, pre-existing and unchanged: the dot is gated on `row.enabled`, so a row a user just disabled while its fiber is still `unloading` shows `已停用` without a dot. + +## Testing + +The package spec pins the rule in both scopes: `loading` and `pending` (global) and `unloading` (preset) keep a dot, while `active`, `failed` and a row without a live fiber render none. `apps/web/tests/settings-chrome.e2e.ts` asserts the assembled global group contains enabled rows and no `role="img"` element named `运行中`. + +The remaining dots' palette, halo and animation, and the tag capsule itself, stay owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md). diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md new file mode 100644 index 0000000000..d344ede75c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 插件列表只为主题标签无法表达的阶段保留状态圆点 + +Status: implemented + +[English](2026-09-17-plugin-inventory-phase-dot.md) | 中文 + +## 问题 + +设置里的插件列表给每个 Loader 条目配一个启停标签(`已启用`、`已停用`、`条件启用`、`预设中启用`、`启动失败`),同时给 fiber 存活的已启用条目画一个彩色根 fiber 状态圆点。对最常见的 `active` 阶段,圆点与标签在同一张收起卡片上把同一件事说了两遍;而标签没有词可用的 `pending`、`loading`、`unloading`,才是圆点真正携带独立信息的阶段。 + +## 决策 + +`PluginInventorySettingsTab` 只为 `pending`、`loading`、`unloading` 渲染状态圆点。`active` 与 `failed` 不渲染:已稳定启用的条目只显示启停标签,失败条目由失败标签和卡片样式说明。`PHASE_DOT_STATES` 只列出有圆点的阶段,`showsPhaseDot` 把阶段收窄到该联合。 + +条目启停、标签色调、分组、搜索与列表行为不变,Loader 生命周期与持久化也不变。展开卡片仍在「运行状态」下给出阶段名,因此不画收起态圆点也能读到运行时阶段。 + +## 否决的备选方案 + +**所有阶段都不画圆点。** 规则更简单,但会丢掉收起态下唯一能说明插件正在加载或卸载的信号——恰恰是标签无法表达的那个状态。 + +**只在预设分组保留 `active` 圆点。** 重复最先是在预设分组被注意到的,但全局分组渲染的是同一套标签加圆点。按分组开例外会让同一条渲染规则取决于条目落在哪个分组,而且全局分组的重复仍然存在。 + +**把阶段并进启停标签。** 标签报告的是 Host 以 `enabled` 发布的配置事实;再塞一个运行时阶段会让一个控件承载两条归属不同的生命周期。 + +## 后果 + +- 收起卡片不再显示已启用条目的 fiber 当前处于 `active`。需要该阶段的读者展开条目,其「运行状态」事实仍然给出。这是本次变更刻意放弃的信息。 +- 过渡中的条目保留圆点、动画与 `role="img"` 名称,无障碍名称仍会读出 `等待依赖`、`加载中` 或 `卸载中`。 +- 既有且未改变的缺口:圆点以 `row.enabled` 为前提,用户刚停用而 fiber 仍在 `unloading` 的条目只显示 `已停用`,没有圆点。 + +## 测试 + +包级用例在两个作用域钉住规则:`loading` 与 `pending`(全局)、`unloading`(预设)保留圆点,`active`、`failed` 与没有存活 fiber 的条目都不渲染。`apps/web/tests/settings-chrome.e2e.ts` 断言装配后的全局分组含有已启用条目,且没有名为 `运行中` 的 `role="img"` 元素。 + +其余圆点的配色、光晕与动画,以及标签胶囊本身,仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)拥有。 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/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.i18n.yaml new file mode 100644 index 0000000000..6ebebc30d8 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-journal.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-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/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index ef20de8097..a08e4b5179 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -13,7 +13,7 @@ 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 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/.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 15e2ec41f4..ce14413b4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,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 575964fb75..7eb0c36e18 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -160,7 +160,6 @@ External packages **directly declared** for development, tests, types, or toolin | 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 | @@ -196,6 +195,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/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/desktop-host/package.json b/apps/desktop-host/package.json index b694f11e5a..c47acf4612 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -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..e162c805d0 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -3,6 +3,14 @@ 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= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index b5d2608e8c..3341693119 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: 9842ceb57928223d0743e283656ee2580af0c38b -README.zh.md: 0ce8dc8229ffd955d8b34283462e1f63bdf42532 +README.md: 14bbdb7ec0fddd47821c12ffe46549dd29abe0de +README.zh.md: 1e704900743ec6f339efdf849e60767c961d07ee diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 9842ceb579..14bbdb7ec0 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 preserves valid vendor signatures and signs unsigned PE executables, DLLs, Python extensions and Node addons in the primary runtime before executing its smoke checks. Each new signature must match the configured certificate and carry a timestamp; invalid existing signatures, signing errors and verification errors stop the run without retries. Electron-builder preserves copied runtime executables only after checking their signature and exact equality with the prepared file, preventing repeat signing during resource copying. Checks include decimal, XML, LZMA, UUID, numpy and pandas. 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. A passing smoke does not establish compatibility for every extension or 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,13 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, and native directory selection. 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 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. On Windows, the main document's language updates desktop menus, recovery and update prompts, plugin-window titles, plugin-manager locale responses, and plugin-operation error text. The repository Client UI i18n gate checks 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. -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 Desktop Plugins, 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. Ctrl+, opens the separate Desktop Plugins window, which manages Desktop packages rather than the sidebar's runtime plugin panel. 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. +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. @@ -61,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 third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic. 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. +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 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 @@ -94,9 +95,28 @@ Workspace development runs the current CLI and private Desktop Host packages und ## 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 @@ -138,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: @@ -154,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. @@ -176,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. @@ -184,8 +210,18 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an ### Windows EV signing +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. A 60-second preflight deadline, signing error or verification failure stops the run without retry. 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 releases that file. 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. @@ -198,7 +234,7 @@ pnpm run package:desktop:win:x64 Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows 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. +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 primary-runtime signing stage 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: @@ -217,13 +253,63 @@ This diagnostic command is an alternative stopping point, not the first half of 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. +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, including from the Windows caption's Application menu. 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. -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 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 @@ -234,3 +320,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 0ce8dc8229..1e70490074 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 可执行文件、DLL、Python 扩展和 Node 插件补签。每个新签名必须匹配配置的证书且带时间戳;已有签名无效、签名错误或验签错误都会停止本轮执行,不自动重试。electron-builder 只有在校验复制后运行时可执行文件的签名、且文件与已准备的源文件逐字节一致后,才保留其签名,避免复制资源时重复签名。检查覆盖 decimal、XML、LZMA、UUID、numpy 和 pandas。开发、仅准备和未签名构建不使用硬件令牌,可能被 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,13 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 +应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 -Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示、插件窗口标题、插件管理器语言响应及插件操作错误文案。仓库 Client UI i18n 检查覆盖桌面端源码。 +Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 -Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供桌面插件、检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。Ctrl+, 打开独立的桌面插件窗口,管理 Desktop 包,与侧栏的运行时插件面板不同。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 +Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 @@ -61,12 +63,12 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。 2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。 -4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。 -5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。 +4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。 +5. 共享管理器负责安装错误、激活和重启要求。即使 Host 无法启动,原生恢复仍可禁用第三方 bundle。 -CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。 +[Web 插件 UI](../../packages/client/ui-plugin-manager/README.zh.md)负责管理界面。Desktop profile 初始化和恢复保留已安装插件文件。 -主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。 +主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。共享 Web 插件管理器报告包操作错误;插件变更后的 Host 启动失败会进入原生恢复。不通过启动超时推断故障。 包含 `listen EADDRINUSE` 的监听失败以退出其他正在运行的 DSH 实例的提示替代诊断和重装建议,仅提供退出和重启。 原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。 @@ -94,9 +96,28 @@ Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Hos ## 打包 + + +### 发布版本 + +每次 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 @@ -138,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`。 更新目标与上传凭据都与所选环境对应: @@ -154,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)负责依赖补丁和验证要求。 @@ -176,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 接入方式和发布验证要求。 @@ -184,8 +211,18 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI ### Windows EV 签名 +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 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 @@ -198,7 +235,7 @@ 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 发布打包会失败,不会生成未签名产物。 -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 +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` 命令可以生成可直接运行的应用目录,而不是安装包,例如: @@ -217,13 +254,63 @@ pnpm run prepare:desktop 每条打包命令都会构建仓库,打包以 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 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 + 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 ## 更新 -打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项手动触发同一检查,Windows 从顶栏的“应用”菜单进入。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 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 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。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)保留这些限制;生产发布要求保持不变。 ## 底层开发覆盖项 @@ -234,3 +321,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..b305647650 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -12,8 +12,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 +28,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 +45,7 @@ export interface DesktopElectronBuilderConfig { readonly installerLanguages: readonly ['en_US', 'zh_CN'] } readonly beforeBuild: () => Promise + readonly beforePack: (context: { readonly appOutDir: string }) => Promise readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise | undefined readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null } @@ -42,12 +55,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..3925ab33c1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,8 +11,10 @@ "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", "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 +38,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,6 +46,7 @@ "@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", 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..c0c4a28513 --- /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..807209a0cd --- /dev/null +++ b/apps/desktop/renderer/mandatory-update.js @@ -0,0 +1,112 @@ +/** 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) { + 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..043c0d0cbc --- /dev/null +++ b/apps/desktop/renderer/update-dialog.css @@ -0,0 +1,26 @@ +: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; 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; } diff --git a/apps/desktop/renderer/update-dialog.html b/apps/desktop/renderer/update-dialog.html new file mode 100644 index 0000000000..176a0dcf12 --- /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..baa4ac75f6 --- /dev/null +++ b/apps/desktop/renderer/update-dialog.js @@ -0,0 +1,42 @@ +/** 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(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() +}) +void api.status().then(state => { + 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 + 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').focus() +}) 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..0166288df4 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -7,13 +7,14 @@ 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' 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 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)$/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 +75,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') { @@ -96,5 +98,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..ea33c60b2a --- /dev/null +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -0,0 +1,204 @@ +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 } from './windows-runtime-signature.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 + 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 => primaryRuntimeDestination === undefined ? false : preserveWindowsRuntimeSignature(path, { + sourceRoot: join(buildPaths.runtime, 'primary-runtime'), + destinationRoot: primaryRuntimeDestination, + runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR, + }), + }) + : 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: [ + '**/*.{node,dylib,dll,so,exe}', + '**/*.so.*', + '**/spawn-helper', + '**/@vscode/ripgrep/bin/rg', + ], + 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 (windowsSigner !== undefined) primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime') + if (policy === undefined) return + const { resolveDesktopPolicyConfig } = await import('../lib/types/mandatory-update-policy.js') + resolveDesktopPolicyConfig(policy) + }, + afterPack: async context => { + const { verifyDesktopRuntime, writeDesktopRuntime } = 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) + } + if (resolvedPlatform === 'win32' && !unsigned) { + // Windows signs copied executable resources before afterPack runs. + const prepared = await verifyDesktopRuntime(buildPaths.dsh, + context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + writeDesktopRuntime(buildPaths.dsh, prepared.release, prepared.sharedPackages.map(entry => entry.name), + { platform: resolvedPlatform, arch: resolvedArch }) + } + await verifyDesktopRuntime(buildPaths.dsh, + context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + }, + afterSign: async context => { + 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..a1c61a5d76 --- /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-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/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..0cbf4c6898 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -11,6 +11,8 @@ 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 } from './packaging-run.mjs' +import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs' const APP_ROOT = resolve(import.meta.dirname, '..') const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') @@ -253,11 +255,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 +285,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 +331,40 @@ 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 + if (signPrimaryRuntime) { + if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run') + 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'], targetEnv) if (invocation.prepareOnly) return if (target.platform === 'darwin' && !invocation.directory) { - await runPnpm([ + await execute([ ...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false', ], electronBuilderEnv) @@ -331,9 +373,9 @@ 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 execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) } 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-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-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.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/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts new file mode 100644 index 0000000000..f196f56126 --- /dev/null +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -0,0 +1,105 @@ +/** Sign Windows runtime code before executing it, retaining vendor signatures and fail-stop hardware protection. */ +import { X509Certificate } from 'node:crypto' +import { lstat, open, readdir, readFile } from 'node:fs/promises' +import { extname, join, resolve } from 'node:path' +import { createWindowsTokenSigner } from './windows-sign.mjs' +import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs' +import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' +import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' + +/** + * Enumerate Windows code without following links or treating foreign .node files as PE binaries. + * @param root - Owned, materialized runtime directory. + * @returns Sorted real PE files; rejects links and malformed Windows executable files. + */ +export async function windowsRuntimeCode(root: string): Promise { + const rootStat = await lstat(root) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('primary runtime: expected a real directory') + const files: string[] = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name) + if (entry.isSymbolicLink()) throw new Error(`primary runtime: directory links are not signable: ${path}`) + if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue } + if (!entry.isFile() || !['.exe', '.dll', '.pyd', '.node'].includes(extname(path).toLowerCase())) 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 || extname(path).toLowerCase() !== '.node') throw new Error(`primary runtime: invalid PE file: ${path}`) + } + return files.sort() +} + +interface RuntimeSigningOptions { + thumbprint: string + sign: ReturnType + inspect?: (path: string) => Promise + record: (event: object) => void + smoke: (root: string) => void +} + +/** + * Preserve valid signatures and sign only unsigned PE files before runtime execution. + * @param root - Owned final runtime directory. + * @param options - Supervised signer, certificate identity, audit sink and runtime check. + * @returns Resolves only after sequential signatures, verification and execution; no retries. + */ +export async function signWindowsPrimaryRuntime(root: string, options: RuntimeSigningOptions): Promise { + const inspect = options.inspect ?? inspectWindowsRuntimeSignature + const files = await windowsRuntimeCode(root) + if (files.length === 0) throw new Error('primary runtime: no Windows code found') + const unsigned: string[] = [] + for (const path of files) { + const signature = await inspect(path) + options.record({ type: 'primary-runtime-signature', path, ...signature }) + if (signature.status === 'NotSigned') unsigned.push(path) + else if (signature.status !== 'Valid') throw new Error(`primary runtime: refusing ${signature.status} signature: ${path}`) + } + options.record({ type: 'primary-runtime-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(`primary runtime: signing verification failed: ${path}`) + } + options.record({ type: 'primary-runtime-signature-verified', path, ...signature }) + } + options.smoke(root) + options.record({ type: 'primary-runtime-smoke-success' }) +} + +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(':', '') + try { + await signWindowsPrimaryRuntime(join(resolveDesktopTargetBuildPaths().runtime, 'primary-runtime'), { + thumbprint, + 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 }), + record: (event) => { recordPackagingEvent(runDirectory, event) }, + smoke: smokePrimaryRuntime, + }) + } 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/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-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts new file mode 100644 index 0000000000..7f9d9cf797 --- /dev/null +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -0,0 +1,26 @@ +/** 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 diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs new file mode 100644 index 0000000000..04b1d7dbb8 --- /dev/null +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -0,0 +1,49 @@ +/** Inspect runtime signatures and preserve byte-identical copies made by electron-builder. */ +import { execFile } from 'node:child_process' +import { readFile, realpath } from 'node:fs/promises' +import { 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' + +/** + * 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(`primary runtime: invalid signature inspection: ${path}`) + } + return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint } +} + +/** + * Preserve a copied primary-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(`primary runtime: linked copy is not signable: ${file}`) + } + const [prepared, copied] = await Promise.all([readFile(source), readFile(path)]) + if (!prepared.equals(copied)) throw new Error(`primary runtime: copied executable changed: ${path}`) + const signature = await (options.inspect ?? inspectWindowsRuntimeSignature)(path) + if (signature.status !== 'Valid') throw new Error(`primary runtime: copied signature is ${signature.status}: ${path}`) + recordPackagingEvent(options.runDirectory, { type: 'primary-runtime-copy-verified', path, ...signature }) + return true +} 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..d491196a74 100644 --- a/apps/desktop/scripts/windows-sign.mjs +++ b/apps/desktop/scripts/windows-sign.mjs @@ -6,6 +6,7 @@ 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' const execFileAsync = promisify(execFile) const { WineVmManager } = wineVmModule @@ -67,7 +68,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 +158,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 } = resolveCertificateFile(options.certificateFile) const signTool = resolveSignTool(options.signTool) const { keyContainer, tokenPin } = resolveTokenIdentity(options) const commandInterpreter = options.commandInterpreter @@ -152,11 +174,14 @@ export function createWindowsTokenSigner(options) { if (configuration.hash !== 'sha256') { throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`) } + if (await options.preserveSignature?.(configuration.path)) return await repairDanglingAuthenticodeDirectory(configuration.path) const secrets = [tokenPin] + const attempt = beginWindowsSigningAttempt({ runDirectory: options.runDirectory, + stateDirectory: options.stateDirectory, target: configuration.path }) let result try { - result = await execFileAsync(commandInterpreter, [ + const operation = execFileAsync(commandInterpreter, [ '/d', '/v:off', '/c', @@ -173,10 +198,15 @@ export function createWindowsTokenSigner(options) { }), windowsHide: true, }) + attempt.started(operation.child?.pid ?? null) + result = await operation } catch (error) { - throw createRedactedWindowsSigningError(error, configuration.path, secrets) + const failure = createRedactedWindowsSigningError(error, configuration.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) diff --git a/apps/desktop/scripts/windows-signing-preflight.ts b/apps/desktop/scripts/windows-signing-preflight.ts new file mode 100644 index 0000000000..fd4546c869 --- /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 and never retry or unlock a token. + */ +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-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/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 5be4570ecf..e78db93fee 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,54 +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 ce8c760d54..03e6f29a57 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -2,6 +2,7 @@ export const en = { application: 'Application', + aboutMenu: 'About DeepSeek Harness', edit: 'Edit', menuBar: 'Application menu', delete: 'Delete', @@ -20,41 +21,71 @@ export const en = { restartApplication: 'Restart', recoveryOperationFailed: 'The recovery operation failed', disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart', - pluginsMenu: 'Desktop Plugins…', checkUpdatesMenu: 'Check for Updates…', updateCheckFailedTitle: 'Update Check Failed', + 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. */ @@ -62,6 +93,7 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', + aboutMenu: '关于 DeepSeek Harness', edit: '编辑', menuBar: '应用菜单', delete: '删除', @@ -80,41 +112,71 @@ export const zh = { restartApplication: '重启', recoveryOperationFailed: '恢复操作失败', disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启', - pluginsMenu: '桌面插件…', 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 3412e6f5e6..d5bfb1e0b3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,7 +2,7 @@ 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, @@ -10,6 +10,7 @@ import { dialog, ipcMain, Menu, + powerMonitor, nativeTheme, protocol, session, @@ -18,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' @@ -28,6 +29,16 @@ 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 => {} @@ -68,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 @@ -178,27 +182,9 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi 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 @@ -206,16 +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 @@ -246,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 => { @@ -273,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 }) @@ -284,11 +341,60 @@ 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) if (url.hostname === 'app') { @@ -301,7 +407,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 })) }) @@ -336,115 +441,149 @@ 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(currentDesktopLocale().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 currentDesktopLocale() - }) // 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: currentDesktopLocale().messages.updateCheckFailedTitle, - message: state.message ?? currentDesktopLocale().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 } - return - } - if (state.phase !== 'available') { - if (manual) { - await dialog.showMessageBox({ - type: 'info', - title: currentDesktopLocale().messages.updateCheckTitle, - message: state.message ?? currentDesktopLocale().messages.updateCurrent, - }) + let state = updates.state + if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { + const controller = new AbortController() + ordinaryDialogs.add(controller) + const progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle, + message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal }) + try { + if (!joinedPolicyAuthentication) { + void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) }) + } + state = await updateSchedule.check(true) + } finally { controller.abort(); ordinaryDialogs.delete(controller); await progress } } - return - } - const result = await dialog.showMessageBox({ - type: 'info', - title: currentDesktopLocale().messages.updateTitle, - message: currentDesktopLocale().messages.updateAvailable, - detail: formatDesktopMessage(currentDesktopLocale().messages.updateDetail, { version: state.version ?? '' }), - buttons: [currentDesktopLocale().messages.installAndRestart, currentDesktopLocale().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: currentDesktopLocale().messages.updateFailedTitle, - message: installed.message ?? currentDesktopLocale().messages.unknownError, - }) - } + 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) { + failedOperation = 'download' + await showUpdateFailure(await downloadUpdate(state.version)) + } + }).catch((error: unknown) => showUpdateFailure({ phase: 'error', failedOperation, + message: desktopErrorState(error).message })) + .finally(() => { promptOperation = undefined; flushQueuedPolicyAuthentication() }) + return promptOperation } - const openPluginWindow = (): void => { - if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) { - pluginWindow.focus() + 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 } - pluginWindow = createWindow(managementPreload) - pluginWindow.setSize(900, 620) - pluginWindow.setTitle(currentDesktopLocale().messages.pluginWindowTitle) - pluginWindow.once('ready-to-show', () => { pluginWindow?.show() }) - pluginWindow.once('closed', () => { pluginWindow = undefined }) - void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`) + 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 + } + // 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' @@ -455,18 +594,15 @@ async function main(): Promise { ? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] : [] const applicationItems = (): MenuItemConstructorOptions[] => [ - { - label: currentDesktopLocale().messages.pluginsMenu, - accelerator: 'CmdOrCtrl+,', - click: openPluginWindow, - }, - { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } }, + { 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: process.platform === 'darwin' ? app.name : currentDesktopLocale().messages.application, + label: darwin ? app.name : currentDesktopLocale().messages.application, submenu: applicationItems(), }, ...platformMenus])) @@ -522,14 +658,7 @@ async function main(): Promise { const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, true, true) mainWindow = window - if (process.platform === 'win32') { - window.webContents.on('before-input-event', (event, input) => { - if (input.type === 'keyDown' && input.control && !input.alt && !input.shift && input.key === ',') { - event.preventDefault() - openPluginWindow() - } - }) - } + 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()) { @@ -548,6 +677,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 } @@ -567,23 +698,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() }) + 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..2705eeb3fb --- /dev/null +++ b/apps/desktop/src/mandatory-update-window.ts @@ -0,0 +1,251 @@ +/** 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 +} + +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 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 + this.window?.destroy() + this.window = undefined + this.error = undefined + return + } + 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 + 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 ff4f1cfb80..26b7334707 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,11 +1,24 @@ -/** 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__', { @@ -19,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-update-dialog.ts b/apps/desktop/src/preload-update-dialog.ts new file mode 100644 index 0000000000..65cf46fcd4 --- /dev/null +++ b/apps/desktop/src/preload-update-dialog.ts @@ -0,0 +1,9 @@ +/** 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: index => ipcRenderer.invoke(UPDATE_DIALOG_IPC.respond, index) as Promise, +} +if (location.href === 'dsh-app://shell/update-dialog.html') contextBridge.exposeInMainWorld('dshUpdateDialog', api) 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 3bbb975430..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, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile, - unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate, + initProfile, PROFILE_TEMPLATES, sanitizeProfile, type ProfileTemplate, } from '@deepseek-ai/dsh-app-boot' import { migrateDesktopProfileLinks } from './profile-packages.ts' import { cleanProfileCorePackages } from './profile-core-cleanup.ts' -/** Desktop plugin record derived from the installed profile. */ -export interface DesktopPluginRecord { - readonly name: string - /** Installed version, or the dependency spec when installed metadata is unavailable. */ - readonly version: string - readonly enabled: boolean -} - -/** Exact executables the desktop shell bundles. */ -export interface DesktopRuntimeExecutables { - readonly node: string - readonly nodeBin?: string - readonly pnpm: string - readonly dsh: string -} - -/** Hooks that stop the backend before profile writes and restart it after each attempted change. */ -export interface DesktopProjectHooks { - /** Stop the active backend and await process exit before modifying its files. */ - beforeChange(): Promise - /** 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,28 +59,17 @@ function migrateProfileSettings(projectDir: string): void { } } -/** Desktop npm project manager with direct writes and no rollback. */ +/** Initializes the Desktop profile and disables third-party bundles during recovery. */ export class DesktopProjectManager { - private lockDescriptor: number | undefined - private descriptor: DesktopRuntimeDescriptor | undefined - /** * @param paths - Electron-owned package state and reserved desktop profile paths. - * @param runtime - absolute Electron and pnpm entry paths. + * @param runtime - location of the bundled application runtime. */ constructor( readonly paths: DesktopPaths, - readonly runtime: DesktopRuntimeExecutables, + readonly runtime: { readonly dsh: string }, ) {} - /** Read the active desktop plugin inventory. */ - listPlugins(): readonly DesktopPluginRecord[] { - if (!existsSync(this.paths.profile)) return [] - return readProfilePlugins(this.pluginLocation(this.paths.profile)).dependencies - .map(({ name, version, enabled }) => ({ name, version, enabled })) - .sort((left, right) => left.name.localeCompare(right.name)) - } - /** * Back up the profile patch and disable third-party bundles without loading application resources. * The caller must stop the Host first. @@ -130,157 +79,33 @@ export class DesktopProjectManager { return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles)) } - /** Read the dsh version supplied by this application's verified resources. */ - dshVersion(): string { - return this.currentRuntime().release.version - } - - private currentRuntime(): DesktopRuntimeDescriptor { - if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded') - return this.descriptor - } - - private readRuntime(): DesktopRuntimeDescriptor { - this.descriptor = undefined - return readDesktopRuntime(this.runtime.dsh) - } - - private pluginLocation(profileDir: string) { - return { binName: 'dsh', profileDir, installAnchor: join(this.runtime.dsh, 'node_modules', DSH_PACKAGE, 'package.json') } - } - /** * Load application metadata and prepare the external plugin profile without installing packages. * @param production - Remove application-owned profile packages before packaged Host startup. */ async applyRelease(production = false): Promise { 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 { @@ -290,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..6ba2fc269a --- /dev/null +++ b/apps/desktop/src/update-dialog.ts @@ -0,0 +1,113 @@ +/** 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', respond: 'dsh-update-dialog:respond' } as const + +/** Text and choices supplied by the main process, never by product documents. */ +export interface UpdateDialogView { + 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(index: number): Promise +} + +const page = 'dsh-app://shell/update-dialog.html' + +/** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */ +export class DesktopUpdateDialog { + private disposed = false + private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number) => 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).view) + ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, index: unknown) => { + const active = this.owned(event) + 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 the cancel response when closed, replaced, aborted, or unable to load. + */ + show(parent: BrowserWindow, options: UpdateDialogOptions): Promise { + this.cancel() + 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 }) + } + const window = createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle) + const view: UpdateDialogView = { 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): void => { + if (this.active?.window !== window) return + this.active = undefined + options.signal?.removeEventListener('abort', abort) + if (!window.isDestroyed()) window.destroy() + resolve({ response, checkboxChecked: false }) + } + this.active = { window, view, finish } + options.signal?.addEventListener('abort', abort, { once: true }) + window.once('closed', abort) + window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() }) + window.webContents.once('render-process-gone', abort) + void window.loadURL(page).catch(abort) + }) + } + + /** 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() + ipcMain.removeHandler(UPDATE_DIALOG_IPC.status) + ipcMain.removeHandler(UPDATE_DIALOG_IPC.respond) + } + + private owned(event: IpcMainInvokeEvent): NonNullable { + const active = this.active + if (active === undefined || event.sender !== active.window.webContents + || event.senderFrame !== active.window.webContents.mainFrame || event.senderFrame.url !== page) { + throw new Error('desktop update: rejected unowned dialog renderer') + } + return active + } +} 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..39bcde23db --- /dev/null +++ b/apps/desktop/src/update-overlay.ts @@ -0,0 +1,53 @@ +/** 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. + * @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): BrowserWindow { + const window = new BrowserWindow({ + parent, modal: true, 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 }, + }) + 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) + 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/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml new file mode 100644 index 0000000000..791271a555 --- /dev/null +++ b/apps/desktop/tests/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/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/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/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/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/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..3626bcfcad --- /dev/null +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -0,0 +1,119 @@ +/** 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) + 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 + 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/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 149e512127..246dcbcfdf 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -2,9 +2,17 @@ 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 { MenuItemConstructorOptions, 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() })) @@ -18,46 +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() readonly setSize = vi.fn() - readonly setTitle = vi.fn() readonly setTitleBarOverlay = vi.fn() - constructor(readonly options: { show: boolean }) { super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) } + 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() @@ -79,34 +116,36 @@ 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<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>() return { - windows, hosts, handlers, app, FakeWindow, FakeHost, failWindow(error: Error) { windowFailure = error }, - popup, - socketHeaders: vi.fn(), + 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>(), - menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn((_items: MenuItemConstructorOptions[]) => ({ popup })) }, - dialog: { - showOpenDialog: vi.fn(), - showErrorBox: vi.fn(), - showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise>(), - }, + 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(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), - mutateFailure: vi.fn<() => void>(), disableAllPlugins: vi.fn(async () => { pluginsEnabled = false return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000' @@ -114,21 +153,44 @@ const harness = await vi.hoisted(async () => { 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, @@ -137,54 +199,99 @@ vi.mock('electron', () => ({ nativeTheme: { themeSource: 'system' }, ipcMain: { on: harness.ipcOn, - 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) }, + 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() }, + 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', 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 () => { @@ -200,7 +307,185 @@ afterEach(async () => { }) describe('desktop main startup', () => { - it.each(['darwin', 'win32', 'linux'] as const)('uses platform-owned titlebar styling on %s', async (platform) => { + 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') await harness.preparing.promise @@ -241,7 +526,7 @@ describe('desktop main startup', () => { listener(event, {}, '#fff', '#000') expect(window.setTitleBarOverlay).toHaveBeenLastCalledWith({ color: '#fff', symbolColor: '#000' }) window.setTitleBarOverlay.mockClear() - window.webContents.mainFrame.url = 'dsh-app://shell/plugin-manager.html' + 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) @@ -262,7 +547,7 @@ describe('desktop main startup', () => { 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([ - '桌面插件…', '检查更新…', 'separator', '退出', + '关于 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') @@ -282,11 +567,6 @@ describe('desktop main startup', () => { } harness.popup.mock.lastCall![0].callback!() await edit - const preventDefault = vi.fn() - window.webContents.emit('before-input-event', { preventDefault }, { type: 'keyDown', control: true, key: ',' }) - expect(preventDefault).toHaveBeenCalledOnce() - expect(harness.windows[1]!.urls).toEqual(['dsh-app://shell/plugin-manager.html']) - expect(harness.windows[1]!.options).not.toHaveProperty('titleBarStyle') }) it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => { @@ -304,8 +584,8 @@ describe('desktop main startup', () => { : ['Application', 'editMenu']) const application = template[0]!.submenu as MenuItemConstructorOptions[] expect(application.map(describeItem)).toEqual(platform === 'darwin' - ? [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] - : [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'quit']) + ? ['about', 'separator', en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] + : ['about', 'separator', en.checkUpdatesMenu, 'separator', 'quit']) expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() }) @@ -372,10 +652,10 @@ describe('desktop main startup', () => { 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: '' }, @@ -385,20 +665,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 } @@ -417,6 +692,472 @@ 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('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(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 }) }) + }).mockResolvedValueOnce({ 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(() => {}) }) @@ -424,7 +1165,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 () => { @@ -440,7 +1181,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/']) }) @@ -452,47 +1193,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 () => { @@ -500,8 +1201,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 third-party plugins, back up profile patch, 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/']) }) @@ -515,7 +1216,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/']) }) @@ -536,7 +1237,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) }) @@ -613,7 +1314,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() @@ -625,7 +1326,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..4df1f34c69 --- /dev/null +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -0,0 +1,132 @@ +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() }) +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) +}) 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..309c446909 --- /dev/null +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -0,0 +1,55 @@ +import { writeFileSync } from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' +import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts' + +// 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(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(writeFileSync).toHaveBeenCalledOnce() +}) + +it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh', + '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') + 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 7f43a9318e..0af35e98c5 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { syncWindowsAppearance } from '../src/preload-windows.ts' -import { DESKTOP_IPC } from '../src/ipc.ts' +import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' const electron = vi.hoisted(() => ({ contextBridge: { exposeInMainWorld: vi.fn() }, @@ -13,7 +13,26 @@ 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 }) 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..56381f4aa6 --- /dev/null +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -0,0 +1,152 @@ +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, windowsRuntimeCode } from '../scripts/sign-primary-runtime.ts' +import { inspectWindowsRuntimeSignature, preserveWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' +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']) + 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([join(root, 'python.exe'), join(root, 'runtime.node')].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') +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 05a1a615c5..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, readdirSync, 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,14 +77,13 @@ describe('desktop external plugin profile', () => { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles).not.toContain('plugin') expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) - expect(calls(root)).toHaveLength(1) await expect(manager.applyRelease()).resolves.toBeUndefined() }) it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => { const { manager } = setup() await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + seedPlugin(manager) const patch = join(manager.paths.profile, 'cordis.patch.yml') writeFileSync(patch, ': broken') const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' }) @@ -151,47 +122,6 @@ describe('desktop external plugin profile', () => { expect(existsSync(manager.paths.lock)).toBe(false) }) - it.each(['missing', 'malformed', 'unversioned'] as const)('lists, disables, and removes a package with %s metadata', async (damage) => { - const { manager } = setup() - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - const path = join(manager.paths.profile, 'node_modules/plugin/package.json') - if (damage === 'missing') unlinkSync(path) - else writeFileSync(path, damage === 'malformed' ? '{broken' : '{}') - expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) - await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks()) - expect(manager.listPlugins()[0]?.enabled).toBe(false) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(manager.listPlugins()).toEqual([]) - }) - - it('preserves custom profile metadata and bundle order during plugin changes', async () => { - const { manager } = setup() - await manager.applyRelease() - const path = join(manager.paths.profile, 'package.json') - writeFileSync(path, JSON.stringify({ name: 'custom', private: false, custom: true, - dependencies: {}, dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } })) - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ name: 'custom', private: false, custom: true, - dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle', 'plugin'] } } }) - await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) - expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ - dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } }) - }) - - it('passes user registry and pnpm configuration through to the bundled executable', async () => { - const { root, manager } = setup() - vi.stubEnv('NPM_CONFIG_REGISTRY', 'https://packages.example.test/') - vi.stubEnv('NPM_CONFIG_USERCONFIG', join(root, 'user.npmrc')) - vi.stubEnv('PNPM_HOME', join(root, 'user-pnpm')) - vi.stubEnv('XDG_CONFIG_HOME', join(root, 'user-config')) - await manager.applyRelease() - await manager.mutate({ type: 'plugin-add', spec: 'plugin@next' }, hooks()) - expect(calls(root)[0]).toMatchObject({ registry: 'https://packages.example.test/', - userconfig: join(root, 'user.npmrc'), pnpmHome: join(root, 'user-pnpm'), configHome: join(root, 'user-config'), - args: ['add', '--', 'plugin@next'] }) - }) - it('initializes the same pnpm settings as a Web profile without a Desktop build allowlist', async () => { const { manager } = setup() await manager.applyRelease() @@ -215,8 +145,6 @@ describe('desktop external plugin profile', () => { writeFileSync(path, custom) await manager.applyRelease() expect(readFileSync(path, 'utf8')).toBe(custom) - await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) - expect(manager.listPlugins()[0]?.name).toBe('plugin') }) it('reports damaged application metadata as a reinstall failure', async () => { @@ -238,11 +166,10 @@ describe('desktop external plugin profile', () => { }) it('initializes and restarts offline without executing pnpm', async () => { - const { root, manager } = setup() + const { manager } = setup() await expect(manager.applyRelease()).resolves.toBeUndefined() await expect(manager.applyRelease()).resolves.toBeUndefined() - expect(manager.listPlugins()).toEqual([]) - expect(calls(root)).toEqual([]) + expect(plugins(manager)).toEqual([]) expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} }) }) @@ -254,7 +181,7 @@ describe('desktop external plugin profile', () => { }) it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { - const { root, manager } = setup() + const { manager } = setup() if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}') if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') @@ -263,173 +190,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/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..3c866c75c7 --- /dev/null +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -0,0 +1,134 @@ +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(), { + mainFrame: { url: 'dsh-app://shell/update-dialog.html' }, + setWindowOpenHandler: vi.fn(), + insertCSS: vi.fn(async () => 'blur'), + removeInsertedCSS: vi.fn(async () => {}), + }) + 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(() => { + 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)! + return fixture.handlers.get(channel)!({ sender: window.webContents, senderFrame: window.webContents.mainFrame }, ...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: true, 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(2) +}) + +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 +}) 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..e8b925cf2f --- /dev/null +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -0,0 +1,194 @@ +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 = { 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 () => {}) + const api: UpdateDialogApi = { status: async () => state, respond } + 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(0) +}) + +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() + 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..712163265b --- /dev/null +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -0,0 +1,27 @@ +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() +}) 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-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..367d418d42 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -4,6 +4,7 @@ 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, @@ -18,7 +19,8 @@ vi.mock('node:child_process', async (importOriginal) => { return { ...actual, execFile: vi.fn() } }) -vi.mock('node:crypto', () => ({ +vi.mock('node:crypto', async importOriginal => ({ + ...await importOriginal(), X509Certificate: class { readonly ca = false readonly keyUsage = ['1.3.6.1.5.5.7.3.3'] @@ -35,6 +37,29 @@ const CERTIFICATE_FILE = 'C:\\release\\server.cer' const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd') describe('Windows token signing', () => { + 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,6 +71,7 @@ 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', }, { platform: 'win32', arch: 'x64' }) @@ -55,12 +81,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 }) 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-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-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/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 834d72d500..5f210b0bab 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -50,6 +50,7 @@ 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' @@ -260,6 +261,8 @@ 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\`.`, @@ -315,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' }) @@ -342,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) @@ -439,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/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/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 24b68b3077..f23d5c693e 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -142,6 +142,11 @@ - text: ", and check the fine print" - superscript: "1" - text: . +- paragraph: + - text: "Preview:" + - link "plain HTTP": + - /url: http://docs.example.test/plain + - text: . - paragraph: - text: "Upstream:" - link "the repository": diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index aeab2ddb9d..12ff65dd6a 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -3,8 +3,6 @@ - button "Inline code links" [disabled] - button "More actions": - img - - button "Open right sidebar": - - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/expected/plan-review/temporary.expected.md b/apps/web/tests/expected/plan-review/temporary.expected.md new file mode 100644 index 0000000000..246480407c --- /dev/null +++ b/apps/web/tests/expected/plan-review/temporary.expected.md @@ -0,0 +1,9 @@ +- region "Temporary review": + - button "Copy": + - img + - heading "Temporary review" [level=1] + - paragraph: Review without a tool invocation. + - heading "Implementation" [level=2] + - list: + - listitem: Keep the complete document readable. + - listitem: Ask before implementation. diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index 6561157aec..fbfa9b1cab 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -1 +1 @@ -When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. +When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index 5df27af9d9..c25211dd13 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -12,10 +12,10 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -24,6 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-r const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url)) const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url)) +const TRAJECTORY_STATUS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory-status.expected.md', import.meta.url)) const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url)) const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url)) const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url)) @@ -108,6 +109,93 @@ describe('web e2e: generic file upload through the real assembly', () => { let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] + let liveTrajectory: string | undefined + + /** Inspect one durable mixed message through the real image slot and all message tabs. */ + async function inspectTrajectoryAttachments(): Promise { + const userMessage = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + if (userMessage?.type !== 'user/message') throw new Error('the replayed turn recorded no user message') + const content = userMessage.data.content + const imageName = IMAGE_NAMES[0]! + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + const chatImage = page.getByRole('img', { name: imageName, exact: true }) + await expect.poll(() => chatImage.getAttribute('src'), { timeout: 10_000 }).toMatch(/^blob:/) + const chatImageUrl = await chatImage.getAttribute('src') + + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + const row = page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true }) + await row.waitFor({ timeout: 10_000 }) + const ledger = await captureStableAria(page, '[data-trajectory-row-key][aria-label*="Files ×1"]', scaffold.workspaceCwd) + await row.click() + + const panel = page.getByRole('tabpanel') + const list = panel.getByRole('list', { name: 'Attachments', exact: true }) + let summaryAttachments: string | undefined + let preview: string | undefined + for (const tab of ['Summary', 'Preview']) { + await page.getByRole('tab', { name: tab, exact: true }).click() + await list.waitFor() + expect(await panel.getByText(PROMPT, { exact: true }).count()).toBe(1) + const rows = list.getByRole('listitem') + expect(await rows.count()).toBe(2) + expect(await rows.nth(0).getByTitle(FILE_NAME, { exact: true }).count()).toBe(1) + expect(await rows.nth(1).getByTitle(imageName, { exact: true }).count()).toBe(1) + expect(await rows.nth(0).textContent()).toContain('TXT · 16B') + expect(await rows.nth(1).textContent()).toContain('image/png · 69B · 1 × 1') + const thumbnail = list.getByRole('img', { name: imageName, exact: true }) + await expect.poll(() => thumbnail.getAttribute('src'), { timeout: 10_000 }).toBe(chatImageUrl) + expect(await thumbnail.evaluate(element => getComputedStyle(element).objectFit)).toBe('contain') + + const attachments = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd) + if (tab === 'Summary') summaryAttachments = attachments + else { + expect(attachments).toBe(summaryAttachments) + preview = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + } + const opener = list.getByRole('button', { name: `${imageName}, click to view original`, exact: true }) + await opener.focus() + await opener.press('Enter') + const dialog = page.getByRole('dialog', { name: 'Original image preview', exact: true }) + await dialog.waitFor() + expect(await dialog.getByRole('img', { name: imageName }).getAttribute('src')).toBe(chatImageUrl) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + expect(await opener.evaluate(element => element === document.activeElement)).toBe(true) + } + + await page.getByRole('tab', { name: 'Raw', exact: true }).click() + const disclosures = panel.locator('details') + expect(await disclosures.count()).toBe(2) + expect(await panel.getByRole('img').count()).toBe(0) + for (const disclosure of await disclosures.all()) { + expect(await disclosure.getAttribute('open')).toBeNull() + expect(await disclosure.locator('pre').isVisible()).toBe(false) + } + const rawCollapsed = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + const blocks = panel.locator('details, section') + expect(await blocks.count()).toBe(content.length) + for (const [index, block] of content.entries()) { + const renderedBlock = blocks.nth(index) + if (block.type === 'text') { + expect(await renderedBlock.locator('pre').textContent()).toBe(block.text) + } else { + expect(['image', 'file']).toContain(block.type) + await renderedBlock.locator('summary').click() + expect(await renderedBlock.locator('pre').isVisible()).toBe(true) + const raw: unknown = JSON.parse((await renderedBlock.locator('pre').textContent())!) + expect(raw).toEqual(block) + } + } + const rawExpanded = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + return [ + '# Ledger', ledger, + '# Summary attachments', summaryAttachments, + '# Preview', preview, + '# Raw (collapsed)', rawCollapsed, + '# Raw (expanded)', rawExpanded, + ].join('\n\n') + } beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -283,23 +371,68 @@ describe('web e2e: generic file upload through the real assembly', () => { await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE) }) - it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => { - await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() - await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) - await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 }) - const snapshot = await captureStableAria( - page, - '[data-trajectory-row-key][aria-label*="Files ×1"]', - scaffold.workspaceCwd, - ) - await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + it.skipIf(MODE === 'record')('shows both attachment kinds throughout the Trajectory inspector', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory')) + liveTrajectory = await inspectTrajectoryAttachments() + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, liveTrajectory, MODE) + }) + + it.skipIf(MODE === 'record')('restores the same Trajectory attachments from persisted history after reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory-reload')) + expect(liveTrajectory).toBeDefined() + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(await inspectTrajectoryAttachments()).toBe(liveTrajectory) + }) + + it.skipIf(MODE === 'record')('keeps thumbnail loading and retry feedback inside the attachment row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-thumbnail-status')) + const releaseRead = Promise.withResolvers() + const read = vi.spyOn(scaffold.ctx.attachments, 'readImage').mockImplementationOnce(async () => { + await releaseRead.promise + throw new Error('fixture image read failed') + }) + try { + // Reload clears the browser image cache so the real image slot observes the delayed read. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true }).click() + await page.getByRole('tab', { name: 'Preview', exact: true }).click() + const panel = page.getByRole('tabpanel') + const thumbnail = panel.locator('[data-variant="thumbnail"]') + await thumbnail.waitFor() + await expect.poll(() => read.mock.calls.length).toBe(1) + const feedbackFits = () => thumbnail.evaluate(element => element.scrollHeight <= element.clientHeight + && element.scrollWidth <= element.clientWidth) + expect(await feedbackFits()).toBe(true) + const loading = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd) + + releaseRead.resolve(undefined) + const retry = panel.getByRole('button', { name: 'Image failed to load; click to retry', exact: true }) + await retry.waitFor() + expect(await feedbackFits()).toBe(true) + const failed = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd) + await retry.focus() + await retry.press('Enter') + await panel.getByRole('img', { name: IMAGE_NAMES[0]!, exact: true }).waitFor() + expect(read).toHaveBeenCalledTimes(2) + await compareOrRefreshGolden(TRAJECTORY_STATUS_EXPECTED, [ + '# Loading', loading, '# Failed', failed, + ].join('\n\n'), MODE) + } finally { + releaseRead.resolve(undefined) + read.mockRestore() + } }) it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', + 'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', 'trajectory-status.expected.md', ]) }) }) diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts new file mode 100644 index 0000000000..e7acd74152 --- /dev/null +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -0,0 +1,81 @@ +/** Cold-replayed Markdown references open Session files through the shipped Web composition. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/markdown-file-links', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: Markdown file links', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await mkdir(join(scaffold.workspaceCwd, 'src')) + await mkdir(join(scaffold.workspaceCwd, 'other')) + await writeFile(join(scaffold.workspaceCwd, 'src/example.txt'), + Array.from({ length: 60 }, (_, i) => `source line ${i + 1}\n`).join('')) + await writeFile(join(scaffold.workspaceCwd, 'other/example.txt'), 'other file\n') + await seedSession(scaffold, await readFile(join(SNAPSHOT_DIR, 'session.v3.jsonl'), 'utf8'), 'markdown-file-links') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).waitFor() + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('previews relative and absolute links at the requested line without duplicate tabs', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-file-links')) + const source = page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }) + const prose = await source.locator('..').ariaSnapshot() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'links.expected.md'), prose, MODE) + const beforeUrl = page.url() + await source.click() + const column = page.locator('[data-rightbar-col]') + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n') + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) + .toBe(join(scaffold.workspaceCwd, 'src/example.txt')) + const absolute = page.getByRole('button', { name: 'src/example.txt:30', exact: true }) + await absolute.focus() + await absolute.press('Enter') + await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('source line 30\n') + expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt']) + expect(page.url()).toBe(beforeUrl) + expect(page.context().pages()).toHaveLength(1) + expect(await page.getByRole('link', { name: 'Website', exact: true }).getAttribute('target')).toBe('_blank') + const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) + await mkdir(artifacts, { recursive: true }) + await page.screenshot({ path: join(artifacts, 'markdown-file-links.png'), animations: 'disabled' }) + await page.getByRole('button', { name: 'other/example.txt', exact: true }).click() + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) + .toBe(join(scaffold.workspaceCwd, 'other/example.txt')) + await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('other file\n') + expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt', 'example.txt']) + const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), preview, MODE) + await page.getByRole('button', { name: 'Missing file', exact: true }).click() + await column.locator('[data-textpreview-failed="workspace-file/not-found"]').waitFor() + expect(page.url()).toBe(beforeUrl) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md']) + }) +}) diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index 62bbf589aa..8fb8b5942c 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -22,6 +22,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-inline-code-links const MODE = webSnapshotMode() const SEED_ID = 'markdown-inline-code-links-web-e2e' const DONE = 'INLINE_CODE_LINK_DONE' +const LINK_URL = 'http://127.0.0.1:3199/?demo=1' /** Build a settled assistant reply with linkable URL code and inert code controls. */ function markdownFixture(linkUrl: string): string { @@ -88,15 +89,17 @@ describe('web e2e: Markdown inline-code links', () => { let scaffold: WebScaffold let browser: Browser let page: Page - let linkUrl: string let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({}) - linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString() - await seedSession(scaffold, markdownFixture(linkUrl), SEED_ID) + await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.route('http://127.0.0.1:3199/**', async route => route.fulfill({ + contentType: 'text/html', + body: '

    Inline-code preview

    ', + })) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -119,24 +122,22 @@ describe('web e2e: Markdown inline-code links', () => { const inlineCodeLink = page.locator('[class*="markdown"] code a') await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1) - expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl) + expect(await inlineCodeLink.getAttribute('href')).toBe(LINK_URL) expect(await inlineCodeLink.getAttribute('target')).toBe('_blank') expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer') await inlineCodeLink.focus() expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true) - const popupPromise = page.waitForEvent('popup') await inlineCodeLink.click() - const popup = await popupPromise - await popup.waitForURL(linkUrl, { timeout: 15_000 }) - expect(popup.url()).toBe(linkUrl) - await popup.close() + const browserAddress = page.locator('[data-rightbar-col]') + .getByRole('textbox', { name: 'Enter an HTTP(S) address' }) + await expect.poll(() => browserAddress.inputValue()).toBe(LINK_URL) - expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0) + expect(await page.getByText(`curl ${LINK_URL}`, { exact: true }).locator('a').count()).toBe(0) expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') - .split(linkUrl).join('{{linkUrl}}') + .split(LINK_URL).join('{{linkUrl}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 0bf7f5d5c9..cdca7a376b 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -14,6 +14,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -27,8 +28,10 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') // transcript the approval leaves behind — the state the card cannot see. const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') +const PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'preview.expected.md') const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md') const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md') +const TEMPORARY_EXPECTED = fileURLToPath(new URL('./expected/plan-review/temporary.expected.md', import.meta.url)) const MODE = webSnapshotMode() // One command line: /plan enters plan mode and submits the rest as the turn's @@ -46,6 +49,7 @@ describe('web e2e: plan review takeover round trip', () => { let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] + let reviewedSession: SessionId beforeAll(async () => { scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true }) @@ -84,6 +88,10 @@ describe('web e2e: plan review takeover round trip', () => { // The plan-review request must NOT land on the generic question flow. expect(await page.locator('[data-question-key]').count()).toBe(0) await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0) + expect(await card.getByRole('heading').count()).toBe(1) + expect(await card.getByRole('heading').textContent()).toContain('--greeting') + expect(await card.getByText('View full plan', { exact: true }).isVisible()).toBe(true) + expect(await card.getByRole('list').count()).toBe(0) const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) @@ -96,6 +104,22 @@ describe('web e2e: plan review takeover round trip', () => { await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } + const planCard = page.locator('[data-plan-card]') + expect(await planCard.count()).toBe(0) + const preview = page.locator('[data-plan-preview]') + await preview.waitFor({ state: 'visible' }) + expect(await card.getByRole('button', { name: 'Approve' }).isVisible()).toBe(true) + expect(await preview.getByRole('heading', { level: 1 }).textContent()).toContain('--greeting') + await card.getByRole('button', { name: 'Open plan in sidebar' }).click() + expect(await page.locator('[data-plan-preview]').count()).toBe(1) + if (MODE !== 'record') { + await compareOrRefreshGolden(PREVIEW_EXPECTED, await captureStableAria(page, '[data-plan-preview]', scaffold.workspaceCwd), MODE) + } + await page.locator('[data-sidebar-right-toggle]').click() + await preview.waitFor({ state: 'hidden' }) + await card.getByRole('button', { name: 'Open plan in sidebar' }).click() + await preview.waitFor({ state: 'visible' }) + await card.getByRole('button', { name: 'Approve' }).click() // Park the pointer: the card unmounts and the ContextMeter ring lands // under the click position, whose 200ms hover delay would arm a tooltip @@ -103,6 +127,7 @@ describe('web e2e: plan review takeover round trip', () => { await page.mouse.move(0, 0) const sessionId = await settled + reviewedSession = sessionId if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) return @@ -115,6 +140,16 @@ describe('web e2e: plan review takeover round trip', () => { expect(await page.locator('[data-plan-review-key]').count()).toBe(0) expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) + // Completed plans join the final artifacts after the Turn's closing reply. + await planCard.waitFor({ state: 'visible' }) + expect(await planCard.locator('xpath=ancestor::*[@data-turn-tail]').count()).toBe(1) + await page.locator('[data-sidebar-right-toggle]').click() + await planCard.click() + await preview.waitFor({ state: 'visible' }) + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-plan-card]').waitFor({ state: 'visible' }) + await page.locator('[data-plan-preview]').waitFor({ state: 'visible' }) + expect(await page.locator('[data-plan-preview]').getByRole('heading', { level: 1 }).textContent()).toContain('--greeting') const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( @@ -127,10 +162,116 @@ describe('web e2e: plan review takeover round trip', () => { expect(tripwire.warnings).toEqual([]) }, 200_000) + it.skipIf(MODE === 'record')('previews an unlogged review automatically and reopens it without deciding', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-temporary')) + const agent = scaffold.ctx.agents.get(reviewedSession) + if (agent === undefined) throw new Error('The reviewed Session has no active agent') + const controller = new AbortController() + // This public question request has no tool invocation; its detail is the only complete document. + const asked = scaffold.ctx.userQuestions.ask({ + agent, signal: controller.signal, + questions: [{ id: 'temporary', question: 'Approve this temporary plan?', + detail: '# Temporary review\n\nReview without a tool invocation.\n\n## Implementation\n\n- Keep the complete document readable.\n- Ask before implementation.', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }) + let answered = false + const outcome = asked.then((value) => { answered = true; return value }, (error: unknown) => ({ error })) + try { + const card = page.locator('[data-plan-review-key]') + const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]') + await preview.waitFor({ state: 'visible' }) + expect(await preview.getByText('Ask before implementation.').isVisible()).toBe(true) + await compareOrRefreshGolden(TEMPORARY_EXPECTED, await captureStableAria(page, '[data-plan-preview^="dsh-resource://plan-review/"]', scaffold.workspaceCwd), MODE) + const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Temporary review' }) + await card.getByRole('button', { name: 'Open plan in sidebar' }).click() + expect(await tab.count()).toBe(1) + await tab.locator('[data-dockkit-tab-close]').click() + await preview.waitFor({ state: 'detached' }) + await card.getByRole('button', { name: 'Open plan in sidebar' }).click() + await preview.waitFor({ state: 'visible' }) + expect(answered).toBe(false) + const saved = await page.evaluate(() => Object.keys(localStorage) + .filter(key => key.startsWith('dsh.sidebar-right.v1.')).map(key => localStorage.getItem(key)).join('\n')) + expect(saved).toContain('dsh-resource://plan-review/') + expect(saved).not.toContain('Ask before implementation.') + await card.getByRole('button', { name: 'Approve', exact: true }).click() + expect(await outcome).toEqual({ answers: [{ id: 'temporary', selected: ['Approve'] }] }) + await card.waitFor({ state: 'detached' }) + expect(await preview.getByText('Ask before implementation.').isVisible()).toBe(true) + await page.reload({ waitUntil: 'load' }) + await page.getByText('This temporary plan preview has expired. Reopen it from the pending review card.', { exact: true }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + controller.abort() + await outcome + } + }, 60_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', + 'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md', 'approved.expected.md', 'approved-expanded.expected.md', ]) }) }) + +describe('web e2e: dismissed plan history', () => { + it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => { + // Replay supplies a finite continuation; the real question rejection and retained document are the assertions. + const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false, paceMs: 15 }) + let browser: Browser | undefined + const events: SessionEvent[] = [] + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) + try { + browser = await chromium.launch() + const page = await newEnglishPage(browser) + await page.setViewportSize({ width: 620, height: 900 }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const input = page.locator('[data-composer-input]').first() + const settled = scaffold.whenTurnSettled(30_000) + await input.fill(LINE) + await input.press('Enter') + const review = page.locator('[data-plan-review-key]') + await review.waitFor({ state: 'visible' }) + await page.locator('[data-plan-preview]').waitFor({ state: 'visible' }) + await page.locator('[data-sidebar-right-toggle]').click() + await review.getByRole('button', { name: 'Request changes', exact: true }).click() + await settled + const call = events.find((event): event is SessionEvent<'tool/call'> => + event.type === 'tool/call' && event.data.name === 'exit_plan_mode') + expect(call).toBeDefined() + const results = events.filter(event => event.type === 'tool/result') + const result = results.find(event => event.data.message.source.callId === call?.data.callId) + expect(result?.data.message.content[0]).toMatchObject({ type: 'tool-result', isError: true }) + expect(JSON.stringify(result)).toContain('dismissed the plan review') + expect(results.some(event => JSON.stringify(event).includes('Plan approved'))).toBe(false) + const modes = events.filter(event => event.type === 'plan/mode') + expect(modes).toHaveLength(1) + expect(modes[0]).toMatchObject({ data: { active: true } }) + expect(await review.count()).toBe(0) + const card = page.locator('[data-plan-card]') + await card.waitFor({ state: 'visible' }) + await card.click() + await page.locator('[data-plan-preview]').waitFor({ state: 'visible' }) + await page.locator('[data-sidebar-right-toggle]').click() + await card.click() + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-plan-card]').waitFor({ state: 'visible' }) + await page.locator('[data-plan-preview]').waitFor({ state: 'visible' }) + expect(await page.locator('[data-plan-preview]').getByRole('heading', { level: 1 }).textContent()).toContain('--greeting') + await page.locator('[data-sidebar-right-toggle]').click() + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-plan-card]').waitFor({ state: 'visible' }) + expect(await page.locator('[data-plan-preview]').isVisible()).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } + }) +}) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 6cbbb43700..ceed3263cb 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -126,6 +126,12 @@ describe('web e2e: settings modal and General preferences', () => { // presets took over included, preset compositions excluded. expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count()) .toBe(expectedPluginCount) + // The enablement tag is the row's collapsed status: an active fiber draws no + // dot, so no global row names the active phase. Guard the assertion against + // matching nothing because no row is enabled. + expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]').count()) + .toBeGreaterThan(0) + expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0) expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) .toBe(String(expectedPluginCount)) expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true') diff --git a/apps/web/tests/sidebar-browser.e2e.ts b/apps/web/tests/sidebar-browser.e2e.ts new file mode 100644 index 0000000000..7a3334bb1b --- /dev/null +++ b/apps/web/tests/sidebar-browser.e2e.ts @@ -0,0 +1,119 @@ +/** Keyless Browser-tab coverage through the shipped Web composition. */ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type {} from '@deepseek-ai/dsh-agent' +import { + assertFixtureInventory, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/sidebar-browser', import.meta.url)) +const EXPECTED = join(SNAPSHOT_DIR, 'browser.expected.md') +const MODE = webSnapshotMode() +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.route('https://browser.test/**', async (route) => { + const name = new URL(route.request().url()).pathname.slice(1) || 'one' + await route.fulfill({ + contentType: 'text/html', + body: `

    ${name}

    Inside navigation

    isolated HTTPS fixture

    `, + }) + }) + await page.route('http://127.0.0.1:3080/**', async route => route.fulfill({ + contentType: 'text/html', + body: '

    loopback

    ', + })) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('navigates sandboxed HTTP(S) with application-known history', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-browser')) + const settled = scaffold.whenTurnSettled() + const composer = page.locator('[data-composer-input]').first() + await composer.fill(PROMPT) + await composer.press('Enter') + await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const column = page.locator('[data-rightbar-col]') + await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="browser"]').click() + const input = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' }) + await input.fill('https://browser.test/one') + await input.press('Enter') + const frame = column.locator('[data-sidebar-browser-frame]') + await frame.waitFor({ state: 'visible' }) + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor() + expect(await frame.getAttribute('allow')).toBeNull() + await column.getByRole('button', { name: 'Disable sandbox restrictions' }).click() + await expect.poll(() => frame.getAttribute('sandbox')).toBeNull() + await column.getByText('Sandbox restrictions are disabled; the page can navigate the top-level app and use downloads, modal dialogs, and input locks.', { exact: true }).waitFor() + await column.getByRole('button', { name: 'Restore sandbox restrictions' }).click() + await expect.poll(() => frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox') + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('link', { name: 'Inside navigation' }).click() + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'inside' }).waitFor() + await column.getByText('URL changed', { exact: true }).waitFor() + expect(await column.getByRole('button', { name: 'Back', exact: true }).isDisabled()).toBe(true) + expect(await column.getByRole('button', { name: 'Forward', exact: true }).isDisabled()).toBe(true) + expect(await column.getByRole('button', { name: 'Open in system browser', exact: true }).isDisabled()).toBe(true) + await column.getByRole('button', { name: 'Reload' }).click() + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor() + await input.fill('https://browser.test/two') + await input.press('Enter') + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor() + await column.getByRole('button', { name: 'Back', exact: true }).click() + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor() + await column.getByRole('button', { name: 'Forward', exact: true }).click() + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor() + + await input.fill('http://127.0.0.1:3080/preview') + await input.press('Enter') + await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'loopback' }).waitFor() + expect(await frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox') + + await input.fill('file:///work/index.html') + await input.press('Enter') + const blocked = await column.getByRole('alert').innerText() + expect(blocked).toBe('Only HTTP and HTTPS addresses are supported; use Document Preview for local files.') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await mkdir(SNAPSHOT_DIR, { recursive: true }) + await compareOrRefreshGolden(EXPECTED, [ + '# Sidebar Browser', '', + '- HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox', + '- Permissions Policy: browser defaults', + '- Sandbox toggle: per-tab and temporary', + '- Unknown navigation: marker shown; Back, Forward, and external-open disabled', + '- HTTPS history: one -> two -> one -> two', + '- Loopback HTTP: loaded under the default sandbox', + `- Invalid protocol: ${blocked}`, + ].join('\n'), MODE) + await assertFixtureInventory(SNAPSHOT_DIR, ['browser.expected.md']) + }) +}) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 68260d26bb..85b878c7a3 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -407,7 +407,7 @@ describe('web e2e: shipped right Sidebar', () => { } await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) - await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(3) await column.locator('[data-sidebar-right-guide-entry="files"]').click() // A manual guide is closable beside Files and suppresses another add diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts new file mode 100644 index 0000000000..2a0fe8caf4 --- /dev/null +++ b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts @@ -0,0 +1,86 @@ +// Web e2e scenario: hovering a sidebar session row reveals a title wider than +// its cell. The jsdom lane pins only the scroll position the handler writes; +// the assembled browser is where the title really clips, really reaches its own +// hovered extent (the trailing relative-time cell yields to the row menu while +// the pointer rests on the row, so the hovered cell is wider than the resting +// one), and really returns to its start when the pointer leaves. +// +// Zero model calls: seeding one renamed session and hovering its row touches no +// provider. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import type { Browser, Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */ +const TITLE = 'Forked session clipped before its suffix (1)' + +describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-title-hover-scroll') + await workspace.attachSession(id) + await scaffold.ctx.sessionController.rename({ sessionId: id, title: TITLE }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reveals the clipped title under the pointer and restores its start after', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll')) + const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) }) + await row.waitFor({ timeout: 20_000 }) + const title = row.getByText(TITLE, { exact: true }) + + // At rest the row clips its title with an ellipsis and holds its start. + expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0) + expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') + expect(await title.evaluate(el => el.scrollLeft)).toBe(0) + expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth') + + await row.hover() + // Poll against the live extent: the reveal must reach the far edge of the + // hovered cell (the wider one — the relative-time label yields to the row + // menu), whatever width the handler itself read. + await expect.poll( + async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), + { timeout: 10_000 }, + ).toBe(true) + // Scrolled to its end, the title must not paint the ellipsis over the + // characters the reveal reached. + expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip') + + // Leaving returns in one step: a smooth return would still be travelling + // inside this window, and it would carry the resting ellipsis back with it. + await page.mouse.move(0, 0) + await expect.poll( + async () => title.evaluate(el => el.scrollLeft), + { timeout: 150, interval: 20 }, + ).toBe(0) + expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') + + // The reduce override reaches the real stylesheet, so the reveal jumps + // instead of gliding. + await page.emulateMedia({ reducedMotion: 'reduce' }) + expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') + await page.emulateMedia({ reducedMotion: null }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index e4a93fce77..efd3677f8e 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -17,7 +17,7 @@ import { launchWebScaffold, readPersistedEvents, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url)) @@ -28,12 +28,14 @@ const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-con const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url)) +const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url)) const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' const ONE_SHOT_LABEL = 'event-sourcing reviewer' const NESTED_LABEL = 'example editor' +const CHILD_TITLE = 'Explain event sourcing in one' const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.' const INITIAL_PROMPT = 'Explain event sourcing in one sentence.' /** The grandchild's own first message; its arrival is what says its history finished loading. */ @@ -97,6 +99,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = let childId: SessionId let oneShotId: SessionId let grandchildId: SessionId + let liveReferenceOptions: string[] let tripwire: ReturnType const apiCalls: string[] = [] @@ -145,6 +148,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }) childId = started.childId await waitForAgentToSettle(scaffold, childId) + const liveInput = page.locator('[data-composer-input][contenteditable="true"]').first() + const liveMenu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await writeComposerDraft(page, liveInput, '@') + await liveMenu.getByText('Subagents', { exact: true }).waitFor({ timeout: 15_000 }) + liveReferenceOptions = await liveMenu.getByRole('option').allTextContents() + await page.keyboard.press('Escape') + await writeComposerDraft(page, liveInput, '') oneShotId = sessionId('recorded-one-shot') const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000 const oneShotAt = Date.now() - oneShotDurationMs @@ -295,6 +305,20 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed') }) + it('lists direct subagents by label in the reference menu', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-reference-title')) + expect(liveReferenceOptions.some(text => text.includes(LABEL))).toBe(true) + expect(liveReferenceOptions.some(text => text.includes(CHILD_TITLE))).toBe(false) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await writeComposerDraft(page, input, '@') + await menu.getByText('Subagents', { exact: true }).waitFor({ timeout: 15_000 }) + await menu.getByRole('option', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 }) + expect(await menu.getByRole('option', { name: new RegExp(CHILD_TITLE) }).count()).toBe(0) + await page.keyboard.press('Escape') + await writeComposerDraft(page, input, '') + }) + it('keeps known descendants reachable across a stale empty catalog response', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog')) const pattern = '**/api/subagents/list' @@ -380,6 +404,29 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape') }) + it('opens child history in the right Sidebar and releases it when closed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-sidebar-chat')) + await page.getByRole('button', { name: '3 subagents' }).hover() + await page.getByRole('button', { name: `Open ${LABEL} in sidebar` }).click() + const sidebarChat = page.locator('[data-sidebar-chat]') + await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden( + SIDEBAR_CHAT_EXPECTED, + await captureStableAria(page, '[data-sidebar-chat]', scaffold.workspaceCwd), + MODE, + ) + await page.locator('[data-sidebar-right-panel] [data-dockkit-tab-close]').click() + await sidebarChat.waitFor({ state: 'detached' }) + + await page.getByRole('button', { name: '3 subagents' }).hover() + await page.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar` }).click() + await page.locator('[data-sidebar-chat]').getByText( + 'One-shot tasks do not accept follow-ups; review the full execution record here.', + ).waitFor({ timeout: 15_000 }) + await page.locator('[data-sidebar-right-panel] [data-dockkit-tab-close]').click() + await page.locator('[data-sidebar-chat]').waitFor({ state: 'detached' }) + }) + it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) await page.getByRole('button', { name: '3 subagents' }).hover() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 725d93b07e..99325b53ef 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -47,12 +47,14 @@ "tests/details-session-lifecycle.e2e.ts", "tests/document-preview.e2e.ts", "tests/office-fixture.ts", + "tests/sidebar-browser.e2e.ts", "tests/plugin-config.e2e.ts", "tests/plugin-manager.e2e.ts", "tests/plugin-install-cancel.e2e.ts", "tests/plugin-install-approve.e2e.ts", "tests/client-plugin-live.e2e.ts", "tests/settings-chrome.e2e.ts", + "tests/desktop-updates.e2e.ts", "tests/models-settings.e2e.ts", "tests/deepseek-messages-settings.e2e.ts", "tests/deepseek-messages-chat.e2e.ts", @@ -75,6 +77,7 @@ "tests/seeded-history.e2e.ts", "tests/stats-paged-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", + "tests/sidebar-title-hover-scroll.e2e.ts", "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", "tests/ptc-round.e2e.ts", @@ -90,6 +93,7 @@ "tests/open-in-app-ssh.e2e.ts", "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", + "tests/markdown-file-links.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", "tests/thinking-markdown.e2e.ts", diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 902a1510ed..56485c7234 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -37,7 +37,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb ## Writing rules - **Document current state.** Keep history in commits, PRs, Agent Notes, postmortems, or scoped persistence records. Other prose names live mechanisms, not changes or stack positions. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence. -- **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)). +- **Apply the Agent Note creation criteria.** Mechanical/local edits are exempt, including local UI changes; keep existing owning notes accurate ([scope](../.agents/notes/README.md#when-to-write-one)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 2ee917d71d..8c28ee4357 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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 docs/capability-seams.md -capability-seams.md: a93d18226eeba66d025fb097d988be8f2ff1f482 -capability-seams.zh.md: 192aff1aa0b5d6306d3165573dd7d478428ea27e +capability-seams.md: edb8cd688768ec49ee3699c3775409a66975129d +capability-seams.zh.md: b06d9378616669407b79ffa1da205924ff6ad757 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a93d18226e..edb8cd6887 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -14,6 +14,10 @@ flowchart LR svc_pluginManager["ctx.pluginManager
    Current-profile plugin and bundle management"] pkg_ui_settings_plugin_inventory["ui-settings-plugin-inventory"] svc_profileContext["ctx.profileContext
    Launcher-owned profile data"] + pkg_client_connection["client-connection"] + svc_connection["ctx.connection
    Authenticated browser transport"] + pkg_api_gateway["api-gateway"] + pkg_host_frontend_static["host-frontend-static"] pkg_mcp_resources["mcp-resources"] svc_mcpResources["ctx.mcpResources
    Scoped MCP resource access"] pkg_mcp_client["mcp-client"] @@ -80,7 +84,6 @@ flowchart LR pkg_typert_registry["typert-registry"] svc_typert["ctx.typert
    Runtime type registry"] pkg_typert_loader["typert-loader"] - pkg_api_gateway["api-gateway"] svc_typertGateway["ctx.typertGateway
    Typert Host invocation gateway"] svc_sessionPersistence["ctx.sessionPersistence
    Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] @@ -236,7 +239,6 @@ flowchart LR pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_webserver["host-webserver"] svc_webServer["ctx.webServer
    HTTP route registration"] - pkg_client_connection["client-connection"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] svc_clientModules["ctx.clientModules
    Client plugin graph host"] @@ -273,6 +275,7 @@ flowchart LR pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell pkg_browser_use --> svc_browserUse + pkg_client_connection --> svc_connection pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_command_feedback --> svc_sessionFeedback @@ -412,6 +415,8 @@ flowchart LR svc_compaction --> pkg_compaction_basic svc_computerUse --> pkg_experimental_computer_use_cua_driver_mcp svc_computerUse --> pkg_experimental_computer_use_cua_driver_native + svc_connection --> pkg_api_gateway + svc_connection --> pkg_host_frontend_static svc_cordisInspect --> pkg_tool_cordis svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek @@ -535,6 +540,7 @@ flowchart LR | `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | Owns module and exact configuration watchers; application mutations share its queue and automatic reloads await the application file lock. | | `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers. | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. | +| `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 192aff1aa0..b06d937861 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -16,6 +16,10 @@ flowchart LR svc_pluginManager["ctx.pluginManager
    Current-profile plugin and bundle management"] pkg_ui_settings_plugin_inventory["ui-settings-plugin-inventory"] svc_profileContext["ctx.profileContext
    Launcher-owned profile data"] + pkg_client_connection["client-connection"] + svc_connection["ctx.connection
    Authenticated browser transport"] + pkg_api_gateway["api-gateway"] + pkg_host_frontend_static["host-frontend-static"] pkg_mcp_resources["mcp-resources"] svc_mcpResources["ctx.mcpResources
    Scoped MCP resource access"] pkg_mcp_client["mcp-client"] @@ -82,7 +86,6 @@ flowchart LR pkg_typert_registry["typert-registry"] svc_typert["ctx.typert
    Runtime type registry"] pkg_typert_loader["typert-loader"] - pkg_api_gateway["api-gateway"] svc_typertGateway["ctx.typertGateway
    Typert Host invocation gateway"] svc_sessionPersistence["ctx.sessionPersistence
    Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] @@ -238,7 +241,6 @@ flowchart LR pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_webserver["host-webserver"] svc_webServer["ctx.webServer
    HTTP route registration"] - pkg_client_connection["client-connection"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] svc_clientModules["ctx.clientModules
    Client plugin graph host"] @@ -275,6 +277,7 @@ flowchart LR pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell pkg_browser_use --> svc_browserUse + pkg_client_connection --> svc_connection pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_command_feedback --> svc_sessionFeedback @@ -414,6 +417,8 @@ flowchart LR svc_compaction --> pkg_compaction_basic svc_computerUse --> pkg_experimental_computer_use_cua_driver_mcp svc_computerUse --> pkg_experimental_computer_use_cua_driver_native + svc_connection --> pkg_api_gateway + svc_connection --> pkg_host_frontend_static svc_cordisInspect --> pkg_tool_cordis svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek @@ -537,6 +542,7 @@ flowchart LR | `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | 负责模块和精确配置监听;应用修改共用其队列,自动重载等待应用文件锁。 | | `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | 与 CLI 共享 profile 包操作,并向 Web 和 Agent 调用方分别报告持久状态与运行状态。 | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 | +| `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 54b1955e19..7da9acd525 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: eeeaff21533f7d39934a5dd1b7bad24c6966456f -config-catalog.zh.md: db3022cae52790f7e02e35e2fc6a755d0f325fd2 +config-catalog.md: 631ce6a34b120f44534a7935ff976662287fa879 +config-catalog.zh.md: 8d72786a79ceff85e35aff72fa6622af7373a3b4 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eeeaff2153..631ce6a34b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -428,7 +428,7 @@ export interface ConnectionRecoveryConfig { } ``` -Source: [`packages/client/connection/src/index.ts:72`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) @@ -3812,6 +3812,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index db3022cae5..8d72786a79 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -430,7 +430,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源:[`packages/client/connection/src/index.ts:72`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) @@ -3814,6 +3814,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7660e32ba2..8bfe85ac05 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: e587a790acff79530ebce242678faabed15c304e -event-producer-consumer.zh.md: b0489c1a8c729c7397abc4e1d6af11fbe393d6f6 +event-producer-consumer.md: fcf95b4268cc41b84099af351f1d6fdb07c80fc4 +event-producer-consumer.zh.md: bd466a2e39f4a268e4a8b1567ae5a5f22d250977 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e587a790ac..fcf95b4268 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,6 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -73,7 +74,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | -| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b0489c1a8c..bd466a2e39 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,6 +32,7 @@ | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:99`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -75,7 +76,7 @@ | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) | -| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a6573bee99..ff54d13bd5 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: b89bb220fa37e70b83fe879adee71f8d042b0c9a -module-graph.zh.md: 7eff2d9faeea8e49d675ad1894b481cd0e1d0ade +module-graph.md: a886619fa5cba78b2c243c525b8f2a173c1e8038 +module-graph.zh.md: 9c40a086df916cd638599176a95c649d6a2adc0d diff --git a/docs/module-graph.md b/docs/module-graph.md index b89bb220fa..a886619fa5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -182,6 +182,7 @@ flowchart TD pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] pkg_client_ui_sidebar["client-ui-sidebar"] + pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] @@ -1115,18 +1116,6 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_session_reference --> pkg_agent - pkg_session_reference --> pkg_compaction - pkg_session_reference --> pkg_llm - pkg_session_reference --> pkg_output_retention - pkg_session_reference --> pkg_session - pkg_session_reference --> pkg_session_projection - pkg_session_reference --> pkg_session_projection_cache - pkg_session_reference --> pkg_session_query - pkg_session_reference --> pkg_session_title - pkg_session_reference --> pkg_spill - pkg_session_reference --> pkg_system_prompt - pkg_session_reference --> pkg_typert_protocol pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session @@ -1210,6 +1199,19 @@ flowchart TD pkg_compaction_basic --> pkg_llm pkg_compaction_basic --> pkg_session pkg_compaction_basic --> pkg_token_meter + pkg_session_reference --> pkg_agent + pkg_session_reference --> pkg_compaction + pkg_session_reference --> pkg_llm + pkg_session_reference --> pkg_output_retention + pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache + pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title + pkg_session_reference --> pkg_spill + pkg_session_reference --> pkg_subagent + pkg_session_reference --> pkg_system_prompt + pkg_session_reference --> pkg_typert_protocol pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_invariants pkg_experimental_agent_team --> pkg_llm @@ -1363,6 +1365,7 @@ flowchart TD | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | @@ -1566,7 +1569,6 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1577,6 +1579,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 7eff2d9fae..9c40a086df 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -184,6 +184,7 @@ flowchart TD pkg_client_ui_settings_plugins["client-ui-settings-plugins"] pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] pkg_client_ui_sidebar["client-ui-sidebar"] + pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] @@ -1117,18 +1118,6 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_session_reference --> pkg_agent - pkg_session_reference --> pkg_compaction - pkg_session_reference --> pkg_llm - pkg_session_reference --> pkg_output_retention - pkg_session_reference --> pkg_session - pkg_session_reference --> pkg_session_projection - pkg_session_reference --> pkg_session_projection_cache - pkg_session_reference --> pkg_session_query - pkg_session_reference --> pkg_session_title - pkg_session_reference --> pkg_spill - pkg_session_reference --> pkg_system_prompt - pkg_session_reference --> pkg_typert_protocol pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session @@ -1212,6 +1201,19 @@ flowchart TD pkg_compaction_basic --> pkg_llm pkg_compaction_basic --> pkg_session pkg_compaction_basic --> pkg_token_meter + pkg_session_reference --> pkg_agent + pkg_session_reference --> pkg_compaction + pkg_session_reference --> pkg_llm + pkg_session_reference --> pkg_output_retention + pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache + pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title + pkg_session_reference --> pkg_spill + pkg_session_reference --> pkg_subagent + pkg_session_reference --> pkg_system_prompt + pkg_session_reference --> pkg_typert_protocol pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_invariants pkg_experimental_agent_team --> pkg_llm @@ -1365,6 +1367,7 @@ flowchart TD | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | +| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | @@ -1568,7 +1571,6 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1579,6 +1581,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/subsystems/client-resources.i18n.yaml b/docs/subsystems/client-resources.i18n.yaml index 9957789d79..b5a597f360 100644 --- a/docs/subsystems/client-resources.i18n.yaml +++ b/docs/subsystems/client-resources.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 docs/subsystems/client-resources.md -client-resources.md: d89ba6af433cd41066835be85d348b8fdf2afef0 -client-resources.zh.md: 6bfbb3e4470f89c1a90853427ca6f3a684120b3e +client-resources.md: 09acaf72eb5ee6f6003d9b275afa8a73f0822771 +client-resources.zh.md: 4a0e30d2d885d143b1bc59ca58f59fd8d1f3e9b7 diff --git a/docs/subsystems/client-resources.md b/docs/subsystems/client-resources.md index d89ba6af43..09acaf72eb 100644 --- a/docs/subsystems/client-resources.md +++ b/docs/subsystems/client-resources.md @@ -15,6 +15,7 @@ A resource address is a `dsh-resource:///…` URL. The host names the prot | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered | | `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed | | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits | +| `dsh-resource://subagentchat/session/c1?parent=p1&mode=continuable` | `subagentchat` | an addressed subagent Conversation whose value owns a `SessionReference` until the resource closes | | `sidebar://guide` | — | `none`: a navigation address | | `/home/me/notes.md` | — | `none`: not a URL | @@ -84,7 +85,7 @@ A consumer presents `failed` itself: the model keeps the last value beside the f A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)). -Streams carry metadata, not content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). +Most streams carry metadata rather than content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). The `subagentchat` provider retains the addressed Session and yields its `SessionReference`, then releases that reference when the resource signal aborts. ## Limits diff --git a/docs/subsystems/client-resources.zh.md b/docs/subsystems/client-resources.zh.md index 6bfbb3e447..4a0e30d2d8 100644 --- a/docs/subsystems/client-resources.zh.md +++ b/docs/subsystems/client-resources.zh.md @@ -15,6 +15,7 @@ | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) | | `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session | | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 | +| `dsh-resource://subagentchat/session/c1?parent=p1&mode=continuable` | `subagentchat` | 已寻址的 subagent Conversation;其值持有一个 `SessionReference`,直到资源关闭 | | `sidebar://guide` | — | `none`:导航地址 | | `/home/me/notes.md` | — | `none`:不是 URL | @@ -84,7 +85,7 @@ export function FileHeader({ useTabInfo, useResource, t }: Props) { 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 -流只推元数据不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。 +多数流只推元数据而不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。`subagentchat` 提供方保留已寻址 Session 并产出其 `SessionReference`,在资源 signal 中止时释放该 reference。 ## 限制 diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index 147a780972..73cb7b9c29 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.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 docs/subsystems/session-reference.md -session-reference.md: 1f44e1b96e31f446ed7f3fe28b625db0958da231 -session-reference.zh.md: 4f5cccf32b0f987f47923fb104e4c66396f89ba9 +session-reference.md: a2567a6cc3f6dde9a32db770a3a5f65c35ada219 +session-reference.zh.md: 43cd567aaa94cf4f54cd286c1dfc29d6a913b3e4 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 1f44e1b96e..a2567a6cc3 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -34,7 +34,7 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, and filtering searches that label alongside session id and cwd, never transcript text. +`SessionReferenceCandidate` is host-facing discovery output. Its `label` uses the latest Session title when present, while its optional display text prefers a subagent's durable creation label; filtering searches both alongside Session id and cwd, never transcript text. The Remote candidate's canonical mention uses the display text when present. ```ts type-equiv /** One host-facing candidate from exact session metadata. */ @@ -43,6 +43,8 @@ interface SessionReferenceCandidate { sessionId: SessionId /** Latest log-backed title, falling back to the opaque session id. */ label: string + /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */ + displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string /** @@ -180,14 +182,13 @@ Exact-read consumer that prepares immutable cross-session message context. /** * List reference candidates, ranked by working-directory affinity. * - * Discovery runs at keystroke rate, so a title only ever comes from a - * projection read: see {@link SessionReferenceResolver.projectedTitle} for - * which sessions can answer one and which fall back to their id. + * Discovery runs at keystroke rate, so titles and subagent labels only ever + * come from projection reads; sessions without either fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd/title substring. + * @param query - optional case-insensitive session-id/cwd/title/display-title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. - * @returns candidates labeled by latest title or, when absent, session id. + * @returns candidates with canonical mention labels and presentation titles. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 4f5cccf32b..43cd567aaa 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -34,7 +34,7 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选搜索该 label 以及 session id 和 cwd,绝不搜索 transcript(文本记录)。 +`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新 Session 标题时,它的 `label` 使用该标题;可选显示文本则优先使用 subagent 的持久创建 label。筛选会同时搜索两者、Session id 与 cwd,绝不搜索 transcript(文本记录)。Remote 候选在 `displayTitle` 存在时用它标记规范 mention。 ```ts type-equiv /** One host-facing candidate from exact session metadata. */ @@ -43,6 +43,8 @@ interface SessionReferenceCandidate { sessionId: SessionId /** Latest log-backed title, falling back to the opaque session id. */ label: string + /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */ + displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string /** @@ -180,14 +182,13 @@ Exact-read consumer that prepares immutable cross-session message context. /** * List reference candidates, ranked by working-directory affinity. * - * Discovery runs at keystroke rate, so a title only ever comes from a - * projection read: see {@link SessionReferenceResolver.projectedTitle} for - * which sessions can answer one and which fall back to their id. + * Discovery runs at keystroke rate, so titles and subagent labels only ever + * come from projection reads; sessions without either fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd/title substring. + * @param query - optional case-insensitive session-id/cwd/title/display-title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. - * @returns candidates labeled by latest title or, when absent, session id. + * @returns candidates with canonical mention labels and presentation titles. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index afcfc56fe9..8b23e7fe7c 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.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 docs/subsystems/sidebar-right.md -sidebar-right.md: 6b7cc4a29535e5f69dcc42e3ab82f445d830c806 -sidebar-right.zh.md: ef41287ec6465af469467e45b48e9a3e9ffc5c47 +sidebar-right.md: a6478e10f19bc11f2a8bfa89d6213a85ffd76ffc +sidebar-right.zh.md: af23b93945ce714cf4f0485752d8e07f155055c2 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 6b7cc4a295..a6478e10f1 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -19,7 +19,7 @@ A tab type is two registrations that share the definition's `id`: a static defin | [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` | | [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider | | [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` | -| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types | +| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md), [`client/ui-sidebar-browser`](../../packages/client/ui-sidebar-browser/README.md) | The shipped `text`, `files`, and `browser` types | ## Addresses @@ -72,13 +72,14 @@ export function apply(ctx: Context): void { ## Navigation: `ctx.sidebarRight` -Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors. +Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box, or an HTTP(S) link in Assistant Markdown. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors. | Option | Meaning | |---|---| | `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). | | `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. | | `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. | +| `preferNewPane` | Prefer a new docked pane under the normal pane budget and room rule; fall back to the target pane when it cannot split. Ignored with `replaceTab`. | | `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. | | `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. | @@ -135,6 +136,8 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). +- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). +- **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). ## Not built @@ -147,4 +150,4 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - Naming a tab implementation when opening: `openResource` names a kind at most; document-renderer selection belongs to the file tab's toolbar. - An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate. - Navigation addresses beyond the Sidebar's own `sidebar://` bookkeeping; their grammar waits for the navigation controller as a whole. -- A user-facing undo, a content navigation stack, and tab icons ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)). +- A user-facing undo and general cross-type content navigation stack ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)); Browser owns only its own page history. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index ef41287ec6..af23b93945 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -19,7 +19,7 @@ | [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` | | [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 | | [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` | -| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 | +| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md)、[`client/ui-sidebar-browser`](../../packages/client/ui-sidebar-browser/README.zh.md) | 内置的 `text`、`files` 与 `browser` 类型 | ## 地址 @@ -72,13 +72,14 @@ export function apply(ctx: Context): void { ## 导航:`ctx.sidebarRight` -两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。 +两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框或 Assistant Markdown 中的 HTTP(S) 链接。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。 | 选项 | 含义 | |---|---| | `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 | | `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 | | `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 | +| `preferNewPane` | 在普通格数预算与空间规则下优先新建停靠格;不能分栏时回退到目标格。与 `replaceTab` 一起使用时忽略。 | | `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 | | `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 | @@ -135,6 +136,8 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 +- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 +- **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 ## 不做 @@ -147,4 +150,4 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - 打开时点名某个 tab 实现:`openResource` 最多点名一个 kind;文档渲染器由文件 tab 的工具栏选择。 - 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。 - Sidebar 自身 `sidebar://` 记账之外的导航地址;其语法等导航控制器整体做时再定。 -- 面向用户的撤销、内容导航栈与 tab 图标([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。 +- 面向用户的撤销与跨类型通用内容导航栈([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred));Browser 只拥有自身页面历史。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d25c088405..3851d2aeea 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.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 docs/subsystems/slots.md -slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363 -slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668 +slots.md: a9f0ee6ab2ef37065ab965a7989f5989350bca8b +slots.zh.md: da6a38eab6aac1c2670b131fa49a832ce41f711e diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 177bbc5b8f..a9f0ee6ab2 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -151,7 +151,8 @@ root │ │ ├─ conversation.session.header.utilities │ │ └─ conversation.session.header.corner │ ├─ conversation.composer -│ │ └─ conversation.approval.detail +│ │ ├─ conversation.approval.detail +│ │ └─ conversation.plan-review.actions │ ├─ conversation.composer.bar │ │ ├─ conversation.input.attachments │ │ ├─ conversation.input.permission diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c746ac8870..da6a38eab6 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -151,7 +151,8 @@ root │ │ ├─ conversation.session.header.utilities │ │ └─ conversation.session.header.corner │ ├─ conversation.composer -│ │ └─ conversation.approval.detail +│ │ ├─ conversation.approval.detail +│ │ └─ conversation.plan-review.actions │ ├─ conversation.composer.bar │ │ ├─ conversation.input.attachments │ │ ├─ conversation.input.permission diff --git a/docs/subsystems/user-questions.i18n.yaml b/docs/subsystems/user-questions.i18n.yaml index 1138e03de1..d3e6082375 100644 --- a/docs/subsystems/user-questions.i18n.yaml +++ b/docs/subsystems/user-questions.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 docs/subsystems/user-questions.md -user-questions.md: fbbfb1435586c7191e47a6eaa5b1783c9f172d48 -user-questions.zh.md: 054ca06cd3c8a85cb35d99658c325848ea780cb5 +user-questions.md: d6515464e562eb5b3d505fd3ff1dcb118c2a7ee3 +user-questions.zh.md: 87536c22ad717ee5c25f4846aee6bd6af6068e8d diff --git a/docs/subsystems/user-questions.md b/docs/subsystems/user-questions.md index fbbfb14355..d6515464e5 100644 --- a/docs/subsystems/user-questions.md +++ b/docs/subsystems/user-questions.md @@ -41,6 +41,8 @@ type AskUserQuestionIntent = { * An `approve` naming no option of its own question is rejected at `ask()`. */ approve: string + /** Logged tool invocation whose arguments contain the reviewed plan. */ + callId?: ToolCallId } ``` diff --git a/docs/subsystems/user-questions.zh.md b/docs/subsystems/user-questions.zh.md index 054ca06cd3..87536c22ad 100644 --- a/docs/subsystems/user-questions.zh.md +++ b/docs/subsystems/user-questions.zh.md @@ -41,6 +41,8 @@ type AskUserQuestionIntent = { * An `approve` naming no option of its own question is rejected at `ask()`. */ approve: string + /** Logged tool invocation whose arguments contain the reviewed plan. */ + callId?: ToolCallId } ``` diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index bec49cab10..5af12b3f43 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.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 docs/subsystems/web-server.md -web-server.md: d9b1ec007bc73274bb0c8b5d58745da1c76e6cb6 -web-server.zh.md: 66375534a89e2c4674e748d4178cb7a31761e57e +web-server.md: 619dc8ed74ea8835aaa16b90b6e0aced5da41f3a +web-server.zh.md: c6f77099f3f8b00b07eaeefa1b1db1cb6f388546 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index d9b1ec007b..619dc8ed74 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -60,6 +60,46 @@ A request whose handling throws (a malformed %-escape hitting `decodeURIComponen Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.connection` — `HostConnectionHandle` + +Host `ctx.connection` shape consumed by transport-independent adapters. + +```ts cordis-catalog +/** + * Compose exact Fetch routes and the shared-channel RPC interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler for trusted, authenticated requests. + */ +createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler + +/** + * Apply Connection's Host/Origin checks and browser authentication to + * another Web route. + * @param request - request headers from the HTTP or upgrade request. + * @returns rejection status, or undefined when the route may accept the request. + */ +requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection + +/** + * Authenticate one frontend index request, owning a token redirect or 401. + * @param request - root or configured-index HTTP request. + * @param response - response owned when the result is false. + * @returns true only when the frontend may serve index.html. + */ +authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean + +/** + * Add the fresh process token to an ordinary Web application URL. + * @param baseUrl - clean canonical browser origin. + * @returns root URL accepted by {@link authorizeIndex} for initial login. + */ +authenticatedUrl(baseUrl: string): string +``` + +Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + ### `ctx.webServer` — `WebServer` @@ -129,6 +169,30 @@ renderIndex(html: string): string Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) + + +### `connection/*` events + + + +#### `connection/request` — waterfall + +Admit or wrap an authenticated shared API request, including body transfer. Existing requests continue when a listener refuses subsequent requests. + +```ts cordis-catalog +/** + * Admit or wrap an authenticated shared API request, including body transfer. + * Existing requests continue when a listener refuses subsequent requests. + * @param request - Authenticated incoming HTTP request. + * @param response - Response owned until the delegated bridge settles. + * @param next - Delegate to the next listener or the shared API bridge. + * @mode waterfall + */ +'connection/request'(request: IncomingMessage, response: ServerResponse, next: () => Promise): Promise +``` + +Source: [`packages/client/connection/src/index.ts`](../../packages/client/connection/src/index.ts) + ### `webserver/*` events diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 66375534a8..c6f77099f3 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -60,6 +60,46 @@ interface Config { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.connection` — `HostConnectionHandle` + +Host `ctx.connection` shape consumed by transport-independent adapters. + +```ts cordis-catalog +/** + * Compose exact Fetch routes and the shared-channel RPC interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler for trusted, authenticated requests. + */ +createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler + +/** + * Apply Connection's Host/Origin checks and browser authentication to + * another Web route. + * @param request - request headers from the HTTP or upgrade request. + * @returns rejection status, or undefined when the route may accept the request. + */ +requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection + +/** + * Authenticate one frontend index request, owning a token redirect or 401. + * @param request - root or configured-index HTTP request. + * @param response - response owned when the result is false. + * @returns true only when the frontend may serve index.html. + */ +authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean + +/** + * Add the fresh process token to an ordinary Web application URL. + * @param baseUrl - clean canonical browser origin. + * @returns root URL accepted by {@link authorizeIndex} for initial login. + */ +authenticatedUrl(baseUrl: string): string +``` + +Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + ### `ctx.webServer` — `WebServer` @@ -129,6 +169,30 @@ renderIndex(html: string): string Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) + + +### `connection/*` events + + + +#### `connection/request` — waterfall + +Admit or wrap an authenticated shared API request, including body transfer. Existing requests continue when a listener refuses subsequent requests. + +```ts cordis-catalog +/** + * Admit or wrap an authenticated shared API request, including body transfer. + * Existing requests continue when a listener refuses subsequent requests. + * @param request - Authenticated incoming HTTP request. + * @param response - Response owned until the delegated bridge settles. + * @param next - Delegate to the next listener or the shared API bridge. + * @mode waterfall + */ +'connection/request'(request: IncomingMessage, response: ServerResponse, next: () => Promise): Promise +``` + +Source: [`packages/client/connection/src/index.ts`](../../packages/client/connection/src/index.ts) + ### `webserver/*` events diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 00d2839352..2cafd271b0 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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 docs/user/guide/python-sdk.md -python-sdk.md: 2cb01ffa19d173231902c5c816929fa6b4d499f0 -python-sdk.zh.md: 3db859116530e51a7c7478ab222cbc4d2435dbf2 +python-sdk.md: a14d656171f37a7feea8c091741f2175f9617cd4 +python-sdk.zh.md: 0846ed95ef3a396b468b0f50bcc176c64a963850 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 2cb01ffa19..a14d656171 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -14,9 +14,14 @@ This tutorial installs the published Python SDK, runs the shipped standalone min ## Install the SDK -### Linux and macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness python -m venv .venv @@ -24,9 +29,7 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] git clone https://github.com/deepseek-ai/deepseek-harness.git Set-Location deepseek-harness py -3.10 -m venv .venv @@ -34,31 +37,43 @@ py -3.10 -m venv .venv python -m pip install deepseek-harness-sdk ``` +::: + The installation includes a matching native runtime wheel and the `dsh` command. Normal SDK execution needs no system Node.js. Repository contributors who build the artifacts should use the [Python contributor workflow](../../../python/development.md). ## Run the checked-in example Export the credential and, when needed, a compatible proxy endpoint: -### Linux and macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] $env:DEEPSEEK_API_KEY = "sk-your-key-here" # $env:DEEPSEEK_BASE_URL = "http://127.0.0.1:8000/v1" ``` +::: + Run one task with explicit workspace and home paths: -### Linux and macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] python python/sdk/examples/minimal.py \ --workspace /absolute/path/to/disposable-workspace \ --dsh-home /absolute/path/to/example-dsh-home \ @@ -66,9 +81,7 @@ python python/sdk/examples/minimal.py \ "Inspect the repository and fix the failing tests." ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] python python/sdk/examples/minimal.py ` --workspace C:\work\disposable-workspace ` --dsh-home C:\work\example-dsh-home ` @@ -76,6 +89,8 @@ python python/sdk/examples/minimal.py ` "Inspect the repository and fix the failing tests." ``` +::: + The script prints the final assistant response. The selected home receives the generated `sdk-minimal` profile, installed plugins, and uncompressed JSONL session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. ## Use the SDK in your program @@ -109,22 +124,27 @@ The SDK starts the bundled `dsh --profile sdk-minimal` process lazily and reuses Use `dsh plugin` for dependencies and bundle layers that should persist in this home: -### Linux and macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] export DSH_HOME=/absolute/path/to/example-dsh-home dsh --profile sdk-minimal --dump-default-config >/dev/null dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] $env:DSH_HOME = "C:\work\example-dsh-home" dsh --profile sdk-minimal --dump-default-config | Out-Null dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle ``` +::: + The first command initializes the shipped standalone profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 3db8591165..0846ed95ef 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -14,9 +14,14 @@ ## 安装 SDK -### Linux 与 macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness python -m venv .venv @@ -24,9 +29,7 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] git clone https://github.com/deepseek-ai/deepseek-harness.git Set-Location deepseek-harness py -3.10 -m venv .venv @@ -34,31 +37,43 @@ py -3.10 -m venv .venv python -m pip install deepseek-harness-sdk ``` +::: + 安装内容包含匹配的原生运行时 wheel 与 `dsh` 命令。普通 SDK 运行不需要系统 Node.js。需要构建产物的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.zh.md)。 ## 运行检入示例 导出凭据;使用兼容代理时再设置 endpoint: -### Linux 与 macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] $env:DEEPSEEK_API_KEY = "sk-your-key-here" # $env:DEEPSEEK_BASE_URL = "http://127.0.0.1:8000/v1" ``` +::: + 使用显式 workspace 与 home 路径运行一个任务: -### Linux 与 macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] python python/sdk/examples/minimal.py \ --workspace /absolute/path/to/disposable-workspace \ --dsh-home /absolute/path/to/example-dsh-home \ @@ -66,9 +81,7 @@ python python/sdk/examples/minimal.py \ "Inspect the repository and fix the failing tests." ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] python python/sdk/examples/minimal.py ` --workspace C:\work\disposable-workspace ` --dsh-home C:\work\example-dsh-home ` @@ -76,6 +89,8 @@ python python/sdk/examples/minimal.py ` "Inspect the repository and fix the failing tests." ``` +::: + 脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk-minimal` profile、已安装插件,以及 `sessions/` 下的未压缩 JSONL 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 ## 在程序中使用 SDK @@ -109,22 +124,27 @@ SDK 会延迟启动内置的 `dsh --profile sdk-minimal` 进程,并复用到 需要在该 home 中持久保存依赖与 bundle 层时,使用 `dsh plugin`: -### Linux 与 macOS +
    + + +
    -```sh +::: code-group + +```sh [Linux/macOS] export DSH_HOME=/absolute/path/to/example-dsh-home dsh --profile sdk-minimal --dump-default-config >/dev/null dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -### Windows PowerShell - -```powershell +```powershell [Windows PowerShell] $env:DSH_HOME = "C:\work\example-dsh-home" dsh --profile sdk-minimal --dump-default-config | Out-Null dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle ``` +::: + 第一个命令初始化随附的独立 profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 diff --git a/package.json b/package.json index 7eb8fb2782..bb4d9c3286 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index a3f414c6f0..79474ae2cf 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -2,6 +2,7 @@ import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, @@ -584,6 +585,15 @@ export class SessionManager { return this.listSnapshotCache } + /** + * Read cached projection values for a Session that may exist only in a loaded subagent catalog. + * @param sessionId - Session whose control or history baseline supplied projections. + * @returns current values, or undefined before any projection store exists. + */ + projectionValues(sessionId: SessionId): Readonly> | undefined { + return this.projectionStores.get(sessionId)?.values() + } + // ---- Live control and Host-event sinks ---- /** diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 2f54a86447..8d0dd75fc7 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -655,16 +655,25 @@ export class ClientSessions implements ISessions { for (const child of catalog.entries) { if (child.kind !== 'child') continue const childId = child.id - const displayTitle = child.label ?? childId const summary = byId[childId] + const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId) + const projectedTitle = projectionValues?.title + const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined + const displayTitle = title ?? child.label ?? childId if (summary === undefined) { byId[childId] = { id: childId, displayTitle, parentId: parentId as SessionId, origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0, retainedBy: this.retentionSnapshot(childId).retainedBy, + ...(projectionValues === undefined ? {} : { projectionValues }), + ...(title === undefined ? {} : { title }), + } + } else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) { + byId[childId] = { + ...summary, + displayTitle, + ...(projectionValues === undefined ? {} : { projectionValues }), } - } else if (summary.displayTitle !== displayTitle) { - byId[childId] = { ...summary, displayTitle } } } } diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 730951d78b..ee3298fbf8 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -703,6 +703,9 @@ describe('borrow-only bindings', () => { describe('catalog-addressed navigation', () => { it('opens an explicit catalog without retaining it and keeps one-shot labels optional', async ({ bench }) => { const b = bench() + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('one-shot'), key: 'title', value: 'Investigate startup', seq: 2, + }) b.mock.remote.subagents.list.mockResolvedValue(ok({ entries: [ { kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive', hasChildren: false }, @@ -714,13 +717,17 @@ describe('catalog-addressed navigation', () => { await b.svc.refreshSubagents(sid('root')) b.svc.setSubagentCatalogOpen(sid('root'), false) - expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe('one-shot') + expect(b.svc.list.getSnapshot().byId[sid('one-shot')]).toMatchObject({ + title: 'Investigate startup', + displayTitle: 'Investigate startup', + projectionValues: { title: 'Investigate startup' }, + }) expect(b.svc.list.getSnapshot().byId[sid('missing')]).toBeUndefined() expect(b.svc.binding(sid('one-shot'))).toBeUndefined() expect(b.mock.remote.subagents.list).toHaveBeenCalledOnce() }) - it('uses catalog labels for a listed addressed route', async ({ bench }) => { + it('keeps projected titles in standard list rows for an addressed route', async ({ bench }) => { const b = bench() b.mock.remote.subagents.list.mockImplementation((payload) => { const parentSessionId = payload @@ -746,7 +753,10 @@ describe('catalog-addressed navigation', () => { }) await feedList(b, [ { id: 'root' }, - { id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' }, + { + id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent', + projections: { title: 'Child session title' }, + }, { id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' }, ]) await b.svc.refreshSubagents(sid('root')) @@ -756,7 +766,11 @@ describe('catalog-addressed navigation', () => { }, { source: 'controllerOperation' }) await _reference.ready - expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child') + expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ + title: 'Child session title', + displayTitle: 'Child session title', + projectionValues: { title: 'Child session title' }, + }) expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild') }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c17c859223..9fd0effbc5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -233,9 +233,13 @@ - id: ui-sidebar-documentpreview name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' - # The right Sidebar's workspace file tree tab type. + # Sandboxed HTTP(S) Browser tabs, including loopback targets. + - id: ui-sidebar-browser + name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + - id: ui-sidebar-terminal name: '@deepseek-ai/dsh-client-ui-sidebar-terminal' + # The right Sidebar's workspace file tree tab type. - id: ui-sidebar-files name: '@deepseek-ai/dsh-client-ui-sidebar-files' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 8d6bd9de6b..aa70ffb352 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -85,6 +85,7 @@ "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 188fb38b6b..2a01a6de69 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: f8146ceafa179508fcc77718c1ecc91bd459d3d0 -README.zh.md: f8db7644a2164c892c4fa2d92aef836ecae9e7e5 +README.md: 3a1a54f801090456a1038d0e3d59fe5223374f3c +README.zh.md: 231449ab574224a3e4fc5436c57e9ece5b0183b5 diff --git a/packages/client/README.md b/packages/client/README.md index f8146ceafa..3a1a54f801 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -45,6 +45,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation | — | | [`ui-sidebar-right/`](ui-sidebar-right/README.md) | Owns the right Sidebar and its tab types | `ctx.sidebarRight`, `ctx.sidebarRightTabs` | | [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.md) | Displays documents in right Sidebar tabs | `ctx.documentPreviews` | +| [`ui-sidebar-browser/`](ui-sidebar-browser/README.md) | Browses sandboxed HTTP(S) pages, including loopback services, in right-Sidebar tabs | — | | [`resources/`](resources/README.md) | Unified resource model: protocol providers behind the `useResource` session standard hook | `ctx.resources` | | [`ui-sidebar-files/`](ui-sidebar-files/README.md) | Right-Sidebar workspace file tree tab type | — | | [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index f8db7644a2..231449ab57 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -45,6 +45,7 @@ kind: "package-group" | [`ui-sidebar/`](ui-sidebar/README.zh.md) | 展示工作区与会话导航 | — | | [`ui-sidebar-right/`](ui-sidebar-right/README.zh.md) | 管理右侧 Sidebar 及其 tab 类型 | `ctx.sidebarRight`, `ctx.sidebarRightTabs` | | [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.zh.md) | 在右侧 Sidebar 的 tab 中显示文档 | `ctx.documentPreviews` | +| [`ui-sidebar-browser/`](ui-sidebar-browser/README.zh.md) | 在右侧 Sidebar tab 中浏览 sandboxed HTTP(S) 页面,包括 loopback 服务 | — | | [`resources/`](resources/README.zh.md) | 统一资源模型:`useResource` 会话标准钩子背后的协议提供方 | `ctx.resources` | | [`ui-sidebar-files/`](ui-sidebar-files/README.zh.md) | 右侧 Sidebar 的工作区文件树 tab 类型 | — | | [`ui-brand-official/`](ui-brand-official/README.zh.md) | 用官方名称与标记填充通用浏览器品牌 slot | — | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 88fe496c95..3077a60252 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: c881b39af2e1f466a84ab463e758bb5e946d25b1 -README.zh.md: ff1e9923bebc4d131d99df51c21538015f0e715e +README.md: 17bcb9940286b91433273cef2fcda3147249133b +README.zh.md: 6a090709414007ddb7fe7ca3fdd8c29b737a39dc diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index c881b39af2..17bcb99402 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -40,6 +40,8 @@ The cookie signing secret is the owner-scoped `client-connection/browser-session Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). +Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway. + ## Connection generation diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index ff1e9923be..6a09070941 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -40,6 +40,8 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi 认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 +通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。 + ## Connection generation diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 98a4354760..7a4457e375 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,5 +1,6 @@ /** Host HTTP bridge for browser-client RPC. */ import type { Context } from '@deepseek-ai/cordis' +import type { IncomingMessage, ServerResponse } from 'node:http' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-credentials' @@ -48,6 +49,20 @@ export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Admit or wrap an authenticated shared API request, including body transfer. + * Existing requests continue when a listener refuses subsequent requests. + * @param request - Authenticated incoming HTTP request. + * @param response - Response owned until the delegated bridge settles. + * @param next - Delegate to the next listener or the shared API bridge. + * @mode waterfall + */ + 'connection/request'(request: IncomingMessage, response: ServerResponse, next: () => Promise): Promise + } +} + /** Headroom for RPC JSON fields around aggregate base64 image payloads. */ const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024 @@ -132,7 +147,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise bridge(req, res, fetchHandler, maxRequestBodyBytes)) }, } webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route') diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 5c681f3c00..5fd40607de 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -118,6 +118,64 @@ function browserCookie(connection: HostConnectionHandle, authority: string): str } describe('connection node half', () => { + it('runs request admission after authentication and removes it with its owning fiber', async () => { + const { ctx, routes, connection, dispose } = await mounted() + let admitted = 0 + const guard = ctx.plugin({ apply(owner: Context) { + owner.on('connection/request', async (_request, response) => { + admitted++ + response.writeHead(503) + response.end() + }) + } }) + try { + await guard.await() + const unauthorized = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: 'localhost' }), unauthorized.response) + expect(unauthorized.state.status).toBe(401) + expect(admitted).toBe(0) + const headers = { host: 'localhost', cookie: browserCookie(connection, 'localhost') } + const refused = fakeResponse() + await routes[0]!.handler(fakeRequest(headers), refused.response) + expect(refused.state.status).toBe(503) + expect(admitted).toBe(1) + await guard.dispose() + const allowed = fakeResponse() + await routes[0]!.handler(fakeRequest(headers), allowed.response) + expect(allowed.state.status).toBe(404) + expect(admitted).toBe(1) + } finally { await guard.dispose(); await dispose() } + }) + + it('awaits delegated response transfer before releasing the admission listener', async () => { + const { ctx, routes, connection, dispose } = await mounted() + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + let completed = false + connection.fetch.register({ path: '/api/held', methods: ['GET'], requestBody: 'buffered', + async fetch() { + return new Response(new ReadableStream({ async start(controller) { + entered.resolve(undefined) + await finish.promise + controller.close() + } })) + }, + }) + const remove = ctx.on('connection/request', async (_request, _response, next) => { + await next() + completed = true + }) + const response = fakeResponse() + const pending = routes[0]!.handler(fakeRequest({ host: 'localhost', cookie: browserCookie(connection, 'localhost') }, '/api/held'), response.response) + try { + await entered.promise + expect(completed).toBe(false) + finish.resolve(undefined) + await pending + expect(completed).toBe(true) + } finally { finish.resolve(undefined); await pending; remove(); await dispose() } + }) + it('provides the carrier-neutral service without a Web server', async () => { const ctx = new Context() provideBrowserCredentials(ctx) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 8b9ac24565..5ce716a2ae 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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-attachment/README.md -README.md: 3c69b387914c631f50d783c4a35089ab8db1668f -README.zh.md: c21b2acdbeb7ced6e9a8733a861c5324121ad194 +README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e +README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3c69b38791..0765068c73 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -35,6 +35,8 @@ Images and generic files retain pick order in one non-wrapping horizontal rail. In Chat, one user message presents files and images in a right-aligned wrapping flow that preserves source order. A lone image without another attachment renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); when the message has more than one attachment, each image is a fixed 64px square beside 240×64px file cards. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. +Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup. + ### Drop overlay While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index c21b2acdbe..5976acfda5 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -35,6 +35,8 @@ kind: "package-reference" Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 +Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 + ### 拖放遮罩 文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态——是否接受由持有方的文档级监听器决定。 diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 6c691c20a1..f551605a4a 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -67,3 +67,41 @@ overflow: hidden; border-radius: 16px; } + +.frame[data-variant='thumbnail'], +.error[data-variant='thumbnail'] { + box-sizing: border-box; + display: grid; + place-items: center; + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; + padding: 2px; + overflow: hidden; + border-radius: 8px; +} + +.frame[data-variant='thumbnail'] img { + object-fit: contain; +} + +.spinner { + animation: spinner-rotate 0.9s linear infinite; +} + +@keyframes spinner-rotate { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} + +.frame:focus-visible, +.error:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 3cf3b6c51e..be35aa650c 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { IconLoadingOutline16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { ImageLightbox } from './ImageLightbox.tsx' import type { ImageLightboxLabels } from './ImageLightbox.tsx' import css from './MessageImage.module.css' @@ -11,7 +12,11 @@ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) /** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ export type MessageImageSpec = - | { readonly attachment: ImageAttachmentRef } + | { + readonly attachment: ImageAttachmentRef + /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */ + readonly label?: string + } | { readonly preview: { readonly url: string @@ -69,17 +74,18 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon * preview. A lone image renders at its `singleFit` size; an image among * several renders as a fixed 64px square tile. The preview arm displays its * local URL directly — no loader round-trip, no failure/retry surface. + * List thumbnails use status icons with localized tooltips and accessible names. * * @param props.image - the durable reference to load, or the local preview to display. * @param props.load - session-authorized URL loader for the durable arm. - * @param props.variant - `single` for a message's lone image, `tile` otherwise. + * @param props.variant - lone image, cropped gallery tile, or uncropped list thumbnail. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ export function MessageImage({ image, load, variant, labels }: { image: MessageImageSpec load: ImageLoader - variant: 'single' | 'tile' + variant: 'single' | 'tile' | 'thumbnail' labels: MessageImageLabels }) { const preview = 'preview' in image ? image.preview : undefined @@ -116,8 +122,23 @@ export function MessageImage({ image, load, variant, labels }: { }, [attachment, load, attempt]) const src = preview?.url ?? loaded - const label = (preview?.name ?? attachment?.name) ?? labels.image - if (error) return + const label = ('attachment' in image ? image.label : undefined) + ?? preview?.name ?? attachment?.name ?? labels.image + const loadingThumbnail = variant === 'thumbnail' && src === null + if (error) return ( + + ) return ( <> {open && src !== null && } @@ -139,16 +165,17 @@ export function MessageImage({ image, load, variant, labels }: { } /** Wrapping image group shared by user and assistant history: a lone image - * renders large unless its owning mixed-attachment row requests compact tiles. */ -export function ImageGallery({ images, load, align, compact = false, labels }: { + * renders large unless its owner requests compact tiles or contained list thumbnails. */ +export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' compact?: boolean + thumbnail?: boolean labels: MessageImageLabels }) { if (images.length === 0) return null - const variant = compact || images.length > 1 ? 'tile' : 'single' + const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single' return (
    {images.map((image, index) => ( diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 92cdaa455b..192147f877 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -3,13 +3,14 @@ import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' /** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) { +export function MessageImages({ images, loadImage, align, compact = false, thumbnail = false, t }: MessageImagesProps) { return ( ) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 3d3711a77a..ee14237ad5 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -144,11 +144,56 @@ describe('MessageImage', () => { expect(frame.getAttribute('style')).toBeNull() }) - it('keeps the tile variant on the failed-load retry control', async () => { + it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) - expect(retry.getAttribute('data-variant')).toBe('tile') + expect(retry.getAttribute('data-variant')).toBe(variant) + }) + + it.each([ + ['Chinese', labels], + ['English', { ...labels, loading: 'Loading image…', loadFailed: 'Image failed to load; click to retry' }], + ] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => { + const pending = Promise.withResolvers() + const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') + const view = render() + const loading = view.getByRole('button', { name: statusLabels.loading }) + expect(loading.getAttribute('title')).toBe(statusLabels.loading) + expect(loading.getAttribute('aria-busy')).toBe('true') + expect(loading.textContent).toBe('') + expect(loading.querySelector('svg')).not.toBeNull() + + await act(async () => { pending.reject(new Error('offline')) }) + const retry = view.getByRole('button', { name: statusLabels.loadFailed }) + expect(retry.getAttribute('title')).toBe(statusLabels.loadFailed) + expect(retry.textContent).toBe('') + expect(retry.querySelector('svg')).not.toBeNull() + fireEvent.click(retry) + await view.findByAltText(attachment.name) + const opener = view.getByRole('button', { name: statusLabels.openNamed(attachment.name) }) + expect(opener.getAttribute('aria-busy')).toBeNull() + expect(opener.getAttribute('title')).toBe(statusLabels.open) + expect(load).toHaveBeenCalledTimes(2) + expect(load).toHaveBeenLastCalledWith(attachment) + }) + + it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => { + const { name: _name, ...unnamed } = attachment + const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail') + const view = render() + fireEvent.click(await view.findByRole('button', { name: labels.loadFailed })) + const image = await view.findByAltText('Image 2') + expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail') + const opener = view.getByRole('button', { name: 'Image 2,点击查看原图' }) + opener.focus() + fireEvent.click(opener) + expect(view.getByRole('dialog')).toBeTruthy() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(view.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(opener) + expect(load).toHaveBeenLastCalledWith(unnamed) + expect(unnamed).not.toHaveProperty('name') }) it('ignores a load settling after unmount', async () => { diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 2b127cdf1b..1d6df37e4d 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 9217ff678690f6eb659bb6b7c3a808d97a26171b -README.zh.md: 1ac5159a466c61ddada5b55e62354d6bfb1e6652 +README.md: e7d3efe37e089f4b7bcbb7d954e6791899cebfc0 +README.zh.md: 8b49332ad39d373fcc2aae66af2e7c284bfee9bb diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 9217ff6786..e7d3efe37e 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -29,7 +29,9 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Reference previews -Sent file references and skills confirmed by the message’s logged invocation open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. + +HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. ## System prompt row diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 1ac5159a46..8b49332ad3 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -29,7 +29,9 @@ kind: "package-reference" ## 引用预览 -已发送的文件引用及消息日志确认调用的 skill 可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 + +Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 ## 系统提示词行 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 88d97cea84..d716f7b89b 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -81,6 +82,7 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index c20d7e7875..c9822804ab 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -6,6 +6,7 @@ import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/cli import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client' import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client' // The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' @@ -142,6 +143,13 @@ export function apply(ctx: Context): void { if (scope === undefined) return ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` }) }, + openExternalLink: (url) => { + if (ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { + ctx.sidebarRight.openTab('browser', { params: { url } }) + } else { + window.open(url, '_blank', 'noopener,noreferrer') + } + }, loadOlder: () => { void session.loadOlder() }, loadThrough: seq => session.loadThrough(seq), loadImage: Object.assign( diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 00d53507c7..4c407c8e8a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,7 +7,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { InboxState } from '@deepseek-ai/dsh-agent/types' -import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutline14, MarkdownDelegateProvider, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' @@ -217,7 +217,7 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod */ export function ChatView({ useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot, - sessionId, openFile, openSkill, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions, + sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) @@ -782,25 +782,27 @@ export function ChatView({
    )} - + + + {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index 36c76b7c26..20399ffe9c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -11,7 +11,7 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlot, renderSlotChain, t, useChat, + node, openFile, forkAt, renderSlot, t, useChat, }: TurnTailNodeViewProps) { const data = node.data const hasLaterChatNode = useChat(snapshot => @@ -23,8 +23,8 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ if (turn === undefined) return null const closing = data.closing const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile } - const tail = renderSlotChain('conversation.chat.turnTail', owner) - if (closing === null) return tail === null ? null :
    {tail}
    + const tail = renderSlot('conversation.chat.turnTail', owner) + if (closing === null) return tail === null ? null :
    {tail}
    const runMs = turn.start === undefined || turn.end === undefined ? undefined : Math.max(0, turn.end.time - turn.start.time) diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index e58ec4a9ad..79b35fd85a 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -48,7 +48,7 @@ export function registerChatNodeRenderers(ctx: Context): void { key: 'turn-tail', locale: NS, children: { - 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' }, }, }, TurnTailNodeView)) diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index fa02cc4092..81cee7d021 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -142,6 +142,8 @@ export interface ChatViewInjected { } /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void + /** Open one HTTP(S) message link in a Sidebar Browser tab. */ + openExternalLink: (url: string) => void openFile: (path: string, options?: OpenFileOptions) => Promise loadOlder: () => void /** Jump loader: page history back through seq; resolves when the window covers it. */ @@ -204,11 +206,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } /** - * Selector-routed extension before a completed Turn's action row. The - * component receives the Turn, closing sequence, and file opener. The first - * selector that accepts the owner renders; an all-declined chain is empty. + * Ordered feature contributions before a completed Turn's action row. Each + * entry receives the Turn, closing sequence, and file opener. A fresh `id` + * adds an entry; entries without content return null. */ - 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } + 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } /** * Ordered actions for one finalized assistant message. Each entry receives * the durable message id; a fresh `id` adds an action and reusing one replaces diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 3802528471..eab2fdf96e 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -51,8 +51,17 @@ async function bench() { runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() } runtime.ctx.provide('layout', layout as never) - const sidebarRight = { openResource: vi.fn<(address: string) => void>() } + const sidebarRight = { + openResource: vi.fn<(address: string) => void>(), + openTab: vi.fn<(kind: string, options?: unknown) => void>(), + } runtime.ctx.provide('sidebarRight', sidebarRight as never) + const sidebarRightTabs = { + get: vi.fn<(kind: string) => object | undefined>(() => ({})), + register: vi.fn(() => () => {}), + } + runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) + runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openWorkspacePath = vi.fn( () => Promise.resolve({ ok: true, value: { opened: true } }), ) @@ -93,7 +102,9 @@ async function bench() { ) => ChatViewInjected)(id, instance.actions) return { instance, injected } } - return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi, rootReference, openSession } + return { + runtime, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, + } } describe('Chat inject API', () => { @@ -145,6 +156,33 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) + it('opens message HTTP(S) links in Sidebar Browser tabs', async () => { + const b = await bench() + const { injected } = b.chatViewApi(b.rootReference) + injected.openExternalLink('http://example.test/path') + injected.openExternalLink('https://example.test/path') + expect(b.sidebarRight.openTab.mock.calls).toEqual([ + ['browser', { params: { url: 'http://example.test/path' } }], + ['browser', { params: { url: 'https://example.test/path' } }], + ]) + await b.runtime.dispose() + }) + + it('opens message HTTP(S) links in the system browser when no Sidebar Browser is registered', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + b.sidebarRightTabs.get.mockReturnValue(undefined) + const { injected } = b.chatViewApi(b.rootReference) + injected.openExternalLink('https://example.test/path') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + expect(open).toHaveBeenCalledWith('https://example.test/path', '_blank', 'noopener,noreferrer') + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) @@ -198,6 +236,9 @@ describe('Chat inject API', () => { const { injected } = b.chatViewApi(b.rootReference) const owner = {} as never + expect(injected.keyedHooks.chatNode('missing')).toBeDefined() + expect(injected.keyedHooks.chatNodeProcess('missing')).toBeDefined() + expect(injected.fileMentions(owner)).toBeUndefined() const mentions = { resolve: vi.fn() } as never const forClosing = vi.fn(() => mentions) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 051b78e01d..83fe28642b 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -43,7 +43,9 @@ async function bench() { : stubSettingsScope().scope, } as never) runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) - runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) + runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never) + runtime.ctx.provide('sidebarRightTabs', { register: vi.fn(() => () => {}) } as never) + runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openSession = vi.fn<(id: SessionId) => void>() runtime.ctx.provide('uiWorkspace', { openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 8a63203668..f974b064c0 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -282,8 +282,6 @@ function makeHarness( }> = [] const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => opts?.fallback ?? null) as unknown as React.ComponentProps['renderSlot'] - const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as - React.ComponentProps['renderSlotChain'] const renderTurnTailSlot = (() => null) as unknown as React.ComponentProps['renderSlot'] let nodeSlotOverride: React.ComponentProps['renderSlot'] | undefined @@ -335,7 +333,6 @@ function makeHarness( ()} renderSlot={renderTurnTailSlot} - renderSlotChain={renderTurnTail} SessionProvider={props.SessionProvider} /> ) @@ -408,6 +405,7 @@ function makeHarness( completeViewRequest: () => {}, openFile, openSkill, + openExternalLink: vi.fn(), loadOlder, loadThrough, loadImage: vi.fn(() => Promise.reject(new Error('not used'))), @@ -508,6 +506,16 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien describe('Chat node rendering', () => { + it('opens Markdown references to unmodified files with line navigation', () => { + const h = makeHarness({ + nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)], + turnEnds: new Map([[1, 2]]), + }) + const view = render() + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(h.openFile).toHaveBeenCalledWith('src/index.ts', { line: 24 }) + }) + it('threads the injected file-mention vocabulary into the closing prose only', () => { const wrote = (seq: number, callId: string): ToolResultNode => ({ ...toolResult(seq, callId, 'write'), diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index b109b1affc..a004571d35 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -59,6 +59,9 @@ { "path": "../../session/session-turn-outline" }, + { + "path": "../../subagent/subagent" + }, { "path": "../../settings/settings" }, @@ -86,6 +89,9 @@ { "path": "../ui-session" }, + { + "path": "../ui-sidebar-browser" + }, { "path": "../ui-sidebar-right" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 73a82ebe58..0bf28301d0 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 23690a0c71f9a3b81639fa763c6a095e1bf93011 -README.zh.md: 251fa9c6513773cfca3e092742a045b0ac57030c +README.md: bc3b582f9d80e97c29e27dcaa680dc064ec30deb +README.zh.md: 21881e7933cdf46cf9e27a66f076fc40c48e4b1a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 23690a0c71..bc3b582f9d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props +The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. [ui-attachment](../ui-attachment/README.md) owns rendering and the lightbox. + The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. @@ -46,7 +48,7 @@ Claimed commands retain their identity and highlight when only their arguments a Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. -The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes one strict-Session local position, `views`; its default adapter renders the existing `conversation.session` entry, so that body and the Header retain one shared strict-Session store. Another occurrence can replace `views` without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. @@ -54,7 +56,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. -Active transcripts expose content-width drag handles in their uncovered side gutters. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)). +Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)). The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 251fa9c651..21881e7933 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props +共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。[ui-attachment](../ui-attachment/README.zh.md) 负责渲染与灯箱。 + 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 @@ -46,7 +48,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 -本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开一个 strict-Session 局部位置 `views`;其默认 adapter 渲染现有 `conversation.session` entry,因此该 body 与 Header 继续共享一个 strict-Session store。其他 occurrence 可以替换 `views`,且不会渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 @@ -54,7 +56,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 -活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。 +主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9f26d51868..79ffd97359 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -255,7 +255,10 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, 'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' }, }, - slots: { views: { scope: 'session' } }, + slots: { + views: { scope: 'session' }, + widthControls: { scope: 'root' }, + }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d6abe6c657..d37387ec4d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -77,7 +77,11 @@ export interface ComposerAttachmentsOwnerProps { * local preview of a submission echo whose admission is still in flight. */ export type MessageImageSource = - | { readonly attachment: ImageAttachmentRef } + | { + readonly attachment: ImageAttachmentRef + /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */ + readonly label?: string + } | { readonly preview: { /** Browser-owned preview URL (lifecycle stays with the submitter). */ @@ -105,6 +109,8 @@ export interface MessageImagesOwnerProps { align: 'start' | 'end' /** Force every image into the compact message-attachment tile size. */ compact?: boolean + /** Fixed, uncropped thumbnail for an attachment list row. */ + thumbnail?: boolean } /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ @@ -120,7 +126,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Conversation shell beneath its root-scoped main-panel entry. */ 'main.conversation': { kind: 'single'; scope: 'session-maybe' } /** Strict per-Session Conversation body. */ - 'conversation.session': { kind: 'single'; scope: 'session' } + 'conversation.session': { + kind: 'single' + scope: 'session' + owner: { view?: string } + } /** Strict per-Session title, actions, and View navigation. */ 'conversation.session.header': { kind: 'single'; scope: 'session' } /** Optional replacement for one Session breadcrumb title. */ @@ -218,6 +228,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { locale: 'conversation' slots: { views: { scope: 'session' } + widthControls: { scope: 'root'; props: ConversationWidthControlsInputProps } } } } @@ -411,14 +422,19 @@ export type ConversationSlotProps = & PropsRenderSlots<'conversation.session.header'> & PropsRenderFactories -/** Main-host inputs for one reusable Conversation content occurrence. */ +/** Inputs shared by main and embedded Conversation content occurrences. */ export interface ConversationContentInputProps { + variant: 'main' | 'embedded' phase: 'settling' | 'hero' | 'active' hero: boolean - onHandleStart: () => number - onHandleDrag: (width: number) => void - onHandleCommit: (width: number) => void - onHandleEnd: () => void +} + +/** Values passed from shared content to its occurrence-selected width controls. */ +export interface ConversationWidthControlsInputProps { + /** Mounted Conversation body measured and styled by the selected controls. */ + container: HTMLDivElement | null + /** Current body phase; handles render only for an active transcript. */ + phase: ConversationContentInputProps['phase'] } /** Full props of the reusable Conversation Factory definition. */ @@ -430,6 +446,10 @@ export type ConversationStore = ReturnType /** Full props of the Factory's caller-selectable Conversation View position. */ export type ConversationViewsProps = FactoryLocalComponentPropsOf<'conversation.content', 'views'> +/** Full props of the Factory's caller-selected width-control position. */ +export type ConversationWidthControlsProps = + FactoryLocalComponentPropsOf<'conversation.content', 'widthControls'> + /** Full props of the strict Session body. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index a1ccf07408..73ddc6d7d3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -57,6 +57,7 @@ export type { ConversationContentInputProps, ConversationContentProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps, ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationViewsProps, + ConversationWidthControlsInputProps, ConversationWidthControlsProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 0118749e8c..adace1c113 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -5,134 +5,29 @@ import type { ConversationContentProps, ConversationViewsProps, InputZone } from import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' -const WHEEL_DELTA_LINE = 1 -const WHEEL_DELTA_PAGE = 2 -const FALLBACK_WHEEL_LINE_PX = 16 - function ConversationSessionView({ renderSlot }: ConversationViewsProps) { return renderSlot('conversation.session', {}) } -/** Convert a wheel event's vertical delta to scrollport pixels. */ -function wheelDeltaY(event: React.WheelEvent, scrollport: HTMLElement): number { - if (event.deltaMode === WHEEL_DELTA_LINE) { - const lineHeight = Number.parseFloat(getComputedStyle(scrollport).lineHeight) - return event.deltaY * (Number.isFinite(lineHeight) ? lineHeight : FALLBACK_WHEEL_LINE_PX) - } - if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * scrollport.clientHeight - return event.deltaY -} - -/** One transcript width handle: pointer capture + rAF-throttled symmetric - * resize (both sides write the one centered width, so outward travel widens - * by 2× the pointer distance). pointermove publishes the pointer's Y as a CSS - * variable so the glow indicator rides it. Mirrors ui-layout AppFrame's - * DragHandle capture model. */ -function WidthHandle(props: { - side: 'left' | 'right' - onStart: () => number - onDrag: (width: number) => void - onCommit: (width: number) => void - onEnd: () => void -}) { - const [dragging, setDragging] = useState(false) - const base = useRef(0) - const origin = useRef(0) - const latest = useRef(0) - const frame = useRef(null) - const callbacks = useRef(props) - callbacks.current = props - - const outwardWidth = () => { - const dx = latest.current - origin.current - const outward = callbacks.current.side === 'right' ? dx : -dx - return base.current + outward * 2 - } - const cancelFrame = () => { - if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } - } - const onPointerDown = useCallback((e: React.PointerEvent) => { - if (e.button !== 0) return - e.preventDefault() - e.currentTarget.setPointerCapture(e.pointerId) - origin.current = e.clientX - latest.current = e.clientX - base.current = callbacks.current.onStart() - setDragging(true) - }, []) - const onPointerMove = useCallback((e: React.PointerEvent) => { - const box = e.currentTarget.getBoundingClientRect() - e.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${e.clientY - box.top}px`) - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return - latest.current = e.clientX - frame.current ??= requestAnimationFrame(() => { - frame.current = null - callbacks.current.onDrag(outwardWidth()) - }) - }, []) - const onPointerUp = useCallback((e: React.PointerEvent) => { - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return - e.currentTarget.releasePointerCapture(e.pointerId) - cancelFrame() - latest.current = e.clientX - // Only a gesture with actual travel commits: a press-and-release on a - // window-clamped width must not overwrite the wider stored preference - // with the clamped display value. - if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth()) - setDragging(false) - callbacks.current.onEnd() - }, []) - // Releasing the button outside the window delivers pointercancel (or drops - // the capture silently) instead of pointerup; without this the glow's - // data-dragging state sticks on. The gesture is abandoned uncommitted — - // onEnd republishes the stored preference. releasePointerCapture inside - // onPointerUp also fires lostpointercapture, so this runs (idempotently) - // after every normal drag end too; keep both paths. - const onPointerCancel = useCallback(() => { - cancelFrame() - setDragging(false) - callbacks.current.onEnd() - }, []) - const onWheel = useCallback((e: React.WheelEvent) => { - const body = e.currentTarget.parentElement - /* v8 ignore next -- a width handle renders only inside the Conversation body. */ - if (body === null) return - const scrollport = body.querySelector(':scope > [data-conversation-scroll]') - /* v8 ignore next -- the Conversation body always contains its direct scroll element. */ - if (scrollport === null) return - if (e.ctrlKey || e.deltaY === 0) return - scrollport.scrollBy({ top: wheelDeltaY(e, scrollport) }) - }, []) - - return ( -
    - ) +function NoConversationWidthControls() { + return null } /** - * Render the existing Conversation body, Composer, and width handles. - * @param props - original Conversation seats plus MainPanel-derived phase and width callbacks. - * @returns the unchanged Conversation body subtree. + * Render the shared Conversation body and its occurrence-selected local Components. + * @param props - Factory input, standard Session sources, and Conversation seats. + * @returns the Conversation view, Composer, and optional width controls. */ -export function ConversationContent({ - sessionId, phase, hero, useSession, useSessions, useSessionStatus, - useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, - selectWorkspace, t, useFactorySlot, - onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd, -}: ConversationContentProps) { +export function ConversationContent(props: ConversationContentProps) { + const { + sessionId, phase, hero, useSession, useSessions, useSessionStatus, + useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, + selectWorkspace, t, useFactorySlot, + } = props const session = useSession(snapshot => snapshot) const Views = useFactorySlot('views', ConversationSessionView) + const WidthControls = useFactorySlot('widthControls', NoConversationWidthControls) + const [body, setBody] = useState(null) const pendingInteraction = useSessionStatus(snapshot => sessionId === undefined ? undefined : snapshot.get(sessionId)?.pendingInteraction) const inputState = useInput(s => s) @@ -285,23 +180,17 @@ export function ConversationContent({ ) return ( -
    +
    {sessionId === undefined ? null : } {composerSeat}
    - {/* Width handles only while a transcript is on screen; the hero has no - content column to size. */} - {phase === 'active' && (['left', 'right'] as const).map(side => ( - - ))} +
    ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx index 9f48eaa494..a46d1b28c1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx @@ -1,38 +1,8 @@ -import { useCallback, useRef } from 'react' import type { ConversationSlotProps } from '../contract/slots.ts' import { conversationPhase } from '../contract/snapshot.ts' +import { ConversationWidthControls } from './ConversationWidthControls.tsx' import css from './ConversationRoot.module.css' -/** localStorage key for the dragged transcript width preference (px). */ -const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth' -/** Floor for a dragged content width; matches the layout center-column minimum. */ -const CONTENT_MIN = 640 -/** Column budget the content must leave free: 88px per side keeps the width - * handles fully placeable (24px inset + 40px strip + 24px safe zone) — a - * larger dragged width would push its own handles off the column and leave no - * way to drag back. */ -const CONTENT_EDGE_BUDGET = 176 - -/** Reads the persisted width preference; durable-storage boundary, so a - * missing or corrupt value resolves to "no preference". - * @returns the stored width in px, or null when unset or invalid. */ -function readWidthPreference(): number | null { - const raw = localStorage.getItem(WIDTH_PREF_KEY) - if (raw === null) return null - const value = Number(raw) - return Number.isFinite(value) && value > 0 ? value : null -} - -/** Resolves the content width the CSS axis would show for a column width. - * @param columnWidth - the conversation column's rendered width in px. - * @param preference - the dragged preference, or null for the adaptive clamp. - * @returns the resolved content width in px (mirrors the CSS clamp). */ -function resolveContentWidth(columnWidth: number, preference: number | null): number { - const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET) - if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max) - return Math.max(680, Math.min(columnWidth * 0.64, 920)) -} - /** * Render the existing main Conversation frame around the extracted content. * @param props - the original `main.conversation` Slot props. @@ -48,63 +18,6 @@ export function ConversationMainPanel(props: ConversationSlotProps) { const openState = session?.openState const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank) - // Publishes the column's live width as --dsh-conversation-column-width so - // the shared width axis can adapt (see the .root CSS), and re-clamps a - // dragged preference against the shrunken column WITHOUT rewriting the - // stored preference — widening the window restores it (the AppFrame - // sidebar-drag rule). Same callback-ref pattern as the seat observer. - const rootEl = useRef(null) - const rootObserver = useRef(null) - const publishWidths = useCallback((root: HTMLDivElement): void => { - const column = root.offsetWidth - root.style.setProperty('--dsh-conversation-column-width', `${column}px`) - const preference = readWidthPreference() - if (preference === null) { - root.style.removeProperty('--dsh-chat-user-width') - } else { - root.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) - } - }, []) - const rootResizeRef = useCallback((root: HTMLDivElement | null): void => { - rootObserver.current?.disconnect() - rootObserver.current = null - rootEl.current = root - if (root === null) return - rootObserver.current = new ResizeObserver(() => { publishWidths(root) }) - rootObserver.current.observe(root) - publishWidths(root) - }, [publishWidths]) - - // Drag plumbing for the two width handles: onStart snapshots the resolved - // width (grabbing a clamped column must not jump back to the raw stored - // preference), onDrag publishes only the live clamped style, onCommit - // persists the width of a gesture that actually travelled, and onEnd - // republishes from storage — an uncommitted press leaves the stored - // preference untouched. - const onHandleStart = useCallback((): number => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return 680 - return resolveContentWidth(root.offsetWidth, readWidthPreference()) - }, []) - const onHandleDrag = useCallback((width: number): void => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return - const clamped = resolveContentWidth(root.offsetWidth, width) - root.style.setProperty('--dsh-chat-user-width', `${clamped}px`) - }, []) - const onHandleCommit = useCallback((width: number): void => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return - localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(root.offsetWidth, width)}`) - }, []) - const onHandleEnd = useCallback((): void => { - const root = rootEl.current - if (root !== null) publishWidths(root) - }, [publishWidths]) - // While a session is still replaying (loading + blank) the hero/docked // choice is unknowable — render the composer hidden instead of flashing // the centered hero and snapping to the docked bar (or vice versa). @@ -128,15 +41,14 @@ export function ConversationMainPanel(props: ConversationSlotProps) { const phase = settling ? 'settling' : hero ? 'hero' : 'active' return ( -
    +
    {sessionId === undefined ? null : renderSlot('conversation.session.header', {})} {renderFactorySlot('conversation.content', { + variant: 'main', phase, hero, - onHandleStart, - onHandleDrag, - onHandleCommit, - onHandleEnd, + }, { + slots: { widthControls: ConversationWidthControls }, })}
    ) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index a57c5462d3..62a1991e57 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -5,32 +5,6 @@ height: 100%; min-width: 0; background: var(--dsw-alias-bg-base); - - /* Shared width axis for the whole column: one content width W - (--dsh-chat-content-width) for the transcript, the dock cards - (todo/goal/queue: card minus four insets, 4 x 8 = 32), and business-owned - takeover cards; the input card alone is W + 32px. - The relation also holds when a narrow viewport shrinks everything: the - chat scroller and the takeover frames pad clearance + 16px per side while - the input card clears the bare clearance, so the input card stays exactly - content + 32px at every width. Declared on the root because the - transcript and the composer seat are sibling subtrees. - - W adapts to the column, not the viewport (a collapsed sidebar widens the - column without resizing the window): the component publishes the column's - live width as --dsh-conversation-column-width (px, via ResizeObserver) - and the clamp keeps a 680px floor (a step under the figma - 748px — the full-width reading felt wide on every screen), takes 64% of - wider columns, and caps at 920px for line-length - readability. A dragged user preference (--dsh-chat-user-width, published - by the width handles) replaces the adaptive term wholesale. */ - --dsh-chat-content-width: var( - --dsh-chat-user-width, - clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px) - ); - --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); - --dsh-composer-side-clearance: 16px; - --dsh-composer-dock-inset: 8px; } /* 76px with its rule: the height of the Sidebar's tab strip and header row @@ -377,6 +351,25 @@ flex: 1; flex-direction: column; min-height: 0; + + /* Shared width axis for the transcript, dock cards, composer, and takeover + cards. Main width controls publish the live body width and optional user + preference; embedded occurrences override this adaptive axis below. */ + --dsh-chat-content-width: var( + --dsh-chat-user-width, + clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px) + ); + --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); + --dsh-composer-side-clearance: 16px; + --dsh-composer-dock-inset: 8px; +} + +.embeddedBody { + overflow: hidden; + --dsh-chat-content-width: min(calc(100% - 32px), 920px); + --dsh-composer-card-max-width: min(calc(100% - 16px), 952px); + --dsh-composer-side-clearance: 8px; + --dsh-composer-dock-inset: 8px; } .scrollBody { @@ -402,12 +395,14 @@ margin: 2px; } -.root[data-phase='active'] .viewArea { +.root[data-phase='active'] .viewArea, +.embeddedBody[data-content-phase='active'] .viewArea { flex: 1 0 auto; min-height: auto; } -.root[data-phase='active'] .composerSeat { +.root[data-phase='active'] .composerSeat, +.embeddedBody[data-content-phase='active'] .composerSeat { position: sticky; bottom: 0; /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never @@ -510,13 +505,15 @@ /* Hero: the composer sits inside the session scroll body; center there so the tree seat matches active (sticky footer) without a Root remount. */ -.root[data-phase='hero'] .scrollBody { +.root[data-phase='hero'] .scrollBody, +.embeddedBody[data-content-phase='hero'] .scrollBody { justify-content: center; overflow-y: auto; } /* Settling (session replaying, hero/docked unknown): keep the composer seat mounted but invisible so no wrong layout flashes before the phase lands. */ -.root[data-phase='settling'] .composerSeat { +.root[data-phase='settling'] .composerSeat, +.embeddedBody[data-content-phase='settling'] .composerSeat { visibility: hidden; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx new file mode 100644 index 0000000000..d2755fee9c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx @@ -0,0 +1,177 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import type { ConversationWidthControlsProps } from '../contract/slots.ts' +import css from './ConversationRoot.module.css' + +/** localStorage key for the dragged transcript width preference (px). */ +const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth' +/** Floor for a dragged content width; matches the layout center-column minimum. */ +const CONTENT_MIN = 640 +/** Horizontal room reserved for both handles and their safe edge zones. */ +const CONTENT_EDGE_BUDGET = 176 +const WHEEL_DELTA_LINE = 1 +const WHEEL_DELTA_PAGE = 2 +const FALLBACK_WHEEL_LINE_PX = 16 + +/** Read a valid persisted width preference, or null when absent or corrupt. */ +function readWidthPreference(): number | null { + const raw = localStorage.getItem(WIDTH_PREF_KEY) + if (raw === null) return null + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : null +} + +/** Resolve the width displayed for one measured Conversation column. */ +function resolveContentWidth(columnWidth: number, preference: number | null): number { + const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET) + if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max) + return Math.max(680, Math.min(columnWidth * 0.64, 920)) +} + +/** Convert a wheel event's vertical delta to scrollport pixels. */ +function wheelDeltaY(event: React.WheelEvent, scrollport: HTMLElement): number { + if (event.deltaMode === WHEEL_DELTA_LINE) { + const lineHeight = Number.parseFloat(getComputedStyle(scrollport).lineHeight) + return event.deltaY * (Number.isFinite(lineHeight) ? lineHeight : FALLBACK_WHEEL_LINE_PX) + } + if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * scrollport.clientHeight + return event.deltaY +} + +/** One pointer-captured transcript width handle. */ +function WidthHandle(props: { + side: 'left' | 'right' + onStart: () => number + onDrag: (width: number) => void + onCommit: (width: number) => void + onEnd: () => void +}) { + const [dragging, setDragging] = useState(false) + const base = useRef(0) + const origin = useRef(0) + const latest = useRef(0) + const frame = useRef(null) + const callbacks = useRef(props) + callbacks.current = props + + const outwardWidth = () => { + const dx = latest.current - origin.current + const outward = callbacks.current.side === 'right' ? dx : -dx + return base.current + outward * 2 + } + const cancelFrame = () => { + if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } + } + const onPointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + origin.current = event.clientX + latest.current = event.clientX + base.current = callbacks.current.onStart() + setDragging(true) + }, []) + const onPointerMove = useCallback((event: React.PointerEvent) => { + const box = event.currentTarget.getBoundingClientRect() + event.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${event.clientY - box.top}px`) + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + latest.current = event.clientX + frame.current ??= requestAnimationFrame(() => { + frame.current = null + callbacks.current.onDrag(outwardWidth()) + }) + }, []) + const onPointerUp = useCallback((event: React.PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + event.currentTarget.releasePointerCapture(event.pointerId) + cancelFrame() + latest.current = event.clientX + // A press-only gesture must not overwrite a wider preference with its window-clamped display value. + if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth()) + setDragging(false) + callbacks.current.onEnd() + }, []) + const onPointerCancel = useCallback(() => { + // Cancellation abandons persistence and restores the saved width through onEnd. + cancelFrame() + setDragging(false) + callbacks.current.onEnd() + }, []) + const onWheel = useCallback((event: React.WheelEvent) => { + const body = event.currentTarget.parentElement + /* v8 ignore next -- a width handle renders only inside the Conversation body. */ + if (body === null) return + const scrollport = body.querySelector(':scope > [data-conversation-scroll]') + /* v8 ignore next -- the Conversation body always contains its direct scroll element. */ + if (scrollport === null) return + if (event.ctrlKey || event.deltaY === 0) return + scrollport.scrollBy({ top: wheelDeltaY(event, scrollport) }) + }, []) + + return ( +
    + ) +} + +/** + * Install the main Conversation width axis and render its drag handles. + * @param props - Mounted Conversation body and current presentation phase. + * @returns two active-phase width handles, or no controls outside the active phase. + */ +export function ConversationWidthControls({ container, phase }: ConversationWidthControlsProps) { + const publishWidths = useCallback((container: HTMLDivElement): void => { + const target = container.parentElement ?? container + const column = container.offsetWidth + target.style.setProperty('--dsh-conversation-column-width', `${column}px`) + const preference = readWidthPreference() + if (preference === null) target.style.removeProperty('--dsh-chat-user-width') + else target.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) + }, []) + + useLayoutEffect(() => { + if (container === null) return + const observer = new ResizeObserver(() => { publishWidths(container) }) + observer.observe(container) + publishWidths(container) + return () => { observer.disconnect() } + }, [container, publishWidths]) + + const onStart = useCallback((): number => { + if (container === null) return 680 + return resolveContentWidth(container.offsetWidth, readWidthPreference()) + }, [container]) + const onDrag = useCallback((width: number): void => { + if (container === null) return + const target = container.parentElement ?? container + target.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(container.offsetWidth, width)}px`) + }, [container]) + const onCommit = useCallback((width: number): void => { + if (container === null) return + localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(container.offsetWidth, width)}`) + }, [container]) + const onEnd = useCallback((): void => { + if (container !== null) publishWidths(container) + }, [container, publishWidths]) + + if (container === null || phase !== 'active') return null + return (['left', 'right'] as const).map(side => ( + + )) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx index 640ca316f1..16d1aedc47 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx @@ -11,7 +11,7 @@ import css from './ConversationRoot.module.css' * @returns the active view area, or null while the Session remains blank. */ export function DefaultConversationViews({ - useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, + view, useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, renderSlot, bindDraftMirror, openView, }: ConversationSessionSlotProps) { const tabs = useConversationViews(value => value) @@ -32,13 +32,14 @@ export function DefaultConversationViews({ }, [inputActions]) if (session.blank && conversationPhase(session, conversation) === 'blank') return null + const viewId = view ?? active?.id return (
    - {active !== undefined && renderSlot('conversation.view', { + {viewId !== undefined && renderSlot('conversation.view', { viewRequest, openView, completeViewRequest: actions.completeViewRequest, - }, { only: active.id })} + }, { only: viewId })}
    ) } diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index c30130972d..e95808c1bc 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -309,7 +309,9 @@ function mount( : (opts?.fallback ?? null) )) as ConversationContentProps['renderSlotChain'] const SessionProvider: ConversationContentProps['SessionProvider'] = ({ children }) => children - const renderFactorySlot = ((_name: string, input: ConversationContentInputProps) => { + const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: { + slots?: Record ReactNode> + }) => { const common: ConversationViewsProps = { sessionId: SID, SessionProvider, @@ -333,7 +335,9 @@ function mount( selectWorkspace: retargetWorkspace, t, } - const useFactorySlot = (() => StableConversationViews) as ConversationContentProps['useFactorySlot'] + const useFactorySlot = ((name: string, fallback: (props: never) => ReactNode) => ( + name === 'views' ? StableConversationViews : factoryOptions?.slots?.[name] ?? fallback + )) as ConversationContentProps['useFactorySlot'] return ( @@ -667,11 +671,12 @@ describe('ConversationRoot resident composer', () => { it('publishes the column width as a px variable for the shared width axis', () => { const b = mount(sessionSnapshotOf()) - const root = b.view.container.querySelector('[data-phase]') as HTMLElement + const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement + const root = content.parentElement as HTMLElement // jsdom offsetWidth is 0 until faked: the observer publishes whatever the // layout reports, and the CSS clamp() floors the axis at 680px either way. - Object.defineProperty(root, 'offsetWidth', { value: 1200, configurable: true }) - act(() => { fireResize(root) }) + Object.defineProperty(content, 'offsetWidth', { value: 1200, configurable: true }) + act(() => { fireResize(content) }) expect(root.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px') // No dragged preference: the user-width override stays absent so the // adaptive clamp term applies. @@ -680,9 +685,10 @@ describe('ConversationRoot resident composer', () => { it('drag → persist → window clamp round-trip on a width handle', () => { const b = mount(sessionSnapshotOf()) - const root = b.view.container.querySelector('[data-phase]') as HTMLElement - Object.defineProperty(root, 'offsetWidth', { value: 1600, configurable: true }) - act(() => { fireResize(root) }) + const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement + const root = content.parentElement as HTMLElement + Object.defineProperty(content, 'offsetWidth', { value: 1600, configurable: true }) + act(() => { fireResize(content) }) const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement expect(handle).not.toBeNull() // jsdom lacks pointer capture: emulate per-element so hasPointerCapture @@ -705,8 +711,8 @@ describe('ConversationRoot resident composer', () => { expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') // Window shrinks: the displayed width re-clamps (900 − 176 = 724) but the // preference stays. - Object.defineProperty(root, 'offsetWidth', { value: 900, configurable: true }) - act(() => { fireResize(root) }) + Object.defineProperty(content, 'offsetWidth', { value: 900, configurable: true }) + act(() => { fireResize(content) }) expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') // A press without travel (a real double-click delivers two such diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index bcec6615fe..041ec3fa6a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: b0137675a207a7aaee3d38060319d5948c8ba432 -README.zh.md: 8dfc6767919ae186962d49da393ef8316f62c417 +README.md: 46e289ddb572928baaee7ac1a370087fd789aebe +README.zh.md: 804ee5c7613510df96ac48cfe08f9b0fd39f7bbc diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index b0137675a2..46e289ddb5 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -54,7 +54,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
    Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session///` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost. +The Node half registers the static `ui:deliverable-file-references` system-prompt section described under [Model Experience](#model-experience). Explicit Markdown links use the shared [Markdown renderer](../ui-primitives/README.md); inline-code matching remains limited to produced or delivered files. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` list alongside other feature artifacts. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session///` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty list at zero cost. Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the review tab's native open of a changed file uses the same coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -68,7 +68,7 @@ Native opening uses an authenticated POST addressed by the viewed Session, event Read these pages when the deliverables surface is not enough. They move from the card to the Host recorder, the turn-tail hole, and the decisions behind the vocabulary. - [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records and serves the summary the card renders. -- [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose. +- [ui-chat](../ui-chat/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose. - [Turn changed-files card](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the decision behind git-recorded summaries replacing the mutation-call row. - [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the earlier produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md). - [Inline file mentions](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md) — the decision behind clickable mentions in the closing prose. @@ -83,11 +83,11 @@ Read these pages when the deliverables surface is not enough. They move from the #### What the model sees -One fixed paragraph instructs the model to name primary files from successful creation or modification calls in its final response and to format those and any other changed-file references as exact-path or unique-basename Markdown inline code, such as `out/report.html`. +The guidance asks the model to name primary outputs after successful creation or modification and link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. #### Token effect -One fixed prompt paragraph whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text. +One fixed paragraph containing an output reminder and file-reference guidance whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text. #### KV Cache effect @@ -101,7 +101,7 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. -- **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`. +- **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and inline-code references require `present`; explicit Markdown links can reference existing files directly. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Native opening needs a Host desktop** — without one the review tab offers no native open; the comparison itself needs only the Host that recorded the turn. - **Comparisons are unhighlighted** — the tab shows plain hunks without syntax colouring, and draws at most 5,000 lines before saying so. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8dfc676791..804ee5c761 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -54,7 +54,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
    实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session///`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。 +Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverable-file-references` 系统提示词段。显式 Markdown 链接使用共享的 [Markdown 渲染器](../ui-primitives/README.zh.md);行内代码匹配仍仅限产出或交付文件。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 列表,与其他功能产物并列。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session///`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空列表以零成本留下。 原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;review tab 对改动文件的原生打开使用同一组坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -68,7 +68,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。 - [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录并提供卡片所渲染摘要的 Host 插件。 -- [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 +- [ui-chat](../ui-chat/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 - [本轮改动文件卡片](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——用 git 记录的摘要取代修改调用行背后的决策。 - [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——早先产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。 - [行内文件提及](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md)——收尾正文可点击提及背后的决策。 @@ -83,11 +83,11 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, #### 模型看到的内容 -一段固定提示词要求模型在最终回复中点名成功创建或修改的主要文件,并将这些文件以及正文中提到的其他本轮变更文件写成采用精确路径或唯一 basename 的 Markdown 行内代码,例如 `out/report.html`。 +提示词要求模型在成功创建或修改文件后点名主要产出,并链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 #### Token 影响 -加载本包时增加一段固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 +加载本包时增加一段包含产出提醒和文件引用指导的固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 #### KV Cache 影响 @@ -101,7 +101,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 -- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。 +- **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和行内代码引用需要调用 `present`;显式 Markdown 链接可以直接引用现有文件。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **原生打开需要 Host 桌面**——没有桌面时 review tab 不提供原生打开;对比本身只需要记录了该轮的 Host。 - **对比没有语法高亮**——tab 只显示纯文本 hunk,最多绘制 5000 行并给出提示。 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index a7ad63dc03..112bc68070 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import type { ChangesSummaryStore } from './changes-summary.ts' @@ -44,6 +44,16 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch return changes === null && presented.length === 0 ? null : { changes, presented } } +/** + * Contribute file deliveries alongside other completed-Turn artifacts. + * @param props - closing Turn, file actions, and localized copy. + * @returns file rows, or null when the Turn declares none. + */ +export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale & InjectFace) { + const matched = selectDeliverables(props) + return matched === null ? null : +} + /** * Render the changed-files card, once the Host has served the announced * summary and it lists a file, and default-application buttons for declared diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index c9167d2605..76cecc33bc 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,12 +1,12 @@ /** * Deliverables plugin, browser half: registers the changed-files card and - * delivery cards into the chat view's turn-tail chain, the `changes-review` + * delivery cards into the chat view's turn-tail list, the `changes-review` * right-Sidebar tab type that reviews one turn's changed files one comparison * at a time, and provides the `chatFileMentions` service that links * inline-code mentions of produced or delivered files in the closing prose. * All policy lives here — the supported mutation calls, mention matching, row * cap, and copy — so composing this plugin out of cordis.yml removes every - * surface; the owning view renders an empty chain and inert prose at zero cost. + * surface; the owning view renders an empty list and inert prose at zero cost. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' @@ -21,7 +21,7 @@ import { ChangesDiffStore } from './changes-diff.ts' import { ChangesSummaryStore } from './changes-summary.ts' import { PresentedOpenController } from './present-open.ts' import { PresentRow } from './PresentRow.tsx' -import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx' +import { DeliverablesTail, type DeliverablesInjected } from './Deliverables.tsx' import { ReviewTab, type ReviewInjected } from './ReviewTab.tsx' import { CHANGES_REVIEW_ID, changesReviewDefinition } from './review-definition.ts' import { createReviewStore } from './review-store.ts' @@ -60,7 +60,7 @@ export function apply(ctx: ClientContext): void { 'conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', - select: selectDeliverables, + id: '@deepseek-ai/dsh-client-ui-deliverables', locale: NS, inject: (): DeliverablesInjected => ({ hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state }, @@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void { ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } }) }, }), - }, Deliverables), + }, DeliverablesTail), ) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow, diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 7c06075fe2..d6fd183ac5 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,6 @@ /** - * Deliverables plugin, node half. Registers the response-format guidance that - * lets the browser half recognize final-response file references and serves - * authenticated native opens of declared files. The browser + * Deliverables plugin, node half. Registers Web file-reference guidance and + * serves authenticated native opens of declared files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ @@ -14,12 +13,13 @@ import { registerPresentOpen } from './present-open.ts' /** Services required for file-reference guidance, change summaries, and authenticated native opens. */ export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'] -/** Stable final-response guidance owned by the matching renderer. */ +/** Static Web guidance for primary outputs and existing-file references. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' - + 'To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.' + + 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. ' + + 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.' /** - * Register model guidance for the file-reference renderer shipped by this package. + * Register Web file-reference guidance and native opens for declared files. * @param ctx - host context carrying the system-prompt registry. */ export function apply(ctx: Context): void { diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index cedb8a2d04..8cff6f2850 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -22,7 +22,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' +import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' import type { ReviewInjected } from '../src/client/ReviewTab.tsx' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts' @@ -681,7 +681,7 @@ describe('plugin registration', () => { ctx.slots.register({ name: 'root', children: { - 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' }, }, @@ -942,3 +942,13 @@ it('loads desktop information once the tail renders and not again while it is kn view.rerender() expect(props.reloadPresentedHost).toHaveBeenCalledOnce() }) + +it('contributes file artifacts to the tail list only for turns with deliveries', () => { + const owner = tailOwner(undefined, 3) + const props = { ...openProps(), ...owner, sessionId: SessionId('session'), t: makeTranslate(en) } as unknown as Parameters[0] + const view = render() + expect(view.container.innerHTML).toBe('') + const withFile = tailOwner({ produced: [], presented: [{ path: 'report.md', seq: 2, index: 0 }] }, 3) + view.rerender() + expect(view.getByText('report.md')).toBeTruthy() +}) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 27d277f0c9..0b7e989104 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -1,5 +1,6 @@ /** Node-half coverage for the model guidance paired with Web file references. */ +import { readFile } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -28,7 +29,12 @@ describe('ui-deliverables node plugin', () => { const section = (await ctx.systemPrompt.assemble()).sections .find(entry => entry.name === 'ui:deliverable-file-references') - expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn."') + expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') + + for (const scenario of ['cordis-tool-round', 'fresh-round-trip', 'ptc-round', 'schedule-catalog']) { + const sidecar = await readFile(new URL(`../../../../snapshots/web/${scenario}/system-prompt.expected.md`, import.meta.url), 'utf8') + expect(sidecar, scenario).toContain(section!.text) + } await mounted.dispose() expect((await ctx.systemPrompt.assemble()).sections diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 970ececb75..0023f8b06c 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/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-plan/README.md -README.md: 4ff275834ec069315580c205b8fd0bf0dee9cfeb -README.zh.md: 588698b273bc52b1e601c7010516c24a9248e543 +README.md: f7e7aede2c76717228d101153e4d3d2f8d3f88c4 +README.zh.md: 9ab37a25dd7cb406b42bf5c531445357d6725e2e diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 4ff275834e..f7e7aede2c 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders the plan-mode status chip in the Web GUI: when the host-computed projection's effective target is plan mode, the composer shows a warn-colored "Plan ×" button that turns plan mode off; otherwise the seat stays empty. Plan mode itself — the `/plan` command, the committed `plan/mode` state, the projection unit, and the policy section — belongs to `dsh-plan-mode`; this package only renders the projection and sends what a user could equally type. The model exits plan mode through the stable `exit_plan_mode` tool; its plan review uses the composed Web question channel. +Plan mode lets you review a plan before implementation. Enter with `/plan` and leave with the composer chip. Submitted plans open automatically in the right sidebar for review and remain available from cards in the completed Turn’s final artifact area after approval, rejection, or dismissal. Reopening a plan focuses its existing tab, and browser reload restores the document from Session history. ## Table of Contents @@ -31,6 +31,12 @@ Mount this plugin alongside `ui-conversation` and `dsh-plan-mode`; the chip then While the effective target is plan mode, the seat renders the warn-colored "Plan ×" status button, which executes `/plan off`. Otherwise the seat stays empty: a host without plan mode, or a Draft with no session, shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan" — unless the owning surface supplies its own placeholder. +### Reading submitted plans + +When a Turn ends, each submitted plan appears in its final artifact area, using the file-delivery card treatment with a Markdown icon, title, and Open action. A pending plan opens automatically once per submission in the current browser session. Closing it stays effective across review remounts; a new submission opens its own plan. Historical cards open only when clicked. Use the card or review strip’s View full plan link to read and copy the complete Markdown. Plan tabs show a document-and-pen icon. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin. + +A review without a logged invocation also opens automatically. Its complete text lives only in the tab’s navigation memory, and the pending review card can reopen it. Reloading the page loses that text; an expired preview directs the user back to a pending review. + ### Failures Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit. @@ -45,6 +51,8 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. The accessible description is "Plan mode on, press to turn off". +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. +
    ----- diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 588698b273..9ab37a25dd 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包在 Web GUI 中渲染 plan 模式状态徽章:当宿主计算的投影有效目标为 plan 模式时,composer 显示一个 warn 色「Plan ×」按钮,可关闭 plan 模式;否则该座位保持为空。plan 模式本身——`/plan` 命令、已提交的 `plan/mode` 状态、投影单元与 policy 段——归 `dsh-plan-mode` 所有;本包只渲染投影并发送用户同样可以手敲的内容。模型经稳定的 `exit_plan_mode` 工具退出 plan 模式;其 plan 评审走已组合的 Web question 通道。 +计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出。提交的计划自动在右侧边栏打开供审阅;批准、拒绝或关闭审批后,仍可通过已完成回合末尾的产物卡片查看。重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。 ## 目录 @@ -31,6 +31,12 @@ kind: "package-reference" 当有效目标为 plan 模式时,该座位渲染 warn 色「Plan ×」状态按钮,执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。 +### 查看已提交的计划 + +回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。计划标签页显示文档加笔的图标。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 + +没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。 + ### 失败 准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,徽章保持显示直至投影确认退出。 @@ -45,6 +51,8 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。无障碍描述是「Plan mode on, press to turn off」。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 +
    ----- diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index c8ada3d792..1a77508ca4 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", - "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", + "description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -30,7 +30,12 @@ "inject": [ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-user-questions", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-api-session-controller" ], "platform": "web" } @@ -44,6 +49,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -56,7 +62,16 @@ "react": "^18.2.0", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx new file mode 100644 index 0000000000..74e65cdefe --- /dev/null +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -0,0 +1,68 @@ +/** Persistent transcript cards and pending-review sidebar navigation. */ +import { useEffect } from 'react' +import { FileTypeIcon, IconChevronRightOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { shallowEqual } from '@deepseek-ai/dsh-client-store' +import type {} from './plan-definition.ts' +import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' +import type { createPlanReviewStore } from './review-store.ts' +import css from './PlanPreview.module.css' + +/** Session-bound navigation for logged plans. */ +export interface PlanOpenInjected { + /** Open or focus the exact submitted plan. */ + openPlan: (callId: ToolCallId) => void +} + +/** Session-bound preview navigation for one pending review. */ +export interface PlanReviewOpenInjected { + /** Open the logged plan, or the request's temporary document when no invocation exists. */ + openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void +} + +/** + * Render the completed Turn's submitted plans in invocation order. + * @param props - Logged plan, localized copy, and Session-bound navigation. + * @returns keyboard-accessible plan cards, or null for a Turn without plans. + */ +export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversation.chat.turnTail'> & InjectFace & PropsLocale<'plan'>) { + const plans = useChat(snapshot => snapshot.nodes.values() + .filter((node): node is ChatNode<'submitted-plan'> => node.kind === 'submitted-plan' + && (node.location.kind === 'turn' || node.location.kind === 'step') + && node.location.turn.turn === turn.turn) + .sort((a, b) => a.anchorSeq - b.anchorSeq), shallowEqual) + if (plans.length === 0) return null + return ( +
    + {plans.map(({ data: plan }) => )} +
    + ) +} + +/** + * Open each pending plan automatically and retain a manual opener without answering it. + * @param props - Review identity, Session store, localized copy, and navigation. + * @returns an opener for either logged or temporary plan text. + */ +export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { + const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}` + const opened = useStore(state => state.opened[identity] === true) + useEffect(() => { + if (opened) return + openReview(review, requestKey) + actions.markOpened(identity) + }, [identity, opened, openReview, review, requestKey, actions]) + return +} diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css new file mode 100644 index 0000000000..0fcac5cd7b --- /dev/null +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -0,0 +1,41 @@ +.cards { display: flex; flex-direction: column; gap: 10px; } +.card { + --plan-card-fill: var(--dsw-static-neutral-50); + --plan-card-hover: var(--dsw-static-neutral-100); + box-sizing: border-box; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + width: 100%; + height: 60px; + padding: 8px 10px; + margin: 0; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: 18px; + background: var(--plan-card-fill); + color: var(--dsw-alias-label-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease; +} +:global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); } +.card:hover { background: var(--plan-card-hover); } +.card:focus-visible, .iconButton:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); } +.cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } +.cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } +.cardDescription { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 16px; } +.cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; } +.reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } +.reviewLink:hover { color: var(--dsw-alias-label-primary); } +.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.iconButton { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } +.iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } +.preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } +.toolbar { display: flex; justify-content: flex-end; margin-bottom: 8px; } +.document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; } +.message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; } +.titleIcon { display: inline-flex; flex: none; } +@media (max-width: 767px) { .preview { padding: 16px 18px 32px; } } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx new file mode 100644 index 0000000000..b417507c77 --- /dev/null +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -0,0 +1,56 @@ +/** Read-only Markdown viewer for logged plans and temporary review documents. */ +import { useMemo } from 'react' +import { IconCopyOutline16, IconPlanOutline14, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from './plan-resource.ts' +import { isReviewPreviewAddress } from './review-preview.ts' +import { planFailureLine } from './failure-line.ts' +import css from './PlanPreview.module.css' + +type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'plan'> + +/** + * Render the submitted plan with its complete Markdown and a copy action. + * @param props - Framework-bound tab identity, resource, and copy. + * @returns the plan document or a localized loading/failure state. + */ +export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { + const tab = useTabInfo() + const resource = useResource<'plan'>(tab.tab.navigation.address) + const temporary = isReviewPreviewAddress(tab.tab.navigation.address) + const params = tab.tab.navigation.params + const plan = temporary ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value + const labels = useMemo(() => ({ + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, + footnotes: t('markdown.footnotes'), + }), [t]) + if (plan === undefined) return ( +
    + {temporary ? t('preview.expired') : resource.status === 'none' ? t('preview.unavailable') + : resource.status === 'failed' ? t('preview.failed') : t('preview.loading')} + {!temporary && resource.failure !== undefined &&

    {planFailureLine(t, resource.failure)}

    } +
    + ) + return ( +
    +
    +
    +
    + ) +} + +/** + * Display a plan icon and the heading in its tab after resource recovery. + * @param props - Framework-bound tab identity and resource reader. + * @returns a decorative plan icon followed by the recovered title or initial localized label. + */ +export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.right.pane.tab.title'>) { + const tab = useTabInfo() + const resource = useResource<'plan'>(tab.tab.navigation.address) + const params = tab.tab.navigation.params + const plan = isReviewPreviewAddress(tab.tab.navigation.address) + ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value + return <>{plan?.title ?? tab.tab.title} +} diff --git a/packages/client/ui-plan/src/client/failure-line.ts b/packages/client/ui-plan/src/client/failure-line.ts new file mode 100644 index 0000000000..f0dd9f74a7 --- /dev/null +++ b/packages/client/ui-plan/src/client/failure-line.ts @@ -0,0 +1,19 @@ +/** Localized plan-owned failures and unmodified external diagnostics. */ +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' + +/** + * Explain a failed plan read in the current locale. + * @param t - Plan namespace translator. + * @param failure - Failure reported by the resource provider. + * @returns localized plan copy, or the external failure's diagnostic. + */ +export function planFailureLine(t: TranslateNS<'plan'>, failure: RemoteFailure): string { + switch (failure.code) { + case 'plan/invalid-address': return t('preview.invalidAddress') + case 'plan/unavailable': return t('preview.historyUnavailable') + case 'plan/not-found': return t('preview.notFound') + // Transport failures, including plan/read-failed, retain the carrier's diagnostic. + default: return failure.message + } +} diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 42d080da76..4a69be9469 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -1,12 +1,4 @@ -/** - * Plan control plugin, browser half: occupies the composer's named - * `conversation.input.plan` seat with an active-state status chip. Plan mode - * is entered through the command source; while the projection's effective - * target is plan mode the chip renders and executes /plan off through - * `command.execute`, otherwise the seat stays empty. Reads ride the generic - * projection pair through the standard-kit `useProjection`; zero client-side - * plan state. - */ +/** Plan-mode control, persistent Chat cards, and Session-backed sidebar previews. */ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -18,6 +10,20 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-plan-mode/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/remote' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from '@deepseek-ai/dsh-client-resources/client' +import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from './PlanCard.tsx' +import { PlanPreview, PlanTitle } from './PlanPreview.tsx' +import { planDefinition } from './plan-definition.ts' +import { planResourceProvider } from './plan-resource.ts' +import { planAddress, parsePlanAddress } from './plan.ts' +import { isReviewPreviewAddress, reviewPreviewAddress } from './review-preview.ts' +import { createPlanReviewStore } from './review-store.ts' import { PlanChip } from './PlanModeControl.tsx' import { en, zh, type PlanKey } from './locales.ts' @@ -42,16 +48,55 @@ export interface PlanChipInjected { exitPlanMode: () => Promise } -/** Required services: the seat's slot registry, commands Remote, and locale registry. */ -export const inject = ['slots', 'remote', 'remote.commands', 'locale'] +/** Services for plan controls, Conversation projection, and resource navigation. */ +export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'] /** - * Client plugin body: register the plan chip over the command channel. + * Register plan controls, permanent Chat cards, and sidebar document reading. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries') + const previewId = '@deepseek-ai/dsh-client-ui-plan' + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.uiConversation.events.register(planDefinition), 'ui-plan: conversation definition') + ctx.effect(() => ctx.resources.register(planResourceProvider(ctx.remote.session)), 'ui-plan: resources') + ctx.effect(() => ctx.sidebarRightTabs.register({ + id: previewId, kind: 'plan', patterns: ['dsh-resource://plan/**', 'dsh-resource://plan-review/**'], priority: 'builtin', + canOpen: address => parsePlanAddress(address) !== undefined || isReviewPreviewAddress(address), + title: () => t('preview.title'), + }), 'ui-plan: sidebar type') + const open = (sessionId: SessionId): PlanOpenInjected => ({ + openPlan: (callId) => { + const child = ctx.sessions.subagentAddress(sessionId) + const session = child === undefined ? { kind: 'session' as const, sessionId } : { kind: 'subagent' as const, ...child } + ctx.sidebarRight.openResourceIn(sessionId, planAddress({ session, callId })) + }, + }) + const reviewWindow = randomUUID() + const reviewStore = createPlanReviewStore() + ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ + name: 'conversation.chat.turnTail', id: previewId, locale: NS, inject: open, + }, PlanCards)) + ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({ + name: 'conversation.plan-review.actions', id: previewId, locale: NS, store: reviewStore, + inject: (sessionId: SessionId): PlanReviewOpenInjected => ({ + openReview: (review, requestKey) => { + if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return } + ctx.sidebarRight.openResourceIn(sessionId, reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), { + params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } }, + }) + }, + }), + }, PlanReviewOpen)) + ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', key: previewId, locale: NS, + }, PlanPreview)) + ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', key: previewId, + }, PlanTitle)) + ctx.slots.inject('conversation.input.plan', () => ctx.slots.register({ name: 'conversation.input.plan', locale: NS, diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index b3048a7d5a..96c15a0237 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -3,6 +3,19 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'chip.label': 'Plan', + 'preview.title': '计划', + 'preview.document': '计划 · Markdown', + 'preview.action': '打开', + 'preview.open': '在侧边栏打开计划', + 'preview.full': '查看全文', + 'preview.openNamed': '打开计划:{title}', + 'preview.loading': '正在读取计划…', + 'preview.failed': '无法读取计划', + 'preview.invalidAddress': '计划地址无效', + 'preview.historyUnavailable': '无法读取会话历史', + 'preview.notFound': '未找到这份计划', + 'preview.unavailable': '计划预览不可用', + 'preview.expired': '临时计划预览已失效,请从仍在等待审批的卡片重新打开。', 'chip.on.aria': 'plan mode 已开启,按下关闭', 'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)', 'chip.off.aria': 'plan mode 已关闭,按下开启', @@ -16,6 +29,19 @@ export type PlanKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { 'chip.label': 'Plan', + 'preview.title': 'Plan', + 'preview.document': 'Plan · Markdown', + 'preview.action': 'Open', + 'preview.open': 'Open plan in sidebar', + 'preview.full': 'View full plan', + 'preview.openNamed': 'Open plan: {title}', + 'preview.loading': 'Loading plan…', + 'preview.failed': 'Could not load plan', + 'preview.invalidAddress': 'Invalid plan address', + 'preview.historyUnavailable': 'Session history is unavailable', + 'preview.notFound': 'This plan was not found', + 'preview.unavailable': 'Plan preview is unavailable', + 'preview.expired': 'This temporary plan preview has expired. Reopen it from the pending review card.', 'chip.on.aria': 'Plan mode on, press to turn off', 'chip.on.title': 'Plan mode on — click to turn off (/plan off)', 'chip.off.aria': 'Plan mode off, press to turn on', diff --git a/packages/client/ui-plan/src/client/plan-definition.ts b/packages/client/ui-plan/src/client/plan-definition.ts new file mode 100644 index 0000000000..8c742f71fa --- /dev/null +++ b/packages/client/ui-plan/src/client/plan-definition.ts @@ -0,0 +1,34 @@ +/** Per-invocation plans use resolved Conversation locations for final Turn artifacts. */ +import type {} from '@deepseek-ai/dsh-tools/types' +import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { submittedPlan, type SubmittedPlan } from './plan.ts' + +declare module '@deepseek-ai/dsh-client-ui-chat/client' { + interface ChatNodeDataMap { + /** Complete plan submitted through exit_plan_mode, including rejected or dismissed reviews. */ + 'submitted-plan': SubmittedPlan + } +} + +/** One card per invocation; a later PTC settlement retains the original card position. */ +export const planDefinition: ConversationNodeDefinition = { + kind: 'submitted-plan', + target: 'chat', + match: (event) => { + const plan = submittedPlan(event) + return plan === undefined ? null : { id: plan.callId, role: event.type === 'tool/ptc-dispatch' ? 'update' : 'start' } + }, + start: (_context, match) => submittedPlan(match.event) as SubmittedPlan, + update: context => context.state, + buildViewNode: (context) => { + const start = context.start ?? context.matches[0] + const data = context.state ?? (start === undefined ? undefined : submittedPlan(start.event)) + if (data === undefined || start === undefined) return null + return { + key: context.key, kind: 'submitted-plan', id: context.id, target: 'chat', + anchorSeq: start.event.seq, location: start.location, + visibility: 'hidden', data, + } satisfies ChatNode<'submitted-plan'> + }, +} diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts new file mode 100644 index 0000000000..95ec5dff30 --- /dev/null +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -0,0 +1,82 @@ +/** Read immutable plan arguments from a Session snapshot and earlier history pages. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { parsePlanAddress, submittedPlan, type SubmittedPlan } from './plan.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The resource URL does not identify a plan invocation. */ + 'plan/invalid-address': Record + /** Session history ended before an opening snapshot. */ + 'plan/unavailable': Record + /** The Session has no readable plan for this invocation. */ + 'plan/not-found': Record + /** The Session history read failed. */ + 'plan/read-failed': Record + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { + /** Immutable Markdown from a logged exit_plan_mode invocation. */ + plan: SubmittedPlan + } +} + +/** + * Bind plan reads to the generated Session Remote face. + * Opening a follow reads projections and may activate a prepared Session on the Host. + * Generated Remote streams can throw carrier failures; the provider reports failed + * reads as resource failure frames and preserves Remote error codes. + * @param remote - Existing Session history API. + * @returns a provider whose reads stop after finding the exact invocation. + */ +export function planResourceProvider(remote: Pick): ResourceProvider<'plan'> { + return { + protocol: 'plan', + async *open(address, { signal }): AsyncIterable> { + const aborted = (): boolean => signal.aborted + if (aborted()) return + const target = parsePlanAddress(address) + if (target === undefined) { + yield { ok: false, error: new RemoteError('plan/invalid-address', 'Invalid plan resource address.', {}) } + return + } + const sessionAddress = target.session + try { + // The snapshot supplies the page API's fixed log cut. Breaking closes the follow stream. + let snapshot: Extract | undefined + for await (const frame of remote.follow({ address: sessionAddress }, signal)) { + if (frame.type === 'snapshot') { snapshot = frame; break } + } + if (aborted()) return + if (snapshot === undefined) throw new RemoteError('plan/unavailable', 'Session history ended before the plan could be read.', {}) + let page = { records: snapshot.records, hasMore: snapshot.hasMore } + while (true) { + for (const entry of page.records) { + const plan = submittedPlan(entry.event) + if (plan?.callId === target.callId) { + yield { ok: true, value: plan } + return + } + } + const beforeSeq = page.records[0]?.event.seq + if (!page.hasMore || beforeSeq === undefined) break + const next = await remote.page({ address: sessionAddress, throughSeq: snapshot.cursor, beforeSeq }, signal) + if (aborted()) return + if (!next.ok) { yield next; return } + page = next.value + } + yield { ok: false, error: new RemoteError('plan/not-found', 'The submitted plan was not found in this Session.', {}) } + } catch (error) { + if (!aborted()) yield { + ok: false, + error: remoteErrorOf(error) ?? new RemoteError('plan/read-failed', error instanceof Error ? error.message : String(error), {}), + } + } + }, + } +} diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts new file mode 100644 index 0000000000..e3dff30e29 --- /dev/null +++ b/packages/client/ui-plan/src/client/plan.ts @@ -0,0 +1,86 @@ +/** Plan text and resource identities derived from logged native or PTC calls. */ +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionAddress } from '@deepseek-ai/dsh-api-session-controller/types' + +/** Complete Markdown and the heading displayed by a plan preview. */ +export interface PlanDocument { + readonly markdown: string + readonly title: string +} + +/** One submitted plan, identified by its originating tool invocation. */ +export interface SubmittedPlan extends PlanDocument { + readonly callId: ToolCallId +} + +/** A saved sidebar resource names one invocation in one Session. */ +export interface PlanAddress { + readonly session: SessionAddress + readonly callId: ToolCallId +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Read a complete plan from untrusted logged arguments. + * @param event - Native call or PTC dispatch event from Session history. + * @returns the submitted plan, or undefined for unrelated or malformed data. + */ +export function submittedPlan(event: { readonly type: string; readonly data: unknown }): SubmittedPlan | undefined { + if (event.type !== 'tool/call' && event.type !== 'tool/ptc-dispatch-start' && event.type !== 'tool/ptc-dispatch') return undefined + const data = event.data + if (!record(data) || data.name !== 'exit_plan_mode') return undefined + const callId = event.type === 'tool/call' ? data.callId : data.subCallId + if (typeof callId !== 'string' || callId === '') return undefined + let args: unknown = data.arguments + if (event.type === 'tool/call') { + if (typeof args !== 'string') return undefined + try { args = JSON.parse(args) as unknown } + catch (_error) { return undefined /* Malformed model JSON remains in the generic tool row. */ } + } + if (!record(args) || typeof args.plan !== 'string') return undefined + const markdown = args.plan + const title = /^#\s+(\S[^\r\n]*)/.exec(markdown.trim())?.[1] + return title === undefined ? undefined : { callId: callId as ToolCallId, markdown, title } +} + +/** + * Encode the durable identity of a plan without retaining its text in layout storage. + * @param target - Session and tool-call identity. + * @returns the plan resource address. + */ +export function planAddress(target: PlanAddress): string { + const { session, callId } = target + const parts = session.kind === 'session' + ? [session.sessionId, callId] + : ['subagent', session.parentSessionId, session.childSessionId, session.mode, callId] + return `dsh-resource://plan/${parts.map(encodeURIComponent).join('/')}` +} + +/** + * Validate a saved or caller-supplied plan resource address. + * @param address - Address submitted to the sidebar or resource provider. + * @returns the decoded identity, or undefined for an unsupported address. + */ +export function parsePlanAddress(address: string): PlanAddress | undefined { + const match = /^dsh-resource:\/\/plan\/([^?#]+)$/.exec(address) + if (match === null) return undefined + try { + const parts = (match[1] as string).split('/').map(decodeURIComponent) + if (parts.some(part => part === '')) return undefined + if (parts.length === 2) return { + session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId, + } + if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return { + session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] }, + callId: parts[4] as ToolCallId, + } + return undefined + } catch (_error) { + // Invalid saved percent encoding cannot identify a Session or invocation. + return undefined + } +} diff --git a/packages/client/ui-plan/src/client/review-preview.ts b/packages/client/ui-plan/src/client/review-preview.ts new file mode 100644 index 0000000000..19307c8c7b --- /dev/null +++ b/packages/client/ui-plan/src/client/review-preview.ts @@ -0,0 +1,29 @@ +/** Temporary review previews carry their document in in-memory tab navigation. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PlanDocument } from './plan.ts' + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightResourceParamsMap { + /** Review text without a logged invocation; never persisted in sidebar layout. */ + 'plan-review': { planReview: PlanDocument } + } +} + +/** + * Name one temporary review within its browser lifetime and Session. + * @param sessionId - Session displaying the review. + * @param requestKey - Browser-unique pending request identity. + * @returns the address used to focus or reopen its preview. + */ +export function reviewPreviewAddress(sessionId: SessionId, requestKey: string): string { + return `dsh-resource://plan-review/${encodeURIComponent(sessionId)}/${encodeURIComponent(requestKey)}` +} + +/** + * Recognize temporary plan navigation without interpreting it as logged history. + * @param address - Saved or caller-supplied navigation address. + * @returns whether the address identifies a temporary review preview. + */ +export function isReviewPreviewAddress(address: string): boolean { + return /^dsh-resource:\/\/plan-review\/[^/?#]+\/[^/?#]+$/.test(address) +} diff --git a/packages/client/ui-plan/src/client/review-store.ts b/packages/client/ui-plan/src/client/review-store.ts new file mode 100644 index 0000000000..b35b5daa39 --- /dev/null +++ b/packages/client/ui-plan/src/client/review-store.ts @@ -0,0 +1,23 @@ +/** Session-owned memory of pending plans already opened automatically. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' + +interface PlanReviewState { + opened: Partial> +} + +type PlanReviewActions = { + markOpened: (draft: PlanReviewState, reviewKey: string) => void +} + +/** + * Keep manual sidebar closure effective across review component remounts. + * @returns a transient store handle whose instances belong to Session scopes. + */ +export function createPlanReviewStore(): EngineStoreHandle { + return defineStore({ + init: (): PlanReviewState => ({ opened: {} }), + actions: { + markOpened: (draft, reviewKey) => { draft.opened[reviewKey] = true }, + }, + }) +} diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index ee5b790a79..bbc55871ed 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -5,6 +5,7 @@ * outcomes into null (admitted) or a user-visible failure line; teardown * empties the seat (HMR safety). */ +import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -12,19 +13,45 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { PlanChip } from '../src/client/PlanModeControl.tsx' +import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from '../src/client/PlanCard.tsx' +import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx' +import { submittedPlan } from '../src/client/plan.ts' import type { PlanChipInjected } from '../src/client/index.ts' import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' +function providePreview(ctx: Context) { + const events = new ConversationEventRegistry(ctx) + ctx.provide('uiConversation', { events }) + const removeResources = vi.fn() + const removeType = vi.fn() + const registerType = vi.fn((_definition: Parameters[0]) => removeType) + const openResourceIn = vi.fn() + const subagentAddress = vi.fn(() => undefined) + ctx.provide('sessions', { subagentAddress }) + ctx.provide('resources', { register: vi.fn(() => removeResources) }) + ctx.provide('sidebarRightTabs', { register: registerType }) + ctx.provide('sidebarRight', { openResourceIn }) + ctx.provide('remote.session', {}) + return { events, removeResources, removeType, registerType, openResourceIn, subagentAddress } +} + const SID = 's-plan' as SessionId async function bench() { const ctx = new Context() + const preview = providePreview(ctx) await ctx.plugin(SlotRegistry).await() const slots = ctx.get('slots') as SlotRegistry slots.register({ name: 'root', - children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } }, + children: { + 'conversation.input.plan': { kind: 'single', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, + 'conversation.plan-review.actions': { kind: 'list', scope: 'session' }, + 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' }, + 'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session' }, + }, } as never, () => null) const execute = vi.fn((_sessionId: SessionId, _line: string) => Promise.resolve({ ok: true, value: { commandId: 'c1', result: { kind: 'success' as const } } })) @@ -32,12 +59,12 @@ async function bench() { ctx.provide('remote', { commands: commandsRemote }) ctx.provide('remote.commands', commandsRemote) ctx.provide('locale', new LocaleRuntime(ctx)) - return { ctx, slots, execute } + return { ctx, slots, execute, ...preview } } describe('ui-plan browser apply', () => { it('declares every service it binds', () => { - expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale']) + expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']) }) it('node-half apply is an intentional no-op', () => { @@ -46,6 +73,7 @@ describe('ui-plan browser apply', () => { it('waits until conversation declares the plan seat', async () => { const ctx = new Context() + providePreview(ctx) await ctx.plugin(SlotRegistry).await() ctx.provide('remote', { commands: {} }) ctx.provide('remote.commands', {}) @@ -83,7 +111,61 @@ describe('ui-plan browser apply', () => { b.execute.mockResolvedValueOnce({ ok: true, value: undefined } as never) await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off') + expect(b.events.entries().map(entry => entry.kind)).toEqual(['submitted-plan']) await fiber.dispose() + expect(b.events.entries()).toEqual([]) + expect(b.removeResources).toHaveBeenCalledOnce() + expect(b.removeType).toHaveBeenCalledOnce() expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) }) + it('binds both entry points to the same Session resource and removes the sidebar seats', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + try { + await fiber.await() + const removeFileEntry = b.slots.register({ name: 'conversation.chat.turnTail', id: 'test-file-deliveries' }, () => null) + expect(b.slots.entries('conversation.chat.turnTail')).toHaveLength(2) + removeFileEntry() + const address = 'dsh-resource://plan/s-plan/call' + const type = b.registerType.mock.calls[0]![0] + expect(type.canOpen!(address)).toBe(true) + expect(type.canOpen!('file:///plan.md')).toBe(false) + expect(type.title(address)).toBeTruthy() + const plan = submittedPlan({ type: 'tool/call', data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } })! + const card = b.slots.entries('conversation.chat.turnTail')[0]! + expect(card.component).toBe(PlanCards) + const injected = (card.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID) + injected.openPlan(plan.callId) + expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address) + const review = b.slots.entries('conversation.plan-review.actions')[0]! + expect(review.component).toBe(PlanReviewOpen) + const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID) + const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } } + reviewInjected.openReview(pending, 'question:1') + expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address) + b.subagentAddress.mockReturnValue({ parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }) + injected.openPlan(plan.callId) + expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call') + reviewInjected.openReview(pending, 'question:1') + expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call') + const temporary = { id: 'review', question: 'Approve?', plan: '# Temporary\n\nComplete body', approve: { label: 'Approve' } } + reviewInjected.openReview(temporary, 'question:2') + const first = b.openResourceIn.mock.calls.at(-1)! + expect(type.canOpen!(first[1])).toBe(true) + expect(first).toEqual([SID, expect.stringMatching(/^dsh-resource:\/\/plan-review\/s-plan\//), { + params: { planReview: { title: 'Temporary', markdown: temporary.plan } }, + }]) + reviewInjected.openReview(temporary, 'question:2') + expect(b.openResourceIn).toHaveBeenLastCalledWith(...first) + reviewInjected.openReview(temporary, 'question:3') + expect(b.openResourceIn.mock.calls.at(-1)![1]).not.toBe(first[1]) + expect(b.slots.entries('sidebar.right.pane.tab')[0]!.component).toBe(PlanPreview) + expect(b.slots.entries('sidebar.right.pane.tab.title')[0]!.component).toBe(PlanTitle) + await fiber.dispose() + expect(b.slots.entries('conversation.chat.turnTail')).toEqual([]) + expect(b.slots.entries('conversation.plan-review.actions')).toEqual([]) + expect(b.slots.entries('sidebar.right.pane.tab')).toEqual([]) + expect(b.slots.entries('sidebar.right.pane.tab.title')).toEqual([]) + } finally { await fiber.dispose() } + }) }) diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx new file mode 100644 index 0000000000..22b2fb006e --- /dev/null +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -0,0 +1,262 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client' +import { chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { en, zh } from '../src/client/locales.ts' +import { PlanCards, PlanReviewOpen } from '../src/client/PlanCard.tsx' +import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx' +import { planAddress, parsePlanAddress, submittedPlan } from '../src/client/plan.ts' +import { reviewPreviewAddress, isReviewPreviewAddress } from '../src/client/review-preview.ts' +import { planDefinition } from '../src/client/plan-definition.ts' +import { createPlanReviewStore } from '../src/client/review-store.ts' + +afterEach(cleanup) +const markdown = '# Keep this plan\n\n## Goal\n\n- Review\n- Implement' +const call = { type: 'tool/call', seq: 12, data: { turn: 1, name: 'exit_plan_mode', callId: 'call:1', arguments: JSON.stringify({ plan: markdown }) } } +const plan = submittedPlan(call)! +const target = { session: { kind: 'session' as const, sessionId: 'session / 中文' as SessionId }, callId: plan.callId } +const t = makeTranslate(en, commonEn) + +function planNode(data: typeof plan) { + return { kind: 'submitted-plan', anchorSeq: 12, location: { kind: 'turn', turn: { turn: 1 } }, data } +} +function planHook(nodes: readonly unknown[]): Parameters[0]['useChat'] { + return ((select: (snapshot: unknown) => unknown) => select({ nodes: { values: () => nodes } })) as Parameters[0]['useChat'] +} + +describe('submitted plan identity', () => { + it('reads native, running PTC, and settled PTC calls', () => { + expect(plan).toEqual({ callId: 'call:1', title: 'Keep this plan', markdown }) + for (const type of ['tool/ptc-dispatch-start', 'tool/ptc-dispatch']) { + expect(submittedPlan({ type, data: { name: 'exit_plan_mode', subCallId: 'call:1', arguments: { plan: markdown } } })).toEqual(plan) + } + }) + it.each([ + { type: 'user/message', data: call.data }, + { type: 'tool/call', data: null }, + { type: 'tool/call', data: [] }, + { type: 'tool/call', data: { ...call.data, name: 'read' } }, + { type: 'tool/call', data: { ...call.data, callId: '' } }, + { type: 'tool/call', data: { ...call.data, arguments: {} } }, + { type: 'tool/call', data: { ...call.data, arguments: '{' } }, + { type: 'tool/call', data: { ...call.data, arguments: 'null' } }, + { type: 'tool/call', data: { ...call.data, arguments: '{"plan":42}' } }, + { type: 'tool/call', data: { ...call.data, arguments: '{"plan":"no title"}' } }, + ])('leaves malformed or unrelated input to its existing renderer: %j', (event) => { + expect(submittedPlan(event)).toBeUndefined() + }) + it('round-trips opaque identifiers and refuses malformed addresses', () => { + expect(parsePlanAddress(planAddress(target))).toEqual(target) + for (const mode of ['one-shot', 'continuable'] as const) { + const child = { session: { kind: 'subagent' as const, parentSessionId: target.session.sessionId, childSessionId: 'child / 中文' as SessionId, mode }, callId: plan.callId } + expect(parsePlanAddress(planAddress(child))).toEqual(child) + } + for (const address of ['file:///plan.md', 'dsh-resource://plan/s/c/extra', 'dsh-resource://plan/s/%XX', 'dsh-resource://plan/s/c?text=x', + 'dsh-resource://plan//c', 'dsh-resource://plan/subagent/p/c/invalid/call', 'dsh-resource://plan/subagent/p/c/one-shot']) { + expect(parsePlanAddress(address)).toBeUndefined() + } + }) + it('keeps a settled PTC update correlated with its existing card', () => { + expect(planDefinition.match(call as never)).toEqual({ id: plan.callId, role: 'start' }) + const event = { type: 'tool/ptc-dispatch', data: { name: 'exit_plan_mode', subCallId: plan.callId, arguments: { plan: markdown } } } + expect(planDefinition.match(event as never)).toEqual({ id: plan.callId, role: 'update' }) + const context = { key: 'plan', id: plan.callId, state: plan, start: { event: { seq: 12 }, location: { kind: 'unresolved' } } } + expect(planDefinition.buildViewNode!(context as never)).toMatchObject({ visibility: 'hidden', anchorSeq: 12, data: plan }) + expect(planDefinition.buildViewNode!({ ...context, state: undefined, start: undefined, matches: [] } as never)).toBeNull() + }) + it('retains the submitted version and recovers a cropped PTC start from settlement', () => { + expect(planDefinition.match({ type: 'user/message', data: {} } as never)).toBeNull() + const state = planDefinition.start({} as never, { event: call } as never, {} as never) + expect(state).toEqual(plan) + expect(planDefinition.update({ state } as never, {} as never)).toBe(state) + const settled = { event: { type: 'tool/ptc-dispatch', seq: 21, data: { name: 'exit_plan_mode', subCallId: plan.callId, arguments: { plan: markdown } } }, location: { kind: 'unresolved' } } + expect(planDefinition.buildViewNode!({ key: 'plan', id: plan.callId, matches: [settled] } as never)).toMatchObject({ anchorSeq: 21, data: plan }) + }) +}) + +it('projects native and PTC submissions into their resolved turns without duplicate cards', () => { + const assembler = new ConversationNodeAssembler( + { entries: () => [planDefinition], fallbackEntry: () => undefined }, + { entries: () => [chatViewDefinition] }, + ) + const events = [ + { type: 'turn/start', data: { turn: 1 } }, + { type: 'step/start', data: { turn: 1, step: 1 } }, + call, + { type: 'tool/call', data: { turn: 1, step: 1, callId: 'root', name: 'run_code', arguments: '{}' } }, + { type: 'tool/ptc-dispatch-start', data: { + rootCallId: 'root', parentCallId: 'root', subCallId: 'ptc', name: 'exit_plan_mode', arguments: { plan: markdown }, + } }, + { type: 'tool/ptc-dispatch', data: { + rootCallId: 'root', parentCallId: 'root', subCallId: 'ptc', name: 'exit_plan_mode', arguments: { plan: markdown }, + isError: false, content: [], + } }, + { type: 'turn/end', data: { turn: 1 } }, + { type: 'turn/start', data: { turn: 2 } }, + { ...call, data: { ...call.data, turn: 2, callId: 'next-turn' } }, + { type: 'turn/end', data: { turn: 2 } }, + ] + const entries = events.map((event, index) => ({ + type: 'event', event: { ...event, seq: index + 1, time: index }, + })) as SessionLiveEventEntry[] + assembler.replaceWindow(entries, false) + assembler.activateTarget('chat') + const snapshot = assembler.snapshot('chat') as ChatSnapshot + expect(snapshot.order).toEqual([]) + expect(snapshot.nodes.values()).toHaveLength(3) + const props = { turn: { turn: 1 }, useChat: (select: (snapshot: ChatSnapshot) => unknown) => select(snapshot), + t, openPlan: vi.fn(), + } as unknown as Parameters[0] + const view = render() + expect(view.container.querySelectorAll('[data-plan-card]')).toHaveLength(2) + view.rerender([0]} />) + expect(view.container.querySelector('[data-plan-card]')?.getAttribute('data-plan-card')).toBe('next-turn') + assembler.replaceWindow(entries.filter((_, index) => index !== 4), true) + expect((assembler.snapshot('chat') as ChatSnapshot).nodes.values()).toHaveLength(3) +}) + +describe('plan entry points and document', () => { + it('opens the exact persistent card in either locale', () => { + for (const dictionary of [en, zh]) { + const openPlan = vi.fn() + const props = { turn: { turn: 1 }, useChat: planHook([planNode(plan)]), seq: 30, + t: makeTranslate(dictionary, commonEn), openPlan, + } as unknown as Parameters[0] + const view = render() + expect(openPlan).not.toHaveBeenCalled() + expect(screen.getByText(dictionary['preview.action'])).toBeTruthy() + expect(screen.queryByText('Implement')).toBeNull() + fireEvent.click(screen.getByRole('button')) + expect(openPlan).toHaveBeenCalledWith(plan.callId) + expect(screen.getByText(plan.title)).toBeTruthy() + view.unmount() + } + }) + it('keeps only the current turn’s plans in invocation order', () => { + const revised = { ...plan, callId: 'call:2' as typeof plan.callId, title: 'Second plan' } + const nodes = [ + { ...planNode(revised), anchorSeq: 20 }, planNode(plan), + { ...planNode(plan), location: { kind: 'turn', turn: { turn: 2 } } }, + { ...planNode(plan), location: { kind: 'unresolved' } }, + { ...planNode(plan), kind: 'tool-call' }, + { ...planNode({ ...revised, callId: 'call:3' as typeof plan.callId }), + anchorSeq: 30, location: { kind: 'step', turn: { turn: 1 } }, + }, + ] + const openPlan = vi.fn() + const props = { turn: { turn: 1 }, useChat: planHook(nodes), t, openPlan } as unknown as Parameters[0] + const view = render() + expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([ + expect.stringContaining(plan.title), expect.stringContaining(revised.title), expect.stringContaining(revised.title), + ]) + fireEvent.click(screen.getAllByRole('button')[1]!) + expect(openPlan).toHaveBeenCalledWith('call:2') + view.rerender() + expect(view.container.innerHTML).toBe('') + }) + it.each([true, false])('opens once, preserves manual closure, and reopens a review (logged: %s)', (logged) => { + const openReview = vi.fn() + const store = createPlanReviewStore().create() + const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) } + const props = { review, requestKey: 'question:1', t, openReview, actions: store.actions, + useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), + } as unknown as Parameters[0] + const first = render() + expect(openReview).toHaveBeenCalledExactlyOnceWith(review, 'question:1') + first.rerender() + first.unmount() + const second = render() + expect(openReview).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) + expect(openReview).toHaveBeenCalledTimes(2) + const revised = { plan: '# Revised', ...(logged ? { callId: 'call:2' } : {}) } + second.rerender([0]} />) + expect(openReview).toHaveBeenLastCalledWith(revised, 'question:2') + expect(openReview).toHaveBeenCalledTimes(3) + second.unmount() + const other = createPlanReviewStore().create() + render() => unknown) => select(other.getSnapshot()), + } as unknown as Parameters[0]} />) + expect(openReview).toHaveBeenCalledTimes(4) + }) + it('renders temporary Markdown and reports expired navigation after reload', () => { + const address = reviewPreviewAddress(target.session.sessionId, 'window:question:1') + expect(isReviewPreviewAddress(address)).toBe(true) + expect(isReviewPreviewAddress(planAddress(target))).toBe(false) + const props = { t, useResource: () => ({ status: 'none' }), + useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address, params: { planReview: { title: plan.title, markdown } } } } }), + } + const view = render([0]} />) + expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy() + expect(screen.getByText('Implement')).toBeTruthy() + view.rerender([0]} />) + expect(screen.getByText(plan.title)).toBeTruthy() + view.rerender( ({ tab: { title: 'Plan', navigation: { address } } }), + } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(en['preview.expired']) + view.rerender( ({ tab: { title: 'Plan', navigation: { address } } }), + } as unknown as Parameters[0]} />) + expect(screen.getByText('Plan')).toBeTruthy() + view.rerender( ({ tab: { title: 'Plan', navigation: { address, params: { line: 2 } } } }), + } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(en['preview.expired']) + }) + it('shows restored Markdown and its heading as the tab title', () => { + const props = { + t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }), + useResource: () => ({ status: 'live', value: plan }), + } + render([0]} />) + expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy() + expect(screen.getAllByRole('listitem')).toHaveLength(2) + cleanup() + render([0]} />) + expect(screen.getByText(plan.title)).toBeTruthy() + }) + it('copies the complete Markdown and keeps a tab label while history loads', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + try { + const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }), useResource: () => ({ status: 'live', value: plan }) } + const view = render([0]} />) + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + await Promise.resolve() + expect(writeText).toHaveBeenCalledWith(markdown) + view.rerender( ({ status: 'loading' }) } as unknown as Parameters[0]} />) + expect(screen.getByText('Plan')).toBeTruthy() + } finally { + if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', clipboard) + } + }) + it.each([en, zh])('localizes plan failures and unavailable providers', (dictionary) => { + const props = { t: makeTranslate(dictionary, commonEn), + useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }), + } + const view = render( ({ status: 'loading' }) } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(dictionary['preview.loading']) + view.rerender( ({ status: 'none' }) } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(dictionary['preview.unavailable']) + for (const [code, message] of [ + ['plan/invalid-address', dictionary['preview.invalidAddress']], + ['plan/unavailable', dictionary['preview.historyUnavailable']], + ['plan/not-found', dictionary['preview.notFound']], + ['plan/read-failed', 'connection lost'], + ['session/not-found', 'connection lost'], + ] as const) { + view.rerender( ({ status: 'failed', failure: { code, message: 'connection lost' } }) } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(dictionary['preview.failed'] + message) + } + }) +}) diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts new file mode 100644 index 0000000000..a27b03c185 --- /dev/null +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { planResourceProvider } from '../src/client/plan-resource.ts' + +const address = 'dsh-resource://plan/session/call' +const event = { type: 'tool/call', seq: 1, data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } } +const entry = { type: 'event', event } + +function setup(records: unknown[], hasMore = false) { + const closed = vi.fn() + const follow = vi.fn(async function* () { + try { yield { type: 'snapshot', cursor: 100, records, hasMore } } + finally { closed() } + }) + const page = vi.fn(async () => ({ ok: true, value: { records: [entry], hasMore: false } })) + const provider = planResourceProvider({ follow, page } as unknown as Parameters[0]) + return { provider, follow, page, closed } +} + +async function read(provider: ReturnType, url = address, signal = new AbortController().signal) { + const values = [] + for await (const value of provider.open(url, { signal })) values.push(value) + return values +} + +describe('plan history resource', () => { + it('restores the exact invocation and releases its snapshot stream', async () => { + const b = setup([entry]) + expect(await read(b.provider)).toEqual([{ ok: true, value: { callId: 'call', markdown: '# Saved plan', title: 'Saved plan' } }]) + expect(b.closed).toHaveBeenCalledOnce() + expect(b.page).not.toHaveBeenCalled() + }) + it('pages older history with the fixed opening cursor without changing the chat window', async () => { + const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + expect((await read(b.provider))[0]).toMatchObject({ ok: true }) + expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) + expect(b.closed).toHaveBeenCalledOnce() + }) + it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { + const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + const address = `dsh-resource://plan/subagent/parent/child/${mode}/call` + const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode } + expect(await read(b.provider, address)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }]) + expect(b.follow).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal)) + expect(b.page).toHaveBeenCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) + expect(b.closed).toHaveBeenCalledOnce() + }) + it('reports missing plans and invalid saved addresses', async () => { + const b = setup([]) + expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }]) + expect(await read(b.provider, 'dsh-resource://plan/s/%')).toMatchObject([{ ok: false, error: { code: 'plan/invalid-address' } }]) + expect(b.follow).toHaveBeenCalledOnce() + }) + it('does not publish a response after cancellation', async () => { + const b = setup([entry]) + const controller = new AbortController() + controller.abort() + expect(await read(b.provider, address, controller.signal)).toEqual([]) + expect(b.follow).not.toHaveBeenCalled() + }) + it('reports stream failures and closes an empty stream', async () => { + const b = setup([]) + b.follow.mockImplementationOnce(async function* () { throw new Error('connection lost') }) + expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }]) + b.follow.mockImplementationOnce(async function* () {}) + expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/unavailable' } }]) + }) + it('preserves Remote failures from paging and stream reads', async () => { + const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + const error = new RemoteError('plan/unavailable', 'history unavailable', {}) + b.page.mockResolvedValueOnce({ ok: false, error } as never) + expect(await read(b.provider)).toEqual([{ ok: false, error }]) + b.follow.mockImplementationOnce(async function* () { throw error }) + expect(await read(b.provider)).toEqual([{ ok: false, error }]) + b.follow.mockImplementationOnce(async function* () { throw 'disconnected' }) + expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'disconnected' } }]) + }) + it('waits for a snapshot and discards it when cancellation occurs during stream closure', async () => { + const b = setup([entry]) + const controller = new AbortController() + b.follow.mockImplementationOnce(async function* () { + try { + yield entry as never + yield { type: 'snapshot', cursor: 100, records: [entry], hasMore: false } + } finally { b.closed(); controller.abort() } + }) + expect(await read(b.provider, address, controller.signal)).toEqual([]) + expect(b.closed).toHaveBeenCalledOnce() + expect(b.page).not.toHaveBeenCalled() + }) + it.each(['resolve', 'reject'] as const)('discards a page that settles after cancellation: %s', async (settlement) => { + const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) + const controller = new AbortController() + const started = Promise.withResolvers() + const pending = Promise.withResolvers>>() + b.page.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise }) + const result = read(b.provider, address, controller.signal) + await started.promise + controller.abort() + if (settlement === 'resolve') pending.resolve({ ok: true, value: { records: [entry], hasMore: false } }) + else pending.reject(new Error('cancelled')) + expect(await result).toEqual([]) + expect(b.closed).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 5362b499de..9ab7d2fed4 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../store" + }, { "path": "../../api/remotes/tsconfig.client.json" }, @@ -34,6 +37,33 @@ }, { "path": "../../plan/plan-mode" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../ui-chat" + }, + { + "path": "../ui-sidebar-right" + }, + { + "path": "../ui-user-questions" + }, + { + "path": "../resources" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../util/crypto" } ] } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 4ea8bf8274..e7dd304b2f 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 2abb32906841f0d112896acb242d0e4ab0557856 -README.zh.md: 340d239cac3a72f9769a29f3f772794f4ca9fa7f +README.md: cefa67e49146d5fc2976d3d43b8ae39c3d4b99f6 +README.zh.md: 3c069d05b2948e8494cef934f87270e86a14d978 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 2abb329068..cefa67e491 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -53,7 +53,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | -| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | +| `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories and known-site marks. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. | @@ -72,7 +72,9 @@ The catalog above lists what each export is for; this section covers the behavio ### Rendering agent output -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. +The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. + +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 340d239cac..3c069d05b2 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -53,7 +53,7 @@ kind: "package-library" | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | -| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | +| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类及已知站点标记。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 | @@ -72,7 +72,9 @@ kind: "package-library" ### 渲染 agent 输出 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 +最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 + +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index a1893a1d39..41baf5b785 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,13 +1,20 @@ // Cloning the anchor preserves its layout context. Fixed positioning lets the // bubble escape ancestor overflow clipping without a portal. -import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' /** Bubble placement relative to the anchor. */ export type TooltipSide = 'right' | 'bottom' | 'top' +/** + * Suppression channel from a tooltip to the tooltips above it: a tooltip hands + * this setter to its own descendants, and a visible descendant bubble calls it + * so the ancestor withdraws its bubble for as long as the descendant shows one. + */ +const TooltipSuppression = createContext<((suppressed: boolean) => void) | null>(null) + /** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */ interface AnchorProps { ref?: Ref | undefined @@ -93,6 +100,20 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, // BOTH clear (hovering away from a focused anchor must not drop it). const triggers = useRef({ hover: false, focus: false }) + // A nested tooltip's bubble owns the pointer position, so this tooltip + // withdraws its own while a descendant shows one; the state below is set by + // the descendants this tooltip wraps. Announcing on every visibility change + // covers hide, disable, and unmount; show() also announces synchronously so + // a nested pair shown in one commit never paints both bubbles. + const suppressAncestors = useContext(TooltipSuppression) + const [suppressed, setSuppressed] = useState(false) + const announce = useCallback((active: boolean) => { suppressAncestors?.(active) }, [suppressAncestors]) + const visible = pos !== null && !disabled + useEffect(() => { + announce(visible) + return () => { announce(false) } + }, [announce, visible]) + // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. const cancelShow = useCallback(() => { @@ -119,6 +140,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, // where this anchor's position demands it. setPlacement(side) setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom }) + announce(true) } const showAfterHoverDelay = () => { cancelShow() @@ -131,21 +153,25 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, show() }, delayMs) } + const withdraw = () => { + setPos(null) + announce(false) + } const hide = () => { cancelShow() - if (!triggers.current.hover && !triggers.current.focus) setPos(null) + if (!triggers.current.hover && !triggers.current.focus) withdraw() } return ( - <> + {cloneElement(children, { ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() }, onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} - {pos !== null && ( + {visible && !suppressed && ( )} - + ) } diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index c720825bbf..ce91b32bde 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -68,6 +68,8 @@ export type { export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' +export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx' +export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx new file mode 100644 index 0000000000..0dfcb56cf0 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx @@ -0,0 +1,55 @@ +/** Consumer-owned navigation for Markdown links. */ +import { createContext, useContext, useMemo } from 'react' +import type { ReactNode } from 'react' + +/** + * Handle one sanitized absolute HTTP(S) URL selected from Markdown. + * @param href - destination URL. + */ +export type MarkdownExternalLinkHandler = (href: string) => void + +/** Navigation capabilities supplied by the nearest Markdown owner. */ +export interface MarkdownDelegate { + /** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */ + readonly openExternalLink?: MarkdownExternalLinkHandler | undefined + /** + * Open a decoded local destination from settled Markdown; absent handlers leave plain text. + * @param path - Absolute or workspace-relative file path. + * @param options - First line to reveal when the destination specifies a line or range. + */ + readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined +} + +const MarkdownDelegateContext = createContext({}) + +/** Props for one Markdown navigation scope. */ +export interface MarkdownDelegateProviderProps extends MarkdownDelegate { + readonly children: ReactNode +} + +/** + * Scope Markdown navigation without threading callbacks through renderers. + * Nested providers replace the enclosing capabilities. Handler changes reach cached links. + * @param props - Child tree and its file and HTTP(S) link handlers. + * @returns the scoped child tree. + */ +export function MarkdownDelegateProvider({ + children, + openExternalLink, + openFile, +}: MarkdownDelegateProviderProps): ReactNode { + const delegate = useMemo(() => ({ openExternalLink, openFile }), [openExternalLink, openFile]) + return ( + + {children} + + ) +} + +/** + * Read the nearest Markdown navigation capabilities. + * @returns Owner callbacks, or an empty delegate outside a provider. + */ +export function useMarkdownDelegate(): MarkdownDelegate { + return useContext(MarkdownDelegateContext) +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index b12cba4897..508c592f67 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -55,7 +55,8 @@ margin-bottom: 8px; } -.markdown a { +.markdown a, +.markdown .fileLink { /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet keeps design-platform brand-text as near-black, so links use the dedicated link alias instead. */ @@ -80,7 +81,8 @@ text-underline-offset: 3px; } -.markdown a:focus-visible { +.markdown a:focus-visible, +.fileMention:focus-visible { box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); } @@ -314,6 +316,11 @@ cursor: pointer; } +.fileLink { + max-width: 100%; + text-align: inherit; +} + .fileMention:hover, .fileMention:focus { outline: none; diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 8eace9bbe2..ab2c0b380a 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -157,15 +157,19 @@ class StreamingRenderer { * identity discards the streaming render cache mid-message. `fileMentions` * links inline-code tokens its resolver recognizes as real files, and * `pathImages` rewrites image destinations that are local file paths into - * displayable URLs its resolver vouches for; both vocabularies are the + * displayable URLs its resolver vouches for. Those two vocabularies are the * single streaming gate — they apply to settled renders only, because a * streaming message's vocabulary is not final and frozen cached elements - * must not bake in handlers that could go stale. `variant="compact"` uses - * secondary text sizing, uniform bold headings, and tight block spacing; - * the default `body` variant uses the full document typography. - * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, - * relative links, and unsafe protocols are disabled, while absolute HTTP(S) - * images render directly. + * must not bake in handlers that could go stale. A surrounding + * `MarkdownDelegateProvider` can delegate ordinary HTTP(S) activation while + * modified clicks retain native behavior. `variant="compact"` uses secondary + * text sizing, uniform bold headings, and tight block spacing; the default + * `body` variant uses the full document typography. + * The provider's `openFile` enables local Markdown links in settled messages, + * including `#L24` and `#L24-L30` destinations (ranges open at their first line). + * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and + * unsafe protocols are disabled. Local links without an opener remain text; + * absolute HTTP(S) images render directly. */ export const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions, pathImages, variant = 'body', diff --git a/packages/client/ui-primitives/src/markdown/file-link.ts b/packages/client/ui-primitives/src/markdown/file-link.ts new file mode 100644 index 0000000000..bb1ca89b0b --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/file-link.ts @@ -0,0 +1,32 @@ +/** Local Markdown destinations accepted by the file-preview callback. */ + +/** + * Decode a file destination and its optional GitHub-style line fragment. + * Literal `?` and `#` in filenames must be percent-encoded. + * @param value - Parsed Markdown link destination. + * @returns A local path and optional first line, or undefined for URLs, + * fragment-only links, queries, malformed escapes, or invalid line ranges. + */ +export function parseFileLink(value: string): { path: string; line?: number } | undefined { + const hash = value.indexOf('#') + const destination = hash < 0 ? value : value.slice(0, hash) + if (destination.includes('?')) return undefined + let path: string + try { + path = decodeURIComponent(destination) + } catch (_error) { + // Malformed percent escapes cannot identify a file unambiguously. + return undefined + } + if (path.length === 0 || /[\u0000-\u001f\u007f]/.test(path) + || /^[\\/]{2}/.test(path) + || (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path))) return undefined + if (hash < 0) return { path } + const fragment = value.slice(hash + 1) + const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(fragment) + if (match === null) return undefined + const line = Number(match[1]) + const end = match[2] === undefined ? line : Number(match[2]) + if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return undefined + return { path, line } +} diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index b9a491a012..79ce4a7e65 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -4,8 +4,8 @@ * cache frozen blocks as React elements; the rendered DOM is pinned * byte-for-byte by `tests/fixtures/markdown-dom` and must not drift. * - * Untrusted-output policy (unchanged from the replaced pipeline): link and - * image destinations pass a protocol allowlist, images additionally require + * External link and image destinations pass a protocol allowlist; settled + * local file links use an explicit owner callback. Images additionally require * absolute HTTP(S), raw HTML renders as literal text (no HTML enters the * DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail * the allowlist, so footnote references and back-references render as plain @@ -23,8 +23,10 @@ import type * as Md from 'mdast' import type {} from 'mdast-util-math' import { normalizeUri } from 'micromark-util-sanitize-uri' import { CodeBlock } from './CodeBlock.tsx' +import { parseFileLink } from './file-link.ts' import { renderTexToReact } from './katex.tsx' import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx' +import { useMarkdownDelegate } from './MarkdownDelegate.tsx' import type { PositionedBlock } from './incremental.ts' import css from './MarkdownText.module.css' @@ -338,7 +340,10 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'table': return renderTable(node, key, context) case 'link': - return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key, !anchorWrapsOnlyImages(node.children)) + return renderAnchor( + node.url, renderChildren(node.children, { ...context, inLink: true }), key, + !anchorWrapsOnlyImages(node.children), context.streaming, + ) case 'linkReference': return renderLinkReference(node, key, context) case 'image': @@ -525,28 +530,66 @@ function anchorWrapsOnlyImages(children: Md.PhrasingContent[]): boolean { return children.length > 0 && children.every(child => child.type === 'image' || child.type === 'imageReference') } -/** Anchor over an already-authored href: allowlisted or unwrapped, external links get the safe attributes. */ +/** Anchor over an already-authored href: allowlisted or unwrapped, with optional owner navigation for HTTP(S). */ function renderSafeLink(href: string, children: ReactNode[], key: Key, glyph = true): ReactNode { const safeHref = sanitizeUrl(href) if (safeHref === '') return {children} - const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return {children} +} + +function MarkdownAnchor({ href, glyph, children }: { + readonly href: string + readonly glyph: boolean + readonly children: ReactNode[] +}): ReactNode { + const { openExternalLink } = useMarkdownDelegate() + const external = ['http:', 'https:'].includes(new URL(href).protocol) + const open = external ? openExternalLink : undefined return ( { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + event.preventDefault() + open(href) + }} > - {glyph && } + {glyph && } {children} ) } -/** Anchor over a parsed markdown destination, which hast normalized before the allowlist saw it. */ -function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true): ReactNode { +/** Local destinations use the scoped file delegate after settlement. */ +function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, streaming = false): ReactNode { + const file = streaming ? undefined : parseFileLink(url) + if (file !== undefined) { + return {children} + } return renderSafeLink(normalizeUri(url), children, key, glyph) } +function MarkdownFileLink({ file, glyph, children }: { + readonly file: { path: string; line?: number } + readonly glyph: boolean + readonly children: ReactNode[] +}): ReactNode { + const { openFile } = useMarkdownDelegate() + if (openFile === undefined) return <>{children} + return ( + + ) +} + /** * The complete inline-code value when it is exactly an absolute HTTP(S) URL * (no surrounding whitespace); anything else stays inert code. @@ -608,7 +651,7 @@ function renderLinkReference( return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } const rendered = renderChildren(node.children, { ...context, inLink: true }) - return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children)) + return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.streaming) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx new file mode 100644 index 0000000000..bff4bcac36 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MarkdownDelegateProvider } from '../src/index.ts' +import { MarkdownText } from './markdown-test-components.tsx' + +afterEach(cleanup) + +describe('Markdown file links', () => { + it.each([ + ['src/index.ts', 'src/index.ts', undefined], + ['/workspace/src/index.ts#L24', '/workspace/src/index.ts', { line: 24 }], + ['../docs/My%20Notes.md#L24-L30', '../docs/My Notes.md', { line: 24 }], + ['C:/work/file.ts#L2', 'C:/work/file.ts', { line: 2 }], + ['C:%5Cwork%5Cfile.ts', 'C:\\work\\file.ts', undefined], + ['docs/%E4%B8%AD%E6%96%87.md', 'docs/中文.md', undefined], + ['file%23name%3F.txt', 'file#name?.txt', undefined], + ])('opens %s through the preview callback', (target, path, options) => { + const openFile = vi.fn() + const view = render( + + + , + ) + const link = view.getByRole('button', { name: 'source' }) + expect(link.getAttribute('title')).toBe(path) + fireEvent.click(link) + expect(openFile).toHaveBeenCalledWith(path, options) + expect(view.container.querySelector('a')).toBeNull() + }) + + it.each(['', '![](https://example.com/image.png)'])('names an empty label %s with the decoded path', (label) => { + const view = render( + + + , + ) + expect(view.getByRole('button', { name: 'docs/My Notes.md' })).toBeTruthy() + }) + + it('preserves an image label’s alternative text as the accessible name', () => { + const view = render( + + + , + ) + expect(view.getByRole('button', { name: 'diagram' })).toBeTruthy() + }) + + it('handles reference links and code labels without nesting file-mention buttons', () => { + const openFile = vi.fn() + const resolve = vi.fn() + const view = render( + + + , + ) + fireEvent.click(view.getByRole('button', { name: 'see index.ts' })) + expect(openFile).toHaveBeenCalledWith('src/index.ts', { line: 3 }) + expect(resolve).not.toHaveBeenCalled() + expect(view.container.querySelector('button button')).toBeNull() + }) + + it.each([ + '//example.com/file', '%2F%2Fexample.com/file', '%5C%5Cserver%5Cfile', + 'javascript:alert', 'data:text/plain,hi', 'file:///etc/passwd', 'vscode:open', + '%6Aavascript:alert', '#L2', 'file.ts?raw=1', 'file%ZZ.ts', 'file%00.ts', + 'file.ts#heading', 'file.ts#L0', 'file.ts#L3-L2', + 'file.ts#L9007199254740992', 'file.ts#L1-L9007199254740992', + ])('keeps unsupported destination %s inert', (target) => { + const openFile = vi.fn() + const view = render( + + + , + ) + expect(view.getByText('source')).toBeTruthy() + expect(view.container.querySelector('button, a')).toBeNull() + expect(openFile).not.toHaveBeenCalled() + }) + + it('preserves external links and leaves local links inert without an opener', () => { + const view = render() + expect(view.getByRole('link', { name: 'web' }).getAttribute('target')).toBe('_blank') + expect(view.getByRole('link', { name: 'mail' }).getAttribute('href')).toBe('mailto:a@example.com') + expect(view.queryByRole('button')).toBeNull() + }) + + it('enables links after streaming and uses a replaced opener', () => { + const first = vi.fn() + const second = vi.fn() + const text = '[source](src/a.ts)\n\nmore\n\n' + const view = render( + + + , + ) + expect(view.queryByRole('button')).toBeNull() + view.rerender( + + + , + ) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(first).toHaveBeenCalledOnce() + view.rerender( + + + , + ) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(second).toHaveBeenCalledOnce() + expect(first).toHaveBeenCalledOnce() + }) + + it('routes file and HTTP(S) links through the same provider', () => { + const openFile = vi.fn() + const openExternalLink = vi.fn() + const view = render( + + + , + ) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(openFile).toHaveBeenCalledExactlyOnceWith('src/a.ts', { line: 4 }) + expect(openExternalLink).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('link', { name: 'web' })) + expect(openExternalLink).toHaveBeenCalledExactlyOnceWith('https://example.com') + expect(openFile).toHaveBeenCalledOnce() + }) + + it('updates cached links when the nearest provider gains, replaces, or removes its file handler', () => { + const outer = vi.fn() + const first = vi.fn() + const second = vi.fn() + const markdown = + const scope = (openFile?: (path: string) => void) => ( + + {markdown} + + ) + const view = render(scope()) + expect(view.queryByRole('button')).toBeNull() + view.rerender(scope(first)) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(first).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined) + view.rerender(scope(second)) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(second).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined) + expect(first).toHaveBeenCalledOnce() + view.rerender(scope()) + expect(view.queryByRole('button')).toBeNull() + expect(view.getByText('source')).toBeTruthy() + expect(outer).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index a275c174f5..81e22b95d2 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { JsonBlock, MarkdownText } from './markdown-test-components.tsx' -import { LinkIcon } from '../src/index.ts' +import { LinkIcon, MarkdownDelegateProvider } from '../src/index.ts' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' @@ -141,6 +141,57 @@ describe('MarkdownText', () => { expect(container.querySelector('pre code a')).toBeNull() }) + it('delegates ordinary HTTP(S) clicks while preserving modified-click behavior', () => { + const openExternalLink = vi.fn<(href: string) => void>() + render( + + + , + ) + + const secure = screen.getByRole('link', { name: 'secure' }) + const plain = screen.getByRole('link', { name: 'plain' }) + const code = screen.getByRole('link', { name: 'https://example.com/code' }) + expect(fireEvent.click(secure)).toBe(false) + expect(fireEvent.click(plain)).toBe(false) + expect(fireEvent.click(code)).toBe(false) + expect(openExternalLink.mock.calls).toEqual([ + ['https://example.com/a'], + ['http://example.com/b'], + ['https://example.com/code'], + ]) + + for (const modified of [ + { button: 1 }, + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { altKey: true }, + ]) expect(fireEvent.click(secure, modified)).toBe(true) + expect(openExternalLink).toHaveBeenCalledTimes(3) + expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull() + }) + + it('updates the delegated link handler while Markdown is streaming', () => { + const first = vi.fn<(href: string) => void>() + const second = vi.fn<(href: string) => void>() + const source = '[web](https://example.com/)' + const view = render( + + + , + ) + fireEvent.click(screen.getByRole('link', { name: 'web' })) + view.rerender( + + + , + ) + fireEvent.click(screen.getByRole('link', { name: 'web' })) + expect(first).toHaveBeenCalledOnce() + expect(second).toHaveBeenCalledOnce() + }) + it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => { const opened: string[] = [] const fileMentions = { diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index a884b58615..26f66958ab 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -354,4 +354,55 @@ describe('Tooltip', () => { ) expect(screen.queryByRole('tooltip')).toBeNull() }) + + it('withdraws the enclosing bubble while a nested tooltip shows its own', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + const badge = screen.getByTestId('badge') + fireEvent.mouseEnter(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + + // Entering the nested anchor withdraws the enclosing bubble instead of + // stacking both; the enclosing anchor stays hovered, so nothing is lost. + fireEvent.mouseEnter(badge) + expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3']) + + // Leaving the nested anchor for the enclosing one restores its bubble; + // the pointer never left the enclosing anchor, so only the badge is left. + fireEvent.mouseLeave(badge, { relatedTarget: anchor }) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + + fireEvent.mouseLeave(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it('releases the enclosing bubble when a shown nested tooltip unmounts', () => { + const view = render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.mouseEnter(screen.getByTestId('badge')) + expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update']) + + view.rerender( + + + , + ) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + }) }) diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 9c62c0c737..dbd1bdcb5c 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-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 packages/client/ui-reference/README.md -README.md: 1a79a22425fff1c7323a3bcb55afd324decbe5e8 -README.zh.md: 7cde5e46e69f008e9fcfffeb3920184d90bddb60 +README.md: 6b5046f1c6300f5ec02d3737617019874762d47a +README.zh.md: 99ba6fda18a73f5686f5e02403e48d5e556f9c3c diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 1a79a22425..6b5046f1c6 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -51,7 +51,7 @@ The source keeps candidate encoding internal to the registration effect: the `/c ### Candidate flow -For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. +For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files, direct subagents of the current Session, and other Sessions. Rows use the resolver's display title, which prefers a subagent's creation label while ordinary Sessions retain their projected title, and render under locale-owned group headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the current directory; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. ### Serialization diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 7cde5e46e6..99ba6fda18 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -51,7 +51,7 @@ Web 用户需要从同一个 `@` 补全菜单提及文件、文件夹或会话 ### 候选流程 -对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 +对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再按文件、当前 Session 的直接 subagent、其他 Session 的确定性顺序排列。各行使用 resolver 的显示标题;subagent 优先使用创建标签,普通 Session 保持投影标题,并渲染在 locale 管理的分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 ### 序列化 diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 49c8922b1a..bf2104a1b3 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -61,15 +61,26 @@ export function apply(ctx: ClientContext): void { const now = Date.now() const home = ctx.remote.$host.home const listed = sessions.list.getSnapshot().byId + const sessionRows = sessionItems.map((candidate) => { + const summary = listed[candidate.sessionId] + const child = summary?.origin === 'subagent' && summary.parentId === session.sessionId + return { + child, + row: sessionCandidate( + candidate, + candidate.displayTitle ?? candidate.label, + summary?.updatedAt ?? candidate.createdAt, + now, + home, + t(child ? 'section.subagents' : 'section.sessions'), + t, + ), + } + }) return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), - ...sessionItems.map(candidate => sessionCandidate( - candidate, - listed[candidate.sessionId]?.updatedAt ?? candidate.createdAt, - now, - home, - t, - )), + ...sessionRows.filter(item => item.child).map(item => item.row), + ...sessionRows.filter(item => !item.child).map(item => item.row), ] }, header(_session: ClientSessionContext, req) { @@ -210,9 +221,11 @@ function fileCandidate( function sessionCandidate( candidate: SessionReferenceMentionCandidate, + label: string, updatedAt: number, now: number, home: string | undefined, + section: string, t: Translate, ) { const { unit, n } = relativeTime(updatedAt, now) @@ -224,14 +237,14 @@ function sessionCandidate( : candidate.cwd === undefined ? t('candidate.noCwd') : abbreviateHomePath(candidate.cwd, home) const value: ReferenceCandidateValue = { kind: 'session', - label: candidate.label, + label, mention: candidate.mention, } return { - name: candidate.label, + name: label, description: location === undefined ? age : `${location} · ${age}`, icon: 'session' as const, - section: t('section.sessions'), + section, value: JSON.stringify(value), } } diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index ed5f67a61d..7d185550cc 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -14,6 +14,7 @@ export const NS = 'reference' */ export const zh = { 'section.files': '文件与文件夹', + 'section.subagents': '子智能体', 'section.sessions': '对话', 'candidate.noCwd': '(无工作目录)', 'crumb.root': '工作区', @@ -38,6 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** English dictionary, checked complete against the zh key set. */ export const en = { 'section.files': 'Files & folders', + 'section.subagents': 'Subagents', 'section.sessions': 'Sessions', 'candidate.noCwd': '(no cwd)', 'crumb.root': 'Workspace', diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index e1a533f760..ff8ae73411 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -74,7 +74,12 @@ async function bench( mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })), - listed: Record = {}, + listed: Record = {}, ): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { const ctx = new Context() ctx.provide('sidebarRight', { openResource: vi.fn() }) @@ -330,6 +335,63 @@ describe('candidates', () => { ]) }) + it('uses current Session titles and groups direct subagents above ordinary Sessions', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [ + { + sessionId: sid('ordinary'), + label: 'Ordinary title', + displayTitle: 'Ordinary title', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[Ordinary title](dsh-session:Im9yZGluYXJ5Ig)', + }, + { + sessionId: sid('worker'), + label: 'Investigate startup', + displayTitle: 'researcher', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[researcher](dsh-session:IndvcmtlciI)', + }, + ], + })) + const { source } = await bench(files, sessions, { + worker: { + updatedAt: UPDATED_AT, + origin: 'subagent', parentId: session.sessionId, + projectionValues: { title: 'Investigate startup' }, + }, + ordinary: { updatedAt: UPDATED_AT, projectionValues: { title: 'Ordinary title' } }, + }) + const candidates = await source.candidates(session, request('worker')) + expect(candidates.map(candidate => ({ name: candidate.name, section: candidate.section }))).toEqual([ + { name: 'researcher', section: 'Subagents' }, + { name: 'Ordinary title', section: 'Sessions' }, + ]) + const [candidate] = candidates + expect(source.onPick({ + candidate: candidate!, + session, + position: 'inline', + via: 'menu', + action: 'pick', + span: { start: 0, end: 7, draftRev: 1 }, + })).toEqual({ + insert: { + source: 'reference', + ref: '@[researcher](dsh-session:IndvcmtlciI)', + label: 'researcher', + appearance: 'session', + clipboardText: '@[researcher](dsh-session:IndvcmtlciI)', + }, + }) + }) + it('reads a session opened moments ago as the present, not a zero distance', async () => { const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) const sessions = vi.fn(() => Promise.resolve({ diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index dfe7e55738..c3c937b599 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: af68c42ad111f74e68037436e18a5be57195a64b -README.zh.md: f929df66a13cde7beb63721922f2a3f4aa90ba43 +README.md: 0ea39cbb56b2040004513723b6d838a1ce9e7557 +README.zh.md: 9566471c68067e4ec461c28aeeb9a806a718fb90 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index af68c42ad1..0ea39cbb56 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -27,6 +27,8 @@ Use this package to give the dsh web client a Settings panel, connection-recover Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows a spinner beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a dot on its top expand button. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. + ### The General section The General section holds rows registered into `settings.general.item` by feature packages — it has no built-in rows. Feature plugins own the row copy and behavior; the shell only provides the section and its slot. The Appearance row, for example, lives in ui-theme. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index f929df66a1..9566471c68 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示 spinner 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以圆点显示相同状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 + ### 「通用」分区 「通用」分区承载由功能包注册进 `settings.general.item` 的行——它没有内置行。功能插件拥有行文案与行为;外壳只提供分区及其 slot。例如「外观」行位于 ui-theme。 diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.module.css b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.module.css new file mode 100644 index 0000000000..a51a817ca4 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.module.css @@ -0,0 +1,47 @@ +/* The entry's seat is the Settings foot row, which bleeds 2px past the sidebar + content box for the Settings hover surface. The New Session bar and panel + rows instead keep a 2px inset inside that box, so this entry gives back both + 2px offsets and ends on their trailing edge rather than flush against the + sidebar edge. */ +.indicator { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + margin-inline-end: 4px; + padding: 1px 8px; + border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 20%, transparent); + border-radius: 6px; + color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); + background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 8%, transparent); + font: inherit; + font-size: 12px; + line-height: 20px; + white-space: nowrap; + cursor: pointer; +} + +.indicator[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +.badge { + position: absolute; + inset-inline-end: 2px; + top: 2px; + width: 6px; + height: 6px; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.badge[data-error], .errorDot { background: var(--dsw-alias-state-error-primary); } +.errorDot { width: 6px; height: 6px; border-radius: 50%; corner-shape: round; } +.spinner { animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } + +.indicator[aria-disabled="true"] { + cursor: default; +} diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx new file mode 100644 index 0000000000..af6a401606 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -0,0 +1,94 @@ +/** Optional Electron status presentation; the native shell owns actions and Web owns visible copy. */ +import { IconDownloadOutline16, IconLoadingOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './DesktopUpdateIndicator.module.css' +import type { DesktopUpdateFailureKind, DesktopUpdatePresentation, DesktopUpdateView } from './desktop-update-bridge.ts' +import type { SettingsRootInjected } from './shell-contract.ts' + +type SettingsTranslate = PropsLocale<'settings'>['t'] + +const BUSY_PHASES: ReadonlySet = new Set([ + 'checking', 'downloading', 'verifying', 'installing', +]) + +function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { label: string; detail: string } { + const labels: Readonly> = { + idle: '', + checking: t('desktop.update.checking'), + available: t('desktop.update.available'), + downloading: t('desktop.update.progress', { percent: state.percent ?? 0 }), + verifying: t('desktop.update.verifying'), + installing: t('desktop.update.installing'), + ready: t('desktop.update.ready'), + error: t('desktop.update.retry'), + } + const label = labels[state.phase] + if (state.phase === 'error') { + const failures: Readonly> = { + check: t('desktop.update.checkFailed'), + 'check-network': t('desktop.update.checkNetworkFailed'), + download: t('desktop.update.downloadFailed'), + 'download-network': t('desktop.update.downloadNetworkFailed'), + install: t('desktop.update.installFailed'), + 'install-network': t('desktop.update.installNetworkFailed'), + 'stop-failed': t('desktop.update.stopFailed'), + 'tasks-changed': t('desktop.update.tasksChanged'), + 'tasks-unavailable': t('desktop.update.tasksUnavailable'), + } + return { label, detail: failures[state.failure ?? 'install'] } + } + if (state.phase === 'downloading' && state.version !== undefined) { + return { label, detail: t('desktop.update.downloadDetail', { percent: state.percent ?? 0, version: state.version }) } + } + return { label, detail: state.version === undefined + ? label + : t('desktop.update.versionDetail', { label, version: state.version }) } +} + +/** + * @param props - Connection priority, sidebar width, and localized bridge-failure copy. + * @returns Desktop-only status beside the account button, or nothing in browsers. + */ +export function DesktopUpdateIndicator({ wide, hidden, t, view, onOpen }: { + wide: boolean + hidden: boolean + t: SettingsTranslate + view: DesktopUpdateView + onOpen: () => void +}) { + const { presentation: state, failed, opening } = view + if (!wide || hidden || (!failed && (state === undefined || state.phase === 'idle'))) return null + const retryLabel = t('desktop.update.retry') + const copy = state === undefined ? { label: retryLabel, detail: retryLabel } : updateCopy(state, t) + const label = failed ? retryLabel : copy.label + const error = failed || state?.phase === 'error' + const busy = opening || (state !== undefined && BUSY_PHASES.has(state.phase)) + return + + +} + +type BadgeProps = PropsRuntime<'sidebar.toggle.badge'> & PropsLocale<'settings'> + & Pick, 'useDesktopUpdate' | 'useConnectionState'> + +/** + * @param props - Framework-bound carrier and connection state. + * @returns A non-interactive notification on the sidebar expand button. + */ +export function DesktopUpdateBadge({ useDesktopUpdate, useConnectionState, t }: BadgeProps) { + const { presentation: state, failed } = useDesktopUpdate(value => value) + const connection = useConnectionState(value => value) + if (((connection === 'disconnected' || connection === 'connecting') && state?.phase !== 'installing') + || (!failed && (state === undefined || state.phase === 'idle'))) return null + const retryLabel = t('desktop.update.retry') + const copy = state === undefined ? { label: retryLabel, detail: retryLabel } : updateCopy(state, t) + const label = failed ? retryLabel : copy.label + return + + +} diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 6608e0f9ef..39703e1b1c 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -1,4 +1,5 @@ .triggerRow { + position: relative; flex: none; display: flex; align-items: center; diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index 3411ac7162..347c03a961 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -20,6 +20,7 @@ import { import type { ConnectionIndicatorState } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts' import css from './SettingsRoot.module.css' +import { DesktopUpdateIndicator } from './DesktopUpdateIndicator.tsx' const RECOVERY_CONFIRMATION_MS = 2_000 @@ -113,6 +114,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP export function SettingsRoot(props: SettingsRootComponentProps) { const { wide, reconnect, useConnectionState, useSections, useOnboardingSteps, useSessions, renderSlot, t, + useDesktopUpdate, openDesktopUpdate, } = props const [open, setOpen] = useState(false) const [activeId, setActiveId] = useState(undefined) @@ -140,6 +142,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) { // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. const rows = useSections(s => s) + const desktopUpdate = useDesktopUpdate(state => state) const connectionState = useConnectionState(state => state) const previousConnectionState = useRef(connectionState) const onboardingSteps = useOnboardingSteps(s => s) @@ -225,7 +228,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) { {renderSlot('settings.trigger', { wide })} +
    {open && ( + open(): Promise + subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void +} + +/** Shared carrier status for the account row and collapsed sidebar badge. */ +export interface DesktopUpdateView { + readonly presentation?: DesktopUpdatePresentation + readonly failed: boolean + readonly opening: boolean +} diff --git a/packages/client/ui-settings-general/src/client/desktop-update-source.ts b/packages/client/ui-settings-general/src/client/desktop-update-source.ts new file mode 100644 index 0000000000..7b34bf0efb --- /dev/null +++ b/packages/client/ui-settings-general/src/client/desktop-update-source.ts @@ -0,0 +1,43 @@ +/** Client-owned observation of the optional Desktop preload. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { DesktopUpdateBridge, DesktopUpdateView } from './desktop-update-bridge.ts' + +/** Owns one preload subscription across both sidebar locations. */ +export class DesktopUpdateSource { + /** Framework-observed carrier status shared by both sidebar controls. */ + readonly store = createSnapshotStore({ failed: false, opening: false }) + private live = true + private received = false + private readonly unsubscribe: (() => void) | undefined + + /** @param bridge - Optional isolated Electron API, absent in ordinary browsers. */ + constructor(private readonly bridge: DesktopUpdateBridge | undefined) { + this.unsubscribe = bridge?.subscribe((presentation) => { + if (!this.live) return + this.received = true + this.store.set({ ...this.store.getSnapshot(), presentation, failed: false }) + }) + void bridge?.status().then((presentation) => { + if (this.live && !this.received) this.store.set({ ...this.store.getSnapshot(), presentation }) + }, () => { + if (this.live && !this.received) this.store.set({ ...this.store.getSnapshot(), failed: true }) + }) + } + + /** Invoke one user action; subsequent clicks join the shell-owned operation. */ + open(): void { + if (!this.live || this.bridge === undefined) return + const state = this.store.getSnapshot() + if (state.opening || (state.presentation !== undefined + && ['checking', 'downloading', 'verifying', 'installing'].includes(state.presentation.phase))) return + this.store.set({ ...state, opening: true }) + void this.bridge.open().catch(() => { + if (this.live) this.store.set({ ...this.store.getSnapshot(), failed: true }) + }).finally(() => { + if (this.live) this.store.set({ ...this.store.getSnapshot(), opening: false }) + }) + } + + /** Detach the carrier and ignore any pending status or action completion. */ + dispose(): void { this.live = false; this.unsubscribe?.() } +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index c8962ec009..3ead14df50 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -24,6 +24,9 @@ import type { SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, } from './shell-contract.ts' import { SettingsRoot } from './SettingsRoot.tsx' +import { DesktopUpdateBadge } from './DesktopUpdateIndicator.tsx' +import type { DesktopUpdateBridge } from './desktop-update-bridge.ts' +import { DesktopUpdateSource } from './desktop-update-source.ts' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' @@ -67,6 +70,13 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settin export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries') const connection = ctx.get('connection') as ConnectionHandle + const carrier = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; updates?: DesktopUpdateBridge } }).dshDesktop + const desktopUpdate = new DesktopUpdateSource(carrier?.protocolVersion === 1 ? carrier.updates : undefined) + ctx.effect(() => () => { desktopUpdate.dispose() }, 'ui-settings-general: desktop update carrier') + ctx.slots.inject('sidebar.toggle.badge', () => ctx.slots.register({ + name: 'sidebar.toggle.badge', locale: NS, + inject: () => ({ hooks: { desktopUpdate: desktopUpdate.store, connectionState: connection.state } }), + }, DesktopUpdateBadge)) // Copy freshness is framework-owned: components read the standard `t` // seat, and the nav label is a thunk the owner resolves per render — no @@ -94,8 +104,10 @@ export function apply(ctx: ClientContext): void { let onboardingVersion = -1 let onboardingSteps: readonly SettingsOnboardingStep[] = [] const shellInjected = (): SettingsRootInjected => ({ + openDesktopUpdate: () => { desktopUpdate.open() }, reconnect: () => { connection.reconnect() }, hooks: { + desktopUpdate: desktopUpdate.store, connectionState: connection.state, sections: { getSnapshot: () => { diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 8f28b1710f..cef0c4aba3 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -3,6 +3,24 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'trigger': '设置', + 'desktop.update.available': '新版本', + 'desktop.update.checking': '正在检查更新…', + 'desktop.update.progress': '{percent}%…', + 'desktop.update.verifying': '正在校验更新文件…', + 'desktop.update.installing': '正在准备重启…', + 'desktop.update.ready': '安装并重启', + 'desktop.update.retry': '重试更新', + 'desktop.update.versionDetail': '{label} — V{version}', + 'desktop.update.downloadDetail': '正在下载更新:{percent}%\n目标版本:V{version}', + 'desktop.update.checkFailed': '检查更新失败,请稍后重试。', + 'desktop.update.downloadFailed': '下载更新失败,请重试。', + 'desktop.update.installFailed': '安装更新失败,请稍后重试。', + 'desktop.update.checkNetworkFailed': '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。', + 'desktop.update.downloadNetworkFailed': '下载更新失败,请重试。网络连接异常,请检查网络后重试。', + 'desktop.update.installNetworkFailed': '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。', + 'desktop.update.stopFailed': '未能安全停止任务,更新未安装。请稍后重试。', + 'desktop.update.tasksChanged': '有新任务开始,请重新确认更新。', + 'desktop.update.tasksUnavailable': '无法确认任务状态,请在工作区就绪后重试更新。', 'title': '设置', 'close': '关闭', 'openDocument': '打开配置文件', @@ -21,6 +39,24 @@ export type SettingsKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { 'trigger': 'Settings', + 'desktop.update.available': 'Update', + 'desktop.update.checking': 'Checking for updates…', + 'desktop.update.progress': '{percent}%…', + 'desktop.update.verifying': 'Verifying update files…', + 'desktop.update.installing': 'Preparing to restart…', + 'desktop.update.ready': 'Install and Restart', + 'desktop.update.retry': 'Retry update', + 'desktop.update.versionDetail': '{label} — V{version}', + 'desktop.update.downloadDetail': 'Downloading update: {percent}%\nTarget version: V{version}', + 'desktop.update.checkFailed': 'Could not check for updates. Please try again later.', + 'desktop.update.downloadFailed': 'Could not download the update. Please try again.', + 'desktop.update.installFailed': 'Could not install the update. Please try again later.', + 'desktop.update.checkNetworkFailed': 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.', + 'desktop.update.downloadNetworkFailed': 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.', + 'desktop.update.installNetworkFailed': 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.', + 'desktop.update.stopFailed': 'Tasks could not be stopped safely. The update was not installed. Please try again later.', + 'desktop.update.tasksChanged': 'New tasks started. Review the update confirmation again.', + 'desktop.update.tasksUnavailable': 'Task status is unavailable. Try updating again when the workspace is ready.', 'title': 'Settings', 'close': 'Close', 'openDocument': 'Open configuration file', diff --git a/packages/client/ui-settings-general/src/client/shell-contract.ts b/packages/client/ui-settings-general/src/client/shell-contract.ts index 210f028711..bc8497361b 100644 --- a/packages/client/ui-settings-general/src/client/shell-contract.ts +++ b/packages/client/ui-settings-general/src/client/shell-contract.ts @@ -15,6 +15,7 @@ import type { import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' // Type-only: pulls the settings slot declarations the shell renders into. import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { DesktopUpdateView } from './desktop-update-bridge.ts' /** One nav row projected from a settings.section registration's options. */ export interface SettingsSectionRow { @@ -35,9 +36,13 @@ export interface SettingsOnboardingStep { * sources, while the reconnect command remains a plain callback. */ export type SettingsRootInjected = { + /** Request the current shell-owned update action. */ + openDesktopUpdate: () => void /** Request a fresh logical generation and physical WebSocket immediately. */ reconnect: () => void hooks: { + /** Shared Electron status for both sidebar locations. */ + desktopUpdate: HostObservable /** Connection-owned state for the current Host connection. */ connectionState: HostObservable /** settings.section ledger projected into ordered nav rows. */ diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index f7a012dade..5d38c9e41f 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -21,6 +21,8 @@ function derivedDocumentStore(remote: object) { return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx)) } import { en } from '../src/client/locales.ts' +import { DesktopUpdateBadge } from '../src/client/DesktopUpdateIndicator.tsx' +import type { DesktopUpdateView } from '../src/client/desktop-update-bridge.ts' afterEach(cleanup) @@ -38,6 +40,37 @@ const kit = { usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, } +describe('Desktop collapsed update badge', () => { + it('shows update status, marks failures, and yields to connection feedback', () => { + let state: DesktopUpdateView = { failed: false, opening: false } + let connection: 'connected' | 'connecting' | 'disconnected' = 'connected' + const props = { ...kit, t, + useDesktopUpdate: (select => select(state)) as Parameters[0]['useDesktopUpdate'], + useConnectionState: (select => select(connection)) as Parameters[0]['useConnectionState'], + } + const view = render() + expect(screen.queryByRole('img')).toBeNull() + state = { ...state, presentation: { phase: 'available', version: '1.0.1' } } + view.rerender() + expect(screen.getByRole('img', { name: 'Update' }).getAttribute('data-error')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + state = { ...state, failed: true } + view.rerender() + expect(screen.getByRole('img', { name: en['desktop.update.retry'] }).getAttribute('data-error')).toBe('true') + state = { failed: true, opening: false } + view.rerender() + expect(screen.getByRole('img', { name: en['desktop.update.retry'] }).getAttribute('data-error')).toBe('true') + state = { failed: false, opening: false, presentation: { phase: 'error', failure: 'install' } } + view.rerender() + expect(screen.getByRole('img', { name: en['desktop.update.retry'] }).getAttribute('data-error')).toBe('true') + for (const value of ['connecting', 'disconnected'] as const) { + connection = value + view.rerender() + expect(screen.queryByRole('img')).toBeNull() + } + }) +}) + describe('chrome content', () => { it('TriggerContent renders the icon with the label in the wide column', () => { const { container } = render() diff --git a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx new file mode 100644 index 0000000000..316204370d --- /dev/null +++ b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useSyncExternalStore } from 'react' +import { afterEach, expect, it, vi } from 'vitest' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { DesktopUpdateIndicator } from '../src/client/DesktopUpdateIndicator.tsx' +import type { DesktopUpdateBridge, DesktopUpdatePresentation } from '../src/client/desktop-update-bridge.ts' +import { DesktopUpdateSource } from '../src/client/desktop-update-source.ts' +import { en, zh } from '../src/client/locales.ts' + +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + +type SettingsTranslate = PropsLocale<'settings'>['t'] + +function translate(dictionary: typeof zh | typeof en): SettingsTranslate { + const messages: Readonly> = dictionary + return (key, params) => Object.entries(params ?? {}) + .reduce((message, [name, value]) => message.replaceAll(`{${name}}`, String(value)), + messages[key] ?? key) +} + +function fixture() { + let listener: ((state: DesktopUpdatePresentation) => void) | undefined + const status = Promise.withResolvers() + const unsubscribe = vi.fn() + const open = vi.fn(async () => {}) + const bridge: DesktopUpdateBridge = { + status: () => status.promise, + open, + subscribe: (next) => { listener = next; return unsubscribe }, + } + const source = new DesktopUpdateSource(bridge) + const subscribe = (notify: () => void) => source.store.subscribe(notify) + const snapshot = () => source.store.getSnapshot() + function Indicator({ wide = true, hidden = false, dictionary = zh }: { + wide?: boolean + hidden?: boolean + dictionary?: typeof zh | typeof en + }) { + const state = useSyncExternalStore(subscribe, snapshot) + return