diff --git a/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml index 54f04c4525..ecb789a14d 100644 --- a/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-cos-upload-transport.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md -2026-09-10-codeblock-preview-interaction.md: 678daa6544f92ab5216251658f9e9471eae4cf10 -2026-09-10-codeblock-preview-interaction.zh.md: afd3a3e2416765b3f71832ddd867c83bf90d8e81 +# 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/simplification/2026-09-14-source-sized-code-block-previews.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml similarity index 54% rename from .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml index c7a3eaf065..b25a4b72b4 100644 --- a/.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md -2026-09-14-source-sized-code-block-previews.md: 66ba419efcf4113886cfddafc4eb9f3ad2583565 -2026-09-14-source-sized-code-block-previews.zh.md: 7e8e96eced72f78f7aff852d993cf32bff22a7e4 +# 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/feature/2026-09-09-markdown-static-previews.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml index 95c617e174..7079d4ec79 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md -2026-09-09-markdown-static-previews.md: cb63b0ccd02abfaf148e61ef452cb714765bfffe -2026-09-09-markdown-static-previews.zh.md: 7dec68564136d2e9d677c1fd03d8a0b6840cf81b +# 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-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-09-09-markdown-static-previews.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md deleted file mode 100644 index cb63b0ccd0..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: Static Markdown fence previews - -Status: implemented - -English | [中文](2026-09-09-markdown-static-previews.zh.md) - -## Problem - -Readers need an optional way to inspect Mermaid and DOT diagrams and SVG artwork directly in Assistant replies while retaining code as the primary representation. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations. - -## Decision - -The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, and `svg` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. HTML, consumers without preview labels, and streaming messages retain code. - -`CodeBlock.preview` is a standard source-preview descriptor containing the renderer and complete localized output and control labels; callers do not pass React nodes. `MarkdownText` creates one descriptor catalog per preview-label identity, and every settled render reuses its language entries while source text, file mentions, or local-image vocabulary changes. The [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes this note's source-first default and release-on-toggle lifetime; it owns the current controls and result retention. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) owns current geometry. This note retains the independent rendering, security, and distribution decisions. - -`SourcePreview` owns pending work, failure, and cancellation of stale result publication. Pending previews show localized status. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview. - -Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Intersecting previews and open lightboxes observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT and SVG colors remain intact. - -SVG is parsed as XML and displayed with Graphviz output as inert images, so SVG scripts, links, and external resources never become active. A magnifier opens the body-portaled dialog whose viewport fit, pan, and zoom behavior is owned by the [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md). - -Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build attestation identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and its DOMPurify dependency's selected Apache-2.0 terms. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy. - -Source highlighting uses the shared lazy Shiki highlighter for both streamed and settled diagram fences. SVG resolves to XML; Mermaid selects the upstream grammar's `#mermaid` body rules because CodeBlock has already removed the Markdown fence. DOT uses the MIT TextMate grammar pinned in the [third-party notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt), with patterns and scopes preserved by mechanical conversion. This keeps source coloring independent of preview rendering and avoids maintaining a separate DOT tokenizer. - -## Alternatives considered - -**Show visualization by default with overlay icon actions.** The source-first decision favored authored code and avoided diagram work before an explicit request. The [interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes that default while retaining actions in the banner. - -**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source. - -**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md). - -**Pass rendered JSX through `CodeBlock.preview`.** Caller-created elements split preview ownership between the renderer and the primitive. A standard data descriptor keeps rendering, result ownership, and controls in one component API. - -**Preview HTML in a sandboxed frame.** HTML preview adds sanitization, iframe policy, a distinct document layout, and another browser dependency. Source display covers the code-review use case without that separate security and distribution surface. - -**Insert rendered markup into Chat.** SVG styles and executable behavior would share the application document. Image mode disables SVG behavior while retaining the rendered result. - -**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering. - -## Consequences - -The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. `SourcePreview` associates a result with both its source and renderer and publishes nothing until the matching result settles. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, HTML rendering, or interactive diagram links. - -Component tests cover source copying, preview rendering, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, inert SVG images, real image decoding, source toggling, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper version and native source downloads so upgrades require renewed review. diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md deleted file mode 100644 index 7dec685641..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: 静态 Markdown fence 预览 - -Status: implemented - -[English](2026-09-09-markdown-static-previews.md) | 中文 - -## Problem - -读者需要在保留代码为主要表示的同时,按需直接查看 Assistant 回复中的 Mermaid 与 DOT 图表和 SVG 图像。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。 - -## Decision - -共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` renderer。HTML、未提供预览文案的调用方与流式消息保留代码显示。 - -`CodeBlock.preview` 是标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案;调用方不传入 React 节点。`MarkdownText` 按预览文案对象的 identity 创建一份描述目录,之后正文、文件提及或本地图片词表变化引起的每次定稿渲染都复用其中的语言条目。[CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代本文的默认源码和切换时释放结果的选择,负责当前控件与结果保留。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责当前几何行为。本文保留独立的渲染、安全与分发决策。 - -`SourcePreview` 负责待完成工作、失败和取消过期结果发布。待完成的预览显示本地化状态。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。 - -Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。与视口相交的预览和已打开的 lightbox 观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。 - -SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示,因此 SVG 脚本、链接与外部资源不会激活。放大镜会打开 body portal 对话框;其视口适应、平移和缩放行为由 [CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)负责。 - -Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建证明标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本和其 DOMPurify 依赖选用的 Apache-2.0 条款。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。 - -源码高亮在流式与定稿图表 fence 中共用按需加载的 Shiki 高亮器。SVG 解析为 XML;Mermaid 选用上游语法的 `#mermaid` 正文规则,因为 CodeBlock 已去除 Markdown 围栏。DOT 使用[第三方声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)中固定的 MIT TextMate 语法,机械转换保留所有模式与 scope。这让源码着色独立于预览渲染,也避免维护单独的 DOT tokenizer。 - -## Alternatives considered - -**默认显示可视化并使用浮层图标操作。** 默认源码的决策优先展示作者代码,并避免显式请求前执行图表工作。[交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代这一默认选择,同时继续将操作放在标题栏中。 - -**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。 - -**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。 - -**通过 `CodeBlock.preview` 传入已渲染 JSX。** 调用方创建的元素会把预览所有权拆分到 renderer 与基础组件。标准数据描述让渲染、结果所有权与控件归入同一个组件 API。 - -**在 sandbox iframe 中预览 HTML。** HTML 预览需要清理策略、iframe 权限、独立文档布局和额外浏览器依赖。源码展示足以覆盖代码审阅场景,无需引入这组独立的安全与分发范围。 - -**把渲染标记插入 Chat。** SVG 样式和可执行行为会与应用共享文档。图片模式会禁用 SVG 行为,同时保留渲染结果。 - -**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。 - -## Consequences - -该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。`SourcePreview` 同时按源码与 renderer 关联结果,在匹配结果完成前不发布预览内容。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、HTML 渲染或交互式图表链接。 - -组件测试覆盖源码复制、预览渲染、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不可执行 SVG 图片、真实图片解码、源码切换、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层版本与原生源码下载地址,升级时须重新审查。 diff --git a/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md b/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md deleted file mode 100644 index 678daa6544..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md +++ /dev/null @@ -1,57 +0,0 @@ -# Agent Note: CodeBlock preview interaction and retained work - -Status: implemented - -English | [中文](2026-09-10-codeblock-preview-interaction.zh.md) - -## Problem - -Readers need to inspect diagrams directly and return to source without paying for the same diagram again. Toolbar state and unrelated grammar notifications can repeat source work even when its text is unchanged. - -## Decision - -Supported settled blocks initially show Preview. Supported streaming blocks show a 180px image placeholder with a localized loading status and a decorative glyph whose opacity pulses over 1.8 seconds; reduced motion disables the animation. Streaming mounts neither source highlighting nor diagram generation, including after a fence freezes in the incremental parser. Controls return when message streaming ends; the placeholder remains until the image loads. Stopping the stream also exits the waiting state; invalid or unloadable images show an error with Source available. This supersedes the source-first and release-on-toggle choices in the [static preview decision](2026-09-09-markdown-static-previews.md), whose renderer isolation, cancellation, security, and distribution rules remain active. - -The language stays on the left. The right side contains magnifier, copy, and Source/Preview controls in that order. Icons follow the Sidebar's 28px circular control and 15px glyph treatment. The segmented control moves only its selected background for 160ms; content switches immediately, and reduced-motion settings suppress that transition. Unsupported blocks have a static selected Source label. Copy always reads source, and its fixed-size icon and localized tooltip show success. Preview provides a magnifier that opens `PreviewLightbox`, disabled with an accessible localized explanation when no usable image is available. Source replaces it with a line-number toggle whose state survives view changes. Preview toolbar opacity transitions over 160ms without a timer or layout change; block hover and keyboard focus reveal it, and touch devices keep it visible. Source toolbar opacity stays at one. - -The stable `data-code-block-content` wrapper and `contentRef` let sidebar owners retain their scrollport and restore scroll position. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) supersedes this note's active-body height and inline-resize choices; it owns current Source/Preview geometry and overflow. - -Each mounted `CodeBlock` owns its preview result and pending renderer call. A shared IntersectionObserver defers diagram work and runtime loading until intersection, and cancels unfinished work on exit unless the lightbox is open. Browsers without this API render immediately. Switching views retains completed results; source, renderer, or resolved-theme changes invalidate the work, and unmounting cancels publication. Reentry reuses results with matching source, renderer and palette. Theme refreshes retain the loaded image, its geometry and the open lightbox until a replacement loads. A failed refresh preserves the usable image and displays the localized error. Retention is bounded to one displayed image and one replacement per block. - -The owner retains failures too, so toggling cannot repeatedly submit invalid source. Previewable blocks mount source on first selection, then retain that DOM with its highlighting state. `CodeBlock`, its source child, and its copy control are independently memoized. Grammar readiness snapshots report only the source language, so loading another grammar does not schedule source work. This is local retention, with no cache shared across blocks or Sessions. - -The lightbox fits the image within 88% of viewport width and 84% of viewport height, independently of inline size. Pointer capture owns dragging; wheel zoom preserves the image point beneath the cursor and stays between 0.25 and 8 times the fitted size. Double-click, Home, source-image changes, and viewport resizing restore the fit. Arrow keys pan and +/− zoom. Gestures write only the image transform and retain its URL; they do not schedule React state or diagram generation. Closing releases capture and listeners and restores focus to the opener. - -The source renderer consumes each highlight frame once. `StreamingHighlightSession.updateFrame` returns the same delta frame for repeated code and language, while refs retain completed-line groups across React render retries. The line cache therefore retains the frame identity and returns its existing body for the same frame, code, and language; memoization alone cannot prevent duplicate line insertion. StrictMode coverage checks multiple streaming chunks and settlement, and the Markdown DOM fixtures run under StrictMode without changing their expected output. - -## Alternatives considered - -**Unmount inactive content.** Releasing images on every Source visit repeats diagram generation and removes the source-view magnifier's result. Retaining one result per mounted block gives the result the same lifetime as its controls. - -**Share a global preview cache.** Cross-block reuse adds eviction, renderer identity, and theme-key ownership while retaining conversation content beyond individual mounts. The measured repeated work occurs within one block, so its mounted lifetime is sufficient. - -**Mount and highlight source before selection.** The retained image determines block dimensions, so hidden source adds work without improving the preview. - -## Consequences - -Previewable blocks mount and highlight source on first selection; preview-only readers do not mount source. Ordinary source-only blocks still defer highlighting until they intersect; streaming diagram placeholders do not mount source. - -Offscreen previews submit no diagram work until intersection; offscreen theme changes wait for reentry. Returning to Source keeps the image and renderer owner alive, and blocks whose Source view has been opened retain source token DOM. Memory therefore follows mounted blocks, with no claim of reduced heap use. Mermaid's queue and synchronous Graphviz layout still run on the browser thread, and cancellation cannot preempt active layout. - -The [viewport tests](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx) pin deferred rendering, viewport cancellation and reuse; the 20-block regression calls every renderer against the unoptimized implementation. The [component tests](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx) cover retained results, source identity, pending and failed controls, copy, streaming transitions, invalidation, and stale publication. The [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises equal Source/Preview geometry, internal overflow, toolbar fading, line-number controls, lightbox interaction, and localized UI snapshots. Highlighting retains the existing streamed/settled parity tests. The streaming browser scenario waits for the turn to finish persisting before closing Session handles, including after failed assertions. - - -## Performance evidence - -The [manual browser diagnostic](../../../../apps/web/tests/diagram-preview.perf.ts), run with `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts`, uses 20 Mermaid flowcharts with 10 nodes and 12 edges each, followed by 40 paragraphs. Three fresh Chromium instances use a 1680×1000 viewport, the shipped Web composition and built Client on macOS arm64, Apple M5 Pro and Node 24.20.0. Opening starts at a real Session click and ends when the tail is visible plus two animation-frame opportunities; theme timing starts immediately before system-theme emulation and ends after a visible replacement plus two frames. These endpoints do not measure hardware presentation or model latency. - -| Metric | Eager previews, samples → median | Viewport previews, samples → median | -| --- | --- | --- | -| Open endpoint (ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 | -| Long tasks in first 3 seconds (ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 | -| Additional initial JS transfer (bytes) | 239449 in every sample | 0 in every sample | -| Initially decoded offscreen diagrams | 20 in every sample | 0 in every sample | -| Visible theme replacement (ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 | -| Theme placeholder frames / height range | 2 / 180–632px in every sample | 0 / 632–632px in every sample | - -Theme preparation scrolls to the loaded target after cold diagrams settle; otherwise an earlier diagram's growth can move the target outside the viewport. Both compared theme phases contain two intersecting previews. First activation and its geometry changes are excluded from timing. Unrelated host activity and filesystem caches are uncontrolled; timings are descriptive, with no CI timing threshold or memory claim. The owning component regression fails on eager rendering, and the browser case verifies deferred Mermaid and Graphviz requests plus a keyless offscreen-state snapshot. diff --git a/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md b/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md deleted file mode 100644 index afd3a3e241..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md +++ /dev/null @@ -1,57 +0,0 @@ -# Agent Note: CodeBlock 预览交互与工作保留 - -Status: implemented - -[English](2026-09-10-codeblock-preview-interaction.md) | 中文 - -## Problem - -读者需要直接查看图表,并返回源码而不必再次生成同一张图。即使文本未变,工具栏状态和无关语法通知也可能重复执行源码工作。 - -## Decision - -支持预览的定稿代码块初始显示预览。受支持的流式代码块显示高 180px 的图片占位、本地化加载状态和以 1.8 秒周期改变透明度的装饰图标;减少动态效果设置禁用动画。流式期间既不挂载源码高亮,也不生成图表,包括 fence 被增量解析器冻结之后。消息流式结束后恢复控件;占位保留至图片加载完成。停止输出也会退出等待状态;无效或无法加载的图片显示错误,并保留源码入口。这取代[静态预览决策](2026-09-09-markdown-static-previews.zh.md)中的默认源码和切换时释放结果的选择;其中的渲染器隔离、取消、安全与分发规则继续适用。 - -语言名位于左侧。右侧依次放置放大镜、复制和源码/预览控件。图标沿用侧边栏的 28px 圆形控件与 15px 图形样式。分段控件只让选中背景移动 160ms;正文立即切换,减少动态效果设置会禁用该过渡。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,通过固定尺寸图标与本地化 tooltip 表示成功。预览视图提供放大镜,打开 `PreviewLightbox`;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,开关状态跨视图切换保留。预览工具栏的透明度在 160ms 内过渡,不使用计时器或改变布局;鼠标移入代码块和键盘聚焦时显示,触摸设备保持可见。源码工具栏透明度始终为一。 - -稳定的 `data-code-block-content` 容器与 `contentRef` 让侧栏所有者保留滚动容器并恢复滚动位置。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)取代本文中由当前正文定高与行内调整尺寸的选择;它负责当前的源码/预览几何与溢出行为。 - -每个已挂载的 `CodeBlock` 拥有预览结果及待完成的 renderer 调用。共享 IntersectionObserver 把图表生成与运行时加载推迟到与视口相交之后;离开视口会取消未完成的工作,已打开 lightbox 时除外。不支持该 API 的浏览器立即渲染。切换视图保留已完成结果;源码、renderer 或解析后的主题变化会使工作失效,卸载则取消发布。重新进入时复用源码、renderer 与配色相同的结果。主题刷新保留已加载图片、其几何尺寸和已打开的 lightbox,直到替换图片加载完成。刷新失败时保留可用图片并显示本地化错误。每个代码块最多保留一张已显示图片和一张替换图片。 - -所有者也保留失败结果,因此切换不会反复提交无效源码。支持预览的代码块在首次选中源码时挂载,随后连同高亮状态一起保留该 DOM。`CodeBlock`、源码子组件和复制控件分别 memo 化。语法就绪快照只报告源码语言,因此加载其他语法不会调度源码工作。结果仅在本地保留,不在代码块或 Session 间共享缓存。 - -大图以屏幕宽度的 88% 和高度的 84% 为上限等比适应,不依赖行内尺寸。指针捕获管理拖拽;滚轮缩放保持鼠标下的图片位置不动,倍率限制在适应尺寸的 0.25 至 8 倍。双击、Home、图片源变化和视口尺寸变化会恢复适应尺寸。方向键平移,+/− 缩放。手势只写入图片 transform 并保留 URL,不调度 React state 或重新生成图表。关闭时释放指针捕获和监听器,并将焦点还给打开按钮。 - -源码渲染器只消费每个高亮帧一次。对于重复的代码与语言,`StreamingHighlightSession.updateFrame` 返回同一个增量帧,而 ref 会跨 React 渲染重试保留已完成行的分组。因此,行缓存保留帧 identity,并在帧、代码与语言相同时返回已有正文;仅靠 memo 化无法防止重复插入行。StrictMode 测试覆盖多个流式分片及定稿,Markdown DOM fixture 也在 StrictMode 下运行,预期输出保持不变。 - -## Alternatives considered - -**卸载未激活内容。** 每次查看源码都释放图片会重复生成图表,也会移除源码视图放大镜所需的结果。按已挂载代码块保留一个结果,让结果与其控件具有相同生命周期。 - -**共享全局预览缓存。** 跨代码块复用需要处理淘汰、renderer identity 和主题键归属,还会在单个组件卸载后保留对话内容。测得的重复工作发生在同一代码块内部,因此其挂载生命周期足以满足需求。 - -**在选中前就挂载并高亮源码。** 保留的图片决定代码块尺寸,隐藏源码只会增加工作,无法改善预览。 - -## Consequences - -支持预览的代码块在首次选中源码时挂载并高亮;只阅读预览时不挂载源码。普通纯源码代码块仍推迟到进入视口后高亮;流式图表占位不挂载源码。 - -视口外的预览直到与视口相交后才提交图表工作;视口外的主题变化等待重新进入视口后处理。返回源码后,图片与 renderer 所有者保持存活,已经打开过源码视图的代码块保留源码 token DOM。因此内存占用随已挂载代码块增长,不声称减少堆内存。Mermaid 队列和同步 Graphviz 布局仍在浏览器线程执行,取消无法抢占已开始的布局。 - -[视口测试](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx)固定延迟渲染、视口取消和复用行为;20 个代码块的回归用例在未优化实现上会调用全部 renderer。[组件测试](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx)覆盖结果保留、源码 identity、待完成与失败控件、复制、流式转换、失效和过期发布。[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证源码/预览几何一致、内部溢出、工具栏淡出、行号控件、lightbox 交互和本地化 UI 快照。高亮保留已有的流式/定稿一致性测试。流式浏览器场景在关闭 Session 句柄前等待回合完成持久化,包括断言失败的情况。 - - -## 性能证据 - -[手动浏览器诊断](../../../../apps/web/tests/diagram-preview.perf.ts)通过 `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts` 运行,使用 20 个 Mermaid 流程图,每图 10 个节点和 12 条边,后接 40 段文本。三个全新 Chromium 实例使用 1680×1000 视口、正式 Web 组合和已构建 Client,运行于 macOS arm64、Apple M5 Pro 与 Node 24.20.0。打开阶段从真实 Session 点击开始,到尾部可见再加两次动画帧机会结束;主题阶段从模拟系统主题前开始,到替换图片可见再加两帧结束。这些终点不测量硬件呈现或模型延迟。 - -| 指标 | 挂载即渲染,样本 → 中位数 | 视口渲染,样本 → 中位数 | -| --- | --- | --- | -| 打开终点(ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 | -| 前 3 秒长任务(ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 | -| 初始新增 JS 传输(bytes) | 每个样本均为 239449 | 每个样本均为 0 | -| 初始已解码的视口外图表 | 每个样本均为 20 | 每个样本均为 0 | -| 可见主题替换(ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 | -| 主题占位帧/高度范围 | 每个样本均为 2 / 180–632px | 每个样本均为 0 / 632–632px | - -主题阶段的准备会在冷图表布局稳定后滚到已加载的目标;否则前方图表增高可能把目标移出视口。对比的两个主题阶段都包含两个与视口相交的预览。首次激活及其几何变化不计时。其他主机活动与文件系统缓存不受控制;计时仅作描述,不设 CI 时间阈值,也不声明内存收益。对应组件回归在挂载即渲染实现上失败,浏览器用例验证 Mermaid 与 Graphviz 请求的延迟加载及无需 API key 的屏外状态快照。 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/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-14-source-sized-code-block-previews.md b/.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md deleted file mode 100644 index 66ba419efc..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Diagram-sized code block previews - -Status: implemented - -English | [中文](2026-09-14-source-sized-code-block-previews.zh.md) - -## Problem - -Source line count does not predict diagram proportions. Sizing a preview from hidden highlighted source can clip a tall diagram, waste space around a short one, and parse a large source tree before the reader requests it. Independent Source and Preview heights move the surrounding transcript on every toggle. - -## Decision - -The browser sizes the retained preview image from its intrinsic aspect ratio and available width. CSS limits image height to the smaller of 60vh and 640px; the canvas adds 16px padding and a 120px minimum height. The image remains in document flow while hidden, so both views keep the same geometry through toggles and viewport resizing without JavaScript measurements or resize state. - -Source mounts on first selection, overlays the preview area, and scrolls internally. Subsequent toggles retain the source DOM and generated image. Preview remains the default. Supported streaming fences keep the 180px placeholder and mount neither source nor the diagram renderer until settlement. - -`CodeBlock` exposes no inline resize controls. Enlarging the image opens the existing lightbox with pan, zoom, and viewport fit. - -## Alternatives considered - -**Use hidden source as the size owner.** Source length and wrapping do not describe the diagram, and eagerly highlighting it adds work to the default preview path. - -**Give each view its own natural height or allow drag resizing.** Independent heights move transcript content; manual resizing adds state and controls where the browser can derive the size from the image. - -## Consequences - -Preview-only readers do not mount or highlight source. Source-only blocks retain viewport-triggered highlighting. Very tall diagrams scale down in the transcript; the lightbox provides detailed reading. Source DOM remains allocated after the reader first opens it, preserving highlight and scroll state across toggles. - -Browser regressions cover wide and narrow viewports, short and tall images, stable toggle geometry, retained DOM, and hidden-source accessibility. Component tests verify that preview settlement performs no source highlighting before Source is selected. diff --git a/.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md b/.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md deleted file mode 100644 index 7e8e96eced..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 按图表定高的代码块预览 - -Status: implemented - -[English](2026-09-14-source-sized-code-block-previews.md) | 中文 - -## Problem - -源码行数无法预测图表比例。根据隐藏的高亮源码确定预览尺寸,会裁切高图、在短图周围浪费空间,并在读者需要之前解析大量源码节点。源码与预览分别使用独立高度时,每次切换都会移动后续对话内容。 - -## Decision - -浏览器根据保留的预览图片的原始宽高比与可用宽度计算尺寸。CSS 将图片高度限制为 60vh 与 640px 中的较小值;画布增加 16px 内边距与 120px 最小高度。图片隐藏时仍保留在文档流中,因此切换视图与调整视口时,两种视图保持相同几何,无需 JavaScript 测量或尺寸状态。 - -源码在首次选中时挂载,覆盖预览区域并在内部滚动。后续切换保留源码 DOM 与已生成图片。默认仍为预览。受支持的流式 fence 保留 180px 占位,在定稿前既不挂载源码,也不挂载图表 renderer。 - -`CodeBlock` 不提供行内尺寸调整控件。放大图片使用现有 lightbox,支持平移、缩放与适应视口。 - -## Alternatives considered - -**用隐藏源码确定尺寸。** 源码长度与换行无法描述图表,提前高亮还会给默认预览路径增加工作。 - -**让两种视图分别使用自然高度或允许拖拽调整。** 独立高度会移动对话内容;浏览器可以根据图片推导尺寸,手动调整却增加了状态与控件。 - -## Consequences - -只阅读预览时不挂载或高亮源码。仅源码代码块保留视口触发的高亮。特别高的图表在对话中等比缩小,读者可通过 lightbox 查看细节。首次打开后,源码 DOM 持续保留,让高亮与滚动状态跨切换保持不变。 - -浏览器回归覆盖宽窄视口、长短图片、稳定的切换几何、DOM 保留及隐藏源码的可访问性。组件测试验证预览定稿不会在选中源码前执行源码高亮。 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/.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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c4caabf4ef..7eb0c36e18 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -61,7 +61,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT | -| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT | | [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT | @@ -90,7 +89,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | -| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | | [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | @@ -125,8 +123,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | -The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside `@viz-js/viz` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and its DOMPurify dependency's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as `preview-third-party-notices.txt`. - pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) @@ -164,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 | @@ -200,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 | @@ -216,6 +212,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | +| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | 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 e92cc34326..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: 227fcb57aa2093aaded0cdf6c9375e32bc4328fb -README.zh.md: 88b5a07e1f7a8e3379cf725f37d2a01528196ba3 +README.md: 14bbdb7ec0fddd47821c12ffe46549dd29abe0de +README.zh.md: 1e704900743ec6f339efdf849e60767c961d07ee diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 227fcb57aa..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. @@ -44,7 +46,7 @@ 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, fatal startup reporting, and native directory selection. Plugin management uses the Web application’s authenticated HTTP APIs; Electron exposes no plugin-management IPC or separate management document. +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. @@ -66,13 +68,12 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version 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. -Electron serializes profile preparation and native recovery through `$DSH_HOME/profiles/desktop/lock`. Web plugin operations use the shared manager’s package lock and lifecycle. ## 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 88b5a07e1f..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 的全局安装目录。 @@ -44,7 +46,7 @@ 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 或独立管理页面。 +应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 @@ -66,13 +68,13 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 [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 使壳的诊断缓冲区无限增长。 恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 -Electron 通过 `$DSH_HOME/profiles/desktop/lock` 串行执行 profile 准备和原生恢复。Web 插件操作使用共享管理器的包锁和生命周期。 +包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。 ## 开发 @@ -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 369bd401c1..4b2a131627 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,153 +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/main.js', - 'lib/preload-app.cjs', - '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/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 647d452d13..e78db93fee 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -7,11 +7,59 @@ export const DESKTOP_IPC = { boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', directoryPick: 'dsh-desktop:directory-pick', + 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' | '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 +} + +/** 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 + readonly updates: { + status(): Promise + open(): Promise + subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void + } +} + /** 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 bd1a334e76..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', @@ -22,15 +23,69 @@ export const en = { disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart', checkUpdatesMenu: 'Check for Updates…', updateCheckFailedTitle: 'Update Check Failed', + updateCheckFailed: 'Could not check for updates. Please try again later.', + updateDownloadFailed: 'Could not download the update. Please try again.', + updateInstallFailed: 'Could not install the update. Please try again later.', + updateCheckNetworkFailed: 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.', + updateDownloadNetworkFailed: 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.', + updateInstallNetworkFailed: 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.', unknownError: 'Unknown error', updateCheckTitle: 'Check for Updates', - updateCurrent: 'You already have the latest version.', + updateCurrent: 'No updates available. Current version: V{version}', + updateChecking: 'Checking for updates…', + updateDownload: 'Download update', + updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded', + updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.', + updateClose: 'Close', + updateAcknowledge: 'OK', + updateLater: 'Update later', + updateDownloading: 'Downloading {percent}%…', + updateVerifying: 'Verifying update files…', + updateInstalling: 'Preparing to restart…', + updateRetry: 'Retry update', + updateActiveTasks: 'Tasks are still in progress', + updateActiveTasksDetail: 'Restarting to update may interrupt these tasks. Continue updating?', + updateStopTasks: 'Stop tasks and update', + updateTasksChanged: 'New tasks started. Review the update confirmation again.', + updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.', + updateStopFailed: 'Tasks could not be stopped safely. The update was not installed. Please try again later.', + updateTechnicalDetails: 'View technical details', updateTitle: 'DeepSeek Harness Update', updateAvailable: 'An update is available', updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.', installAndRestart: 'Install and Restart', later: 'Later', updateFailedTitle: 'Update Failed', + 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. */ @@ -38,6 +93,7 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', + aboutMenu: '关于 DeepSeek Harness', edit: '编辑', menuBar: '应用菜单', delete: '删除', @@ -58,22 +114,82 @@ export const zh = { disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启', checkUpdatesMenu: '检查更新…', updateCheckFailedTitle: '更新检查失败', + updateCheckFailed: '检查更新失败,请稍后重试。', + updateDownloadFailed: '下载更新失败,请重试。', + updateInstallFailed: '安装更新失败,请稍后重试。', + updateCheckNetworkFailed: '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。', + updateDownloadNetworkFailed: '下载更新失败,请重试。网络连接异常,请检查网络后重试。', + updateInstallNetworkFailed: '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。', unknownError: '未知错误', updateCheckTitle: '检查更新', - updateCurrent: '当前已是最新版本。', + updateCurrent: '当前暂无可用更新。当前版本:V{version}', + updateChecking: '正在检查更新…', + updateDownload: '下载更新', + updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成', + updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。', + updateClose: '关闭', + updateAcknowledge: '确定', + updateLater: '稍后更新', + updateDownloading: '正在下载 {percent}%…', + updateVerifying: '正在校验更新文件…', + updateInstalling: '正在准备重启…', + updateRetry: '重试更新', + updateActiveTasks: '仍有进行中的任务', + updateActiveTasksDetail: '重启更新可能中断这些任务,是否要继续更新?', + updateStopTasks: '停止任务并更新', + updateTasksChanged: '有新任务开始,请重新确认更新。', + updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。', + updateStopFailed: '未能安全停止任务,更新未安装。请稍后重试。', + updateTechnicalDetails: '查看技术详情', updateTitle: 'DeepSeek Harness 更新', updateAvailable: '发现可用更新', updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。', installAndRestart: '安装并重启', later: '稍后', updateFailedTitle: '更新失败', + 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. */ +export interface DesktopLocale { + readonly id: 'en' | 'zh-CN' + readonly messages: DesktopMessages +} + /** Resolve Electron's locale to one shipped Desktop dictionary. */ -export function resolveDesktopLocale(locale: string): DesktopMessages { +export function resolveDesktopLocale(locale: string): DesktopLocale { return locale.toLowerCase().startsWith('zh') - ? zh - : en + ? { id: 'zh-CN', messages: zh } + : { id: 'en', messages: en } } /** Replace named placeholders in one locale-owned message. */ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 88f2219390..d5bfb1e0b3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ -import { writeFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -10,23 +10,35 @@ import { dialog, ipcMain, Menu, + powerMonitor, nativeTheme, protocol, session, shell, + type IpcMainInvokeEvent, type MenuItemConstructorOptions, } from 'electron' import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager } from './project-manager.ts' -import { DesktopHostProcess } from './host-process.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 } from './ipc.ts' +import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' 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 => {} @@ -37,7 +49,7 @@ function currentDesktopLocale(): ReturnType { return resolveDesktopLocale(windowsLanguage ?? app.getLocale()) } const recovery = new DesktopFatalRecovery({ - messages: () => currentDesktopLocale(), + messages: () => currentDesktopLocale().messages, show: options => dialog.showMessageBox(options), stop: () => { shuttingDown = true; return stopForRecovery() }, disablePlugins: async () => { @@ -96,14 +108,14 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } -function createWindow(): BrowserWindow { +function createWindow(preload: string, show = false, primary = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, height: 840, minWidth: 880, minHeight: 600, - show: true, - ...(process.platform === 'win32' ? { + show, + ...(process.platform === 'win32' && primary ? { titleBarStyle: 'hidden' as const, titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb', symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' }, @@ -120,7 +132,7 @@ function createWindow(): BrowserWindow { backgroundColor: '#00000000', } : {}), webPreferences: { - preload: fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), + preload, nodeIntegration: false, contextIsolation: true, sandbox: true, @@ -149,7 +161,7 @@ function createWindow(): BrowserWindow { } // Empty accelerators suppress Electron's default shortcut labels for native roles. if (items.length > 0) { - const messages = currentDesktopLocale() + const messages = currentDesktopLocale().messages Menu.buildFromTemplate(items.map(item => ({ ...item, ...(process.platform === 'win32' && item.role !== undefined && item.role in messages @@ -171,6 +183,8 @@ function createWindow(): BrowserWindow { } 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 @@ -178,12 +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 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 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 @@ -214,12 +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.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 + } + stopForRecovery = () => backend.close() const reconcileBackend = (): Promise => { @@ -228,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 }) @@ -237,12 +339,62 @@ 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') { @@ -294,48 +446,144 @@ async function main(): Promise { if (mainWindow === undefined || event.sender !== mainWindow.webContents) return if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source }) - const checkAndPrompt = async (manual: boolean): Promise => { - const state = await updates.check() - if (state.phase === 'error') { - if (manual) { - await dialog.showMessageBox({ - type: 'error', - title: currentDesktopLocale().updateCheckFailedTitle, - message: state.message ?? currentDesktopLocale().unknownError, - }) + ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { + assertProductSender(event) + return presentDesktopUpdate(updates.state) + }) + ipcMain.handle(DESKTOP_IPC.updatesOpen, async (event) => { + assertProductSender(event) + await openUpdatePrompt() + }) + + 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().updateCheckTitle, - message: state.message ?? currentDesktopLocale().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().updateTitle, - message: currentDesktopLocale().updateAvailable, - detail: formatDesktopMessage(currentDesktopLocale().updateDetail, { version: state.version ?? '' }), - buttons: [currentDesktopLocale().installAndRestart, currentDesktopLocale().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().updateFailedTitle, - message: installed.message ?? currentDesktopLocale().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 } + 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 + } + 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' @@ -346,13 +594,15 @@ async function main(): Promise { ? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] : [] const applicationItems = (): MenuItemConstructorOptions[] => [ - { label: currentDesktopLocale().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().exitApplication } : {}) }, + { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, ] Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ - label: process.platform === 'darwin' ? app.name : currentDesktopLocale().application, + label: darwin ? app.name : currentDesktopLocale().messages.application, submenu: applicationItems(), }, ...platformMenus])) @@ -376,15 +626,15 @@ async function main(): Promise { }, }) const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [ - editItem(currentDesktopLocale().undo, 'Z', ['control'], 'Ctrl+Z'), - editItem(currentDesktopLocale().redo, 'Y', ['control'], 'Ctrl+Y'), + editItem(currentDesktopLocale().messages.undo, 'Z', ['control'], 'Ctrl+Z'), + editItem(currentDesktopLocale().messages.redo, 'Y', ['control'], 'Ctrl+Y'), { type: 'separator' }, - editItem(currentDesktopLocale().cut, 'X', ['control'], 'Ctrl+X'), - editItem(currentDesktopLocale().copy, 'C', ['control'], 'Ctrl+C'), - editItem(currentDesktopLocale().paste, 'V', ['control'], 'Ctrl+V'), - editItem(currentDesktopLocale().delete, 'Delete', []), + editItem(currentDesktopLocale().messages.cut, 'X', ['control'], 'Ctrl+X'), + editItem(currentDesktopLocale().messages.copy, 'C', ['control'], 'Ctrl+C'), + editItem(currentDesktopLocale().messages.paste, 'V', ['control'], 'Ctrl+V'), + editItem(currentDesktopLocale().messages.delete, 'Delete', []), { type: 'separator' }, - editItem(currentDesktopLocale().selectAll, 'A', ['control'], 'Ctrl+A'), + editItem(currentDesktopLocale().messages.selectAll, 'A', ['control'], 'Ctrl+A'), ] const zoom = mainWindow.webContents.getZoomFactor() return new Promise((resolve) => { @@ -406,8 +656,9 @@ async function main(): Promise { } const createMainWindow = (): BrowserWindow => { - const window = createWindow() + const window = createWindow(appPreload, true, true) mainWindow = window + window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => { if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) { @@ -426,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 } @@ -445,22 +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' }) } - setTimeout(() => { void checkAndPrompt(false) }, 10_000) + publishUpdate(updateState) } 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-menu.ts b/apps/desktop/src/preload-menu.ts index 7da95b761b..aa2f54a423 100644 --- a/apps/desktop/src/preload-menu.ts +++ b/apps/desktop/src/preload-menu.ts @@ -95,7 +95,7 @@ export function installWindowsMenu(): { update(): void; dispose(): void } { observer.observe(document.body, { childList: true, subtree: true }) mount() const update = (): void => { - const messages = resolveDesktopLocale(document.documentElement.lang) + const { messages } = resolveDesktopLocale(document.documentElement.lang) bar.setAttribute('aria-label', messages.menuBar) buttons[0].textContent = messages.application buttons[1].textContent = messages.edit 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/project-manager.ts b/apps/desktop/src/project-manager.ts index c9ee14abda..af7804e1a5 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -6,6 +6,7 @@ import { lstatSync, mkdirSync, openSync, + realpathSync, closeSync, readFileSync, unlinkSync, @@ -94,16 +95,17 @@ export class DesktopProjectManager { 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: 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 { @@ -114,8 +116,8 @@ export class DesktopProjectManager { } } if (active) throw new Error('desktop project: another profile operation is active') - unlinkSync(this.paths.lock) - descriptor = openSync(this.paths.lock, 'wx', 0o600) + unlinkSync(lockPath) + descriptor = openSync(lockPath, 'wx', 0o600) } else { throw error } @@ -126,7 +128,7 @@ export class DesktopProjectManager { return await operation() } finally { 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 1a6af1b68f..c7bf91c384 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -1,96 +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 -/** Result of checking or installing a Desktop release. */ -export interface DesktopUpdateState { - readonly phase: 'idle' | 'available' | 'ready' | 'error' - readonly version?: string - readonly message?: string -} - -/** 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 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 beforeRestart: () => Promise = async () => {}, + private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState, + 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 } + /** 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 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) + } + 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 { - if (!this.enabled()) { - this.availableVersion = undefined - return { phase: 'idle' } - } + this.assertLive() + if (!this.enabled()) throw new Error('desktop update: this application has no packaged update source') const result = await this.updater.checkForUpdates() - const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined - this.availableVersion = version - return version === undefined - ? { phase: 'idle' } - : { phase: 'available', version } + 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) { - this.availableVersion = undefined - return { - phase: 'error', - message: error instanceof Error ? error.message : String(error), - } - } - } - - private async doInstall(): Promise { - const version = this.availableVersion - if (version === undefined) { - throw new Error('desktop update: no verified update is available') - } - try { - await this.updater.downloadUpdate() - this.availableVersion = undefined - await this.beforeRestart() - this.updater.quitAndInstall(false, true) - return { phase: 'ready', version } - } catch (error) { - return { - 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/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/fatal-recovery.spec.ts b/apps/desktop/tests/fatal-recovery.spec.ts index cfb2235a02..05fb893c0d 100644 --- a/apps/desktop/tests/fatal-recovery.spec.ts +++ b/apps/desktop/tests/fatal-recovery.spec.ts @@ -7,7 +7,7 @@ function fixture(locale = 'en') { const choice = Promise.withResolvers<{ response: number; checkboxChecked: boolean }>() const stopped = Promise.withResolvers() const operations = { - messages: () => resolveDesktopLocale(locale), + messages: () => resolveDesktopLocale(locale).messages, show: vi.fn((_options: MessageBoxOptions) => choice.promise), stop: vi.fn(() => stopped.promise), disablePlugins: vi.fn(async () => {}), 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 fb9de826fe..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,9 +36,9 @@ describe('installer preparation preserves application dependencies', () => { try { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(env, platform, 'x64') - expect(config.files.filter(file => typeof file === 'string')).toEqual([ - 'lib/main.js', 'lib/preload-app.cjs', 'package.json', - ]) + 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 1bf6cf0bc8..99dde404aa 100644 --- a/apps/desktop/tests/locale.spec.ts +++ b/apps/desktop/tests/locale.spec.ts @@ -4,9 +4,9 @@ import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/local 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(zh) - expect(resolveDesktopLocale('en-US')).toEqual(en) - expect(resolveDesktopLocale('fr-FR')).toEqual(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', () => { 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 6f7dee506a..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,32 +116,34 @@ 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, - protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Promise) => void>(), - 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 }), disableAllPlugins: vi.fn(async () => { @@ -114,20 +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, @@ -136,12 +199,27 @@ 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: harness.protocolHandle }, + 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 { @@ -150,27 +228,70 @@ vi.mock('../src/project-manager.ts', () => ({ }, })) -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: 'dsh-app://app/' } }, ...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 () => { @@ -186,19 +307,185 @@ afterEach(async () => { }) describe('desktop main startup', () => { - it.each(['darwin', 'win32', 'linux'] as const)('exposes application IPC and rejects the removed shell document on %s', async (platform) => { - vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) - await import('../src/main.ts') - await harness.preparing.promise - expect([...harness.handlers.keys()].sort()).toEqual([ - DESKTOP_IPC.boot, DESKTOP_IPC.bootFailed, DESKTOP_IPC.directoryPick, - ...(platform === 'win32' ? [DESKTOP_IPC.windowsMenu] : []), - ].sort()) - const handler = harness.protocolHandle.mock.calls[0]![1] - expect((await handler(new Request('dsh-app://shell/plugin-manager.html'))).status).toBe(404) + it.each([ + ['darwin', true, 'en-US'], + ['darwin', false, 'zh-CN'], + ['win32', true, 'zh-CN'], + ['win32', false, 'en-US'], + ] as const)('offers the native About panel before other commands on %s (packaged=%s, locale=%s)', async (platform, packaged, locale) => { + vi.stubGlobal('process', { ...process, platform }) + harness.app.isPackaged = packaged + vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale) + await readyForUpdate() + const submenu = applicationMenuItems() + const options = harness.app.setAboutPanelOptions.mock.calls[0]![0] + const expected = JSON.parse(readFileSync(new URL('./expected/about-panel.json', import.meta.url), 'utf8')) as Record + expect({ menu: submenu.slice(0, 2), options: { ...options, iconPath: '' } }).toEqual(expected[locale]) + expect(options.iconPath).toBe(packaged ? join('desktop-test-resources', 'icon.png') + : join('desktop-test-app', 'resources', 'icon-windows.png')) }) - it.each(['darwin', 'win32', 'linux'] as const)('uses platform-owned titlebar styling on %s', async (platform) => { + 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 @@ -239,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) @@ -260,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') @@ -280,10 +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).not.toHaveBeenCalled() - expect(harness.windows).toHaveLength(1) }) it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => { @@ -301,8 +584,8 @@ describe('desktop main startup', () => { : ['Application', 'editMenu']) const application = template[0]!.submenu as MenuItemConstructorOptions[] expect(application.map(describeItem)).toEqual(platform === 'darwin' - ? [en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit'] - : [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() }) @@ -369,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: '' }, @@ -382,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 } @@ -414,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(() => {}) }) @@ -421,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 () => { @@ -437,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/']) }) @@ -449,7 +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') + 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 () => { @@ -457,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/']) }) @@ -472,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/']) }) @@ -493,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) }) @@ -570,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() 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/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 c705fdb861..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://unowned/index.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 }) @@ -41,7 +60,7 @@ it('exposes a directory picker only to the local application document', async () electron.ipcRenderer.invoke.mockResolvedValue('/workspace') await expect(api.pick()).resolves.toBe('/workspace') expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick) - for (const url of ['dsh-app://unowned/index.html', 'https://example.com/']) { + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { vi.resetModules() electron.contextBridge.exposeInMainWorld.mockClear() vi.stubGlobal('location', new URL(url)) 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/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 632223a2f3..d600b5eecb 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -1,7 +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', () => ({ @@ -40,56 +44,181 @@ describe('desktop release metadata', () => { }) }) -describe('desktop update coordinator', () => { - it('installs one Electron release and restarts after download', async () => { - 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( - 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) +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(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 55d366cd06..aed896b427 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,9 +12,9 @@ export default defineConfig([ clean: false, deps: { neverBundle: ['electron'] }, }, - { + ...(['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: { 'preload-app': 'lib/types/preload-app.js' }, + entry: { [name]: `lib/types/${name}.js` }, outDir: 'lib', format: ['cjs'] as const, platform: 'node' as const, @@ -23,5 +23,5 @@ export default defineConfig([ dts: false, clean: false, deps: { neverBundle: ['electron'] }, - }, + })), ]) 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/diagram-preview.perf.ts b/apps/web/tests/diagram-preview.perf.ts deleted file mode 100644 index db4cb20482..0000000000 --- a/apps/web/tests/diagram-preview.perf.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** Manual built-Web diagnostic for offscreen diagrams and visible theme refreshes; no timing budgets. */ -import { execFileSync } from 'node:child_process' -import { mkdir, writeFile } from 'node:fs/promises' -import { arch, cpus, platform, release } from 'node:os' -import { join } from 'node:path' -import { chromium } from 'playwright' -import type { Browser, Locator, Page } from 'playwright' -import { describe, expect, it } from 'vitest' -import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-title' -import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' -import type { WebScaffold } from './scaffold.ts' -import { newEnglishPage, REPO_ROOT } from './support.ts' - -const SAMPLE_COUNT = 3 -const DIAGRAM_COUNT = 20 -const TAIL_PARAGRAPHS = 40 -const OBSERVATION_MS = 3_000 -const SESSION_ID = 'diagram-preview-performance' -const TITLE = 'DIAGRAM_PREVIEW_PERF' -const TAIL = 'DIAGRAM_PREVIEW_TAIL' -const PREVIEW = '[data-code-block-preview]' -const PROBE_KEY = '__dshDiagramPreviewPerf' - -interface LongTask { - readonly startTime: number - readonly duration: number -} - -interface Frame { - readonly elapsedMs: number - readonly height: number - readonly placeholder: boolean -} - -interface Probe { - start?: number - opportunity?: number - oldSrc?: string - trustedClick?: boolean - frames: Frame[] - longTasks: LongTask[] - observer: PerformanceObserver - raf: number -} - -function fixture(): string { - const session = Session.create(SessionId(SESSION_ID)) - session.append('turn/start', { turn: 1 }) - const user = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Inspect this synthetic diagram history.' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } }) - session.append('step/start', { turn: 1, step: 1 }) - const diagrams = Array.from({ length: DIAGRAM_COUNT }, (_, index) => [ - `Diagram ${index + 1}`, - '```mermaid', - 'flowchart TD', - ` A[Request ${index + 1}] --> B[Read history]`, - ' B --> C{Cached}', - ' C --> D[Load source]', - ' C --> E[Reuse source]', - ' D --> F[Parse]', - ' E --> F', - ' F --> G[Layout]', - ' G --> H[Draw]', - ' H --> I{Loaded}', - ' I --> J[Display]', - ' I --> D', - ' E --> G', - '```', - ].join('\n')) - const paragraphs = Array.from({ length: TAIL_PARAGRAPHS }, (_, index) => - `History note ${index + 1}. This ordinary paragraph keeps the diagrams above the initial reading position. ` - + 'The reader can inspect the completed discussion without opening a chart.') - session.append('assistant/message', { - turn: 1, - step: 1, - stream: [], - message: createAssistantMessage({ - content: [{ type: 'text', text: [...diagrams, ...paragraphs, TAIL].join('\n\n') }], - source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - usage: { inputTokens: 20, outputTokens: 2_000 }, - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - return [JSON.stringify({ - type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', - createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, - }), ...session.snapshotEvents().map(event => JSON.stringify(event)), ''].join('\n') -} - -async function installProbe(phase: 'open' | 'theme', target: Locator): Promise { - await target.evaluate((node, options) => { - const longTasks: LongTask[] = [] - const observer = new PerformanceObserver((list) => { - for (const entry of list.getEntries()) longTasks.push({ startTime: entry.startTime, duration: entry.duration }) - }) - observer.observe({ type: 'longtask' }) - const probe: Probe = { frames: [], longTasks, observer, raf: 0 } - Reflect.set(globalThis, options.key, probe) - const visible = (element: Element): boolean => { - const rect = element.getBoundingClientRect() - const scroll = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect() - const style = getComputedStyle(element) - return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' - && rect.bottom > (scroll?.top ?? 0) && rect.top < (scroll?.bottom ?? innerHeight) - } - const frame = (): void => { - if (probe.start === undefined) return - let ready = false - if (options.phase === 'open') { - const tail = Array.from(document.querySelectorAll('[data-conversation-scroll] p')) - .find(element => element.textContent === options.tail) - ready = tail !== undefined && visible(tail) - } else { - const preview = document.querySelectorAll(options.preview)[options.diagramCount - 1] - if (preview === undefined) throw new Error('theme probe has no last diagram') - probe.frames.push({ - elapsedMs: performance.now() - probe.start, - height: preview.getBoundingClientRect().height, - placeholder: Array.from(preview.querySelectorAll('[data-preview-placeholder]')).some(visible), - }) - ready = Array.from(preview.querySelectorAll('img')).some(image => - image.src !== probe.oldSrc && image.complete && image.naturalWidth > 0 && visible(image)) - } - if (ready && probe.opportunity === undefined) { - // The second callback is a rendering opportunity, not hardware presentation. - probe.opportunity = -1 - requestAnimationFrame(() => { - requestAnimationFrame(() => { probe.opportunity = performance.now() }) - }) - } - probe.raf = requestAnimationFrame(frame) - } - if (options.phase === 'open') { - node.addEventListener('click', (event) => { - probe.trustedClick = event.isTrusted - probe.start = performance.now() - probe.raf = requestAnimationFrame(frame) - }, { capture: true, once: true }) - } else { - const image = node.querySelector('img') - if (image === null) throw new Error('theme probe has no loaded image') - probe.oldSrc = image.src - probe.start = performance.now() - probe.raf = requestAnimationFrame(frame) - } - }, { phase, key: PROBE_KEY, preview: PREVIEW, diagramCount: DIAGRAM_COUNT, tail: TAIL }) -} - -async function collectProbe(page: Page) { - try { - await page.waitForFunction(({ key, windowMs }) => { - const probe = Reflect.get(globalThis, key) as Probe - return probe.start !== undefined && (probe.opportunity ?? -1) > 0 - && performance.now() - probe.start >= windowMs - }, { key: PROBE_KEY, windowMs: OBSERVATION_MS }, { timeout: 30_000 }) - } catch (error) { - const diagnostic = await page.evaluate(({ key, previewSelector }) => { - const probe = Reflect.get(globalThis, key) as Probe - const preview = Array.from(document.querySelectorAll(previewSelector)).at(-1) - const bounds = (element: Element | null | undefined) => { - const rect = element?.getBoundingClientRect() - return rect === undefined ? null : { top: rect.top, bottom: rect.bottom, height: rect.height } - } - return { - opportunity: probe.opportunity, - frames: probe.frames.slice(-5), - theme: getComputedStyle(document.documentElement).colorScheme, - scroll: bounds(document.querySelector('[data-conversation-scroll]')), - preview: bounds(preview), - images: Array.from(preview?.querySelectorAll('img') ?? []).map(image => ({ - sameSource: image.src === probe.oldSrc, complete: image.complete, naturalWidth: image.naturalWidth, - hidden: image.hidden, rect: bounds(image), - })), - } - }, { key: PROBE_KEY, previewSelector: PREVIEW }) - throw new Error(`diagram probe endpoint unavailable: ${JSON.stringify(diagnostic)}`, { cause: error }) - } - return await page.evaluate(({ key, windowMs, previewSelector }) => { - const probe = Reflect.get(globalThis, key) as Probe - probe.observer.disconnect() - cancelAnimationFrame(probe.raf) - Reflect.deleteProperty(globalThis, key) - if (probe.start === undefined || probe.opportunity === undefined) throw new Error('incomplete diagram probe') - const start = probe.start - const stop = start + windowMs - const tasks = probe.longTasks.filter(task => task.startTime < stop && task.startTime + task.duration > start) - const scripts = performance.getEntriesByType('resource').filter((entry): entry is PerformanceResourceTiming => - entry instanceof PerformanceResourceTiming && new URL(entry.name).pathname.endsWith('.js') - && entry.startTime >= start && entry.startTime < stop) - .map(entry => ({ - path: new URL(entry.name).pathname, - transferBytes: entry.transferSize, - encodedBytes: entry.encodedBodySize, - decodedBytes: entry.decodedBodySize, - durationMs: entry.duration, - })) - const previews = Array.from(document.querySelectorAll(previewSelector)) - const scrollRect = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect() - const intersectingPreviews = previews.filter((preview) => { - const rect = preview.getBoundingClientRect() - return rect.bottom > (scrollRect?.top ?? 0) && rect.top < (scrollRect?.bottom ?? innerHeight) - }).length - const frames = probe.frames.filter(frame => frame.elapsedMs < windowMs) - return { - opportunityMs: probe.opportunity - start, - observedMs: performance.now() - start, - trustedClick: probe.trustedClick, - longTaskMs: tasks.reduce((sum, task) => sum + Math.max(0, - Math.min(stop, task.startTime + task.duration) - Math.max(start, task.startTime)), 0), - longTasks: tasks.map(task => ({ startMs: task.startTime - start, durationMs: task.duration })), - loadedDiagramImages: previews.flatMap(preview => Array.from(preview.querySelectorAll('img'))) - .filter(image => image.complete && image.naturalWidth > 0).length, - previewCount: previews.length, - intersectingPreviews, - scriptTransferBytes: scripts.reduce((sum, script) => sum + script.transferBytes, 0), - scriptEncodedBytes: scripts.reduce((sum, script) => sum + script.encodedBytes, 0), - scripts, - frames, - heightMin: frames.length === 0 ? null : Math.min(...frames.map(frame => frame.height)), - heightMax: frames.length === 0 ? null : Math.max(...frames.map(frame => frame.height)), - placeholderFrames: frames.filter(frame => frame.placeholder).length, - } - }, { key: PROBE_KEY, windowMs: OBSERVATION_MS, previewSelector: PREVIEW }) -} - -async function sample() { - let scaffold: WebScaffold | undefined - let browser: Browser | undefined - try { - scaffold = await launchWebScaffold() - await seedSession(scaffold, fixture(), SESSION_ID) - browser = await chromium.launch() - const page = await newEnglishPage(browser) - await page.emulateMedia({ colorScheme: 'light' }) - const tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - const group = page.getByRole('treeitem').first() - await group.waitFor({ timeout: 30_000 }) - await group.click() - const session = page.getByRole('treeitem').nth(1) - await session.waitFor({ timeout: 30_000 }) - const bootScriptBytes = await page.evaluate(() => performance.getEntriesByType('resource') - .filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming - && new URL(entry.name).pathname.endsWith('.js')) - .reduce((sum, entry) => sum + entry.transferSize, 0)) - await installProbe('open', session) - await session.click() - const open = await collectProbe(page) - expect(open.trustedClick).toBe(true) - expect(open.previewCount).toBe(DIAGRAM_COUNT) - expect(open.intersectingPreviews).toBe(0) - const last = page.locator(PREVIEW).last() - await last.scrollIntoViewIfNeeded() - await expect.poll(() => last.locator('img').evaluateAll(images => images.some((node) => { - const image = node as HTMLImageElement - return image.complete && image.naturalWidth > 0 && image.getBoundingClientRect().height > 0 - }))).toBe(true) - // Earlier cold previews may grow after the placeholder was scrolled into view. - await last.scrollIntoViewIfNeeded() - await expect.poll(() => last.evaluate((node) => { - const rect = node.getBoundingClientRect() - const scroll = document.querySelector('[data-conversation-scroll]')!.getBoundingClientRect() - return rect.bottom > scroll.top && rect.top < scroll.bottom - }), { timeout: 30_000 }).toBe(true) - await installProbe('theme', last) - await page.emulateMedia({ colorScheme: 'dark' }) - const theme = await collectProbe(page) - expect(await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe('dark') - expect(tripwire.pageErrors).toEqual([]) - return { browser: browser.version(), bootScriptBytes, open, theme, consoleWarnings: tripwire.warnings } - } finally { - try { - await browser?.close() - } finally { - await scaffold?.close() - } - } -} - -function median(values: number[]): number { - return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]! -} - -describe('manual web performance: diagram previews', () => { - it('reports cold offscreen opening and a visible theme refresh', async () => { - if (webSnapshotMode() !== 'replay') throw new Error('diagram performance requires keyless replay mode') - const directory = join(REPO_ROOT, '.playwright-mcp/preview-perf', new Date().toISOString().replaceAll(':', '-')) - await mkdir(directory, { recursive: true }) - const samples: Awaited>[] = [] - for (let index = 0; index < SAMPLE_COUNT; index++) { - const result = await sample() - samples.push(result) - await writeFile(join(directory, `sample-${index + 1}.json`), `${JSON.stringify(result, null, 2)}\n`) - console.log(JSON.stringify({ sample: index + 1, openMs: result.open.opportunityMs, themeMs: result.theme.opportunityMs })) - } - const summary = { - revision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim(), - runtime: { node: process.version, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model }, - workload: { - diagrams: DIAGRAM_COUNT, nodesPerDiagram: 10, edgesPerDiagram: 12, - tailParagraphs: TAIL_PARAGRAPHS, viewport: [1680, 1000], observationMs: OBSERVATION_MS, - }, - semantics: 'Fresh Chromium and private shipped-composition scaffold per sample; built Client with source-resolved test Host. Opening starts at trusted session click; theme starts immediately before media emulation. Endpoints require visible output and two animation-frame opportunities. Long tasks are clipped to the fixed observation window. Samples include local transport, no model call. No memory or hardware-presentation measurement and no timing verdict.', - medians: Object.fromEntries((['open', 'theme'] as const).map(phase => [phase, { - opportunityMs: median(samples.map(sample => sample[phase].opportunityMs)), - longTaskMs: median(samples.map(sample => sample[phase].longTaskMs)), - scriptTransferBytes: median(samples.map(sample => sample[phase].scriptTransferBytes)), - loadedDiagramImages: median(samples.map(sample => sample[phase].loadedDiagramImages)), - placeholderFrames: median(samples.map(sample => sample[phase].placeholderFrames)), - }])), - samples, - } - await writeFile(join(directory, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`) - console.log(JSON.stringify({ directory, medians: summary.medians })) - }) -}) 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 03025fb3f0..f23d5c693e 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -172,9 +172,7 @@ - paragraph: - img "Token preview" - text: css -- button "Line numbers" - button "Copy" -- text: Source - code: "--inline-code: #F5F5F5;" - paragraph: LINK_GALLERY_DONE - heading "Footnotes" [level=2] diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 88de7153d9..4ed654fa1a 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -46,9 +46,7 @@ - tab "Host" [selected] - tabpanel "Host": - text: javascript - - button "Line numbers" - button "Copy" - - text: Source - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings - button "Inspect" diff --git a/apps/web/tests/expected/markdown-mermaid/lightbox.expected.md b/apps/web/tests/expected/markdown-mermaid/lightbox.expected.md deleted file mode 100644 index d464d8f15f..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/lightbox.expected.md +++ /dev/null @@ -1,4 +0,0 @@ -- dialog "Mermaid diagram": - - img "Mermaid diagram" - - text: Drag or arrow keys to pan · Scroll or +/− to zoom · Double-click or Home to fit - - button "Close preview" diff --git a/apps/web/tests/expected/markdown-mermaid/offscreen.expected.md b/apps/web/tests/expected/markdown-mermaid/offscreen.expected.md deleted file mode 100644 index d476d54658..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/offscreen.expected.md +++ /dev/null @@ -1,6 +0,0 @@ -- text: mermaid -- button "Enlarge preview" [disabled] -- button "Copy" -- button "Source" -- button "Preview" [pressed] -- status: Generating diagram… diff --git a/apps/web/tests/expected/markdown-mermaid/source-highlight.expected.md b/apps/web/tests/expected/markdown-mermaid/source-highlight.expected.md deleted file mode 100644 index 69ce704e61..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/source-highlight.expected.md +++ /dev/null @@ -1,427 +0,0 @@ -[ - { - "language": "mermaid", - "lines": [ - [ - { - "text": "flowchart", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": " LR", - "style": "color: var(--shiki-token-function);" - } - ], - [ - { - "text": " A", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "[", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": "输入", - "style": "color: var(--shiki-token-string);" - }, - { - "text": "]", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": " --", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": "> B[共享渲染器] ", - "style": "color: var(--shiki-token-string);" - }, - { - "text": "-->", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": " C", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "[", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": "图形预览", - "style": "color: var(--shiki-token-string);" - }, - { - "text": "]", - "style": "color: var(--shiki-token-keyword);" - } - ] - ] - }, - { - "language": "dot", - "lines": [ - [ - { - "text": "digraph", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": " { ", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "rankdir", - "style": "color: var(--shiki-token-constant);" - }, - { - "text": "=LR; Input ", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "->", - "style": "color: var(--shiki-token-keyword);" - }, - { - "text": " Preview }", - "style": "color: var(--shiki-foreground);" - } - ] - ] - }, - { - "language": "svg", - "lines": [ - [ - { - "text": "<", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "svg", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " xmlns", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"http://www.w3.org/2000/svg\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " width", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"460\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " height", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"100\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " onload", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"parent.document.body.dataset.previewEscaped='yes'\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": ">", - "style": "color: var(--shiki-foreground);" - } - ], - [ - { - "text": "<", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "rect", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " width", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"460\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " height", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"100\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " fill", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"lightblue\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": "/><", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "text", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " x", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"20\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " y", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"55\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": ">SVG preview", - "style": "color: var(--shiki-foreground);" - } - ], - [ - { - "text": "<", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "script", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": ">parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')", - "style": "color: var(--shiki-foreground);" - } - ], - [ - { - "text": "<", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "image", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " href", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"https://preview.invalid/svg-image\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " width", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"1\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " height", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"1\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": "/>", - "style": "color: var(--shiki-foreground);" - } - ], - [ - { - "text": "<", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "foreignObject", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " width", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"1\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " height", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"1\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": "><", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "div", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": " xmlns", - "style": "color: var(--shiki-token-function);" - }, - { - "text": "=", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "\"http://www.w3.org/1999/xhtml\"", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": "><", - "style": "color: var(--shiki-foreground);" - }, - { - "text": "script", - "style": "color: var(--shiki-token-string-expression);" - }, - { - "text": ">alert('unsafe HTML')", - "style": "color: var(--shiki-foreground);" - } - ], - [ - { - "text": "", - "style": "color: var(--shiki-foreground);" - } - ] - ] - } -] diff --git a/apps/web/tests/expected/markdown-mermaid/ui.expected.md b/apps/web/tests/expected/markdown-mermaid/ui.expected.md deleted file mode 100644 index 00cc604c4b..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/ui.expected.md +++ /dev/null @@ -1,42 +0,0 @@ -- heading "Mermaid previews" [level=1] -- text: mermaid -- button "Enlarge preview" -- button "Copy" -- button "Source" -- button "Preview" [pressed] -- img "Mermaid diagram" -- text: mermaid -- button "Enlarge preview" -- button "Copy" -- button "Source" -- button "Preview" [pressed] -- img "Mermaid diagram" -- text: mermaid -- button "Enlarge preview" [disabled] -- button "Copy" -- button "Source" -- button "Preview" [pressed] -- status: Unable to render this diagram. Select Source to inspect the code. -- text: mermaid -- button "Enlarge preview" -- button "Copy" -- button "Source" -- button "Preview" [pressed] -- img "Mermaid diagram" -- text: dot -- button "Enlarge preview" -- button "Copy" -- button "Source" -- button "Preview" [pressed] -- img "Graphviz diagram" -- text: svg -- button "Enlarge preview" -- button "Copy" -- button "Source" -- button "Preview" [pressed] -- img "SVG preview" -- text: html -- button "Line numbers" -- button "Copy" -- text: Source -- code:

Static HTML

diff --git a/apps/web/tests/expected/markdown-mermaid/zh-lightbox.expected.md b/apps/web/tests/expected/markdown-mermaid/zh-lightbox.expected.md deleted file mode 100644 index 4e78c807cd..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/zh-lightbox.expected.md +++ /dev/null @@ -1,4 +0,0 @@ -- dialog "Mermaid 图表": - - img "Mermaid 图表" - - text: 拖拽或方向键移动 · 滚轮或 +/− 缩放 · 双击或 Home 适应屏幕 - - button "关闭预览" diff --git a/apps/web/tests/expected/markdown-mermaid/zh.expected.md b/apps/web/tests/expected/markdown-mermaid/zh.expected.md deleted file mode 100644 index 9bc32ab6e9..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/zh.expected.md +++ /dev/null @@ -1,42 +0,0 @@ -- heading "Mermaid previews" [level=1] -- text: mermaid -- button "放大预览" -- button "复制" -- button "源码" -- button "预览" [pressed] -- img "Mermaid 图表" -- text: mermaid -- button "放大预览" -- button "复制" -- button "源码" -- button "预览" [pressed] -- img "Mermaid 图表" -- text: mermaid -- button "放大预览" [disabled] -- button "复制" -- button "源码" -- button "预览" [pressed] -- status: 无法渲染此图表,可切换到源码查看。 -- text: mermaid -- button "放大预览" -- button "复制" -- button "源码" -- button "预览" [pressed] -- img "Mermaid 图表" -- text: dot -- button "放大预览" -- button "复制" -- button "源码" -- button "预览" [pressed] -- img "Graphviz 图表" -- text: svg -- button "放大预览" -- button "复制" -- button "源码" -- button "预览" [pressed] -- img "SVG 预览" -- text: html -- button "行号" -- button "复制" -- text: 源码 -- code:

Static HTML

diff --git a/apps/web/tests/expected/thinking-markdown/ui.expected.md b/apps/web/tests/expected/thinking-markdown/ui.expected.md index 925b4ccad4..a94d4bbf3c 100644 --- a/apps/web/tests/expected/thinking-markdown/ui.expected.md +++ b/apps/web/tests/expected/thinking-markdown/ui.expected.md @@ -216,8 +216,6 @@ - text: . - math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x - text: typescript -- button "Line numbers" - button "Copy" -- text: Source - code: const value = "reasoning code" - paragraph: {{longToken}} diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts deleted file mode 100644 index babb3992d5..0000000000 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import type { Browser, Locator, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-title' -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('./expected/markdown-mermaid', import.meta.url)) -const MODE = webSnapshotMode() -const SEED_ID = 'markdown-mermaid-web-e2e' -const DIAGRAM_MESSAGE = '[class*="markdown"]:has(.md-code-block)' -const FLOW = 'flowchart LR\n A[输入] --> B[共享渲染器] --> C[图形预览]' -const SEQUENCE = 'sequenceDiagram\n participant U as User\n participant R as Renderer\n U->>R: Mermaid source\n R-->>U: Diagram' -const INVALID = 'flowchart LR\n A[unfinished' -const UNTRUSTED = [ - '%%{init: {"securityLevel":"loose","htmlLabels":true,"themeCSS":"body {display:none!important}"}}%%', - 'flowchart LR', - ' A[""] --> B[Safe]', - ' click B "javascript:alert(1)"', -].join('\n') - -const DOT = 'digraph { rankdir=LR; Input -> Preview }' -const SVG = ` -SVG preview - - -
-
` -const HTML = '

Static HTML

' - -const CPU = `flowchart LR - subgraph CPU["CPU 中央处理器"] - direction TB - CU["控制器 Control Unit"] - ALU["运算器 ALU"] - REG["寄存器 Registers"] - end - - MEM["存储器 Memory(指令 + 数据)"] - IN["输入设备 Input"] - OUT["输出设备 Output"] - - IN -->|"数据 / 指令"| MEM - MEM -->|"指令"| CU - MEM -->|"数据"| REG - REG -->|"数据"| MEM - CU -->|"控制信号"| ALU - CU -->|"控制信号"| MEM - CU -->|"地址总线"| MEM - ALU -->|"结果"| REG - MEM -->|"数据"| OUT - CU -->|"控制信号"| OUT` - -function fixture(diagramSources?: string[], trailingText = ''): string { - const session = Session.create(SessionId('markdown-mermaid-source')) - session.append('turn/start', { turn: 1 }) - const user = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Preview these Mermaid diagrams.' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('session/title', { title: 'Mermaid previews', messageSeqs: [user.seq], source: { kind: 'fallback' } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { - stream: [], turn: 1, step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: diagramSources === undefined ? [ - '# Mermaid previews', - ...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``), - ...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``), - ].join('\n\n') : ['# Mermaid previews', ...diagramSources.map(code => `\`\`\`mermaid\n${code}\n\`\`\``), trailingText].join('\n\n') }], - source: { kind: 'model', provider: 'fixture', model: 'fixture' }, - }), - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const noon = new Date().setHours(12, 0, 0, 0) - return [ - JSON.stringify({ - type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, - cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, - }), - ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: noon + event.seq * 1000 })), - '', - ].join('\n') -} - -async function openConversation(page: Page, scaffold: WebScaffold): Promise { - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('treeitem').first().click({ timeout: 30_000 }) - await page.getByRole('treeitem').nth(1).click() - await page.getByRole('heading', { name: 'Mermaid previews' }).waitFor() -} - -async function settleBox(locator: Locator): Promise<{ width: number; height: number }> { - return await locator.evaluate(async (node) => { - let previous = { width: -1, height: -1 } - let stableFrames = 0 - for (let frame = 0; frame < 180; frame += 1) { - await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) - const { width, height } = node.getBoundingClientRect() - const current = { width, height } - stableFrames = Math.abs(width - previous.width) < 0.25 && Math.abs(height - previous.height) < 0.25 - ? stableFrames + 1 - : 0 - if (stableFrames === 5) return current - previous = current - } - throw new Error('layout did not settle within 180 animation frames') - }) -} - -async function showAllPreviews(page: Page, label = 'Preview'): Promise { - const blocks = page.locator('.md-code-block') - for (let index = 0; index < await blocks.count(); index += 1) { - const button = blocks.nth(index).getByRole('button', { name: label, exact: true }) - if (await button.count() !== 1) continue - await button.click() - await expect.poll(() => blocks.nth(index).locator('[data-preview-placeholder]').count()).toBe(0) - } -} - -describe('web e2e: Mermaid chat previews', () => { - let scaffold: WebScaffold - let browser: Browser - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - await seedSession(scaffold, fixture(), SEED_ID) - browser = await chromium.launch() - }, 120_000) - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it.skipIf(MODE === 'record')('keeps preview and source at the same height across viewport widths', async () => { - const page = await newEnglishPage(browser) - let cpuScaffold: WebScaffold | undefined - try { - cpuScaffold = await launchWebScaffold({}) - await seedSession(cpuScaffold, fixture([CPU]), SEED_ID) - await openConversation(page, cpuScaffold) - const block = page.locator('.md-code-block') - await block.scrollIntoViewIfNeeded() - const image = block.getByRole('img', { name: 'Mermaid diagram', exact: true }) - await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) - expect(await block.locator('pre').count()).toBe(0) - const heights: number[] = [] - for (const width of [1680, 360]) { - await page.setViewportSize({ width, height: 1000 }) - await settleBox(page.locator('[data-chat-flow]')) - await image.waitFor({ state: 'visible' }) - const previewBlock = await settleBox(block) - const previewGeometry = await block.evaluate((node) => { - const body = node.querySelector('[data-code-block-content]')! - const source = node.querySelector('[data-code-block-source-view]')! - const preview = node.querySelector('[data-code-block-preview]')! - return { - block: node.getBoundingClientRect().height, - body: body.getBoundingClientRect().height, - source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height, - preview: preview.getBoundingClientRect().height, - sourceVisibility: getComputedStyle(source).visibility, - sourceAriaHidden: source.getAttribute('aria-hidden'), - } - }) - expect(previewGeometry.sourceVisibility).toBe('hidden') - expect(previewGeometry.sourceAriaHidden).toBe('true') - expect(previewGeometry.preview).toBeCloseTo(previewGeometry.source, 0) - expect(previewGeometry.body).toBeCloseTo(previewGeometry.source, 0) - expect(await block.locator('pre:visible').count()).toBe(0) - await block.getByRole('button', { name: 'Source', exact: true }).click() - await block.locator('pre:visible').waitFor() - const sourceBlock = await settleBox(block) - expect(sourceBlock.height).toBeCloseTo(previewBlock.height, 0) - expect(sourceBlock.width).toBeCloseTo(previewBlock.width, 0) - await block.getByRole('button', { name: 'Preview', exact: true }).click() - await image.waitFor({ state: 'visible' }) - const restoredBlock = await settleBox(block) - expect(restoredBlock.height).toBeCloseTo(previewBlock.height, 0) - expect(restoredBlock.width).toBeCloseTo(previewBlock.width, 0) - heights.push(previewGeometry.source) - } - expect(heights.every(height => height > 0 && height <= 632)).toBe(true) - expect(await block.locator('[data-code-block-resize]').count()).toBe(0) - expect(await block.getByRole('button', { name: 'Reset size', exact: true }).count()).toBe(0) - await block.getByRole('button', { name: 'Source', exact: true }).click() - expect(await block.locator('pre code').textContent()).toBe(CPU) - } catch (error) { - await saveFailureShot(page, 'web-e2e-cpu-preview') - throw error - } finally { - await page.close() - await cpuScaffold?.close() - } - }, 60_000) - - it.skipIf(MODE === 'record')('retains both equal-height views and keeps toolbar geometry stable', async () => { - const page = await newEnglishPage(browser) - try { - await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await openConversation(page, scaffold) - const block = page.locator('.md-code-block').nth(5) - await block.scrollIntoViewIfNeeded() - const image = block.getByRole('img', { name: 'SVG preview', exact: true }) - await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) - const imgNode = await image.elementHandle() - const sourceView = block.locator('[data-code-block-source-view]') - const previewLayer = block.locator('[data-code-block-preview]') - expect(await sourceView.getAttribute('aria-hidden')).toBe('true') - expect(await block.locator('pre').count()).toBe(0) - const previewHeight = (await previewLayer.boundingBox())!.height - expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true) - const sourceButton = block.getByRole('button', { name: 'Source', exact: true }) - await sourceButton.click() - await block.locator('pre.shiki:visible').waitFor() - const source = await block.locator('pre.shiki').elementHandle() - expect(await block.locator('pre code').textContent()).toBe(SVG) - expect(await block.getByRole('button', { name: 'Enlarge preview', exact: true }).count()).toBe(0) - expect(await sourceView.getAttribute('aria-hidden')).toBeNull() - const numbers = block.getByRole('button', { name: 'Line numbers', exact: true }) - await numbers.click() - expect(await numbers.getAttribute('aria-pressed')).toBe('true') - expect(await block.getAttribute('data-line-numbers')).toBe('true') - expect(await source!.evaluate(node => node.isConnected)).toBe(true) - const copy = block.getByRole('button', { name: 'Copy', exact: true }) - const selectionGeometry = () => sourceButton.evaluate((node) => { - const rect = node.getBoundingClientRect() - return { width: rect.width, height: rect.height, right: node.closest('.md-code-block')!.getBoundingClientRect().right - rect.right } - }) - const toolbarBefore = await selectionGeometry() - await copy.click() - await block.getByRole('button', { name: 'Copied', exact: true }).waitFor() - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(SVG) - expect(await selectionGeometry()).toEqual(toolbarBefore) - const sourceHeight = (await block.locator('[data-code-block-content]').boundingBox())!.height - expect(sourceHeight).toBeCloseTo(previewHeight, 0) - await block.getByRole('button', { name: 'Preview', exact: true }).click() - expect(await source!.evaluate(node => node.isConnected)).toBe(true) - expect(await sourceView.getAttribute('aria-hidden')).toBe('true') - expect((await previewLayer.boundingBox())!.height).toBeCloseTo(sourceHeight, 0) - expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true) - expect(await block.locator('[data-code-block-resize]').count()).toBe(0) - await page.setViewportSize({ width: 360, height: 800 }) - await image.waitFor({ state: 'visible' }) - expect((await block.boundingBox())!.width).toBeLessThanOrEqual(360) - } finally { - await page.close() - } - }) - - it.skipIf(MODE === 'record')('fades only preview toolbar opacity and reveals it for hover or keyboard focus', async () => { - const page = await newEnglishPage(browser) - try { - await openConversation(page, scaffold) - const block = page.locator('.md-code-block').first() - await block.scrollIntoViewIfNeeded() - await block.getByRole('img', { name: 'Mermaid diagram', exact: true }).evaluate(async (node: HTMLImageElement) => { await node.decode() }) - const toolbar = block.locator('[data-code-block-banner]').locator('..') - await block.hover() - await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1') - const shown = await block.boundingBox() - const toolbarBox = await toolbar.boundingBox() - await page.mouse.move(0, 0) - await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('0') - expect(await block.boundingBox()).toEqual(shown) - expect(await toolbar.boundingBox()).toEqual(toolbarBox) - expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0.16s') - await block.getByRole('button', { name: 'Source', exact: true }).focus() - await page.keyboard.press('Shift+Tab') - await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1') - await block.getByRole('button', { name: 'Source', exact: true }).click() - await page.getByRole('heading', { name: 'Mermaid previews' }).click() - await page.mouse.move(0, 0) - expect(await toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1') - expect(await block.locator('[data-code-block-resize]').count()).toBe(0) - await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0s') - } finally { - await page.close() - } - }) - - it.skipIf(MODE === 'record')('highlights diagram source with theme tokens', async () => { - const page = await newEnglishPage(browser) - try { - await openConversation(page, scaffold) - const trees = [] - for (const [index, language, source] of [[0, 'mermaid', FLOW], [4, 'dot', DOT], [5, 'svg', SVG]] as const) { - const block = page.locator('.md-code-block').nth(index) - await block.scrollIntoViewIfNeeded() - await block.getByRole('button', { name: 'Source', exact: true }).click() - await block.locator('pre.shiki').waitFor() - expect(await block.locator('pre code').textContent()).toBe(source) - const lines = await block.locator('pre.shiki .line').evaluateAll(nodes => nodes.map(line => - [...line.querySelectorAll('span')].map(span => ({ text: span.textContent, style: span.style.cssText })), - )) - expect(new Set(lines.flat().map(span => span.style)).size).toBeGreaterThan(1) - trees.push({ language, lines }) - } - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'source-highlight.expected.md'), JSON.stringify(trees, null, 2), MODE) - } finally { - await page.close() - } - }) - - it.skipIf(MODE === 'record').each(['light', 'dark'] as const)( - 'fits diagram previews to image dimensions within viewport limits in %s mode', async (scheme) => { - const page = await newEnglishPage(browser) - try { - await page.emulateMedia({ colorScheme: scheme }) - await openConversation(page, scaffold) - await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe(scheme) - await showAllPreviews(page) - - - for (const title of ['Mermaid diagram', 'Graphviz diagram', 'SVG preview']) { - const image = page.getByRole('img', { name: title, exact: true }).first() - await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) - const geometry = await image.evaluate((node) => { - const block = node.closest('.md-code-block')! - const source = block.querySelector('[data-code-block-source-view]')! - const preview = block.querySelector('[data-code-block-preview]')! - return { - image: node.getBoundingClientRect().height, - source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height, - preview: preview.getBoundingClientRect().height, - client: preview.clientHeight, - scroll: preview.scrollHeight, - } - }) - expect(geometry.image).toBeGreaterThan(0) - expect(geometry.preview).toBeCloseTo(geometry.source, 0) - expect(geometry.scroll).toBeGreaterThanOrEqual(geometry.client) - expect(geometry.image).toBeLessThanOrEqual(600) - expect(geometry.preview).toBeCloseTo(Math.max(120, geometry.image + 32), 0) - } - - await page.setViewportSize({ width: 360, height: 800 }) - const svg = page.getByRole('img', { name: 'SVG preview', exact: true }) - await expect.poll(() => svg.evaluate((node) => { - const { width, height } = node.getBoundingClientRect() - return width > 0 && height > 0 && width < 460 - })).toBe(true) - const compact = await svg.evaluate((node: HTMLImageElement) => ({ - width: node.getBoundingClientRect().width, - height: node.getBoundingClientRect().height, - sourceHeight: node.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height, - previewHeight: node.closest('.md-code-block')!.querySelector('[data-code-block-preview]')!.getBoundingClientRect().height, - ratio: node.naturalWidth / node.naturalHeight, - })) - expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1) - expect(compact.previewHeight).toBeCloseTo(compact.sourceHeight, 0) - } finally { - await page.close() - } - }, - ) - - it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => { - const page = await newEnglishPage(browser) - try { - await openConversation(page, scaffold) - await page.locator('.md-code-block').first().getByRole('button', { name: 'Preview', exact: true }).click() - const image = page.getByRole('img', { name: 'Mermaid diagram' }).first() - await image.waitFor() - let previous = await image.getAttribute('src') - for (const scheme of ['dark', 'light'] as const) { - await page.evaluate((value) => { - document.documentElement.style.colorScheme = value - document.body.toggleAttribute('data-ds-dark-theme', value === 'dark') - }, scheme) - await expect.poll(() => image.getAttribute('src')).not.toBe(previous) - await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - const colors = await image.evaluate((node) => { - const reference = document.createElement('span') - reference.style.backgroundColor = 'var(--dsw-alias-markdown-code-block)' - document.body.append(reference) - try { - return { - canvas: getComputedStyle(node.parentElement!).backgroundColor, - expected: getComputedStyle(reference).backgroundColor, - } - } finally { - reference.remove() - } - }) - expect(colors.canvas).toBe(colors.expected) - previous = await image.getAttribute('src') - } - } finally { - await page.close() - } - }) - - it.skipIf(MODE === 'record')('renders diagrams, switches to source, copies source, and contains malformed content', async () => { - const page = await newEnglishPage(browser) - onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-mermaid')) - const tripwire = watchConsole(page) - const dialogs: string[] = [] - page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() }) - await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await openConversation(page, scaffold) - const first = page.locator('.md-code-block').first() - const invalid = page.locator('.md-code-block').nth(2) - await invalid.scrollIntoViewIfNeeded() - await invalid.getByRole('status').filter({ hasText: 'Unable to render this diagram' }).waitFor() - await invalid.getByRole('button', { name: 'Source', exact: true }).click() - expect((await invalid.locator('[data-code-block-content]').boundingBox())!.height).toBeCloseTo(120, 0) - await invalid.getByRole('button', { name: 'Preview', exact: true }).click() - expect(await first.locator('pre').count()).toBe(0) - expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true') - expect(await first.getByText('mermaid', { exact: true }).count()).toBe(1) - expect(await page.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(6) - const html = page.locator('.md-code-block').nth(6) - expect(await html.locator('pre code').textContent()).toBe(HTML) - expect(await html.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(0) - await first.scrollIntoViewIfNeeded() - const image = first.getByRole('img', { name: 'Mermaid diagram', exact: true }) - await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) - const diagram = await image.elementHandle() - const layout = await first.evaluate((block) => { - const source = block.querySelector('[data-code-block-source-view]')! - const preview = block.querySelector('[data-code-block-preview]')! - return { - block: block.getBoundingClientRect().height, - source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height, - preview: preview.getBoundingClientRect().height, - width: block.getBoundingClientRect().width, - } - }) - expect(layout.preview).toBeCloseTo(layout.source, 0) - await first.getByRole('button', { name: 'Enlarge preview', exact: true }).click() - const dialog = page.getByRole('dialog', { name: 'Mermaid diagram', exact: true }) - const enlarged = dialog.getByRole('img', { name: 'Mermaid diagram', exact: true }) - const enlargedGeometry = await enlarged.evaluate((node: HTMLImageElement) => ({ - width: node.getBoundingClientRect().width, - height: node.getBoundingClientRect().height, - ratio: node.naturalWidth / node.naturalHeight, - })) - const viewport = page.viewportSize()! - expect(enlargedGeometry.width).toBeGreaterThan(layout.width) - expect(enlargedGeometry.width / enlargedGeometry.height).toBeCloseTo(enlargedGeometry.ratio, 1) - expect(Math.max(enlargedGeometry.width / (viewport.width * 0.88), enlargedGeometry.height / (viewport.height * 0.84))).toBeCloseTo(1, 2) - const fitted = (await enlarged.boundingBox())! - expect(fitted.x).toBeGreaterThanOrEqual(viewport.width * 0.06 - 1) - expect(fitted.y).toBeGreaterThanOrEqual(viewport.height * 0.08 - 1) - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE) - const anchor = { x: fitted.x + fitted.width * 0.6, y: fitted.y + fitted.height * 0.6 } - await page.mouse.move(anchor.x, anchor.y) - await page.mouse.wheel(0, -360) - await expect.poll(async () => (await enlarged.boundingBox())!.width).toBeGreaterThan(fitted.width * 1.5) - const zoomed = (await enlarged.boundingBox())! - expect((anchor.x - zoomed.x) / zoomed.width).toBeCloseTo(0.6, 2) - expect((anchor.y - zoomed.y) / zoomed.height).toBeCloseTo(0.6, 2) - await page.mouse.down() - await page.mouse.move(anchor.x + 100, anchor.y + 60, { steps: 15 }) - await page.mouse.up() - const panned = (await enlarged.boundingBox())! - expect(panned.x - zoomed.x).toBeCloseTo(100, 0) - expect(panned.y - zoomed.y).toBeCloseTo(60, 0) - expect(panned.width).toBeCloseTo(zoomed.width, 2) - await page.mouse.dblclick(viewport.width / 2, viewport.height / 2) - await expect.poll(async () => (await enlarged.boundingBox())!.width).toBe(fitted.width) - expect((await enlarged.boundingBox())!.x).toBe(fitted.x) - expect(await image.getAttribute('src')).toBe(await enlarged.getAttribute('src')) - await dialog.getByRole('button', { name: 'Close preview', exact: true }).click() - await showAllPreviews(page) - const images = page.getByRole('img', { name: 'Mermaid diagram' }) - await expect.poll(() => images.count(), { timeout: 20_000 }).toBe(3) - await expect.poll(() => images.evaluateAll(nodes => nodes.every(node => (node as HTMLImageElement).naturalWidth > 0))).toBe(true) - expect(await images.evaluateAll(nodes => nodes.every(node => - node.getBoundingClientRect().width <= (node as HTMLImageElement).naturalWidth))).toBe(true) - expect(await page.getByRole('status').filter({ hasText: 'Unable to render this diagram. Select Source to inspect the code.' }).count()).toBe(1) - const failed = page.locator('.md-code-block').filter({ has: page.locator('[role="status"]') }) - await failed.getByRole('button', { name: 'Source', exact: true }).click() - expect(await failed.locator('pre code').textContent()).toBe(INVALID) - await failed.getByRole('button', { name: 'Preview', exact: true }).click() - expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器') - await first.getByRole('button', { name: 'Copy', exact: true }).click() - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW) - await first.getByRole('button', { name: 'Source', exact: true }).focus() - await page.keyboard.press('Enter') - expect(await first.getByRole('button', { name: 'Source', exact: true }) - .evaluate(node => node === document.activeElement)).toBe(true) - expect(await first.locator('pre code').textContent()).toBe(FLOW) - expect(await diagram!.evaluate(node => node.isConnected && getComputedStyle(node).visibility === 'hidden')).toBe(true) - await first.getByRole('button', { name: 'Preview', exact: true }).click() - await first.getByRole('img', { name: 'Mermaid diagram' }).waitFor() - await first.getByRole('button', { name: 'Copy', exact: true }).waitFor() - expect(await page.locator('body').evaluate(node => getComputedStyle(node).display)).not.toBe('none') - expect(await page.locator('[id^="dsh-mermaid-"]').count()).toBe(0) - expect(dialogs).toEqual([]) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - await page.getByRole('heading', { name: 'Mermaid previews' }).click() - await page.mouse.move(0, 0) - const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd) - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE) - await page.close() - }, 60_000) - - it.skipIf(MODE === 'record')('localizes the preview and failure states in Chinese', async () => { - const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', hasTouch: true }) - await openConversation(page, scaffold) - expect(await page.getByRole('button', { name: '预览', exact: true }).count()).toBe(6) - const first = page.locator('.md-code-block').first() - expect(await first.locator('pre').count()).toBe(0) - expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true') - await first.getByRole('button', { name: '预览', exact: true }).click() - const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first() - await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - await first.getByRole('button', { name: '放大预览', exact: true }).click() - await page.getByRole('dialog', { name: 'Mermaid 图表' }).waitFor() - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh-lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE) - await page.keyboard.press('Escape') - expect(await first.getByRole('button', { name: '源码', exact: true }).textContent()).toBe('源码') - expect(await first.getByRole('button', { name: '复制', exact: true }).getAttribute('aria-label')).toBe('复制') - await showAllPreviews(page, '预览') - await page.getByRole('status').filter({ hasText: '无法渲染此图表,可切换到源码查看。' }).waitFor() - const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd) - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE) - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md', 'source-highlight.expected.md', 'lightbox.expected.md', 'zh-lightbox.expected.md', 'offscreen.expected.md']) - await page.close() - }, 60_000) - - it.skipIf(MODE === 'record')('defers offscreen Mermaid and Graphviz runtimes until a preview enters the viewport', async () => { - const page = await newEnglishPage(browser) - let history: WebScaffold | undefined - const runtimes: string[] = [] - page.on('request', (request) => { - const path = new URL(request.url()).pathname - if (/\/(mermaid\.core|viz)-[^/]+\.js$/.test(path)) runtimes.push(path) - }) - try { - history = await launchWebScaffold({}) - const tail = ['```dot', DOT, '```', ...Array.from({ length: 40 }, (_, index) => `Closing note ${index + 1}.`), 'History complete.'].join('\n\n') - await seedSession(history, fixture(Array.from({ length: 20 }, (_, index) => index === 0 ? FLOW : SEQUENCE), tail), SEED_ID) - await openConversation(page, history) - await page.getByText('History complete.', { exact: true }).waitFor() - await page.evaluate(async () => { - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() }))) - }) - const blocks = page.locator('.md-code-block') - expect(await blocks.count()).toBe(21) - expect(await blocks.locator('img').count()).toBe(0) - expect(runtimes).toEqual([]) - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'offscreen.expected.md'), - await captureStableAria(page, '.md-code-block', history.workspaceCwd), MODE) - await blocks.first().scrollIntoViewIfNeeded() - await blocks.first().getByRole('img', { name: 'Mermaid diagram', exact: true }) - .evaluate(async (node: HTMLImageElement) => { await node.decode() }) - expect(runtimes.some(path => path.includes('/mermaid.core-'))).toBe(true) - expect(runtimes.some(path => path.includes('/viz-'))).toBe(false) - await blocks.last().scrollIntoViewIfNeeded() - await blocks.last().getByRole('img', { name: 'Graphviz diagram', exact: true }) - .evaluate(async (node: HTMLImageElement) => { await node.decode() }) - expect(runtimes.some(path => path.includes('/viz-'))).toBe(true) - } finally { - await page.close() - await history?.close() - } - }, 60_000) - - it.skipIf(MODE === 'record')('rejects native Mermaid image nodes before requests and renders the next diagram', async () => { - const page = await newEnglishPage(browser) - let imageScaffold: WebScaffold | undefined - const requests: string[] = [] - try { - await page.route('https://preview.invalid/**', async (route) => { - requests.push(route.request().url()) - await route.abort() - }) - imageScaffold = await launchWebScaffold({}) - await seedSession(imageScaffold, fixture([ - 'flowchart LR\n A@{ img: "https://preview.invalid/mermaid-image", h: 80 }', FLOW, - ]), SEED_ID) - await openConversation(page, imageScaffold) - await page.locator('.md-code-block').first().scrollIntoViewIfNeeded() - await page.locator('.md-code-block').first().getByRole('status') - .filter({ hasText: 'Unable to render this diagram' }).waitFor() - await page.locator('.md-code-block').nth(1).scrollIntoViewIfNeeded() - const image = page.getByRole('img', { name: 'Mermaid diagram', exact: true }) - await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) - expect(requests).toEqual([]) - } finally { - await page.close() - await imageScaffold?.close() - } - }, 60_000) - - it.skipIf(MODE === 'record')('previews inert DOT and SVG images', async () => { - const page = await newEnglishPage(browser) - const requests: string[] = [] - const dialogs: string[] = [] - // A data-URL SVG image must not fetch source-authored external resources. - await page.route('https://preview.invalid/**', async (route) => { - requests.push(route.request().url()) - await route.abort() - }) - page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() }) - await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await openConversation(page, scaffold) - for (const [index, title, code] of [[4, 'Graphviz diagram', DOT], [5, 'SVG preview', SVG]] as const) { - const block = page.locator('.md-code-block').nth(index) - await block.getByRole('button', { name: 'Preview', exact: true }).click() - const preview = block.getByRole('img', { name: title, exact: true }) - await preview.waitFor() - await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - await block.getByRole('button', { name: 'Copy', exact: true }).click() - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code) - await block.getByRole('button', { name: 'Source', exact: true }).click() - expect(await block.locator('pre code').textContent()).toBe(code) - await block.getByRole('button', { name: 'Preview', exact: true }).click() - await preview.waitFor() - } - expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull() - expect(dialogs).toEqual([]) - expect(requests).toEqual([]) - const notices = await page.request.get(new URL('/preview-third-party-notices.txt', scaffold.authenticatedUrl).href) - expect(notices.ok()).toBe(true) - expect(await notices.text()).toContain('Eclipse Public License - v 2.0') - await page.close() - }) -}) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 062e9119b0..d155ce5042 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -22,9 +22,7 @@ - img - text: Context injection @deepseek-ai/dsh-system-prompt - text: ts -- button "Line numbers" - button "Copy" -- text: Source - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/preview-placeholder.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/preview-placeholder.expected.md deleted file mode 100644 index cd152ffdbe..0000000000 --- a/apps/web/tests/snapshots/streaming-fence-highlight/preview-placeholder.expected.md +++ /dev/null @@ -1,34 +0,0 @@ -- banner: - - navigation "Session hierarchy": - - button "Stream an SVG diagram." [disabled] - - img - - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img - - tablist: - - tab "Chat" [selected] - - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt -- text: Stream an SVG diagram. {{clock}} -- button "Copy": - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- text: svg -- status: Generating diagram… -- status: Deep diving... -- textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img -- 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current streaming-fence-highlight-test/streaming-fence": - - text: streaming-fence-highlight-test/streaming-fence - - img -- button "Stop generating" diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 3ba0beeb8e..6d65cdca0d 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -58,21 +58,17 @@ class StreamingFenceAdapter extends LlmAdapter { this.finish() } - constructor(private readonly first = FIRST_REPLY, private readonly open = OPEN_REPLY, private readonly reply = REPLY) { - super() - } - override async *stream(options: GenerateOptions): AsyncIterable { yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text: this.first } + yield { type: 'text-delta', index: 0, text: FIRST_REPLY } this.resolveFirstPaused() await this.firstContinuation - yield { type: 'text-delta', index: 0, text: this.open.slice(this.first.length) } + yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) } this.resolveSecondPaused() await this.secondContinuation if (options.signal?.aborted === true) throw options.signal.reason - yield { type: 'text-delta', index: 0, text: this.reply.slice(this.open.length) } - yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } } + yield { type: 'text-delta', index: 0, text: '\n```' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } } yield { type: 'finish', reason: { kind: 'stop' } } } } @@ -133,62 +129,6 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', await scaffold?.close() }) - it('holds a stable image placeholder while a diagram streams, then restores preview controls', async () => { - const first = '```svg\n\n' - const open = `${first}\n` - const response = `${open}\n\`\`\`` - const pending = new StreamingFenceAdapter(first, open, response) - const local = await launchWebScaffold() - let previewPage: Page | undefined - let settled: ReturnType | undefined - try { - local.ctx.effect(() => local.ctx.llm.registerAdapter([PROVIDER], pending), 'streaming preview adapter') - await local.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL }) - previewPage = await newEnglishPage(browser) - await previewPage.goto(local.authenticatedUrl, { waitUntil: 'load' }) - await connectFreshWorkspace(previewPage, local.workspaceCwd) - const input = previewPage.locator('[data-composer-input]').first() - await writeComposerDraft(previewPage, input, 'Stream an SVG diagram.') - await input.press('Enter') - await pending.firstPaused - settled = local.whenTurnSettled(30_000) - const block = previewPage.locator('.md-code-block') - const placeholder = block.locator('[data-preview-placeholder]') - await placeholder.waitFor() - const height = (await block.boundingBox())!.height - expect((await placeholder.boundingBox())!.height).toBe(180) - expect(await block.locator('pre, button').count()).toBe(0) - const glyph = placeholder.locator('svg') - expect(await glyph.evaluate(node => getComputedStyle(node).animationDuration)).toBe('1.8s') - pending.grow() - await pending.secondPaused - expect((await block.boundingBox())!.height).toBe(height) - expect(await block.locator('pre, button').count()).toBe(0) - await compareOrRefreshGolden( - fileURLToPath(new URL('./snapshots/streaming-fence-highlight/preview-placeholder.expected.md', import.meta.url)), - await captureStableAria(previewPage, '[class*="centerCol"]', local.workspaceCwd), MODE, - ) - await previewPage.emulateMedia({ reducedMotion: 'reduce' }) - expect(await glyph.evaluate(node => getComputedStyle(node).animationName)).toBe('none') - pending.finish() - await settled - await block.getByRole('img', { name: 'SVG preview', exact: true }).waitFor() - expect(await placeholder.count()).toBe(0) - expect(await block.locator('pre').count()).toBe(0) - expect(await block.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true') - await block.getByRole('button', { name: 'Source', exact: true }).click() - expect(await block.locator('pre code').textContent()).toBe(response.slice(7, -4)) - } finally { - pending.continue() - try { - await settled - } finally { - await previewPage?.close() - await local.close() - } - } - }, 60_000) - it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight')) const input = page.locator('[data-composer-input]').first() @@ -240,6 +180,6 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', }))).toEqual({ block: 'true', pre: 'true', line: 'true' }) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md', 'preview-placeholder.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md']) }, 60_000) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 987f0424ae..0a29f4884e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,7 @@ "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", @@ -95,7 +96,6 @@ "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", - "tests/markdown-mermaid.e2e.ts", "tests/clickable-links-gallery.e2e.ts", "tests/queue-actions.e2e.ts", "tests/cold-blank-session.e2e.ts", @@ -134,7 +134,6 @@ "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", "tests/complex-history.perf.ts", - "tests/diagram-preview.perf.ts", "tests/pwsh-terminal.e2e.ts", "tests/workflow-run.e2e.ts" ], diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index f6b289efa8..ddf3683c59 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -29,20 +29,6 @@ function clientDocumentTitle(): Plugin { } } -/** Preserve preview licenses and Graphviz source availability in every browser distribution. */ -function previewNotices(): Plugin { - return { - name: 'dsh-preview-notices', - async generateBundle() { - this.emitFile({ - type: 'asset', - fileName: 'preview-third-party-notices.txt', - source: await readFile(src('../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8'), - }) - }, - } -} - /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { return { @@ -170,7 +156,7 @@ export default defineConfig({ // directory, and the served index resolves identically from the site root. base: './', plugins: [ - previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(), + rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(), productWebBundleIsolation(src('../..'), src('.')), ], build: { 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 b0e786da9d..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: 80d9f42e0c4957bd22730b2f577c029027916abb -config-catalog.zh.md: 52be394871e4717c857f52c188f80805692ce4fa +config-catalog.md: 631ce6a34b120f44534a7935ff976662287fa879 +config-catalog.zh.md: 8d72786a79ceff85e35aff72fa6622af7373a3b4 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 80d9f42e0c..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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 52be394871..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) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 8b783e3b25..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: db38def5cea9d2275bac564c2d0c582c79e72fc3 -event-producer-consumer.zh.md: ba8dd6efe3aa96cb52f9ce8fd7c185fd8ebfb18e +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 db38def5ce..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` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ba8dd6efe3..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` | 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/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 1065023458..ca0d70fffa 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: 19dc12b4946353634a0f71a566564454cf1f8de8 -README.zh.md: b02631005fd50a82733545c5a537728be47d3d80 +README.md: f72dd40db4c6414c368e039300198151cf2bd96a +README.zh.md: 4644dfb23074338bdd5888d20e0f0115811115d8 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 19dc12b494..f72dd40db4 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 b02631005f..4644dfb230 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/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index 7890c62b99..d16b4913ae 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -36,19 +36,6 @@ export const en = { 'truncated': 'Truncated', 'json.label': 'JSON', 'markdown.footnotes': 'Footnotes', - 'markdown.preview.graphviz': 'Graphviz diagram', - 'markdown.preview.svg': 'SVG preview', - 'markdown.preview.error': 'Unable to render this preview. Select Source to inspect the code.', - 'markdown.mermaid.diagram': 'Mermaid diagram', - 'markdown.preview.preview': 'Preview', - 'markdown.preview.source': 'Source', - 'markdown.code.source': 'Source', - 'markdown.code.lineNumbers': 'Line numbers', - 'markdown.preview.pending': 'Generating diagram…', - 'markdown.preview.zoom': 'Enlarge preview', - 'markdown.preview.interaction': 'Drag or arrow keys to pan · Scroll or +/− to zoom · Double-click or Home to fit', - 'markdown.preview.close': 'Close preview', - 'markdown.mermaid.error': 'Unable to render this diagram. Select Source to inspect the code.', 'markdown.truncatedCharacters': '… truncated at {total} characters', 'number.thousand': '{value}K', 'number.million': '{value}M', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 076235bcfe..2e932cef41 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -34,19 +34,6 @@ export const zh = { 'truncated': '已截断', 'json.label': 'JSON', 'markdown.footnotes': '脚注', - 'markdown.preview.graphviz': 'Graphviz 图表', - 'markdown.preview.svg': 'SVG 预览', - 'markdown.preview.error': '无法渲染此预览,可切换到源码查看。', - 'markdown.mermaid.diagram': 'Mermaid 图表', - 'markdown.preview.preview': '预览', - 'markdown.preview.source': '源码', - 'markdown.code.source': '源码', - 'markdown.code.lineNumbers': '行号', - 'markdown.preview.pending': '正在生成图表…', - 'markdown.preview.zoom': '放大预览', - 'markdown.preview.interaction': '拖拽或方向键移动 · 滚轮或 +/− 缩放 · 双击或 Home 适应屏幕', - 'markdown.preview.close': '关闭预览', - 'markdown.mermaid.error': '无法渲染此图表,可切换到源码查看。', 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', 'number.thousand': '{value}K', 'number.million': '{value}M', diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index f9b5c7bf90..780e0b76ad 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: 1c8d99b4a37fa82bdde5d6d97b021c3b714a3a2e -README.zh.md: d2dd2462535892a1a0dd0d1bf57255e4177c5b5e +README.md: b5314413df8195834152c354df89c25600afc63b +README.zh.md: 23288907f6f0dd4dec457ac56e403b050b6ef4e5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 1c8d99b4a3..b5314413df 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -16,7 +16,6 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [Reference previews](#reference-previews) - [System prompt row](#system-prompt-row) -- [Mermaid previews](#mermaid-previews) - [Turn token usage](#turn-token-usage) - [Completed-turn footer](#completed-turn-footer) - [Turn Process Folding](#turn-process-folding) @@ -37,15 +36,6 @@ HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordi Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives. ------ - - -## Mermaid previews - -Settled Assistant Markdown defaults to diagram previews for `mermaid`, `graphviz`/`dot`, and `svg` fences. Preview height follows the image within viewport limits; Source uses the same area and mounts on first selection. Both views copy the original source. The magnifier opens a viewport-fitted image with pan and zoom. Supported streaming fences show a placeholder; HTML fences remain code. Invalid diagrams show an error and keep Source available. Chat enables the shared [preview primitive](../ui-primitives/README.md) through localized labels. This presentation adds no model prompt, tool, or Session event. - ------ - ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index d2dd246253..23288907f6 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -16,7 +16,6 @@ kind: "package-reference" - [引用预览](#reference-previews) - [系统提示词行](#system-prompt-row) -- [Mermaid 预览](#mermaid-previews) - [轮次 token 用量](#turn-token-usage) - [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) @@ -39,13 +38,6 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注 ----- - -## Mermaid 预览 - -已定稿的 Assistant Markdown 默认把 `mermaid`、`graphviz`/`dot` 和 `svg` fence 显示为图表预览。预览高度按图片计算并受视口限制;源码在首次选中时挂载,使用同一区域。两种视图复制的都是原始源码。放大镜打开适应视口、支持平移与缩放的图片。受支持的流式 fence 显示占位;HTML fence 保持代码显示。非法图表显示错误并保留源码入口。Chat 通过本地化 label 启用共享的[预览原语](../ui-primitives/README.zh.md)。这种展示不增加模型提示词、工具或 Session 事件。 - ------ - ## 轮次 token 用量 diff --git a/packages/client/ui-chat/src/client/markdown-labels.ts b/packages/client/ui-chat/src/client/markdown-labels.ts index a839373ad1..6c30b99396 100644 --- a/packages/client/ui-chat/src/client/markdown-labels.ts +++ b/packages/client/ui-chat/src/client/markdown-labels.ts @@ -9,23 +9,8 @@ import type { ChatViewSlotProps } from './contract/slots.ts' * @returns Labels for code fences and footnotes. */ export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { - const status = { error: t('markdown.preview.error') } return { - code: { copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'), lineNumbersLabel: t('markdown.code.lineNumbers') }, + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes'), - preview: { - preview: t('markdown.preview.preview'), - source: t('markdown.preview.source'), - zoom: t('markdown.preview.zoom'), - pending: t('markdown.preview.pending'), - close: t('markdown.preview.close'), - interaction: t('markdown.preview.interaction'), - mermaid: { - diagram: t('markdown.mermaid.diagram'), - error: t('markdown.mermaid.error'), - }, - graphviz: { ...status, diagram: t('markdown.preview.graphviz') }, - svg: { ...status, diagram: t('markdown.preview.svg') }, - }, } } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index 30e09030ed..bea4918f02 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -22,10 +22,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { 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'), sourceLabel: t('markdown.code.source'), - lineNumbersLabel: t('markdown.code.lineNumbers'), - }, + code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes'), }), [t]) if (plan === undefined) return ( diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 97a7e8e28f..706236dc2a 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: e9a6b4d1271d3633b5cdb592ee322c3a60d0aa77 -README.zh.md: 9468ac4905019948bf0f8df5a08a97c80593d4d0 +README.md: d466de8e4a26a6476e373e19c45c72ae22197f1b +README.zh.md: eb6f4ce836eb8a219aad38f0941e666f3391ebc6 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index e9a6b4d127..d466de8e4a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -72,17 +72,7 @@ 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. 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, source-only rendering 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. - -`CodeBlock` highlights `dot`/`graphviz`, `svg`, and `mermaid` when displaying source, with case-insensitive language hints. These grammars load on demand; mounted source automatically gains theme-token colors when loading completes. SVG shares the XML grammar. Mermaid uses the diagram-body rules from `@shikijs/langs/mermaid`. The pinned DOT grammar and its mechanical conversion are recorded in the [third-party notices](THIRD_PARTY_PREVIEW_NOTICES.txt). - -Supply `MarkdownLabels.preview` to show settled `mermaid`, `graphviz`/`dot`, and `svg` fences as previews by default. Supported streaming fences show a 180px image placeholder with a pulsing glyph and localized status; controls become available after streaming ends. Consumers without these labels show source. Mermaid and Graphviz use the active document palette on the code-block background and refresh intersecting previews or open lightboxes when resolved colors change. Offscreen previews defer rendering and runtime loading until entry. Explicit colors in DOT and SVG remain authored content. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render or image-load failure shows the supplied error label with Source available in the toolbar; replacing the source discards late results from the previous render. - -`CodeBlock.preview` supplies a standard source-preview descriptor with a renderer and complete localized output and control labels. Callers do not pass React nodes. The language stays on the left; magnifier and copy icons precede the Source/Preview segmented control on the right. Only the selected background slides for 160ms; the body switches immediately. Unsupported blocks show a static selected Source label. Copy always reads source and confirms success through its icon and tooltip without changing toolbar width. Preview shows a magnifier that opens a body-portaled lightbox fitted within 88% of viewport width and 84% of its height. Drag to pan, scroll around the cursor to zoom, or double-click to fit again. Arrow keys pan, +/− zoom, and Home resets. The lightbox reuses the generated image; output without a usable image disables it with a localized accessible explanation. Source replaces the magnifier with a line-number toggle that preserves the source text and copy output. In Preview, the toolbar fades out through opacity alone over 160ms and appears on block hover or keyboard focus; touch devices keep it visible. Source keeps its toolbar visible. - -Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same canvas as Mermaid; malformed SVG and DOT show an error and leave Source available. The browser sizes previews from the image aspect ratio and available width, limiting image height to the smaller of 60vh and 640px. The canvas has 16px padding and a 120px minimum height. Source mounts on first selection, scrolls within that same area, and remains mounted with the generated image across view changes. HTML fences use the ordinary highlighted-code presentation. - -[Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution choices. +`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. @@ -118,11 +108,7 @@ The package enforces one separation: presentational React atoms with zero Cordis ### Streaming markdown -While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. Re-rendering the same highlight frame reuses its rendered body, so React render retries cannot append completed lines twice. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary. - -### Preview ownership - -The streaming placeholder mounts neither the source highlighter nor the diagram renderer. Once streaming ends, the waiting canvas remains until the image loads; stopping a stream also exits the streaming wait. Reduced motion disables its 1.8-second opacity animation. A shared viewport observer activates intersecting previews; leaving cancels unfinished rendering unless the lightbox is open. Each mounted block retains its image across view changes and viewport exits. Reentry reuses matching source, renderer and palette results, including failures. Theme refreshes retain the loaded image and lightbox until the replacement loads; refresh failures keep the usable image and announce the error. Previewable blocks mount and highlight source on first selection, then retain its DOM; source-only blocks retain viewport activation. Independent memoized source and copy controls keep toolbar feedback and unchanged parent props from redoing source work, while grammar readiness is specific to the source language. The [interaction decision](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md) owns retention, and the [preview sizing decision](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md) owns geometry. +While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary. ### Geometry and overflow @@ -158,12 +144,9 @@ None; this package neither assembles nor sends a provider request. + These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap. -- **Diagram preview limits** — Mermaid image nodes are rejected before layout to prevent source-authored resource loads. Browsers without IntersectionObserver render immediately. - -- **Diagram rendering runs on the browser thread** — Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Cancellation skips queued work but cannot interrupt active layout. Loaded images and visited source DOM remain until unmount; viewport deferral is not virtualization or worker rendering. - - **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations. - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 9468ac4905..eb6f4ce836 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -72,17 +72,7 @@ kind: "package-library" ### 渲染 agent 输出 -`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 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 - -`CodeBlock` 显示源码时对 `dot`/`graphviz`、`svg` 与 `mermaid` 高亮,语言提示不区分大小写。这些语法按需加载;加载完成后,已挂载的源码会自动采用主题 token 颜色。SVG 复用 XML 语法。Mermaid 使用 `@shikijs/langs/mermaid` 的图表正文规则。固定的 DOT 语法及其机械转换记录在[第三方声明](THIRD_PARTY_PREVIEW_NOTICES.txt)中。 - -传入 `MarkdownLabels.preview`,让定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence 默认显示预览。受支持的流式 fence 显示高 180px 的图片占位、呼吸图标与本地化状态;流式结束后提供控件。未提供这些文案的调用方显示源码。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,并在解析后的颜色变化时更新与视口相交的预览或已打开的大图。视口外的预览推迟到进入视口后才渲染和加载运行时。DOT 与 SVG 中明确指定的颜色保留为作者内容。渲染按需加载 Mermaid,采用严格安全模式,并将生成的 SVG 作为不带图表链接处理器的图片显示。渲染或图片加载失败时显示传入的错误文案,工具栏仍可切换源码;替换源码会丢弃上一次渲染迟到的结果。 - -`CodeBlock.preview` 提供标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案。调用方不传入 React 节点。语言名位于左侧;右侧依次放置放大镜、复制图标和源码/预览分段控件。只有选中背景滑动 160ms,正文立即切换。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,并通过图标和 tooltip 确认成功,不改变工具栏宽度。预览中的放大镜打开 body portal lightbox,图片等比适应屏幕宽度的 88% 和高度的 84%。拖拽可平移,滚轮围绕鼠标位置缩放,双击恢复适应屏幕。方向键平移,+/− 缩放,Home 复位。大图复用已生成的图片;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,源码文本和复制结果保持不变。预览工具栏仅通过透明度在 160ms 内淡出,鼠标移入代码块或键盘聚焦时显示;触摸设备保持可见。源码工具栏始终可见。 - -Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出都作为不可执行图片显示在与 Mermaid 相同的画布中;无效 SVG 与 DOT 显示错误并保留源码入口。浏览器按图片宽高比与可用宽度计算预览高度,图片高度不超过 60vh 与 640px 中的较小值。画布保留 16px 内边距与 120px 最小高度。源码在首次选中时挂载,在同一区域内滚动;后续切换保留源码 DOM 与已生成图片。HTML fence 使用普通高亮代码视图。 - -[预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MIT;Viz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发选择。 +`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 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 @@ -118,11 +108,7 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr ### 流式 Markdown -回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。再次渲染同一高亮帧会复用其已渲染正文,因此 React 重试渲染不会重复追加已完成行。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用。 - -### 预览所有权 - -流式占位既不挂载源码高亮组件,也不调用图表渲染器。流式结束后,等待画布保留至图片加载完成;停止输出也会退出流式等待。减少动态效果设置禁用其 1.8 秒透明度动画。共享视口观察器激活与视口相交的预览;离开视口会取消未完成的渲染,已打开 lightbox 时除外。每个已挂载的代码块在视图切换和离开视口时保留图片。重新进入时复用源码、renderer 与配色相同的结果,包括失败结果。主题刷新保留已加载图片和 lightbox,直到替换图片加载完成;刷新失败则保留可用图片并报告错误。支持预览的代码块在首次选中源码时挂载并高亮,之后保留其 DOM;纯源码代码块仍在进入视口后激活高亮。独立 memo 化的源码与复制控件让工具栏反馈和属性未变的父组件更新不必重做源码工作,语法就绪状态也只针对该源码语言。[交互决策](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md)负责保留机制,[预览尺寸决策](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责几何行为。 +回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用。 ### 几何与溢出 @@ -158,12 +144,9 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr + 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 -- **图表预览限制**:Mermaid 原生图片节点在布局前被拒绝,避免加载源码指定的资源。不支持 IntersectionObserver 的浏览器会立即渲染。 - -- **图表渲染在浏览器线程上执行**:Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。取消会跳过排队工作,但无法中断已开始的布局。已加载图片和访问过的源码 DOM 保留至卸载;视口延迟渲染不等同于虚拟化或 worker 渲染。 - - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 diff --git a/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt b/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt deleted file mode 100644 index c17fd2333b..0000000000 --- a/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt +++ /dev/null @@ -1,722 +0,0 @@ -Markdown preview and syntax grammar third-party notices - -Mermaid 11.16.0: MIT; source https://github.com/mermaid-js/mermaid/tree/mermaid%4011.16.0 -@viz-js/viz 3.30.0: MIT JavaScript wrapper; source and build instructions: -https://github.com/mdaines/viz-js/tree/99da545270e6e7b127a5c7ba65974b8a604a6358 - -The Viz.js WebAssembly payload contains Graphviz 16.0.0 under EPL-2.0, -Expat 2.8.4 under MIT, and Emscripten 5.0.7 runtime code under MIT/NCSA. -Graphviz remains under EPL-2.0; it is not relicensed as MIT by DeepSeek Harness. -Graphviz source code is available under EPL-2.0 at: -https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz -SHA-256: 36a1de1aaf5a2023b14f95170a5f8f0b12522d1c305b517fc261966597050749 -Viz.js's backend build instructions and additions are available at the commit above. -DeepSeek Harness uses the unmodified upstream Viz.js package. - -Expat source: https://github.com/libexpat/libexpat/tree/R_2_8_4 -Emscripten source: https://github.com/emscripten-core/emscripten/tree/5.0.7 -DOMPurify 3.4.11: Apache-2.0 OR MPL-2.0; this distribution selects Apache-2.0. -Source: https://github.com/cure53/DOMPurify/tree/3.4.11 - -The following license texts and copyright notices apply to their respective components. - - -===== Mermaid ===== - -The MIT License (MIT) - -Copyright (c) 2014 - 2022 Knut Sveidqvist - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -===== Viz.js ===== - -MIT License - -Copyright (c) Michael Daines - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -===== Graphviz ===== - -Eclipse Public License - v 2.0 - - THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE - PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION - OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. - -1. DEFINITIONS - -"Contribution" means: - - a) in the case of the initial Contributor, the initial content - Distributed under this Agreement, and - - b) in the case of each subsequent Contributor: - i) changes to the Program, and - ii) additions to the Program; - where such changes and/or additions to the Program originate from - and are Distributed by that particular Contributor. A Contribution - "originates" from a Contributor if it was added to the Program by - such Contributor itself or anyone acting on such Contributor's behalf. - Contributions do not include changes or additions to the Program that - are not Modified Works. - -"Contributor" means any person or entity that Distributes the Program. - -"Licensed Patents" mean patent claims licensable by a Contributor which -are necessarily infringed by the use or sale of its Contribution alone -or when combined with the Program. - -"Program" means the Contributions Distributed in accordance with this -Agreement. - -"Recipient" means anyone who receives the Program under this Agreement -or any Secondary License (as applicable), including Contributors. - -"Derivative Works" shall mean any work, whether in Source Code or other -form, that is based on (or derived from) the Program and for which the -editorial revisions, annotations, elaborations, or other modifications -represent, as a whole, an original work of authorship. - -"Modified Works" shall mean any work in Source Code or other form that -results from an addition to, deletion from, or modification of the -contents of the Program, including, for purposes of clarity any new file -in Source Code form that contains any contents of the Program. Modified -Works shall not include works that contain only declarations, -interfaces, types, classes, structures, or files of the Program solely -in each case in order to link to, bind by name, or subclass the Program -or Modified Works thereof. - -"Distribute" means the acts of a) distributing or b) making available -in any manner that enables the transfer of a copy. - -"Source Code" means the form of a Program preferred for making -modifications, including but not limited to software source code, -documentation source, and configuration files. - -"Secondary License" means either the GNU General Public License, -Version 2.0, or any later versions of that license, including any -exceptions or additional permissions as identified by the initial -Contributor. - -2. GRANT OF RIGHTS - - a) Subject to the terms of this Agreement, each Contributor hereby - grants Recipient a non-exclusive, worldwide, royalty-free copyright - license to reproduce, prepare Derivative Works of, publicly display, - publicly perform, Distribute and sublicense the Contribution of such - Contributor, if any, and such Derivative Works. - - b) Subject to the terms of this Agreement, each Contributor hereby - grants Recipient a non-exclusive, worldwide, royalty-free patent - license under Licensed Patents to make, use, sell, offer to sell, - import and otherwise transfer the Contribution of such Contributor, - if any, in Source Code or other form. This patent license shall - apply to the combination of the Contribution and the Program if, at - the time the Contribution is added by the Contributor, such addition - of the Contribution causes such combination to be covered by the - Licensed Patents. The patent license shall not apply to any other - combinations which include the Contribution. No hardware per se is - licensed hereunder. - - c) Recipient understands that although each Contributor grants the - licenses to its Contributions set forth herein, no assurances are - provided by any Contributor that the Program does not infringe the - patent or other intellectual property rights of any other entity. - Each Contributor disclaims any liability to Recipient for claims - brought by any other entity based on infringement of intellectual - property rights or otherwise. As a condition to exercising the - rights and licenses granted hereunder, each Recipient hereby - assumes sole responsibility to secure any other intellectual - property rights needed, if any. For example, if a third party - patent license is required to allow Recipient to Distribute the - Program, it is Recipient's responsibility to acquire that license - before distributing the Program. - - d) Each Contributor represents that to its knowledge it has - sufficient copyright rights in its Contribution, if any, to grant - the copyright license set forth in this Agreement. - - e) Notwithstanding the terms of any Secondary License, no - Contributor makes additional grants to any Recipient (other than - those set forth in this Agreement) as a result of such Recipient's - receipt of the Program under the terms of a Secondary License - (if permitted under the terms of Section 3). - -3. REQUIREMENTS - -3.1 If a Contributor Distributes the Program in any form, then: - - a) the Program must also be made available as Source Code, in - accordance with section 3.2, and the Contributor must accompany - the Program with a statement that the Source Code for the Program - is available under this Agreement, and informs Recipients how to - obtain it in a reasonable manner on or through a medium customarily - used for software exchange; and - - b) the Contributor may Distribute the Program under a license - different than this Agreement, provided that such license: - i) effectively disclaims on behalf of all other Contributors all - warranties and conditions, express and implied, including - warranties or conditions of title and non-infringement, and - implied warranties or conditions of merchantability and fitness - for a particular purpose; - - ii) effectively excludes on behalf of all other Contributors all - liability for damages, including direct, indirect, special, - incidental and consequential damages, such as lost profits; - - iii) does not attempt to limit or alter the recipients' rights - in the Source Code under section 3.2; and - - iv) requires any subsequent distribution of the Program by any - party to be under a license that satisfies the requirements - of this section 3. - -3.2 When the Program is Distributed as Source Code: - - a) it must be made available under this Agreement, or if the - Program (i) is combined with other material in a separate file or - files made available under a Secondary License, and (ii) the initial - Contributor attached to the Source Code the notice described in - Exhibit A of this Agreement, then the Program may be made available - under the terms of such Secondary Licenses, and - - b) a copy of this Agreement must be included with each copy of - the Program. - -3.3 Contributors may not remove or alter any copyright, patent, -trademark, attribution notices, disclaimers of warranty, or limitations -of liability ("notices") contained within the Program from any copy of -the Program which they Distribute, provided that Contributors may add -their own appropriate notices. - -4. COMMERCIAL DISTRIBUTION - -Commercial distributors of software may accept certain responsibilities -with respect to end users, business partners and the like. While this -license is intended to facilitate the commercial use of the Program, -the Contributor who includes the Program in a commercial product -offering should do so in a manner which does not create potential -liability for other Contributors. Therefore, if a Contributor includes -the Program in a commercial product offering, such Contributor -("Commercial Contributor") hereby agrees to defend and indemnify every -other Contributor ("Indemnified Contributor") against any losses, -damages and costs (collectively "Losses") arising from claims, lawsuits -and other legal actions brought by a third party against the Indemnified -Contributor to the extent caused by the acts or omissions of such -Commercial Contributor in connection with its distribution of the Program -in a commercial product offering. The obligations in this section do not -apply to any claims or Losses relating to any actual or alleged -intellectual property infringement. In order to qualify, an Indemnified -Contributor must: a) promptly notify the Commercial Contributor in -writing of such claim, and b) allow the Commercial Contributor to control, -and cooperate with the Commercial Contributor in, the defense and any -related settlement negotiations. The Indemnified Contributor may -participate in any such claim at its own expense. - -For example, a Contributor might include the Program in a commercial -product offering, Product X. That Contributor is then a Commercial -Contributor. If that Commercial Contributor then makes performance -claims, or offers warranties related to Product X, those performance -claims and warranties are such Commercial Contributor's responsibility -alone. Under this section, the Commercial Contributor would have to -defend claims against the other Contributors related to those performance -claims and warranties, and if a court requires any other Contributor to -pay any damages as a result, the Commercial Contributor must pay -those damages. - -5. NO WARRANTY - -EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT -PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" -BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR -IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF -TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR -PURPOSE. Each Recipient is solely responsible for determining the -appropriateness of using and distributing the Program and assumes all -risks associated with its exercise of rights under this Agreement, -including but not limited to the risks and costs of program errors, -compliance with applicable laws, damage to or loss of data, programs -or equipment, and unavailability or interruption of operations. - -6. DISCLAIMER OF LIABILITY - -EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT -PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS -SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST -PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE -EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - -7. GENERAL - -If any provision of this Agreement is invalid or unenforceable under -applicable law, it shall not affect the validity or enforceability of -the remainder of the terms of this Agreement, and without further -action by the parties hereto, such provision shall be reformed to the -minimum extent necessary to make such provision valid and enforceable. - -If Recipient institutes patent litigation against any entity -(including a cross-claim or counterclaim in a lawsuit) alleging that the -Program itself (excluding combinations of the Program with other software -or hardware) infringes such Recipient's patent(s), then such Recipient's -rights granted under Section 2(b) shall terminate as of the date such -litigation is filed. - -All Recipient's rights under this Agreement shall terminate if it -fails to comply with any of the material terms or conditions of this -Agreement and does not cure such failure in a reasonable period of -time after becoming aware of such noncompliance. If all Recipient's -rights under this Agreement terminate, Recipient agrees to cease use -and distribution of the Program as soon as reasonably practicable. -However, Recipient's obligations under this Agreement and any licenses -granted by Recipient relating to the Program shall continue and survive. - -Everyone is permitted to copy and distribute copies of this Agreement, -but in order to avoid inconsistency the Agreement is copyrighted and -may only be modified in the following manner. The Agreement Steward -reserves the right to publish new versions (including revisions) of -this Agreement from time to time. No one other than the Agreement -Steward has the right to modify this Agreement. The Eclipse Foundation -is the initial Agreement Steward. The Eclipse Foundation may assign the -responsibility to serve as the Agreement Steward to a suitable separate -entity. Each new version of the Agreement will be given a distinguishing -version number. The Program (including Contributions) may always be -Distributed subject to the version of the Agreement under which it was -received. In addition, after a new version of the Agreement is published, -Contributor may elect to Distribute the Program (including its -Contributions) under the new version. - -Except as expressly stated in Sections 2(a) and 2(b) above, Recipient -receives no rights or licenses to the intellectual property of any -Contributor under this Agreement, whether expressly, by implication, -estoppel or otherwise. All rights in the Program not expressly granted -under this Agreement are reserved. Nothing in this Agreement is intended -to be enforceable by any entity that is not a Contributor or Recipient. -No third-party beneficiary rights are created under this Agreement. - -Exhibit A - Form of Secondary Licenses Notice - -"This Source Code may also be made available under the following -Secondary Licenses when the conditions for such availability set forth -in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), -version(s), and exceptions or additional permissions here}." - - Simply including a copy of this Agreement, including this Exhibit A - is not sufficient to license the Source Code under Secondary Licenses. - - If it is not possible or desirable to put the notice in a particular - file, then You may include the notice in a location (such as a LICENSE - file in a relevant directory) where a recipient would be likely to - look for such a notice. - - You may add additional accurate notices of copyright ownership. - - -===== Expat ===== - -Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper -Copyright (c) 2001-2025 Expat maintainers - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -===== Emscripten ===== - -Emscripten is available under 2 licenses, the MIT license and the -University of Illinois/NCSA Open Source License. - -Both are permissive open source licenses, with little if any -practical difference between them. - -The reason for offering both is that (1) the MIT license is -well-known, while (2) the University of Illinois/NCSA Open Source -License allows Emscripten's code to be integrated upstream into -LLVM, which uses that license, should the opportunity arise. - -The full text of both licenses follows. - -============================================================================== - -Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -============================================================================== - -Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. -All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the -"Software"), to deal with the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - - Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimers. - - Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimers - in the documentation and/or other materials provided with the - distribution. - - Neither the names of Mozilla, - nor the names of its contributors may be used to endorse - or promote products derived from this Software without specific prior - written permission. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR -ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE. - -============================================================================== - -This program uses portions of Node.js source code located in src/library_path.js, -in accordance with the terms of the MIT license. Node's license follows: - - """ - Copyright Joyent, Inc. and other Node contributors. All rights reserved. - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to - deal in the Software without restriction, including without limitation the - rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - sell copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS - IN THE SOFTWARE. - """ - -The musl libc project is bundled in this repo, and it has the MIT license, see -system/lib/libc/musl/COPYRIGHT - -The third_party/ subdirectory contains code with other licenses. None of it is -used by default, but certain options use it (e.g., the optional closure compiler -flag will run closure compiler from third_party/). - - -===== DOMPurify ===== - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - -===== Graphviz (DOT) TextMate grammar ===== - -Source: joaompinto/vscode-graphviz, commit e001e27f7831fb34cb9a8de9c456485bbc509ec3 -https://github.com/joaompinto/vscode-graphviz/blob/e001e27f7831fb34cb9a8de9c456485bbc509ec3/dot/syntaxes/dot.tmLanguage - -The plist is mechanically decoded into a Shiki LanguageRegistration array. -The display name is retained as displayName; name is dot and aliases is [graphviz]. -An empty repository supplies Shiki's required registration field. -Editor-only fileTypes, firstLineMatch, keyEquivalent, uuid, and rule comments are omitted. -All patterns, captures, and scopes are unchanged. - -The MIT License (MIT) - -Copyright (c) 2016 João Pinto - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 224f8f5a25..c10bf53c7d 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -47,15 +47,11 @@ "mdast-util-math": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-util-sanitize-uri": "^2.0.1", - "simple-icons": "16.31.0", - "mermaid": "11.16.0", - "@viz-js/viz": "3.30.0" + "simple-icons": "16.31.0" }, "files": [ "lib/index.js", - "lib/*-*.js", "lib/**/*.css", - "THIRD_PARTY_PREVIEW_NOTICES.txt", "lib/types/**/*.d.ts" ], "peerDependencies": { 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 547aea420a..ff8bd4213a 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -66,9 +66,7 @@ export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView, WebBlockLabels, } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' -export type { - CodeBlockPreview, CodeBlockPreviewLabels, CodeBlockPreviewRenderer, CodeBlockProps, -} 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 { MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index bbb4fbd462..96a9b62ec1 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -7,13 +7,10 @@ /* Local 11/18 instead of the shared --dsw-font-xs-13: the banner scales with the markdown 0.875 ladder without shrinking every other xs-13 use. */ --dsl-code-block-banner-font: 11px/18px var(--dsw-font-family); - --dsl-code-block-banner-height: 36px; --dsl-code-block-content-font: var(--dsw-font-markdown-code-block); --dsl-code-block-background: var(--dsw-alias-markdown-code-block); position: relative; - width: 100%; - max-width: 100%; margin: 16px 0; color: var(--dsw-alias-label-primary); background: var(--dsl-code-block-background); @@ -31,21 +28,11 @@ background-color: var(--dsw-alias-bg-base); border-top-left-radius: var(--dsl-code-block-border-radius); border-top-right-radius: var(--dsl-code-block-border-radius); - transition: opacity 160ms ease; -} - -.block[data-preview] .bannerWrap { - opacity: 0; -} - -.block[data-preview]:hover .bannerWrap, -.block[data-preview]:has(:focus-visible) .bannerWrap { - opacity: 1; } .banner { background: var(--dsl-code-block-banner-background-color); - padding: 4px 10px 4px 14px; + padding: 9px 14px; display: flex; justify-content: space-between; align-items: center; @@ -67,124 +54,25 @@ } .action { - min-height: 28px; display: flex; align-items: center; flex-shrink: 0; - gap: 2px; } -/* Match the sidebar pane controls: a 28px circle around a 15px glyph. */ -.iconButton { - display: inline-flex; - flex: none; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: 28px; - height: 28px; - padding: 6px; - color: var(--dsw-alias-label-secondary); - line-height: 1; - background: transparent; +.copyButton { + background-color: rgb(255 255 255 / 0); border: none; - border-radius: 28px; - cursor: pointer; -} - -.iconButton svg { - width: 15px; - height: 15px; -} - -.iconButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.iconButton[aria-pressed='true'] { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); -} - -.iconButton:disabled { - color: var(--dsw-alias-label-tertiary); - cursor: default; -} - -.previewActionSlot, -.iconSlot { - display: inline-flex; -} - -.segments { - position: relative; - display: grid; - grid-auto-flow: column; - grid-auto-columns: 1fr; - padding: 2px; - margin-left: 4px; - border-radius: 8px; - background: var(--dsw-alias-interactive-bg-hover); - isolation: isolate; -} - -.selection { - position: absolute; - inset: 2px; - width: calc((100% - 4px) / 2); - border-radius: 6px; - background: var(--dsw-alias-bg-base); - transition: transform 160ms ease; - z-index: -1; -} - -.segments[data-preview-selected] .selection { - transform: translateX(100%); -} - -.segments:has(.sourceOnly) .selection { - width: calc(100% - 4px); -} - -.segments button, -.sourceOnly { - border: 0; - background: transparent; + padding: 0; + margin: 0; color: inherit; - padding: 2px 8px; - font: inherit; - white-space: nowrap; - text-align: center; -} - -.segments button { cursor: pointer; + font: inherit; } -.segments button[aria-pressed='false'] { - color: var(--dsw-alias-label-secondary); -} - -.body { - min-height: 0; -} - -.previewActionSlot[hidden] { - display: none; -} - -.sourceHidden { - visibility: hidden; - pointer-events: none; -} - -@media (prefers-reduced-motion: reduce) { - .selection { transition: none; } - .bannerWrap { transition: none; } -} - -@media (hover: none) { - .block[data-preview] .bannerWrap { opacity: 1; } +/* Consumers may turn the stable content node into a viewport without changing + the default CodeBlock layout. */ +.content { + display: contents; } .block :where(pre) { @@ -216,32 +104,6 @@ color: var(--dsw-alias-label-primary); } -.previewLayer { - display: grid; - overflow: auto; - background: var(--dsl-code-block-background); - border-bottom-right-radius: var(--dsl-code-block-border-radius); - border-bottom-left-radius: var(--dsl-code-block-border-radius); -} - -/* The retained image sizes both views without laying out hidden source. */ -.previewLayer[hidden] { - display: grid; - visibility: hidden; - pointer-events: none; -} - -.previewable > .body { - position: absolute; - inset: 0; - overflow: auto; - border-radius: inherit; -} - -.previewable[data-code-block-header] > .body { - inset-block-start: var(--dsl-code-block-banner-height); -} - .numbered :where(pre) code { display: block; counter-reset: source-line; @@ -266,19 +128,3 @@ content: counter(source-line); user-select: none; } - -@media (max-width: 480px) { - .banner { - gap: 4px; - padding-inline: 8px; - } - - .segments { - margin-left: 0; - } - - .segments button, - .sourceOnly { - padding-inline: 4px; - } -} diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 562a098207..14bfd6290b 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -1,42 +1,21 @@ -/** Diagram-sized code surface with retained preview rendering and toolbar state. */ -import { memo, useRef, useState } from 'react' -import type { Ref } from 'react' +import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import type { CSSProperties, ReactNode, Ref } from 'react' import clsx from 'clsx' -import { Tooltip } from '../Tooltip.tsx' -import { CopyCodeButton } from './CopyCodeButton.tsx' -import { CodeBlockSource } from './CodeBlockSource.tsx' -import { SourcePreview } from './SourcePreview.tsx' -import { PreviewPlaceholder } from './PreviewPlaceholder.tsx' -import type { PreviewRenderer, SourcePreviewLabels } from './SourcePreview.tsx' +import { writeClipboard } from '../clipboard.ts' +import { + StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded, +} from './highlight.ts' +import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts' +import { useViewportHighlighting } from './useViewportHighlighting.ts' import css from './CodeBlock.module.css' -/** Renderer accepted by the public {@link CodeBlockPreview} descriptor. */ -export type CodeBlockPreviewRenderer = PreviewRenderer - -/** Localized output and controls for a {@link CodeBlock} preview. */ -export interface CodeBlockPreviewLabels extends SourcePreviewLabels { - /** Control label when source is visible. */ - preview: string - /** Control label when the preview is visible. */ - source: string -} - -/** Standard source-backed content displayed by a {@link CodeBlock} preview. */ -export interface CodeBlockPreview { - /** Convert the source into an isolated image. */ - render: CodeBlockPreviewRenderer - /** Localized failure, accessible output, and control labels. */ - labels: CodeBlockPreviewLabels -} - export interface CodeBlockProps { /** The source text, rendered verbatim (trailing newline trimmed for display). */ code: string /** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */ lang?: string | undefined /** - * The code is still growing. Previewable fences show a placeholder without - * rendering or highlighting; other streaming fences highlight through + * The code is still growing (a streaming markdown fence): highlight through * a per-instance {@link StreamingHighlightSession}, which re-tokenizes only * appended text and keeps completed line groups (and DOM) untouched. The * caller must keep the component instance stable across growth (a @@ -46,9 +25,9 @@ export interface CodeBlockProps { streaming?: boolean | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined - /** Ref for the stable content wrapper, for owners that use it as a scrollport. */ + /** Ref for the stable source-content wrapper, for owners that use it as a scrollport. */ contentRef?: Ref | undefined - /** Initial gutter preference until the reader toggles line numbers. Defaults to false; copied source excludes numbers. */ + /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */ lineNumbers?: boolean | undefined /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */ showHeader?: boolean | undefined @@ -56,77 +35,166 @@ export interface CodeBlockProps { copyLabel: string /** Copy-button label during the post-copy confirmation window. */ copiedLabel: string - /** Selected source-view label when no preview is available. */ - sourceLabel: string - /** Localized tooltip and accessible name for the source line-number toggle. */ - lineNumbersLabel: string - /** Image preview, selected initially; streaming shows a placeholder. Copy always uses source. */ - preview?: CodeBlockPreview | undefined } /** - * Display source or a retained diagram with localized controls. - * @param props - Source, presentation preferences, complete labels and optional preview renderer. - * @returns A content-sized preview, a streaming preview placeholder, or full-width source. + * The `pre` attributes shiki's HTML arm emits for the css-variables theme, + * mirrored so the streaming arm's tree is interchangeable with the settled + * swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms' + * parity). */ -export const CodeBlock = memo(function CodeBlock({ - code, lang, streaming, className, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, sourceLabel, - lineNumbersLabel, preview, contentRef, +const SHIKI_PRE_PROPS = { + className: 'shiki css-variables', + style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' }, + tabIndex: 0, +} as const + +/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */ +const STREAMING_LINE_GROUP_SIZE = 32 + +function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { + return ( + + {index > 0 && '\n'} + + {line.map((span, spanIndex) => {span.text})} + + + ) +} + +export function CodeBlock({ + code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code - const previewAvailable = preview !== undefined - const pendingStream = previewAvailable && streaming === true - const [view, setView] = useState<'source' | 'preview'>('preview') - const [sourceVisited, setSourceVisited] = useState(false) - const [numberedOverride, setNumberedOverride] = useState() - const numbered = numberedOverride ?? lineNumbers - const [actions, setActions] = useState(null) + const sourceLines = lineNumbers ? trimmed.split('\n') : undefined const rootRef = useRef(null) - const showingPreview = previewAvailable && (pendingStream || view === 'preview') + const highlighting = useViewportHighlighting(rootRef, lang) + // Re-render when a lazy grammar finishes loading, so a fence that showed plain + // text while its language's grammar imported picks up highlighting. The + // snapshot value is opaque; only its change across renders drives the memo. + const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount) + // Streaming state lives in refs mutated inside the memo (the MarkdownText + // streaming-cache pattern): the session's caches carry across chunks only + // because the owner keys this instance stably while the fence grows. + const sessionRef = useRef(null) + const lineCacheRef = useRef<{ + code: string + lang: string | undefined + generation: number + frame: StreamingHighlightFrame + groups: ReactNode[] + pending: ReactNode[] + nextLine: number + body: ReactNode + } | null>(null) + const settledRef = useRef(false) + const streamedBody = useMemo(() => { + if (!highlighting) { + sessionRef.current = null + lineCacheRef.current = null + settledRef.current = false + return undefined + } + if (streaming !== true) { + const previous = lineCacheRef.current + if (previous !== null && previous.code === trimmed && previous.lang === lang) { + settledRef.current = true + return previous.body + } + sessionRef.current = null + lineCacheRef.current = null + settledRef.current = true + return undefined + } + if (settledRef.current) { + sessionRef.current = null + lineCacheRef.current = null + settledRef.current = false + } + sessionRef.current ??= new StreamingHighlightSession() + const frame = sessionRef.current.updateFrame(trimmed, lang) + if (frame === undefined) { + lineCacheRef.current = null + return undefined + } + const previous = lineCacheRef.current + if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) { + return previous.body + } + const sameGeneration = previous?.generation === frame.generation + const groups = sameGeneration ? [...previous.groups] : [] + let pending = sameGeneration ? [...previous.pending] : [] + let nextLine = sameGeneration ? previous.nextLine : 0 + for (const line of frame.appended) { + pending.push(renderLine(line, nextLine)) + nextLine += 1 + if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue + const start = nextLine - pending.length + groups.push({pending}) + pending = [] + } + const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index)) + const tailGroup = {[...pending, ...tail]} + const body =
{groups}{tailGroup}
+ lineCacheRef.current = { + code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body, + } + return body + }, [streaming, highlighting, trimmed, lang, loaded]) + const html = useMemo( + () => (highlighting && streaming !== true && streamedBody === undefined + ? highlightToHtml(trimmed, lang) + : undefined), + [streaming, highlighting, streamedBody, trimmed, lang, loaded], + ) + const [copied, setCopied] = useState(false) + + const onCopy = useCallback(() => { + if (copied) return + /* v8 ignore next -- both arms always mount a
; trimmed is the
+       typed fallback if the DOM shape ever diverges. */
+    const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
+    void writeClipboard(text).then((ok) => {
+      if (!ok) return
+      setCopied(true)
+      window.setTimeout(() => { setCopied(false) }, 1000)
+    })
+  }, [copied, trimmed])
+
+  // shiki's HTML output is a static span tree it generated from `code` (no
+  // user HTML passes through), the sanctioned innerHTML consumption path per
+  // shiki's own docs.
+  const body = streamedBody !== undefined
+    ? streamedBody
+    : html === undefined
+      ? (
+        
{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
+          {index > 0 && '\n'}{line}
+        ))}
+ ) + : ( +
+ ) + return ( -
+
+ {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */} {showHeader &&
{lang ?? ''}
- {!pendingStream && <> - {previewAvailable &&
} -
- {!pendingStream &&
- {(!previewAvailable || sourceVisited) && } -
} -
- {previewAvailable && } +
{body}
) -}) +} diff --git a/packages/client/ui-primitives/src/markdown/CodeBlockSource.tsx b/packages/client/ui-primitives/src/markdown/CodeBlockSource.tsx deleted file mode 100644 index 146f3c0673..0000000000 --- a/packages/client/ui-primitives/src/markdown/CodeBlockSource.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/** Memoized source rendering, including retained incremental highlight groups. */ -import { Fragment, memo, useCallback, useMemo, useRef, useSyncExternalStore } from 'react' -import type { CSSProperties, ReactNode, RefObject } from 'react' -import { StreamingHighlightSession, isGrammarLoaded, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts' -import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts' -import type { CodeBlockProps } from './CodeBlock.tsx' -import { useViewportHighlighting } from './useViewportHighlighting.ts' -import css from './CodeBlock.module.css' - -/** - * The `pre` attributes shiki's HTML arm emits for the css-variables theme, - * mirrored so the streaming arm's tree is interchangeable with the settled - * swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms' - * parity). - */ -const SHIKI_PRE_PROPS = { - className: 'shiki css-variables', - style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' }, - tabIndex: 0, -} as const - -/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */ -const STREAMING_LINE_GROUP_SIZE = 32 - -function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { - return ( - - {index > 0 && '\n'} - - {line.map((span, spanIndex) => {span.text})} - - - ) -} - -/** - * Highlight one source surface independently of toolbar and preview state. - * @param props - Authored source, grammar hint, streaming state and gutter choice. - * @returns Source DOM retained across view changes and repeated rendering of the same highlight frame. - */ -export const CodeBlockSource = memo(function CodeBlockSource({ code, lang, streaming, lineNumbers, target, highlightImmediately }: - Pick & { - target: RefObject - highlightImmediately: boolean - }) { - const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code - const sourceLines = lineNumbers ? trimmed.split('\n') : undefined - const highlighting = useViewportHighlighting(target, lang, highlightImmediately) - const snapshot = useCallback(() => isGrammarLoaded(lang), [lang]) - const loaded = useSyncExternalStore(subscribeGrammarLoaded, snapshot, snapshot) - // Streaming state lives in refs mutated inside the memo (the MarkdownText - // streaming-cache pattern): the session's caches carry across chunks only - // because the owner keys this instance stably while the fence grows. - const sessionRef = useRef(null) - const lineCacheRef = useRef<{ - code: string - lang: string | undefined - generation: number - frame: StreamingHighlightFrame - groups: ReactNode[] - pending: ReactNode[] - nextLine: number - body: ReactNode - } | null>(null) - const settledRef = useRef(false) - const streamedBody = useMemo(() => { - if (!highlighting) { - sessionRef.current = null - lineCacheRef.current = null - settledRef.current = false - return undefined - } - if (streaming !== true) { - const previous = lineCacheRef.current - if (previous !== null && previous.code === trimmed && previous.lang === lang) { - settledRef.current = true - return previous.body - } - sessionRef.current = null - lineCacheRef.current = null - settledRef.current = true - return undefined - } - if (settledRef.current) { - sessionRef.current = null - lineCacheRef.current = null - settledRef.current = false - } - sessionRef.current ??= new StreamingHighlightSession() - const frame = sessionRef.current.updateFrame(trimmed, lang) - if (frame === undefined) { - lineCacheRef.current = null - return undefined - } - const previous = lineCacheRef.current - if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) { - return previous.body - } - const sameGeneration = previous?.generation === frame.generation - const groups = sameGeneration ? [...previous.groups] : [] - let pending = sameGeneration ? [...previous.pending] : [] - let nextLine = sameGeneration ? previous.nextLine : 0 - for (const line of frame.appended) { - pending.push(renderLine(line, nextLine)) - nextLine += 1 - if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue - const start = nextLine - pending.length - groups.push({pending}) - pending = [] - } - const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index)) - const tailGroup = {[...pending, ...tail]} - const body =
{groups}{tailGroup}
- lineCacheRef.current = { - code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body, - } - return body - }, [streaming, highlighting, trimmed, lang, loaded]) - const html = useMemo( - () => (highlighting && streaming !== true && streamedBody === undefined - ? highlightToHtml(trimmed, lang) - : undefined), - [streaming, highlighting, streamedBody, trimmed, lang, loaded], - ) - // shiki's HTML output is a static span tree it generated from `code` (no - // user HTML passes through), the sanctioned innerHTML consumption path per - // shiki's own docs. - const body = streamedBody !== undefined - ? streamedBody - : html === undefined - ? ( -
{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
-          {index > 0 && '\n'}{line}
-        ))}
- ) - : ( -
- ) - - return
{body}
-}) diff --git a/packages/client/ui-primitives/src/markdown/CopyCodeButton.tsx b/packages/client/ui-primitives/src/markdown/CopyCodeButton.tsx deleted file mode 100644 index 3ef416f0bb..0000000000 --- a/packages/client/ui-primitives/src/markdown/CopyCodeButton.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** Copy confirmation is local to the icon button, independent of source rendering. */ -import { memo, useEffect, useRef, useState } from 'react' -import { writeClipboard } from '../clipboard.ts' -import { Tooltip } from '../Tooltip.tsx' -import { IconCheckOutline16, IconCopyOutline16 } from '../icons/index.tsx' -import css from './CodeBlock.module.css' - -/** - * Copy source without changing toolbar geometry. - * @param props - Source and localized idle/success labels. - * @returns A fixed-size icon action with local confirmation state. - */ -export const CopyCodeButton = memo(function CopyCodeButton({ code, copyLabel, copiedLabel }: { - code: string - copyLabel: string - copiedLabel: string -}) { - const [copied, setCopied] = useState(false) - const alive = useRef(false) - const timer = useRef>() - useEffect(() => { - alive.current = true - return () => { alive.current = false; clearTimeout(timer.current) } - }, []) - const label = copied ? copiedLabel : copyLabel - return - - -}) diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 25fee3dacf..57b65e12ae 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -17,13 +17,10 @@ import clsx from 'clsx' import { IncrementalMarkdownParser } from './incremental.ts' import { parseGfm, parseGfmWithMath } from './parse.ts' import { - collectReferenceTargets, createFencePreviewCatalog, createReferenceTargets, renderBlocks, renderFootnoteSection, + collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, wrapBlockChildren, } from './render.tsx' -import type { - FencePreviewCatalog, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages, MarkdownRenderContext, - ReferenceTargets, -} from './render.tsx' +import type { MarkdownFileMentions, MarkdownLabels, MarkdownPathImages, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' @@ -33,7 +30,6 @@ export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, Markdown function renderSettled( text: string, labels: MarkdownLabels, - previews: FencePreviewCatalog | undefined, fileMentions: MarkdownFileMentions | undefined, pathImages: MarkdownPathImages | undefined, ): ReactNode[] { @@ -43,7 +39,6 @@ function renderSettled( const context: MarkdownRenderContext = { streaming: false, labels, - previews, fileMentions, pathImages, targets, @@ -79,11 +74,8 @@ class StreamingRenderer { private lastText: string | null = null private lastRendered: ReactNode[] = [] - /** - * @param labels - Localized chrome baked into cached elements. - * @param previews - Stable preview capabilities shared with the settled render. - */ - constructor(private readonly labels: MarkdownLabels, private readonly previews: FencePreviewCatalog | undefined) {} + /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */ + constructor(private readonly labels: MarkdownLabels) {} /** * Render the current accumulated text. Idempotent per text value, so React @@ -116,7 +108,6 @@ class StreamingRenderer { const frozenContext: MarkdownRenderContext = { streaming: true, labels: this.labels, - previews: this.previews, fileMentions: undefined, pathImages: undefined, targets: frameTargets, @@ -136,7 +127,6 @@ class StreamingRenderer { const tailContext: MarkdownRenderContext = { streaming: true, labels: this.labels, - previews: this.previews, fileMentions: undefined, pathImages: undefined, targets: frameTargets, @@ -160,8 +150,7 @@ class StreamingRenderer { * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; * `streaming` parses incrementally across chunks and highlights fences as - * they grow unless preview labels select a loading placeholder (source-only - * fences re-tokenize only appended text; TeX stays literal + * they grow (each fence re-tokenizes only appended text; TeX stays literal * until the finalize swap so incomplete formulae never flash errors); * `labels` forwards localized fence and footnote chrome — pass a * reference-stable object (memoized per locale revision), because a new @@ -192,18 +181,17 @@ export const MarkdownText = memo(function MarkdownText({ }) { const streamRef = useRef(null) const streamLabelsRef = useRef(labels) - const previews = useMemo(() => createFencePreviewCatalog(labels.preview), [labels.preview]) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, labels, previews, fileMentions, pathImages) + return renderSettled(text, labels, fileMentions, pathImages) } if (streamRef.current === null || streamLabelsRef.current !== labels) { - streamRef.current = new StreamingRenderer(labels, previews) + streamRef.current = new StreamingRenderer(labels) streamLabelsRef.current = labels } return streamRef.current.render(text) - }, [text, streaming, labels, previews, fileMentions, pathImages]) + }, [text, streaming, labels, fileMentions, pathImages]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/PreviewLightbox.module.css b/packages/client/ui-primitives/src/markdown/PreviewLightbox.module.css deleted file mode 100644 index ed920f460c..0000000000 --- a/packages/client/ui-primitives/src/markdown/PreviewLightbox.module.css +++ /dev/null @@ -1,65 +0,0 @@ -.backdrop { - position: fixed; - inset: 0; - z-index: 1000; - overflow: hidden; - overscroll-behavior: contain; -} - -.mask { - position: fixed; - inset: 0; - background: var(--dsw-alias-bg-mask-1); - backdrop-filter: var(--dsw-mask-blur); -} - -.image { - position: absolute; - left: 50%; - top: 50%; - max-width: none; - transform: translate(-50%, -50%); - cursor: grab; - touch-action: none; - user-select: none; - border-radius: 12px; - background: var(--dsw-specific-input-major); - box-shadow: var(--dsw-shadow-lv3); -} - -.image[data-dragging] { - cursor: grabbing; -} - -.hint { - position: absolute; - bottom: 16px; - left: 50%; - transform: translateX(-50%); - max-width: calc(100% - 32px); - width: max-content; - padding: 6px 12px; - border-radius: 12px; - background: var(--dsw-specific-input-major); - color: var(--dsw-alias-label-secondary); - font: var(--dsw-font-xs-13); - text-align: center; - pointer-events: none; -} - -.close { - position: fixed; - top: 20px; - right: 20px; - z-index: 1; - display: grid; - place-items: center; - width: 36px; - height: 36px; - border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 999px; - corner-shape: round; - background: var(--dsw-specific-input-major); - color: var(--dsw-alias-label-primary); - cursor: pointer; -} diff --git a/packages/client/ui-primitives/src/markdown/PreviewLightbox.tsx b/packages/client/ui-primitives/src/markdown/PreviewLightbox.tsx deleted file mode 100644 index a7771efc64..0000000000 --- a/packages/client/ui-primitives/src/markdown/PreviewLightbox.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** Viewport-fitted image dialog with local pan and zoom transforms. */ -import { useEffect, useId, useRef } from 'react' -import { createPortal } from 'react-dom' -import { IconCloseOutline16 } from '../icons/index.tsx' -import css from './PreviewLightbox.module.css' - -/** - * Fit a rendered diagram inside the viewport, with pointer pan and cursor-anchored zoom. - * @param props - Image source, accessible labels, gesture instructions, and close callback. - * @returns A dialog with drag, wheel and keyboard controls; double-click or Home restores the fitted view. - */ -export function PreviewLightbox({ src, alt, closeLabel, interactionLabel, onClose }: { - src: string - alt: string - closeLabel: string - interactionLabel: string - onClose: () => void -}) { - const closeRef = useRef(null) - const imageRef = useRef(null) - const surfaceRef = useRef(null) - const hintId = useId() - - useEffect(() => { - const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null - closeRef.current?.focus() - const onKeyDown = (event: globalThis.KeyboardEvent): void => { - if (event.key === 'Escape') onClose() - // The close control is the dialog's only focusable element. - if (event.key === 'Tab') event.preventDefault() - } - window.addEventListener('keydown', onKeyDown) - return () => { - window.removeEventListener('keydown', onKeyDown) - restore?.focus() - } - }, [onClose]) - - useEffect(() => { - // oxlint-disable-next-line typescript/no-non-null-assertion -- refs belong to this mounted dialog. - const image = imageRef.current! - // oxlint-disable-next-line typescript/no-non-null-assertion -- refs belong to this mounted dialog. - const surface = surfaceRef.current! - const view = { x: 0, y: 0, scale: 1 } - let drag: { id: number; x: number; y: number } | undefined - const paint = () => { - image.style.transform = `translate(-50%, -50%) translate(${view.x}px, ${view.y}px) scale(${view.scale})` - } - const fit = () => { - if (image.naturalWidth === 0 || image.naturalHeight === 0) return - const scale = Math.min(window.innerWidth * 0.88 / image.naturalWidth, window.innerHeight * 0.84 / image.naturalHeight) - image.style.width = `${image.naturalWidth * scale}px` - image.style.height = `${image.naturalHeight * scale}px` - view.x = 0 - view.y = 0 - view.scale = 1 - paint() - } - const zoom = (factor: number, x = 0, y = 0) => { - const scale = Math.min(8, Math.max(0.25, view.scale * factor)) - const ratio = scale / view.scale - view.x = x - (x - view.x) * ratio - view.y = y - (y - view.y) * ratio - view.scale = scale - paint() - } - const onWheel = (event: WheelEvent) => { - event.preventDefault() - const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1 - const rect = surface.getBoundingClientRect() - zoom(Math.exp(-event.deltaY * unit * 0.002), event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2) - } - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0 || !event.isPrimary) return - drag = { id: event.pointerId, x: event.clientX, y: event.clientY } - image.setPointerCapture(event.pointerId) - image.dataset['dragging'] = '' - event.preventDefault() - } - const onPointerMove = (event: PointerEvent) => { - if (drag === undefined || drag.id !== event.pointerId) return - view.x += event.clientX - drag.x - view.y += event.clientY - drag.y - drag.x = event.clientX - drag.y = event.clientY - paint() - } - const endDrag = () => { - if (drag === undefined) return - const id = drag.id - drag = undefined - delete image.dataset['dragging'] - if (image.hasPointerCapture(id)) image.releasePointerCapture(id) - } - const onPointerEnd = (event: PointerEvent) => { - if (drag?.id === event.pointerId) endDrag() - } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === '+' || event.key === '=') zoom(1.2) - else if (event.key === '-') zoom(1 / 1.2) - else if (event.key === 'Home' || event.key === '0') fit() - else { - const dx = event.key === 'ArrowRight' ? 32 : event.key === 'ArrowLeft' ? -32 : 0 - const dy = event.key === 'ArrowDown' ? 32 : event.key === 'ArrowUp' ? -32 : 0 - if (dx === 0 && dy === 0) return - view.x += dx - view.y += dy - paint() - } - event.preventDefault() - } - image.addEventListener('load', fit) - image.addEventListener('dblclick', fit) - image.addEventListener('pointerdown', onPointerDown) - image.addEventListener('pointermove', onPointerMove) - image.addEventListener('pointerup', onPointerEnd) - image.addEventListener('pointercancel', onPointerEnd) - image.addEventListener('lostpointercapture', onPointerEnd) - surface.addEventListener('wheel', onWheel, { passive: false }) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('resize', fit) - fit() - return () => { - endDrag() - image.removeEventListener('load', fit) - image.removeEventListener('dblclick', fit) - image.removeEventListener('pointerdown', onPointerDown) - image.removeEventListener('pointermove', onPointerMove) - image.removeEventListener('pointerup', onPointerEnd) - image.removeEventListener('pointercancel', onPointerEnd) - image.removeEventListener('lostpointercapture', onPointerEnd) - surface.removeEventListener('wheel', onWheel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('resize', fit) - } - }, [src]) - - return createPortal( -
- , - document.body, - ) -} diff --git a/packages/client/ui-primitives/src/markdown/PreviewPlaceholder.tsx b/packages/client/ui-primitives/src/markdown/PreviewPlaceholder.tsx deleted file mode 100644 index 59acec0919..0000000000 --- a/packages/client/ui-primitives/src/markdown/PreviewPlaceholder.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** Stable waiting canvas shared by streaming diagrams and image loading. */ -import { memo } from 'react' -import css from './SourcePreview.module.css' - -/** - * Display a quiet loading status without depending on the growing source. - * @param props - Localized loading announcement. - * @returns A fixed-height canvas with a decorative pulsing image glyph. - */ -export const PreviewPlaceholder = memo(function PreviewPlaceholder({ label }: { label: string }) { - return
- - {label} -
-}) diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.module.css b/packages/client/ui-primitives/src/markdown/SourcePreview.module.css deleted file mode 100644 index a9aa4a0e56..0000000000 --- a/packages/client/ui-primitives/src/markdown/SourcePreview.module.css +++ /dev/null @@ -1,66 +0,0 @@ -.root { - min-width: 0; - border-radius: inherit; -} - -.canvas { - display: grid; - place-items: center; - min-height: 120px; - padding: 16px; - box-sizing: border-box; - background: var(--dsw-alias-markdown-code-block); - border-radius: inherit; -} - -.diagram { - display: block; - max-width: 100%; - height: auto; - max-height: min(60vh, 640px); - margin: 0 auto; -} - -.status { - padding: 16px; - color: var(--dsw-alias-label-secondary); -} - -.placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - height: 180px; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; -} - -.placeholderIcon { - animation: previewPulse 1.8s ease-in-out infinite; -} - -@keyframes previewPulse { - 0%, 100% { opacity: 0.35; } - 50% { opacity: 0.8; } -} - -.canvas[hidden], .diagram[hidden] { display: none; } - -@media (prefers-reduced-motion: reduce) { - .placeholderIcon { animation: none; } -} - -.failure { - min-height: 120px; - background: var(--dsw-alias-markdown-code-block); -} - -.staging { - position: fixed; - top: 0; - left: -100000px; - visibility: hidden; - pointer-events: none; -} diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx deleted file mode 100644 index 62b5a677c1..0000000000 --- a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** Read-only diagram preview with image loading status and per-source async ownership. */ - -import { memo, useCallback, useEffect, useId, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { Tooltip } from '../Tooltip.tsx' -import { IconSearchOutline16 } from '../icons/index.tsx' -import { PreviewLightbox } from './PreviewLightbox.tsx' -import { PreviewPlaceholder } from './PreviewPlaceholder.tsx' -import css from './SourcePreview.module.css' -import blockCss from './CodeBlock.module.css' -import { readPreviewTheme } from './preview-theme.ts' -import { observeViewport } from './viewport.ts' - -/** Localized preview output; the diagram source remains verbatim. */ -export interface PreviewLabels { - diagram: string - error: string -} - -/** Localized controls added by the interactive preview surface. */ -export interface SourcePreviewLabels extends PreviewLabels { - zoom: string - pending: string - close: string - interaction: string -} - -/** - * Convert source into an isolated preview image URL. - * @param code - Complete preview source. - * @param signal - Cancels publication of obsolete work. - * @returns The isolated image URL, synchronously or asynchronously. - */ -export type PreviewRenderer = (code: string, signal: AbortSignal) => string | Promise - -/** Inputs whose identity controls preview rendering and localized status copy. */ -export interface SourcePreviewProps { - code: string - labels: SourcePreviewLabels - render: PreviewRenderer - /** Existing action container that receives the zoom control. */ - actions?: HTMLElement | undefined -} - -type Result = { - code: string - render: PreviewRenderer - palette: string - /** Last loaded image of the same source, retained while its replacement loads or fails. */ - previous: string | undefined -} & ( - | { kind: 'ok'; src: string; loaded: boolean } - | { kind: 'error' } -) - -function loadedImage(result: Result | null): string | undefined { - if (result === null) return undefined - switch (result.kind) { - case 'ok': return result.loaded ? result.src : result.previous - case 'error': return result.previous - /* v8 ignore next -- closed-union backstop; every declared result kind is handled above. */ - default: return assertNever(result) - } -} - -/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged. */ -function assertNever(value: never): never { - throw new Error(`unreachable preview result: ${String(value)}`) -} - -/** - * Render visible diagrams and retain their loaded images during theme refreshes. - * @param props - Source and complete localized labels. Source, visibility and document theme changes cancel obsolete renders. - * @returns A pending status, inert image or error, with a persistent zoom action. - */ -export const SourcePreview = memo(function SourcePreview({ code, labels, render, actions }: SourcePreviewProps) { - const pendingId = useId() - const target = useRef(null) - const [visible, setVisible] = useState(false) - const [result, setResult] = useState(null) - const [expanded, setExpanded] = useState<{ code: string; render: PreviewRenderer } | null>(null) - const close = useCallback(() => { setExpanded(null) }, []) - const current = result?.code === code && result.render === render ? result : null - const renderedPalette = current?.palette - const expandedCurrent = expanded?.code === code && expanded.render === render - const active = visible || expandedCurrent - useEffect(() => { - // oxlint-disable-next-line typescript/no-non-null-assertion -- React attaches this surface before effects run. - return observeViewport(target.current!, setVisible) - }, []) - useEffect(() => { - if (!active) return - let controller: AbortController | undefined - let palette: string | undefined - const update = () => { - const next = JSON.stringify(readPreviewTheme()) - if (next === palette) return - palette = next - controller?.abort() - if (next === renderedPalette) return - const current = new AbortController() - controller = current - void (async () => await render(code, current.signal))().then( - (src) => { - if (current.signal.aborted) return - controller = undefined - setResult((prior) => { - const previous = prior?.code === code && prior.render === render ? loadedImage(prior) : undefined - return { kind: 'ok', code, render, palette: next, src, loaded: src === previous, previous: src === previous ? undefined : previous } - }) - }, - () => { - if (current.signal.aborted) return - controller = undefined - setResult(prior => ({ kind: 'error', code, render, palette: next, - previous: prior?.code === code && prior.render === render ? loadedImage(prior) : undefined })) - }, - ) - } - // Isolated images cannot inherit the host's CSS variables. - const observer = new MutationObserver(update) - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) - observer.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] }) - update() - return () => { observer.disconnect(); controller?.abort() } - }, [active, code, render, renderedPalette]) - - const displayed = loadedImage(current) - const ready = displayed !== undefined - const pending = !ready && current?.kind !== 'error' - const markLoaded = () => { - if (current?.kind !== 'ok' || current.loaded) return - setResult(prior => prior === current ? { ...current, loaded: true, previous: undefined } : prior) - } - return
- {actions !== undefined && createPortal( - - - - {!ready && } - - , actions, - )} - {pending && } - {current?.kind === 'error' &&
-
{labels.error}
-
} - - {ready && expandedCurrent && } -
-}) diff --git a/packages/client/ui-primitives/src/markdown/grammars/dot.ts b/packages/client/ui-primitives/src/markdown/grammars/dot.ts deleted file mode 100644 index 8be72126ff..0000000000 --- a/packages/client/ui-primitives/src/markdown/grammars/dot.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * DOT TextMate grammar from joaompinto/vscode-graphviz (MIT), commit - * e001e27f7831fb34cb9a8de9c456485bbc509ec3, dot/syntaxes/dot.tmLanguage. - * Mechanically decoded from plist; patterns and scopes are unchanged. - * Editor metadata and rule comments are omitted; Shiki uses the dot id and graphviz alias. - * Source and license: ../../../THIRD_PARTY_PREVIEW_NOTICES.txt. - */ -import type { LanguageRegistration } from 'shiki/core' - -/** Pinned DOT registration for the shared lazy highlighter. */ -const grammar: LanguageRegistration[] = [ - { - 'name': 'dot', - 'repository': {}, - 'patterns': [ - { - 'captures': { - '1': { - 'name': 'storage.type.dot', - }, - '2': { - 'name': 'variable.other.dot', - }, - '4': { - 'name': 'punctuation.section.dot', - }, - }, - 'match': ' ?(digraph)[ \\t]+([A-Za-z0-9]+) ?(\\{)', - }, - { - 'match': '(<|-)(>|-)', - 'name': 'keyword.operator.dot', - }, - { - 'match': '\\b(node|edge|graph|digraph|subgraph|strict)\\b', - 'name': 'storage.type.dot', - }, - { - 'match': '\\b(bottomlabel|color|comment|distortion|fillcolor|fixedsize|fontcolor|fontname|fontsize|group|height|label|layer|orientation|peripheries|regular|shape|shapefile|sides|skew|style|toplabel|URL|width|z)\\b', - 'name': 'support.constant.attribute.node.dot', - }, - { - 'match': '\\b(arrowhead|arrowsize|arrowtail|color|comment|constraint|decorate|dir|fontcolor|fontname|fontsize|headlabel|headport|headURL|label|labelangle|labeldistance|labelfloat|labelcolor|labelfontname|labelfontsize|layer|lhead|ltail|minlen|samehead|sametail|splines|style|taillabel|tailport|tailURL|weight)\\b', - 'name': 'support.constant.attribute.edge.dot', - }, - { - 'match': '\\b(bgcolor|center|clusterrank|color|comment|compound|concentrate|fillcolor|fontname|fontpath|fontsize|label|labeljust|labelloc|layers|margin|mclimit|nodesep|nslimit|nslimit1|ordering|orientation|page|pagedir|quantum|rank|rankdir|ranksep|ratio|remincross|rotate|samplepoints|searchsize|size|style|URL)\\b', - 'name': 'support.constant.attribute.graph.dot', - }, - { - 'match': '\\b(box|polygon|ellipse|oval|circle|point|egg|triangle|plaintext|plain|diamond|trapezium|parallelogram|house|pentagon|hexagon|septagon|octagon|doublecircle|doubleoctagon|tripleoctagon|invtriangle|invtrapezium|invhouse|Mdiamond|Msquare|Mcircle|rect|rectangle|square|star|none|underline|cylinder|note|tab|folder|box3d|component|promoter|cds|terminator|utr|primersite|restrictionsite|fivepoverhang|threepoverhang|noverhang|assembly|signature|insulator|ribosite|rnastab|proteasesite|proteinstab|rpromoter|rarrow|larrow|lpromoter)\\b', - 'name': 'variable.other.dot', - }, - { - 'begin': '"', - 'beginCaptures': { - '0': { - 'name': 'punctuation.definition.string.begin.dot', - }, - }, - 'end': '"', - 'endCaptures': { - '0': { - 'name': 'punctuation.definition.string.end.dot', - }, - }, - 'name': 'string.quoted.double.dot', - 'patterns': [ - { - 'match': '\\\\.', - 'name': 'constant.character.escape.dot', - }, - ], - }, - { - 'begin': '(^[ \\t]+)?(?=//)', - 'beginCaptures': { - '1': { - 'name': 'punctuation.whitespace.comment.leading.dot', - }, - }, - 'end': '(?!\\G)', - 'patterns': [ - { - 'begin': '//', - 'beginCaptures': { - '0': { - 'name': 'punctuation.definition.comment.dot', - }, - }, - 'end': '\\n', - 'name': 'comment.line.double-slash.dot', - }, - ], - }, - { - 'begin': '(^[ \\t]*)(?=#)', - 'beginCaptures': { - '1': { - 'name': 'punctuation.whitespace.comment.leading.dot', - }, - }, - 'end': '(?!\\G)', - 'patterns': [ - { - 'begin': '#', - 'beginCaptures': { - '0': { - 'name': 'punctuation.definition.comment.dot', - }, - }, - 'end': '\\n', - 'name': 'comment.line.number-sign.dot', - }, - ], - }, - { - 'begin': '/\\*', - 'captures': { - '0': { - 'name': 'punctuation.definition.comment.dot', - }, - }, - 'end': '\\*/', - 'name': 'comment.block.dot', - }, - ], - 'scopeName': 'source.dot', - 'displayName': 'Graphviz (DOT)', - 'aliases': [ - 'graphviz', - ], - }, -] - -export default grammar diff --git a/packages/client/ui-primitives/src/markdown/graphviz.ts b/packages/client/ui-primitives/src/markdown/graphviz.ts deleted file mode 100644 index 7af1e5832d..0000000000 --- a/packages/client/ui-primitives/src/markdown/graphviz.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** Lazy Graphviz WebAssembly layout; generated SVG is displayed only as an inert image. */ - -import type { instance } from '@viz-js/viz' -import { renderSvg } from './svg.ts' -import { readPreviewTheme } from './preview-theme.ts' - -let runtime: ReturnType | undefined - -/** - * Render Graphviz DOT with document-theme defaults; authored attributes override those defaults. - * @param code - Complete Graphviz DOT source. - * @param signal - Cancels work waiting for the runtime; synchronous layout cannot be interrupted. - * @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject. - */ -export async function renderGraphviz(code: string, signal: AbortSignal): Promise { - signal.throwIfAborted() - runtime ??= import('@viz-js/viz').then(module => module.instance()).catch((error: unknown) => { - runtime = undefined - throw error - }) - const viz = await runtime - signal.throwIfAborted() - const palette = readPreviewTheme() - const color = (value: string): string => value.replace( - /^rgb\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)\s*\)$/, - (_match, r: string, g: string, b: string) => `#${[r, g, b].map(channel => Number(channel).toString(16).padStart(2, '0')).join('')}`, - ) - return renderSvg(viz.renderString(code, { - engine: 'dot', format: 'svg', - graphAttributes: { bgcolor: 'transparent', fontcolor: color(palette.text) }, - nodeAttributes: { - color: color(palette.border), fontcolor: color(palette.text), fillcolor: color(palette.surface), style: 'filled', - }, - edgeAttributes: { color: color(palette.line), fontcolor: color(palette.text) }, - }), signal) -} diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 7e2a0c6474..889ccf9325 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -9,10 +9,10 @@ * JSON) load into the singleton at boot — the set every session renders. The * read card's wider extension set (the file-extension language hints the read * tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml, - * markup, …) and diagram source grammars are imported lazily and registered - * on first use, keeping their grammar modules and synchronous initialization - * out of sessions that never render those languages. - * The first render of a lazy language falls back to plain text while its + * markup, …) is imported lazily and registered the first time such a language + * is requested, so a session that never opens a read card in one of those + * languages pays neither the ~1.6 MB of grammar modules nor their synchronous + * init. The first render of a lazy language falls back to plain text while its * grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render * with highlighting. An unknown or absent language falls back to plain text (no * highlighting, still monospace) — never an error. @@ -42,8 +42,8 @@ type LangModule = { default: typeof langTs } const LANGS = [langTs, langBash, langJson] /** - * Additional source grammars, each behind a dynamic import so its module - * stays out of the boot chunk until that language renders. + * The read card's extension grammars, each behind a dynamic import so its + * module stays out of the boot chunk until a read of that language renders. * Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to. * `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader * hands the whole array to `loadLanguageSync`, which registers each entry @@ -73,12 +73,6 @@ const LAZY_GRAMMARS = new Map Promise>([ ['less', () => import('@shikijs/langs/less')], ['sql', () => import('@shikijs/langs/sql')], ['xml', () => import('@shikijs/langs/xml')], - ['dot', () => import('./grammars/dot.ts')], - // Upstream's entry matches Markdown fences; CodeBlock supplies only their body. - ['mermaid', async () => { - const { default: grammars } = await import('@shikijs/langs/mermaid') - return { default: grammars.map(grammar => ({ ...grammar, patterns: [{ include: '#mermaid' }] })) } - }], ['lua', () => import('@shikijs/langs/lua')], ]) @@ -135,10 +129,6 @@ const LANG_ALIASES = new Map([ ['less', 'less'], ['sql', 'sql'], ['xml', 'xml'], - ['svg', 'xml'], - ['dot', 'dot'], - ['graphviz', 'dot'], - ['mermaid', 'mermaid'], ['lua', 'lua'], ]) @@ -235,16 +225,6 @@ export function grammarLoadCount(): number { return loadCount } -/** - * Read readiness without loading a grammar or invalidating unrelated source views. - * @param lang - Case-insensitive language hint. - * @returns Whether its grammar is already registered. - */ -export function isGrammarLoaded(lang: string | undefined): boolean { - const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase()) - return resolved !== undefined && highlighter().getLoadedLanguages().includes(resolved) -} - /** * Ensure the grammar `resolved` names is registered. A boot grammar (not in * {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready diff --git a/packages/client/ui-primitives/src/markdown/mermaid.ts b/packages/client/ui-primitives/src/markdown/mermaid.ts deleted file mode 100644 index efd8c013f7..0000000000 --- a/packages/client/ui-primitives/src/markdown/mermaid.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */ - -import type { Mermaid } from 'mermaid' -import type { FlowDB } from 'mermaid/dist/diagrams/flowchart/flowDb.js' -import clsx from 'clsx' -import css from './SourcePreview.module.css' -import { readPreviewTheme } from './preview-theme.ts' - -let runtime: Promise | undefined -let nextDiagramId = 0 -let pending: Promise = Promise.resolve() - -function loadMermaid(): Promise { - runtime ??= import('mermaid').then(({ default: mermaid }) => mermaid).catch((error: unknown) => { - runtime = undefined - throw error - }) - return runtime -} - -function initialize(mermaid: Mermaid): void { - const palette = readPreviewTheme() - mermaid.initialize({ - theme: 'base', - startOnLoad: false, - securityLevel: 'strict', - suppressErrorRendering: true, - themeVariables: { - darkMode: palette.dark, - background: palette.background, - primaryColor: palette.surface, - primaryTextColor: palette.text, - primaryBorderColor: palette.border, - secondaryColor: palette.surface, - tertiaryColor: palette.surface, - lineColor: palette.line, - textColor: palette.text, - actorBkg: palette.surface, - actorBorder: palette.border, - actorTextColor: palette.text, - actorLineColor: palette.border, - signalColor: palette.line, - signalTextColor: palette.text, - labelBoxBkgColor: palette.surface, - labelTextColor: palette.text, - edgeLabelBackground: palette.background, - }, - htmlLabels: false, - // Mermaid still accepts this legacy override alongside the global HTML-label setting. - // oxlint-disable-next-line typescript/no-deprecated - flowchart: { htmlLabels: false }, - secure: [ - 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', - 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart', - ], - }) -} - -/** - * Render untrusted diagram source using the document palette, without installing SVG or link handlers in the UI. - * @param code - Complete Mermaid source. - * @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup. - * @returns An SVG data URL. Image nodes, import, parse, rendering, and cancellation failures reject. - */ -export async function renderMermaid(code: string, signal: AbortSignal): Promise { - const task = pending.then(() => renderOne(code, signal)) - pending = task.catch(() => { /* The caller owns rejection; subsequent diagrams still render. */ }) - return task -} - -async function renderOne(code: string, signal: AbortSignal): Promise { - signal.throwIfAborted() - const mermaid = await loadMermaid() - signal.throwIfAborted() - initialize(mermaid) - // Mermaid image nodes fetch resources during layout, even under strict security. - // The replacement parse API exposes validity, not parsed nodes. - // oxlint-disable-next-line typescript/no-deprecated - const diagram = await mermaid.mermaidAPI.getDiagramFromText(code) - signal.throwIfAborted() - if (diagram.type.startsWith('flowchart')) { - for (const node of (diagram.db as FlowDB).getVertices().values()) { - if (node.img !== undefined) throw new Error('Mermaid image nodes are not supported') - } - } - const stage = document.createElement('div') - stage.className = clsx(css.staging) - stage.setAttribute('aria-hidden', 'true') - document.body.append(stage) - try { - // Initialization and rendering share the queue because Mermaid owns global configuration. - const { svg } = await mermaid.render(`dsh-mermaid-${nextDiagramId++}`, code, stage) - const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement - const viewBox = root.getAttribute('viewBox') - if (viewBox !== null) { - // An SVG image needs intrinsic dimensions; Mermaid's percentage width is for inline SVG. - const [, , width, height] = viewBox.split(/\s+/) as [string, string, string, string] - root.setAttribute('width', width) - root.setAttribute('height', height) - } - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(root))}` - } finally { - stage.remove() - } -} diff --git a/packages/client/ui-primitives/src/markdown/preview-theme.ts b/packages/client/ui-primitives/src/markdown/preview-theme.ts deleted file mode 100644 index e497e10a80..0000000000 --- a/packages/client/ui-primitives/src/markdown/preview-theme.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Resolved document colors used by isolated diagram renderers. */ - -interface PreviewTheme { - dark: boolean - background: string - surface: string - text: string - line: string - border: string -} - -/** - * Read the palette after the application's theme presenter updates the DOM. - * @returns Semantic preview colors and the resolved browser color scheme. - */ -export function readPreviewTheme(): PreviewTheme { - const style = getComputedStyle(document.body) - const color = (name: string): string => style.getPropertyValue(`--dsw-alias-${name}`).trim() - return { - dark: getComputedStyle(document.documentElement).colorScheme === 'dark', - background: color('markdown-code-block'), - surface: color('bg-layer-2'), - text: color('label-primary'), - line: color('label-secondary'), - border: color('label-tertiary'), - } -} diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 3527c00582..97b09d7e89 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -23,81 +23,24 @@ 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 type { CodeBlockPreview } from './CodeBlock.tsx' -import { renderGraphviz } from './graphviz.ts' -import { renderSvg } from './svg.ts' -import { renderMermaid } from './mermaid.ts' -import type { PreviewLabels } from './SourcePreview.tsx' import { renderTexToReact } from './katex.tsx' import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx' import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx' import type { PositionedBlock } from './incremental.ts' import css from './MarkdownText.module.css' -const FENCE_PREVIEW_RENDERERS = { - mermaid: renderMermaid, - graphviz: renderGraphviz, - svg: renderSvg, -} as const - /** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */ export interface MarkdownCodeLabels { /** Copy-button idle label. */ copyLabel: string /** Copy-button label during the post-copy confirmation window. */ copiedLabel: string - /** Selected source-view label. */ - sourceLabel: string - /** Localized source line-number toggle label. */ - lineNumbersLabel: string } /** Localized chrome for a Markdown document. */ export interface MarkdownLabels { code: MarkdownCodeLabels footnotes: string - /** Opt into settled Mermaid, Graphviz, and SVG previews with complete localized labels. */ - preview?: { - mermaid: PreviewLabels - graphviz: PreviewLabels - svg: PreviewLabels - preview: string - source: string - zoom: string - pending: string - close: string - interaction: string - } -} - -type FencePreviewKind = keyof typeof FENCE_PREVIEW_RENDERERS - -/** Stable preview descriptors shared by every settled fence in one label revision. */ -export type FencePreviewCatalog = Readonly> - -/** - * Build the preview descriptors owned by one localized label revision. - * @param labels - Localized preview labels, or undefined when previews are disabled. - * @returns Stable language descriptors for the lifetime of `labels`. - */ -export function createFencePreviewCatalog( - labels: MarkdownLabels['preview'], -): FencePreviewCatalog | undefined { - if (labels === undefined) return undefined - const previewLabels = (kind: K) => ({ - ...labels[kind], - preview: labels.preview, - source: labels.source, - zoom: labels.zoom, - pending: labels.pending, - close: labels.close, - interaction: labels.interaction, - }) - return { - mermaid: { render: FENCE_PREVIEW_RENDERERS.mermaid, labels: previewLabels('mermaid') }, - graphviz: { render: FENCE_PREVIEW_RENDERERS.graphviz, labels: previewLabels('graphviz') }, - svg: { render: FENCE_PREVIEW_RENDERERS.svg, labels: previewLabels('svg') }, - } } function sanitizeUrl(url: string): string { @@ -236,8 +179,6 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly labels: MarkdownLabels - /** Settled fence previews resolved once for the current localized labels. */ - readonly previews: FencePreviewCatalog | undefined /** Inside a blockquote's children: tables there always fill the quote's width. */ readonly inBlockquote?: boolean /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ @@ -422,10 +363,8 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode { const language = node.lang ?? undefined - const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0] - const preview = fencePreview(lang, context.previews) - if (node.value === '' && !(context.streaming && preview !== undefined)) { - // Empty source-only fences keep the stock Markdown
.
+  if (node.value === '') {
+    // Parity: the replaced pipeline kept the stock 
 for an empty fence.
     return (
       
         
@@ -434,6 +373,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
   }
   // The replaced pipeline recovered the grammar id from the hast class with
   // /language-([\w-]+)/, which truncates at the first non-word character.
+  const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
   if (!context.streaming && lang === 'math') {
     // ```math fences render as display TeX once settled (rehype-katex parity);
     // its text extraction saw the code block's trailing newline.
@@ -449,26 +389,16 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
       lang={lang}
       // Streaming keys are source offsets, stable while the fence grows, so
       // the CodeBlock instance (and its incremental highlight session)
-      // survives every chunk. The language hint is final once content starts.
+      // survives every chunk. A fence whose info string is still mid-chunk
+      // has no content yet and took the empty-fence arm above, so `lang`
+      // here is final: it can never re-resolve to a different grammar.
       streaming={context.streaming}
-      sourceLabel={context.labels.code.sourceLabel}
-      lineNumbersLabel={context.labels.code.lineNumbersLabel}
       copyLabel={context.labels.code.copyLabel}
       copiedLabel={context.labels.code.copiedLabel}
-      preview={preview}
     />
   )
 }
 
-/** Resolve the supported fence language without treating arbitrary HTML in Markdown as a preview. */
-function fencePreview(lang: string | undefined, previews: FencePreviewCatalog | undefined): CodeBlockPreview | undefined {
-  if (previews === undefined) return undefined
-  const normalized = lang?.toLowerCase()
-  const kind = normalized === 'dot' ? 'graphviz' : normalized
-  if (kind !== 'mermaid' && kind !== 'graphviz' && kind !== 'svg') return undefined
-  return previews[kind]
-}
-
 /** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
 function listLoose(list: Md.List): boolean {
   return (list.spread ?? false) || list.children.some(listItemLoose)
diff --git a/packages/client/ui-primitives/src/markdown/svg.ts b/packages/client/ui-primitives/src/markdown/svg.ts
deleted file mode 100644
index 52ab356536..0000000000
--- a/packages/client/ui-primitives/src/markdown/svg.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/** SVG image previews without executable source markup. */
-
-/**
- * Prepare an SVG image URL without activating SVG scripts, links, or external resources.
- * @param code - Complete SVG XML; malformed XML or a non-SVG root throws.
- * @param signal - Prevents preparation after the preview owner is cancelled.
- * @returns A data URL for an image whose intrinsic dimensions determine the preview height.
- */
-export function renderSvg(code: string, signal: AbortSignal): string {
-  signal.throwIfAborted()
-  const parsed = new DOMParser().parseFromString(code, 'image/svg+xml')
-  if (parsed.querySelector('parsererror') !== null || parsed.documentElement.localName !== 'svg'
-    || parsed.documentElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
-    throw new Error('Invalid SVG document')
-  }
-  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(code)}`
-}
diff --git a/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts b/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts
index 41f4c75fb7..2f4114ce71 100644
--- a/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts
+++ b/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts
@@ -1,7 +1,48 @@
 import { useCallback, useEffect, useState } from 'react'
 import type { RefObject } from 'react'
 import { supportsHighlighting } from './highlight.ts'
-import { observeViewport } from './viewport.ts'
+
+const noop = (): void => {}
+
+/** One document-wide observer; activated elements leave it permanently. */
+class HighlightViewport {
+  private observer: IntersectionObserver | undefined
+  private readonly activators = new Map void>()
+
+  observe(element: Element, activate: () => void): () => void {
+    if (typeof IntersectionObserver === 'undefined') {
+      activate()
+      return noop
+    }
+    this.observer ??= new IntersectionObserver((entries) => {
+      for (const entry of entries) {
+        if (!entry.isIntersecting) continue
+        const current = this.activators.get(entry.target)
+        /* v8 ignore next -- the observer reports only elements still registered with it. */
+        if (current === undefined) continue
+        this.activators.delete(entry.target)
+        this.observer?.unobserve(entry.target)
+        current()
+      }
+      this.releaseEmptyObserver()
+    })
+    this.activators.set(element, activate)
+    this.observer.observe(element)
+    return () => {
+      this.activators.delete(element)
+      this.observer?.unobserve(element)
+      this.releaseEmptyObserver()
+    }
+  }
+
+  private releaseEmptyObserver(): void {
+    if (this.activators.size > 0) return
+    this.observer?.disconnect()
+    this.observer = undefined
+  }
+}
+
+const highlightViewport = new HighlightViewport()
 
 /**
  * Activate one supported code surface when it first intersects the viewport.
@@ -9,24 +50,22 @@ import { observeViewport } from './viewport.ts'
  * IntersectionObserver activate immediately.
  * @param target - Code surface whose plain rendering reserves its geometry.
  * @param lang - Optional language hint.
- * @param initiallyActive - Activate at mount on explicit reader intent without waiting for viewport delivery.
  * @returns Whether this component may build highlighted output.
  */
 export function useViewportHighlighting(
   target: RefObject,
   lang: string | undefined,
-  initiallyActive = false,
 ): boolean {
   const supported = supportsHighlighting(lang)
-  const [activated, setActivated] = useState(initiallyActive)
-  const activate = useCallback((visible: boolean) => { if (visible) setActivated(true) }, [])
+  const [activated, setActivated] = useState(false)
+  const activate = useCallback(() => { setActivated(true) }, [])
 
   useEffect(() => {
     if (activated || !supported) return
     const element = target.current
     /* v8 ignore next -- React attaches the host ref before running effects. */
     if (element === null) return
-    return observeViewport(element, activate)
+    return highlightViewport.observe(element, activate)
   }, [activate, activated, supported, target])
 
   return activated && supported
diff --git a/packages/client/ui-primitives/src/markdown/viewport.ts b/packages/client/ui-primitives/src/markdown/viewport.ts
deleted file mode 100644
index 9b72a92532..0000000000
--- a/packages/client/ui-primitives/src/markdown/viewport.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/** Shared viewport observation for deferred code and diagram work. */
-
-let observer: IntersectionObserver | undefined
-const listeners = new Map void>()
-
-/**
- * Observe visibility until disposal; unsupported browsers activate immediately.
- * @param element - Stable surface whose geometry remains available while work is deferred.
- * @param changed - Receives intersection changes, including exits.
- * @returns A disposer that releases the shared observer after the last surface leaves.
- */
-export function observeViewport(element: Element, changed: (visible: boolean) => void): () => void {
-  if (typeof IntersectionObserver === 'undefined') {
-    changed(true)
-    return () => {}
-  }
-  observer ??= new IntersectionObserver((entries) => {
-    for (const entry of entries) listeners.get(entry.target)?.(entry.isIntersecting)
-  })
-  listeners.set(element, changed)
-  observer.observe(element)
-  return () => {
-    listeners.delete(element)
-    observer?.unobserve(element)
-    if (listeners.size > 0) return
-    observer?.disconnect()
-    observer = undefined
-  }
-}
diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx
index 305de7467f..5e6475297c 100644
--- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx
@@ -7,22 +7,11 @@ import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx'
 import { highlightToHtml, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
 import { markdownLabels } from './labels.client.ts'
 
-function CodeBlock(props: Omit, 'copyLabel' | 'copiedLabel' | 'sourceLabel' | 'lineNumbersLabel'>) {
+function CodeBlock(props: Omit, 'copyLabel' | 'copiedLabel'>) {
   return 
 }
 
-const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
-const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand')
-
-afterEach(() => {
-  cleanup()
-  vi.useRealTimers()
-  vi.restoreAllMocks()
-  if (clipboardDescriptor === undefined) Reflect.deleteProperty(navigator, 'clipboard')
-  else Object.defineProperty(navigator, 'clipboard', clipboardDescriptor)
-  if (execCommandDescriptor === undefined) Reflect.deleteProperty(document, 'execCommand')
-  else Object.defineProperty(document, 'execCommand', execCommandDescriptor)
-})
+afterEach(cleanup)
 
 beforeEach(() => {
   vi.useRealTimers()
@@ -70,13 +59,6 @@ describe('highlightToHtml', () => {
 })
 
 describe('CodeBlock', () => {
-  it('omits its header when the owner supplies toolbar controls', () => {
-    const view = render()
-    expect(view.container.querySelector('[data-code-block-header]')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-banner]')).toBeNull()
-    expect(view.container.querySelector('pre')?.textContent).toBe('plain text')
-  })
-
   it('reports the stable source-content wrapper to its owner', () => {
     const contentRef = vi.fn<(node: HTMLDivElement | null) => void>()
     const view = render()
@@ -124,7 +106,7 @@ describe('CodeBlock', () => {
     const view = render()
     expect(view.container.querySelectorAll('code > .line')).toHaveLength(1)
     expect(view.container.querySelector('pre')!.textContent).toBe('')
-    const gutter = () => view.container.querySelector('[style*="--dsl-code-block-line-number-width"]')!
+    const gutter = () => view.container.querySelector('[data-line-numbers]')!
       .style.getPropertyValue('--dsl-code-block-line-number-width')
     expect(gutter()).toBe('2ch')
     view.rerender()
@@ -151,88 +133,27 @@ describe('CodeBlock', () => {
     expect(view.getByText('plain text')).toBeTruthy()
   })
 
-  it('shows a noninteractive selected source label for a block without preview', () => {
-    const view = render()
-    const banner = view.container.querySelector('[data-code-block-banner]')!
-    expect(screen.getByText(markdownLabels.code.sourceLabel).tagName).toBe('SPAN')
-    expect(screen.queryByRole('button', { name: markdownLabels.code.sourceLabel })).toBeNull()
-    expect([...banner.querySelectorAll('button')].map(button => button.getAttribute('aria-label')))
-      .toEqual([markdownLabels.code.lineNumbersLabel, markdownLabels.code.copyLabel])
-  })
-
-  it.each(['ts', undefined])('trims exactly one trailing newline from %s source and its clipboard text', async (lang) => {
-    const writeText = vi.fn().mockResolvedValue(undefined)
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
-    const view = render()
-    expect(view.container.querySelector('pre')?.textContent).toBe('const a = 1\n')
-    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
-    expect(writeText).toHaveBeenCalledWith('const a = 1\n')
-  })
-
-  it('shows the language banner and copies the displayed source text', async () => {
+  it('shows the language banner and copies the pre textContent', async () => {
     vi.useFakeTimers()
     const writeText = vi.fn().mockResolvedValue(undefined)
     Object.defineProperty(navigator, 'clipboard', {
       configurable: true,
       value: { writeText },
     })
-    const view = render()
-    const sourceElement = view.container.querySelector('pre')
+    render()
     expect(screen.getByText('ts')).toBeTruthy()
-    const copy = screen.getByRole('button', { name: '复制' })
-    expect(copy.textContent).toBe('')
-    const idleIcon = copy.querySelector('svg')?.outerHTML
-    fireEvent.click(copy)
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
     expect(writeText).toHaveBeenCalledWith('const a = 1')
     // Flush the clipboard promise under fake timers before asserting the label.
     await act(async () => {
       await Promise.resolve()
     })
-    expect(screen.getByRole('button', { name: '复制成功' })).toBe(copy)
-    expect(copy.textContent).toBe('')
-    expect(copy.querySelector('svg')?.outerHTML).not.toBe(idleIcon)
-    expect(view.container.querySelector('pre')).toBe(sourceElement)
+    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
     // While the ok label is showing, further clicks are no-ops.
     fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
     expect(writeText).toHaveBeenCalledTimes(1)
-    await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
-    expect(screen.getByRole('button', { name: '复制' })).toBe(copy)
-    expect(copy.querySelector('svg')?.outerHTML).toBe(idleIcon)
-  })
-
-  it('shows localized copy and confirmation tooltips for keyboard focus', async () => {
-    const writeText = vi.fn().mockResolvedValue(undefined)
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
-    render()
-    const copy = screen.getByRole('button', { name: '复制' })
-    fireEvent.focus(copy)
-    expect(screen.getByRole('tooltip').textContent).toBe('复制')
-    await act(async () => { fireEvent.click(copy) })
-    expect(screen.getByRole('tooltip').textContent).toBe('复制成功')
-  })
-
-  it('does not schedule confirmation after an outstanding clipboard write finishes on an unmounted block', async () => {
-    vi.useFakeTimers()
-    const pending = Promise.withResolvers()
-    const writeText = vi.fn(() => pending.promise)
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
-    const view = render()
-    fireEvent.click(screen.getByRole('button', { name: '复制' }))
-    expect(writeText).toHaveBeenCalledWith('plain body')
-    view.unmount()
-    await act(async () => { pending.resolve(undefined) })
-    expect(vi.getTimerCount()).toBe(0)
-    expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
-  })
-
-  it('clears the confirmation timer when the block unmounts', async () => {
-    vi.useFakeTimers()
-    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } })
-    const view = render()
-    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
-    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
-    view.unmount()
-    expect(vi.getTimerCount()).toBe(0)
+    await vi.advanceTimersByTimeAsync(1000)
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
   })
 
   it('does not claim success when clipboard.writeText rejects', async () => {
diff --git a/packages/client/ui-primitives/tests/diagram-highlight.client.spec.tsx b/packages/client/ui-primitives/tests/diagram-highlight.client.spec.tsx
deleted file mode 100644
index cda7214de2..0000000000
--- a/packages/client/ui-primitives/tests/diagram-highlight.client.spec.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-// @vitest-environment jsdom
-
-import { act, cleanup, render } from '@testing-library/react'
-import { afterEach, describe, expect, it } from 'vitest'
-import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
-import { highlightToHtml, StreamingHighlightSession, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
-import { markdownLabels } from './labels.client.ts'
-
-const samples = [
-  {
-    lang: 'dot',
-    code: 'digraph G {\n  // route\n  /* multi\n     line */\n  node [shape=box];\n  A -> B [label="hello"];\n}',
-    tokens: [['digraph', 'keyword'], ['// route', 'comment'], ['line */', 'comment'], ['"hello"', 'string-expression']],
-  },
-  {
-    lang: 'svg',
-    code: '\n  \n  \n',
-    tokens: [['svg', 'string-expression'], ['line -->', 'comment'], ['width', 'function'], ['"red"', 'string-expression']],
-  },
-  {
-    lang: 'mermaid',
-    code: 'flowchart LR\n  %% route\n  A["Input"] --> B["Preview"]',
-    tokens: [['flowchart', 'keyword'], ['%% route', 'comment'], ['"Input"', 'string']],
-  },
-] as const
-
-afterEach(cleanup)
-
-function tokenTree(root: ParentNode) {
-  return [...root.querySelectorAll('pre.shiki .line')].map(line =>
-    [...line.querySelectorAll('span[style]')].map(span => [span.textContent, span.style.cssText]),
-  )
-}
-
-describe('diagram source highlighting', () => {
-  // Each grammar's first use is isolated from the other highlighter spec files.
-  it.each(samples)('automatically highlights cold $lang source in both rendering modes', async ({ lang, code, tokens }) => {
-    const ready = Promise.withResolvers()
-    const stop = subscribeGrammarLoaded(() => { ready.resolve(undefined) })
-    try {
-      const settled = render()
-      const streamed = render()
-      expect(settled.container.querySelector('pre.shiki')).toBeNull()
-      expect(streamed.container.querySelector('pre.shiki')).toBeNull()
-      await act(async () => { await ready.promise })
-      expect(settled.container.querySelector('pre.shiki')?.textContent).toBe(code)
-      expect(streamed.container.querySelector('pre.shiki')?.textContent).toBe(code)
-      expect(tokenTree(streamed.container)).toEqual(tokenTree(settled.container))
-      const spans = [...settled.container.querySelectorAll('pre.shiki span[style]')]
-      for (const [text, color] of tokens) {
-        expect(spans.some(span => span.textContent?.trim() === text
-          && span.style.color === `var(--shiki-token-${color})`), text).toBe(true)
-      }
-    } finally {
-      stop()
-    }
-  })
-
-  it('uses one grammar for DOT aliases and reuses XML for SVG', () => {
-    const code = samples[0].code
-    expect(highlightToHtml(code, 'DoT')).toBe(highlightToHtml(code, 'GrApHvIz'))
-    expect(highlightToHtml(samples[1].code, 'SVG')).toBe(highlightToHtml(samples[1].code, 'xml'))
-    const session = new StreamingHighlightSession()
-    const first = session.update(code, 'dot')
-    expect(session.update(code, 'GRAPHVIZ')?.[0]).toBe(first?.[0])
-  })
-
-  it.each(samples)('matches full $lang highlighting at every appended character and retains settled DOM', ({ lang, code }) => {
-    const session = new StreamingHighlightSession()
-    for (let end = 1; end <= code.length; end += 1) {
-      const prefix = code.slice(0, end)
-      expect(session.update(prefix, lang)).toEqual(new StreamingHighlightSession().update(prefix, lang))
-    }
-    const firstChunk = code.slice(0, code.indexOf('\n') + 1)
-    const view = render()
-    const firstLine = view.container.querySelector('pre.shiki .line')
-    expect(firstLine).not.toBeNull()
-    view.rerender()
-    expect(view.container.querySelector('pre.shiki .line')).toBe(firstLine)
-    const pre = view.container.querySelector('pre.shiki')
-    const streamed = tokenTree(view.container)
-    const settled = render()
-    expect(streamed).toEqual(tokenTree(settled.container))
-    view.rerender()
-    expect(view.container.querySelector('pre.shiki')).toBe(pre)
-    expect(tokenTree(view.container)).toEqual(streamed)
-  })
-})
diff --git a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx b/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx
deleted file mode 100644
index 3de99630c9..0000000000
--- a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-// @vitest-environment jsdom
-
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
-import { renderGraphviz } from '../src/markdown/graphviz.ts'
-import { renderSvg } from '../src/markdown/svg.ts'
-import { markdownLabels } from './labels.client.ts'
-
-const status = { error: 'Cannot preview' }
-const preview = {
-  mermaid: { ...status, diagram: 'Mermaid diagram' },
-  graphviz: { ...status, diagram: 'Graphviz diagram' },
-  svg: { ...status, diagram: 'SVG preview' },
-  preview: 'Preview', source: 'Source', zoom: 'Enlarge preview', pending: 'Preparing preview…', close: 'Close preview', interaction: 'Drag to pan, scroll to zoom',
-}
-const svg = '示例'
-const cases = [
-  ['graphviz', 'digraph { Input -> Preview }', preview.graphviz.diagram],
-  ['dot', 'digraph { Input -> Preview }', preview.graphviz.diagram],
-  ['svg', svg, preview.svg.diagram],
-] as const
-
-afterEach(() => { cleanup(); vi.unstubAllGlobals() })
-
-describe('Markdown fence previews', () => {
-  it('renders real DOT', async () => { await renderGraphviz('digraph { Input -> Preview }', new AbortController().signal) })
-  it.each(cases)('selects settled %s preview and retains source and image across view changes', async (lang, code, title) => {
-    const writeText = vi.fn().mockResolvedValue(undefined)
-    vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
-    const props = { text: `\`\`\`${lang}\n${code}\n\`\`\``, labels: { ...markdownLabels, preview } }
-    const view = render()
-    expect(view.container.querySelector('iframe')).toBeNull()
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(screen.getByRole('status').textContent).toBe(preview.pending)
-    view.rerender()
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
-    expect(screen.getByRole('button', { name: 'Preview' }).getAttribute('aria-pressed')).toBe('true')
-    const findPreview = async () => {
-      const img = await waitFor(() => {
-        const node = document.querySelector('img')
-        expect(node).not.toBeNull()
-        return node!
-      })
-      fireEvent.load(img)
-      return screen.getByRole('img', { name: title })
-    }
-    const element = await findPreview()
-    expect(element.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
-    expect(view.container.querySelector('iframe')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-preview]')).not.toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Source' }))
-    expect(view.container.querySelector('pre code')?.textContent).toBe(code)
-    expect(view.container.querySelector('[data-code-block-preview]')?.hasAttribute('hidden')).toBe(true)
-    expect(screen.queryByRole('img', { name: title })).toBeNull()
-    expect(element.isConnected).toBe(true)
-    fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
-    await waitFor(() => { expect(writeText).toHaveBeenCalledWith(code) })
-    fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
-    expect(await findPreview()).toBe(element)
-    view.unmount()
-    render()
-    await findPreview()
-    fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
-    await waitFor(() => { expect(writeText).toHaveBeenCalledTimes(2) })
-  })
-
-  it.each(['svg', 'graphviz'])('retains invalid %s source and recovers after replacement', async (lang) => {
-    const view = render()
-    await waitFor(() => { expect(screen.getByRole('status').textContent).toBe(status.error) })
-    expect(view.container.querySelector('pre')).toBeNull()
-    expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
-    fireEvent.click(screen.getByRole('button', { name: 'Source' }))
-    expect(view.container.querySelector('pre code')?.textContent).toBe('broken')
-    fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
-    view.rerender()
-    await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
-    fireEvent.load(view.container.querySelector('img')!)
-    await screen.findByRole('img', { name: preview.svg.diagram })
-    expect(screen.queryByRole('status')).toBeNull()
-  })
-
-  it('requires an opted-in supported code fence and leaves HTML and other languages unrendered', () => {
-    const view = render(Example\n```'} labels={{ ...markdownLabels, preview }} />)
-    expect(view.container.querySelector('pre code')?.textContent).toBe('

Example

') - expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull() - view.rerender(Example\n\n```xml\n\n```'} labels={{ ...markdownLabels, preview }} />) - expect(view.container.querySelector('iframe')).toBeNull() - expect(view.container.querySelector('h1')).toBeNull() - }) -}) - -describe('static SVG previews', () => { - it('encodes SVG as an image rather than executable document markup', () => { - const code = '' - const url = renderSvg(code, new AbortController().signal) - expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/) - expect(decodeURIComponent(url.slice(url.indexOf(',') + 1))).toBe(code) - }) - - it.each(['', '', ''])('rejects non-SVG or malformed XML: %s', (code) => { - expect(() => renderSvg(code, new AbortController().signal)).toThrow('Invalid SVG document') - }) - - it('does not prepare a cancelled SVG', () => { - const controller = new AbortController() - controller.abort() - expect(() => renderSvg(svg, controller.signal)).toThrow(controller.signal.reason) - }) -}) diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt index f76ff8bbac..5b41c005f8 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt @@ -5,113 +5,69 @@
#text "ts"
- + , + ) + 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-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
) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx index 53ae79791b..7e76f1b5ab 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx @@ -18,12 +18,10 @@ export type MarkdownBodyProps = DocumentPreviewProps & PropsLocale<'documentMark export function MarkdownBody({ content, t }: MarkdownBodyProps): ReactNode { const copyLabel = t('code.copy') const copiedLabel = t('code.copied') - const sourceLabel = t('markdown.code.source') - const lineNumbersLabel = t('markdown.code.lineNumbers') const footnotes = t('footnotes') const labels = useMemo(() => ({ - code: { copyLabel, copiedLabel, sourceLabel, lineNumbersLabel }, footnotes, - }), [copyLabel, copiedLabel, sourceLabel, lineNumbersLabel, footnotes]) + code: { copyLabel, copiedLabel }, footnotes, + }), [copyLabel, copiedLabel, footnotes]) if (content.kind !== 'text') return null return (
diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 59c916c6f2..b9e04d95cb 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 14ef1e69eabc4089f5d4d8279a3acb6de5fcabd7 -README.zh.md: 20b48eb01653560e0245bb84f68c842b609ee265 +README.md: a0b9ffe8ca601fade50ff86fc1921d6b41b84748 +README.zh.md: 44b08c8ef76ec266c813c8199db8291f47db3d53 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 14ef1e69ea..a0b9ffe8ca 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -37,6 +37,8 @@ Plugins add an icon component to the root-scoped `sidebar.panellist` list with a ### Collapse behavior +The top expand button hosts the optional, non-interactive `sidebar.toggle.badge` slot while collapsed. Its occupant supplies status and tooltip content without adding another action or changing the button's navigation behavior. + During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 20b48eb016..44b08c8ef7 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -37,6 +37,8 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### 折叠行为 +侧栏收起时,顶部展开按钮承载可选、不可交互的 `sidebar.toggle.badge` slot。占用方提供状态和提示内容,不增加操作,也不改变按钮的导航行为。 + 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。 在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 64aac2b9ad..1309dd2322 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -293,6 +293,7 @@ } .iconButton { + position: relative; flex: none; display: inline-flex; align-items: center; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index a7de0a7b83..6ed94f3b0d 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -183,6 +183,7 @@ export function SidebarRoot({ )} {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */} + {!wide && renderSlot('sidebar.toggle.badge', {})} ) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index df2933430e..d94b5935ed 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -14,6 +14,8 @@ import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Non-interactive notification inside the collapsed sidebar expand button. */ + 'sidebar.toggle.badge': { kind: 'single'; scope: 'root'; owner: Record } /** * Brand mark rendered in the expanded brand row and collapsed rail. * Declared by this package's `sidebar` entry; deployments may replace @@ -136,6 +138,7 @@ export type SidebarRootComponentProps = & PropsRenderSlots< | 'sidebar.brand.mark' | 'sidebar.brand.name' + | 'sidebar.toggle.badge' | 'sidebar.panellist' | 'sidebar.workspaces' | 'sidebar.settings' diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index a768048c44..573ed2cb61 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -77,6 +77,7 @@ export function apply(ctx: ClientContext): void { children: { 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, 'sidebar.brand.name': { kind: 'single', scope: 'root' }, + 'sidebar.toggle.badge': { kind: 'single', scope: 'root' }, 'sidebar.panellist': { kind: 'list', scope: 'root' }, 'sidebar.workspaces': { kind: 'single', scope: 'root' }, 'sidebar.settings': { kind: 'single', scope: 'root' }, diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index 669a054912..d0833b1a6b 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -26,6 +26,10 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded width="16" xmlns="http://www.w3.org/2000/svg" /> +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b059d190d7..2eeac7f3f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,9 +536,6 @@ importers: specifier: ^7.8.5 version: 7.8.5 devDependencies: - '@aws-sdk/client-s3': - specifier: 3.1067.0 - version: 3.1067.0 '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot @@ -563,6 +560,9 @@ importers: app-builder-lib: specifier: 26.15.3 version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + cos-nodejs-sdk-v5: + specifier: 3.0.0 + version: 3.0.0 electron: specifier: ^44.0.0 version: 44.0.0 @@ -596,6 +596,9 @@ importers: '@deepseek-ai/dsh': specifier: workspace:^ version: link:../cli + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../packages/core/agent '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot @@ -608,6 +611,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../packages/jobs/jobs '@deepseek-ai/dsh-skill-office': specifier: workspace:^ version: link:../../packages/skill/skill-office @@ -3782,9 +3788,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - '@viz-js/viz': - specifier: 3.30.0 - version: 3.30.0 anser: specifier: ^2.3.5 version: 2.3.5 @@ -3806,9 +3809,6 @@ importers: mdast-util-math: specifier: ^3.0.0 version: 3.0.0 - mermaid: - specifier: 11.16.0 - version: 11.16.0 micromark-core-commonmark: specifier: ^2.0.3 version: 2.0.3 @@ -13121,9 +13121,6 @@ packages: resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -13137,26 +13134,14 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/checksums@3.1000.29': - resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock-runtime@3.1048.0': resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1067.0': - resolution: {integrity: sha512-3f64o9YWzwJ9WzMIC4JlUQiMOm7R/EtkIDyFdj8yaQXuh8SR9ezz2R32UMpvTlVMtpoPan3Uj8oveAHr2UeExw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.20': resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.977.9': - resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.46': resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} engines: {node: '>=20.0.0'} @@ -13197,14 +13182,6 @@ packages: resolution: {integrity: sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.54': - resolution: {integrity: sha512-cDplgLpXZy7MfREXbAeOm8PFT8ibjD5B5rbqxocFR/5rdSbXUPURIAvRToVqjcOR5gHYbEkndKAs+Zw7FfZj1A==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-sdk-s3@3.972.75': - resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.28': resolution: {integrity: sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw==} engines: {node: '>= 14.0.0'} @@ -13217,10 +13194,6 @@ packages: resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.46': - resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1048.0': resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} @@ -13233,10 +13206,6 @@ packages: resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.974.5': - resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.7': resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} engines: {node: '>=20.0.0'} @@ -13245,18 +13214,10 @@ packages: resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.40': - resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} - engines: {node: '>=20.0.0'} - '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} - '@aws/lambda-invoke-store@0.3.0': - resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} - engines: {node: '>=18.0.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -15474,10 +15435,6 @@ packages: resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} - '@smithy/core@3.33.3': - resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} - engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.9': resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} engines: {node: '>=18.0.0'} @@ -15502,18 +15459,10 @@ packages: resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.7.3': - resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} - engines: {node: '>=18.0.0'} - '@smithy/types@4.14.4': resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.17.2': - resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} - engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -16005,9 +15954,6 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - '@viz-js/viz@3.30.0': - resolution: {integrity: sha512-2zmcP55QY44uQfZ+GvgNHmGd8S6KUD+2eMur9AbYybSzrOd/m8nMUg0ZircJNiJRKGgVBKAE8kh0hAwpUFUFQQ==} - '@vscode/ripgrep-darwin-arm64@1.18.0': resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==} cpu: [arm64] @@ -16284,10 +16230,17 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asn1js@3.0.10: resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} engines: {node: '>=12.0.0'} + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -16313,6 +16266,9 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} @@ -16380,6 +16336,9 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -16490,6 +16449,9 @@ packages: caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -16647,6 +16609,9 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -16658,6 +16623,19 @@ packages: resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} engines: {node: '>= 0.4.0'} + cos-fast-xml-parser@1.0.0: + resolution: {integrity: sha512-kOPJb1cuj+gc5E8jN5ekZn4rgQHaxYTLcQT+jmbHtlTNcNjv7wnOBEYH2uRA0pG3h8giot4z9h4WFLDuXATZwg==} + engines: {node: '>= 9'} + hasBin: true + + cos-nodejs-sdk-v5@3.0.0: + resolution: {integrity: sha512-xUqiDdUxfEjfaWoyBA3qsSnlQVHPz13DRFErL+NliadBVUeeZhAPHGVa2inGpUvPTOs52ALIKkMbzL+inSqvXg==} + engines: {node: '>= 9'} + + cos-request@1.3.3: + resolution: {integrity: sha512-zD7fKMAIMfJNHssx8VZE5mUQ67hs3QDi7S2BX7JuD8MzT1XYqEOAN42PJ0BtRhgutnn+8HiUF+Fup+j5zORasQ==} + engines: {node: '>= 8'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -16834,6 +16812,10 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -16981,6 +16963,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -17235,6 +17220,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + fast-check@4.8.0: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} @@ -17328,9 +17317,16 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@2.5.6: + resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} + engines: {node: '>= 0.12'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -17437,6 +17433,9 @@ packages: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -17571,6 +17570,10 @@ packages: engines: {node: '>=12'} hasBin: true + http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + http2-wrapper@1.0.3: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} @@ -17695,6 +17698,9 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -17729,6 +17735,9 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -17770,6 +17779,9 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jscpd-darwin-arm64@5.0.12: resolution: {integrity: sha512-CQCDYhQY9yOGGeauzkRGYW/QtXurPCjfDkEhUoM/M5UdmpzxfG3i9mnNsaheJ0RdFRf73mL7JaLVRP8U2l5/kA==} cpu: [arm64] @@ -17841,6 +17853,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -17858,6 +17873,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + jsx-ast-utils-x@0.1.0: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -18481,6 +18500,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -18652,6 +18674,9 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -18778,6 +18803,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} @@ -18807,6 +18835,9 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -19165,6 +19196,11 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -19229,6 +19265,9 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} @@ -19333,6 +19372,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -19427,6 +19470,9 @@ packages: resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} engines: {node: '>=18', npm: '>=9'} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -19518,6 +19564,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -19541,6 +19591,9 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-sync-external-store@1.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -19560,6 +19613,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -20165,15 +20222,6 @@ snapshots: '@aws-sdk/types': 3.973.12 tslib: 2.8.1 - '@aws-crypto/sha1-browser@5.2.0': - dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.12 - '@aws-sdk/util-locate-window': 3.965.7 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -20200,14 +20248,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/checksums@3.1000.29': - dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 - tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.1048.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -20225,23 +20265,6 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1067.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.20 - '@aws-sdk/credential-provider-node': 3.972.55 - '@aws-sdk/middleware-flexible-checksums': 3.974.54 - '@aws-sdk/middleware-sdk-s3': 3.972.75 - '@aws-sdk/signature-v4-multi-region': 3.996.34 - '@aws-sdk/types': 3.973.12 - '@smithy/core': 3.24.7 - '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.8 - '@smithy/types': 4.14.4 - tslib: 2.8.1 - '@aws-sdk/core@3.974.20': dependencies: '@aws-sdk/types': 3.973.12 @@ -20253,17 +20276,6 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/core@3.977.9': - dependencies: - '@aws-sdk/types': 3.974.5 - '@aws-sdk/xml-builder': 3.972.40 - '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.33.3 - '@smithy/signature-v4': 5.7.3 - '@smithy/types': 4.17.2 - bowser: 2.14.1 - tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.46': dependencies: '@aws-sdk/core': 3.974.20 @@ -20362,20 +20374,6 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.54': - dependencies: - '@aws-sdk/checksums': 3.1000.29 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-s3@3.972.75': - dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/signature-v4-multi-region': 3.996.46 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 - tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.28': dependencies: '@aws-sdk/core': 3.974.20 @@ -20406,13 +20404,6 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.46': - dependencies: - '@aws-sdk/types': 3.974.5 - '@smithy/signature-v4': 5.7.3 - '@smithy/types': 4.17.2 - tslib: 2.8.1 - '@aws-sdk/token-providers@3.1048.0': dependencies: '@aws-sdk/core': 3.974.20 @@ -20436,11 +20427,6 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 - '@aws-sdk/types@3.974.5': - dependencies: - '@smithy/types': 4.17.2 - tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.7': dependencies: tslib: 2.8.1 @@ -20451,15 +20437,8 @@ snapshots: fast-xml-parser: 5.7.3 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.40': - dependencies: - '@smithy/types': 4.17.2 - tslib: 2.8.1 - '@aws/lambda-invoke-store@0.2.4': {} - '@aws/lambda-invoke-store@0.3.0': {} - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -22223,11 +22202,6 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 - '@smithy/core@3.33.3': - dependencies: - '@smithy/types': 4.17.2 - tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.9': dependencies: '@smithy/core': 3.24.7 @@ -22262,20 +22236,10 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 - '@smithy/signature-v4@5.7.3': - dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.17.2 - tslib: 2.8.1 - '@smithy/types@4.14.4': dependencies: tslib: 2.8.1 - '@smithy/types@4.17.2': - dependencies: - tslib: 2.8.1 - '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -22839,8 +22803,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@viz-js/viz@3.30.0': {} - '@vscode/ripgrep-darwin-arm64@1.18.0': optional: true @@ -23178,12 +23140,18 @@ snapshots: dependencies: dequal: 2.0.3 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + asn1js@3.0.10: dependencies: pvtsutils: 1.3.6 pvutils: 1.2.0 tslib: 2.8.1 + assert-plus@1.0.0: {} + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -23206,6 +23174,8 @@ snapshots: at-least-node@1.0.0: {} + aws-sign2@0.7.0: {} + aws4@1.13.2: {} b4a@1.8.1: {} @@ -23251,6 +23221,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -23393,6 +23367,8 @@ snapshots: caniuse-lite@1.0.30001806: {} + caseless@0.12.0: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -23512,6 +23488,8 @@ snapshots: dependencies: is-what: 5.5.0 + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -23521,6 +23499,37 @@ snapshots: corser@2.0.1: {} + cos-fast-xml-parser@1.0.0: + dependencies: + strnum: 1.1.2 + + cos-nodejs-sdk-v5@3.0.0: + dependencies: + cos-fast-xml-parser: 1.0.0 + cos-request: 1.3.3 + mime-types: 2.1.35 + + cos-request@1.3.3: + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.5.6 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.15.3 + safe-buffer: 5.2.1 + tough-cookie: 4.1.4 + tunnel-agent: 0.6.0 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -23729,6 +23738,10 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + data-uri-to-buffer@4.0.1: {} data-urls@7.0.0(@noble/hashes@2.3.0): @@ -23861,6 +23874,11 @@ snapshots: eastasianwidth@0.2.0: {} + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -24274,6 +24292,8 @@ snapshots: transitivePeerDependencies: - supports-color + extsprintf@1.3.0: {} + fast-check@4.8.0: dependencies: pure-rand: 8.4.0 @@ -24373,8 +24393,19 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forever-agent@0.6.1: {} + form-data-encoder@1.7.2: {} + form-data@2.5.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -24500,6 +24531,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + github-from-package@0.0.0: {} glob-parent@6.0.2: @@ -24695,6 +24730,12 @@ snapshots: - debug - supports-color + http-signature@1.2.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + http2-wrapper@1.0.3: dependencies: quick-lru: 5.1.1 @@ -24784,6 +24825,8 @@ snapshots: is-stream@4.0.1: {} + is-typedarray@1.0.0: {} + is-unicode-supported@2.1.0: {} is-what@5.5.0: {} @@ -24804,6 +24847,8 @@ snapshots: isexe@4.0.0: {} + isstream@0.1.2: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -24845,6 +24890,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbn@0.1.1: {} + jscpd-darwin-arm64@5.0.12: optional: true @@ -24917,10 +24964,11 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} - json-stringify-safe@5.0.1: - optional: true + json-stringify-safe@5.0.1: {} json5@2.2.3: {} @@ -24934,6 +24982,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsprim@1.4.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + jsx-ast-utils-x@0.1.0: {} jszip@3.10.1: @@ -25704,6 +25759,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + oauth-sign@0.9.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -25883,6 +25940,8 @@ snapshots: perfect-debounce@1.0.0: {} + performance-now@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -26015,6 +26074,10 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + publint@0.3.21: dependencies: '@publint/pack': 0.1.4 @@ -26044,6 +26107,8 @@ snapshots: quansync@1.0.0: {} + querystringify@2.2.0: {} + quick-lru@5.1.1: {} range-parser@1.3.0: {} @@ -26507,6 +26572,18 @@ snapshots: sprintf-js@1.1.3: optional: true + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stackback@0.0.2: {} standardwebhooks@1.1.1: @@ -26581,6 +26658,8 @@ snapshots: strip-json-comments@2.0.1: {} + strnum@1.1.2: {} + strnum@2.4.0: dependencies: anynum: 1.0.0 @@ -26711,6 +26790,13 @@ snapshots: toidentifier@1.0.1: {} + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tough-cookie@6.0.1: dependencies: tldts: 7.4.5 @@ -26784,6 +26870,8 @@ snapshots: dependencies: '@mixmark-io/domino': 2.2.0 + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -26873,6 +26961,8 @@ snapshots: universalify@0.1.2: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -26897,6 +26987,11 @@ snapshots: url-join@4.0.1: {} + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-sync-external-store@1.2.0(react@18.3.1): dependencies: react: 18.3.1 @@ -26909,6 +27004,12 @@ snapshots: vary@1.1.2: {} + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 43957c5fa8..c824e41b3d 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -206,16 +206,3 @@ it('requires Office skill bodies and helpers in the published payload', () => { ...manifest, files: ['lib/index.js', 'lib/types/**/*.d.ts'], } })).toEqual([expect.stringContaining('package.json files must be')]) }) - -it.each(['lib/*-*.js', 'THIRD_PARTY_PREVIEW_NOTICES.txt'])('requires %s in the published UI primitives payload', (required) => { - const name = '@deepseek-ai/dsh-client-ui-primitives' - const files = expectedDshPackageFiles({ name }) - expect(files).toContain(required) - const check = (entries: readonly string[]) => checkWorkspaceManifest({ - dir: 'packages/client/ui-primitives', manifest: { name, files: [...entries] }, - }).filter(message => message.includes('package.json files')) - expect(check(files)).toEqual([]) - expect(check(files.filter(file => file !== required))).toEqual([ - expect.stringContaining('package.json files must be'), - ]) -}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 1f4891bdb7..7e9dea5287 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -155,8 +155,7 @@ const packageFileExtras: Readonly> = { // them through its own CSS pipeline, so the sheets are published artifacts. // The glob covers whichever sheets a package emits; sourcemaps stay // unpublished, as everywhere else in the repository. - // Lazy grammar and diagram imports resolve hashed runtime chunks beside index.js. - '@deepseek-ai/dsh-client-ui-primitives': ['lib/*-*.js', 'lib/**/*.css', 'THIRD_PARTY_PREVIEW_NOTICES.txt'], + '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'], '@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'], '@deepseek-ai/dsh-client-ui-sidebar-documentpreview': ['lib/client.*.js'], '@deepseek-ai/dsh-client-ui-sidebar-terminal': ['lib/client.*.js'], diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 90360dd47e..2c7f7968cb 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -54,6 +54,7 @@ export { REGION_BEGIN, REGION_END } * errors, so the partition can never silently drift from the service API. */ export const SERVICE_PAGE: Record = { + connection: 'web-server.md', pluginManager: 'boot.md', profileContext: 'boot.md', hmr: 'boot.md', @@ -165,7 +166,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', pluginPackages: 'profile-boot-owned package resolver service used by optional consumers — packages/boot/app-boot/README.md owns this internal API', - connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', fileUpload: 'client-side browser upload service — packages/client/file-upload/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', uiSession: 'client-side Session source adapter — packages/client/ui-session/README.md owns the API', @@ -209,6 +209,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'api-session': 'session.md', 'approval': 'approval.md', 'commands': 'commands.md', + 'connection': 'web-server.md', 'compaction': 'compaction.md', 'cordis': 'extensions.md', 'authorization': 'credentials.md', @@ -730,12 +731,19 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'ReadonlyMap', 'Request', 'Response', + 'IncomingMessage', + 'ServerResponse', 'ReturnType', 'Uint8Array', ]) /** Project types deliberately documented outside the subsystems catalog. */ export const TYPE_LINK_EXEMPTIONS: Readonly> = { + ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts', + ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts', + ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts', + ConnectionIndexRequest: 'frontend authentication request is owned by packages/client/connection/src/rpc.ts', + ConnectionIndexResponse: 'frontend authentication response is owned by packages/client/connection/src/rpc.ts', Profile: 'resolved profile layers are owned by packages/boot/app-boot/README.md', PatchOptions: 'Include patch entries are owned by vendor/include (vendored upstream)', McpResourceProvider: 'scoped resource provider is owned by packages/mcp/mcp-resources/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 399af14348..5b32da720a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -123,6 +123,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['plugin-manager'], note: 'The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr.', }, + { + key: 'connection', + pkg: 'client-connection', + title: 'Authenticated browser transport', + mode: 'core', + consumers: ['api-gateway', 'host-frontend-static'], + note: 'Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams.', + }, { key: 'mcpResources', pkg: 'mcp-resources', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index e0d87828d0..3ab18f9291 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -1,11 +1,10 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' } import { CLAUDE_AGENT_SDK_PACKAGE, - assertPreviewDistribution, assertRuntimeLicenses, claudeDistributionFromManifest, collectPythonDependencies, @@ -14,7 +13,6 @@ import { isPermissive, type Manifest, manifestPatterns, - mermaidDomPurifyVersion, parsePyprojectRequirements, parseVendoredRows, render, @@ -469,66 +467,3 @@ describe('manifestPatterns', () => { ]) }) }) - -describe('preview distribution licenses', () => { - const dependencies = { mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' } - const sources = [ - 'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64', - 'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz', - 'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz', - ] - it('checks Mermaid\'s selected DOMPurify when other versions remain installed', () => { - const fixture = mkdtempSync(join(tmpdir(), 'dsh-notices-mermaid-')) - try { - const modules = join(fixture, 'node_modules') - const store = join(modules, '.pnpm') - const mermaidModules = join(store, 'mermaid@11.16.0', 'node_modules') - const mermaid = join(mermaidModules, 'mermaid') - mkdirSync(mermaid, { recursive: true }) - for (const version of ['3.4.11', '3.4.12']) { - const dir = join(store, `dompurify@${version}`, 'node_modules', 'dompurify') - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dompurify', version })) - } - const reviewed = join(store, 'dompurify@3.4.11', 'node_modules', 'dompurify') - const upgraded = join(store, 'dompurify@3.4.12', 'node_modules', 'dompurify') - const linkedMermaid = join(modules, 'mermaid') - const dependency = join(mermaidModules, 'dompurify') - symlinkSync(mermaid, linkedMermaid, 'junction') - symlinkSync(reviewed, join(modules, 'dompurify'), 'junction') - symlinkSync(upgraded, dependency, 'junction') - - const version = mermaidDomPurifyVersion(linkedMermaid) - expect(version).toBe('3.4.12') - expect(() => { assertPreviewDistribution({ ...dependencies, dompurify: version }, sources) }) - .toThrow('review dompurify 3.4.12') - - unlinkSync(dependency) - symlinkSync(reviewed, dependency, 'junction') - expect(() => { - assertPreviewDistribution({ ...dependencies, dompurify: mermaidDomPurifyVersion(linkedMermaid) }, sources) - }).not.toThrow() - - writeFileSync(join(reviewed, 'package.json'), JSON.stringify({ name: 'dompurify' })) - expect(() => mermaidDomPurifyVersion(linkedMermaid)).toThrow('missing Mermaid DOMPurify dependency') - unlinkSync(dependency) - expect(() => mermaidDomPurifyVersion(linkedMermaid)).toThrow('ENOENT') - } finally { - rmSync(fixture, { recursive: true, force: true }) - } - }) - it('accepts the reviewed wrapper and native sources', () => { - expect(() => { assertPreviewDistribution(dependencies, sources) }).not.toThrow() - const notices = readFileSync(resolve(root, 'packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8') - expect(notices).toContain('Eclipse Public License - v 2.0') - expect(notices).toContain(sources[2]) - expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/mermaid/LICENSE'), 'utf8').trim()) - expect(notices).toContain('===== DOMPurify =====') - }) - it.each(Object.keys(dependencies))('rejects an unreviewed %s upgrade', (name) => { - expect(() => { assertPreviewDistribution({ ...dependencies, [name]: '99.0.0' }, sources) }).toThrow('preview notices: review') - }) - it.each([sources.slice(1), [...sources, 'https://example.com/new-library'], [...sources.slice(0, 2), 'graphviz-next']])('rejects changed embedded sources', (...uris) => { - expect(() => { assertPreviewDistribution(dependencies, uris) }).toThrow('native sources') - }) -}) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index f2e94eaf6b..c77fbd6b38 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -8,7 +8,7 @@ * `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`. */ -import { existsSync, globSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' @@ -737,40 +737,6 @@ ${rows.join('\n')} ` } -/** - * Read the DOMPurify version selected by Mermaid's pnpm dependency link. - * @param mermaidDirectory - Installed Mermaid directory, including workspace symlinks. - * @returns The installed version; throws when the dependency or its version is missing. - */ -export function mermaidDomPurifyVersion(mermaidDirectory: string): string { - const path = resolve(realpathSync(mermaidDirectory), '..', 'dompurify/package.json') - const manifest = JSON.parse(readFileSync(path, 'utf8')) as Manifest - if (manifest.version === undefined) throw new Error('preview notices: missing Mermaid DOMPurify dependency') - return manifest.version -} - -/** - * Require renewed license review when a preview dependency or embedded source changes. - * @param dependencies - UI primitives' browser build dependencies. - * @param sources - Native source URIs from the installed Viz.js build attestation. - * @returns Nothing; throws when a version or native source differs from the reviewed distribution. - */ -export function assertPreviewDistribution(dependencies: Record, sources: readonly string[]): void { - for (const [name, version] of Object.entries({ mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' })) { - if (dependencies[name] !== version) { - throw new Error(`preview notices: review ${name} ${String(dependencies[name])} licenses and update THIRD_PARTY_PREVIEW_NOTICES.txt`) - } - } - const expected = [ - 'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64', - 'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz', - 'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz', - ] - if (sources.length !== expected.length || expected.some(uri => !sources.includes(uri))) { - throw new Error('preview notices: review the changed Viz.js native sources and their distribution licenses') - } -} - /** * Render the complete notices document. * @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs. @@ -781,19 +747,6 @@ export async function render(): Promise { // the manifests map it was resolved from; render() owns that single load. workspaceLinkedManifestCache.clear() const { manifests, names } = loadWorkspaceManifests() - const previewPackage = 'packages/client/ui-primitives' - // Viz.js fixes the upstream SLSA statement filename; the repository uses concrete local names. - const statementPath = resolve(root, previewPackage, 'node_modules/@viz-js/viz/lib', 'prove' + 'nance.json') - const buildStatement = JSON.parse(readFileSync(statementPath, 'utf8')) as { - predicate: { buildDefinition: { resolvedDependencies: { uri: string }[] } } - } - const previewDependencies = manifests.get(`${previewPackage}/package.json`)?.devDependencies - if (previewDependencies === undefined) throw new Error('preview notices: missing UI primitives browser dependencies') - const dompurify = mermaidDomPurifyVersion(resolve(root, previewPackage, 'node_modules/mermaid')) - assertPreviewDistribution( - { ...previewDependencies, dompurify }, - buildStatement.predicate.buildDefinition.resolvedDependencies.map(source => source.uri), - ) const npm = collectNpmDeps(manifests, names, browser) const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) @@ -837,8 +790,6 @@ External packages installed for runtime use or distributed inside the prebuilt b ${renderNpmTable(runtimeDeps)} -The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside \`@viz-js/viz\` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and its DOMPurify dependency's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as \`preview-third-party-notices.txt\`. - pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: ${patchedLines.join('\n')} diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 8fb0712de8..71df065b9c 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -85,13 +85,9 @@ - text: — 随机选中的包: - code: packages/context/session-reference - text: 📦 -- button "Line numbers" - button "Copy" -- text: Source - code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts -- button "Line numbers" - button "Copy" -- text: Source - code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试" - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 - button "Copy": @@ -146,13 +142,9 @@ - text: — 随机选中的包: - code: packages/llm/token-meter - text: 📦 -- button "Line numbers" - button "Copy" -- text: Source - code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts -- button "Line numbers" - button "Copy" -- text: Source - code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试" - paragraph: - text: 两个 turn 都完成了(turn 1: @@ -182,18 +174,14 @@ - text: — 随机选中 - code: packages/context/session-reference - text: ,输出了文件结构: - - button "Line numbers" - button "Copy" - - text: Source - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts - listitem: - strong: Turn 2 - text: — 随机选中 - code: packages/llm/token-meter - text: ,输出了文件结构: - - button "Line numbers" - button "Copy" - - text: Source - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts - paragraph: - strong: 验证方式: diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index a21197caed..1743627de4 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -30,13 +30,9 @@ - text: — 随机选中的包: - code: packages/context/session-reference - text: 📦 -- button "Line numbers" - button "Copy" -- text: Source - code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts -- button "Line numbers" - button "Copy" -- text: Source - code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试" - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 - button "Copy": @@ -67,18 +63,14 @@ - text: — 随机选中 - code: packages/context/session-reference - text: ,输出了文件结构: - - button "Line numbers" - button "Copy" - - text: Source - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts - listitem: - strong: Turn 2 - text: — 随机选中 - code: packages/llm/token-meter - text: ,输出了文件结构: - - button "Line numbers" - button "Copy" - - text: Source - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts - paragraph: - strong: 验证方式: diff --git a/tsconfig.host.json b/tsconfig.host.json index a695623a45..046fa42630 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -7,6 +7,9 @@ "noEmit": true, "rewriteRelativeImportExtensions": false }, + "files": [ + "packages/client/ui-settings-general/src/client/desktop-update-bridge.ts" + ], "include": [ "apps/web/product-isolation.ts", "apps/web/tests/default-product-isolation.e2e.ts", @@ -86,7 +89,6 @@ "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", - "apps/web/tests/markdown-mermaid.e2e.ts", "apps/web/tests/clickable-links-gallery.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/queue-image.e2e.ts", @@ -123,7 +125,6 @@ "apps/web/tests/chat-continuous-conversation.e2e.ts", "apps/web/tests/composer-tab-geometry.e2e.ts", "apps/web/tests/complex-history.perf.ts", - "apps/web/tests/diagram-preview.perf.ts", "apps/web/tests/pwsh-terminal.e2e.ts", "apps/web/tests/workflow-run.e2e.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", @@ -156,6 +157,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/client/connection/tsconfig.host.json" }, { "path": "./packages/skill/skill-office" }, { "path": "./packages/browser-use/browser-use" }, { "path": "./packages/experimental/browser-use-runtime" },