Merge master into feat/subagent-catalog-names

This commit is contained in:
Dudu-0223
2026-11-04 13:51:31 +08:00
244 changed files with 5332 additions and 845 deletions
@@ -18,11 +18,11 @@
en: ce28268021a38b89
zh: 7802540e4ef04752
/agent-note-the-slot-system-standard-single-register-four-props-shares-and-the-framework-store-seat/decision/component-props-four-shares-each-from-its-own-source-of-truth:
en: f3fca569fa0c5168
zh: 647a82662f6a887d
en: c1892cb7309c0824
zh: 21983594d0c7f24f
/agent-note-the-slot-system-standard-single-register-four-props-shares-and-the-framework-store-seat/decision/the-chain-kind-entries-self-nominate-first-match-renders:
en: 56278be7f3688805
zh: 16602ffbf4baef85
en: dad15e2e1309c9a4
zh: ee2d13b27c92a73b
/agent-note-the-slot-system-standard-single-register-four-props-shares-and-the-framework-store-seat/decision/the-store-seat-framework-engine-registrant-schema:
en: 2645acc9d29c20ec
zh: a8a2fd293ef5da04
@@ -50,12 +50,13 @@ A contributor whose activation order is independent from the declaring entry use
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
| business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use<Name>` selector hooks (`InjectFace<I>`) |
| kind-specific | `MatchedShare` / `HocShare` | SlotMap entry's kind | chain `matched`; hoc `children`, the next inner layer or the owner content |
`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
### The chain kind: entries self-nominate, first match renders
The fourth `SlotKind`, `'chain'`, inverts routing authority relative to `keyed`: a keyed dispatch site picks its occupant by `entryKey`, while a chain entry nominates itself — the owner dispatches one common currency of owner props and never learns who takes over, so a new takeover package registers with zero owner edits. A chain registration carries a `select` pure selector (`ChainSelect<O, M>`: `(owner) => matched | null`) and an optional `priority` (ascending; ties keep registration = assembly order — the deployment-controllable inject topology — under the same stable sort as list `order`); registering without `select` is one of the loud-at-load cases above. At render, the outlet runs the selectors in chain order: the first non-null return elects its entry and the returned value joins the component's props as `matched` (the component never re-derives its own match), `null` passes the turn to the next entry, and all-null renders the owner's fallback body (`ChainRenderOpts`).
A fifth kind, `'hoc'`, nests entries around owner-supplied `children` and adds a kind-specific `children` prop the same way `chain` adds `matched`; its decision lives in the [hoc slot kind note](2026-11-03-hoc-slot-kind.md). The fourth `SlotKind`, `'chain'`, inverts routing authority relative to `keyed`: a keyed dispatch site picks its occupant by `entryKey`, while a chain entry nominates itself — the owner dispatches one common currency of owner props and never learns who takes over, so a new takeover package registers with zero owner edits. A chain registration carries a `select` pure selector (`ChainSelect<O, M>`: `(owner) => matched | null`) and an optional `priority` (ascending; ties keep registration = assembly order — the deployment-controllable inject topology — under the same stable sort as list `order`); registering without `select` is one of the loud-at-load cases above. At render, the outlet runs the selectors in chain order: the first non-null return elects its entry and the returned value joins the component's props as `matched` (the component never re-derives its own match), `null` passes the turn to the next entry, and all-null renders the owner's fallback body (`ChainRenderOpts`).
The decline decision lives in `select`, never in a mounted component probing its own props: a component that mounts only to render null still runs its hooks and effects for nothing, and the resulting mount/unmount churn breaks memoization and React key semantics, whereas a selector is a pure function — unit-testable, zero mount side effects — the same discipline as "presentation methods are pure functions of `args`". Purity is the selector's contract: it reads no external mutable state and produces no side effects, so the routing decision is entirely a function of the owner props and safe to run on every dispatch. Selectors route; they never mint — per-dispatch object construction would churn identity every render, so wrapping a matched value in a richer face happens inside the elected component (`useMemo` keyed on `matched`).
@@ -50,12 +50,13 @@ ctx.slots.register({
| 子 slot 渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector 钩子 + `actions.*`(剥去 draft 形参) |
| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留的 `hooks` 区域内,裸 observable 经绑定后以 `use<Name>` 选择器钩子的形式到达(`InjectFace<I>`) |
| 按 kind | `MatchedShare` / `HocShare` | SlotMap entry 的 kind | chain 的 `matched`;hoc 的 `children`,即内一层或宿主内容 |
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双重类型约束的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
### chain kind:entry 自荐,首个匹配项负责渲染
第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占用 slot 的 entry,chain 则由 entry 自荐——owner 只分派一套格式统一的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect<O, M>`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。
第五种 kind `'hoc'` 把 entry 层层嵌套在 owner 提供的 `children` 外面,并像 `chain` 提供 `matched` 那样提供按 kind 的 `children` prop;其决策见 [hoc 插槽类型 Note](2026-11-03-hoc-slot-kind.zh.md)。第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占用 slot 的 entry,chain 则由 entry 自荐——owner 只分派一套格式统一的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect<O, M>`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。
「不接」的判定住在 `select` 里,绝不在挂载后的组件里自探 props:组件为了渲染 null 也得先挂载,其钩子与 effect 全部白跑,随之而来的挂载/卸载抖动还会破坏 memo 化与 React key 语义;而选择器是纯函数——可单测、零挂载副作用——与「presentation methods are pure functions of `args`」是同一条纪律。纯,就是选择器的约定:不读外部可变状态、不产副作用,路由判定因此完全是 owner props 的函数,每次分派都可安全执行。选择器只做路由,绝不创建新对象——按分派逐次构造对象会让引用每次渲染都换新;把匹配值包成更丰富的面这件事,发生在当选组件内部(以 `matched` 为依赖的 `useMemo`)。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-11-03-hoc-slot-kind.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers:
en: 8292b8f9884b8802
zh: 3a4f34ca79313c9d
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/problem:
en: d16c3bfef49e16be
zh: 6714da1c9c984fe9
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision:
en: 3a4380324cfc1679
zh: 44db368e091cbd46
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision/implementation:
en: 01189e457c91deeb
zh: 94f7e8865b332a07
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision/first-consumer:
en: 1afdf4ceeef7a3b3
zh: be01cf39850db0ca
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/alternatives-considered:
en: b4824619c502b422
zh: 610364f2dc8cb2b7
/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/consequences:
en: 7542ee2bc2c70258
zh: bc0dd1dc6e50de66
@@ -0,0 +1,72 @@
# Agent Note: Add the `hoc` slot kind so plugins can wrap owner content in layers
Status: implemented
English | [中文](2026-11-03-hoc-slot-kind.zh.md)
## Problem
The home whale plays when blank space is clicked. Its canvas must paint below the content, and `.scrollBody` fills the whole body, so the canvas itself never receives clicks; only an ancestor of the content does.
No existing slot kind lets a plugin become an ancestor of the content:
| Kind | Behavior | Why it is not enough |
|---|---|---|
| `single` / `list` / `keyed` | Render the occupant at an owner-chosen position | The occupant is a sibling of the content |
| `chain` | The elected entry replaces the fallback; with `overlay` it renders beside the fallback | It does not wrap |
| Factory local slot | The caller replaces a local Component per occurrence | Only the caller chooses; plugins cannot stack |
The previous approach had the Conversation classify blank clicks on its body and notify the background plugin through `onBlankClick(listener) => dispose`. The Conversation thereby maintained click classification and subscription forwarding that served only a decorative plugin.
## Decision
Add a fifth kind: `hoc`.
| Item | Contract |
|---|---|
| Declaration | The parent entry's `children` lists `{ kind: 'hoc', scope }`; `SlotMap` declares `kind: 'hoc'` |
| Rendering | `renderSlot(key, owner, { children })`; `entryKey` / `only` / `fallback` / `hookContext` are unavailable; omitted `children` renders the layers around nothing |
| Registration | Same as `list`: required `id`, optional `order` and `priority`; the same `id` at the same `priority` throws, a different `priority` shadows |
| Nesting order | Ascending `order`, then `priority`, then registration order; lowest outermost |
| Layer props | Every layer receives the same owner props plus the framework-supplied `children` (the next inner layer; the innermost is the owner content); `children` is not an owner prop, `HocShare` types it, and owner props must not use that name |
| Layer obligation | Render `children` exactly once without creating a box of its own; do not change the props passed inward |
| No entry | `children` renders unchanged |
| Layer crash | Each layer has its own error boundary; a crashed layer renders its `children` and abdicates, so the next render omits it |
| Content error | An error thrown by the wrapped content passes every layer as its original error and is reported against no layer |
| Remounting | Adding or removing a layer remounts the content inside it; a `session` or `session-maybe` layer also remounts it when its Session binding changes |
### Implementation
| Package | Realization |
|---|---|
| `ui-slots` | `SlotKind` includes `'hoc'`; `HocRenderOpts`, `HocKeysOf`, `HocShare`; `RenderSlotFn` has a hoc-key overload taking optional `{ children }`; `KindOptions` has a hoc branch; `SlotCore.register` / `entriesOfSlot` key hoc cells by `id` |
| `ui-renderer` | `HocOutlet` calls `composeHocLayers` when the slot's ledger version or declaration changes (`useMemo` on the outlet instance) and nests the layers into one render function. Each layer is one Component that fixes its scope-specific Entry (`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`), entry, slot inject, and crash reporting at composition, and receives only `owner` and the next inner layer when rendering. With no layer the outlet returns the owner content directly. `HocContentBoundary` wraps each layer's inner content and rethrows its errors as `HocContentError`, which layer boundaries pass through and `HocOutletBoundary` unwraps to the original error. The Entry chain passes `children` to the registered Component as JSX children; non-hoc entries receive `undefined`. `SlotErrorBoundary` takes a `crashed` replacement node |
| `scripts/gen-client-catalog.ts` | Register options, a `children`-rendering example, and the `wraps-owner-content` replace risk for hoc slots |
### First consumer
| Location | Realization |
|---|---|
| `ui-conversation` | `conversation.hero.frame` (hoc, root) with owner props `{ hero: boolean }` wraps `.scrollBody` in every phase, so the composer does not remount when the home ends. On the home (`.root[data-phase='hero']`), the Conversation root, which paints the background, sets `isolation: isolate` as part of the frame contract, so layer decorations at negative `z-index` paint above that background and below all Conversation content. The Conversation performs no click classification for its layers |
| `ui-brand-official` | `PixelWhale` is a frame layer: a React `onClick` on a `display: contents` frame plays the whale for primary clicks on blank space, excluding portaled overlays (`currentTarget.contains(target)`), controls (a selector), and clicks that end a text selection inside the frame. While `hero` is true it paints a canvas at negative `z-index`. The frame atlas loads through the package asset route; the renderer returns `{ play, dispose }` |
## Alternatives considered
- `onBlankClick` subscription forwarding: the Conversation owns click classification that serves only a decorative plugin.
- Canvas below with `pointer-events: none` on `.scrollBody`: blank space cannot scroll when the home overflows.
- A canvas with no `z-index`: it needs no stacking context, but it paints over every unpositioned Conversation content outside the `z-index: 1` home composer stack, such as composer takeovers, and depends on that unrelated rule.
- A layer box that establishes the stacking context itself: the layer has to mirror the body's flex column layout.
- A separate `renderNestedSlot` or a JSX `<HocSlot>` component: one more set of bindings; `hookContext` already shows that `renderSlot` can constrain its third argument per key.
- Precompiling every slot kind per ledger version: no measurable end-to-end time difference, see #7838.
## Consequences
- Adding or removing a layer remounts everything inside it, including the composer on the home.
- Every non-hoc registered Component receives `children: undefined`, which overrides any owner prop of that name.
- Each layer adds one Component and two error boundaries to the tree; each hoc slot with layers adds one outlet boundary.
- The outlet's `[data-slot]` element sits between the owner and its content, so owner selectors cannot assume the content is a direct child.
- React 18 recollects the component stack where an error is rethrown, so a content error reaching boundaries above a hoc slot with layers carries a component stack that starts at `HocOutletBoundary`.
- A layer that wraps its `children` in its own error boundary catches the internal `HocContentError` rather than the original content error.
- The frame contract holds only on the home: other phases leave the Conversation root out of the stacking order, so unportaled Conversation popovers still rise above AppFrame chrome that floats over the column, while on the home they stack only within the root.
- `HocOutlet` keeps its layer Components through `useMemo`, as Factory local Components keep theirs; if React ever discards memoized values, both need an explicit cache, because these Components are the remount boundary of the content inside them.
- [The slot system standard](2026-07-22-slot-type-chain-implementation.md) remains authoritative for the other kinds; this note adds the fifth.
@@ -0,0 +1,72 @@
# Agent Note: 新增 `hoc` 插槽类型,让插件层层包裹宿主内容
Status: implemented
[English](2026-11-03-hoc-slot-kind.md) | 中文
## 问题
首页鲸鱼需要在空白处被点击时播放。canvas 必须画在内容下面,`.scrollBody` 铺满整个 body,所以 canvas 自己收不到点击;能收到点击的只有内容的祖先节点。
现有插槽类型都不能让插件成为内容的祖先:
| 类型 | 行为 | 为什么不够 |
|---|---|---|
| `single` / `list` / `keyed` | 在宿主指定位置渲染 occupant | occupant 和内容是兄弟节点 |
| `chain` | 选中的 entry 替换 fallback;`overlay` 时与 fallback 并排 | 不包裹 |
| Factory 局部 slot | 调用方按次替换局部组件 | 只有调用方能选,插件无法叠加 |
此前的做法是由 Conversation 在 body 上判定空白点击,再通过 `onBlankClick(listener) => dispose` 通知背景插件。这让 Conversation 维护一套只服务于装饰插件的点击分类和订阅转发。
## Decision
新增第五种 kind:`hoc`。
| 项 | 约定 |
|---|---|
| 声明 | 父 entry 的 `children` 写 `{ kind: 'hoc', scope }`;`SlotMap` 中 `kind: 'hoc'` |
| 渲染 | `renderSlot(key, owner, { children })`;`entryKey` / `only` / `fallback` / `hookContext` 不可用;省略 `children` 时各层包裹空内容 |
| 注册 | 与 `list` 相同:必填 `id`,可选 `order`、`priority`;同 `id` 同 `priority` 抛错,不同 `priority` 遮蔽 |
| 嵌套顺序 | 依次按 `order`、`priority`、注册顺序升序,最小者在最外层 |
| 层的 props | 每层收到同一份 owner props,加上框架注入的 `children`(内一层,最内层是宿主内容);`children` 不属于 owner props,类型由 `HocShare` 提供,owner props 不得使用这个名字 |
| 层的义务 | 恰好渲染一次 `children`,自身不产生盒子;不能修改传给内层的 props |
| 无 entry | 原样渲染 `children` |
| 层崩溃 | 每层有独立 error boundary;崩溃时该层渲染自己的 `children` 并 abdicate,下一次渲染不再包含该层 |
| 内容出错 | 被包裹内容抛出的错误以原始错误穿过所有层,不记到任何层名下 |
| 重新挂载 | 增减一层会让其内部内容重新挂载;`session` 或 `session-maybe` 作用域的层在 Session binding 变化时也会让内部内容重新挂载 |
### 实现
| 包 | 实现 |
|---|---|
| `ui-slots` | `SlotKind` 包含 `'hoc'`;`HocRenderOpts`、`HocKeysOf`、`HocShare`;`RenderSlotFn` 为 hoc key 提供接受可选 `{ children }` 的重载;`KindOptions` 有 hoc 分支;`SlotCore.register` / `entriesOfSlot` 按 `id` 划分 hoc cell |
| `ui-renderer` | `HocOutlet` 在插槽 ledger 版本或声明变化时调用 `composeHocLayers`(`useMemo` 缓存在 outlet 实例上),把各层组合成一个嵌套渲染函数。每层是一个组件,在组合时固定按 scope 选定的 Entry(`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`)、entry、slot inject 与崩溃上报,渲染时只接收 `owner` 与内一层。没有层时 outlet 直接返回宿主内容。`HocContentBoundary` 包住每层的内层内容,把其中的错误包装为 `HocContentError` 重新抛出;层的边界放行这种错误,`HocOutletBoundary` 再解开为原始错误。Entry 链以 JSX children 把 `children` 交给注册组件;非 hoc entry 收到 `undefined`。`SlotErrorBoundary` 接受 `crashed` 替换节点 |
| `scripts/gen-client-catalog.ts` | hoc 插槽的注册选项、渲染 `children` 的示例,以及 `wraps-owner-content` 替换风险 |
### 首个使用者
| 位置 | 实现 |
|---|---|
| `ui-conversation` | `conversation.hero.frame`(hoc,root),owner props 为 `{ hero: boolean }`,在所有阶段包裹 `.scrollBody`,首页结束时输入框不重新挂载。首页阶段(`.root[data-phase='hero']`),绘制背景的 Conversation 根元素设置 `isolation: isolate`,这是 frame 契约的一部分:层中负 `z-index` 的装饰画在该背景之上、所有 Conversation 内容之下。Conversation 不为层做任何点击判定 |
| `ui-brand-official` | `PixelWhale` 是 frame 层:`display: contents` 外框上的 React `onClick` 在主按钮点击留白时播放,排除 portal 浮层(`currentTarget.contains(target)`)、控件(选择器)和结束外框内文字选择的点击。`hero` 为 true 时以负 `z-index` 绘制 canvas。帧图经包 asset 路由加载;渲染器返回 `{ play, dispose }` |
## Alternatives considered
- `onBlankClick` 订阅转发:Conversation 承担只为装饰插件服务的点击分类。
- canvas 放底下、`.scrollBody` 设 `pointer-events: none`:首页溢出时空白处无法滚动。
- 不设 `z-index` 的 canvas:不需要层叠上下文,但会画在 `z-index: 1` 的首页输入区以外的所有非定位 Conversation 内容之上(例如输入区接管面板),并依赖那条无关的规则。
- 由层自己的盒子建立层叠上下文:层必须复刻 body 的 flex 列布局。
- 单独的 `renderNestedSlot` / JSX 组件 `<HocSlot>`:多一套绑定;`hookContext` 已证明可以按 key 约束 `renderSlot` 的第三个参数。
- 按 ledger 版本预编译所有插槽类型:端到端测不出耗时差异,见 #7838。
## Consequences
- 增减一层会让其内部所有内容重新挂载,包括首页的输入框。
- 所有非 hoc 的注册组件都会收到 `children: undefined`,会覆盖同名的 owner prop。
- 每层在组件树中多一个组件和两个 error boundary;有层的 hoc 插槽另多一个 outlet 边界。
- outlet 的 `[data-slot]` 元素位于 owner 与内容之间,owner 侧选择器不能假定内容是直接子元素。
- React 18 会在错误被重新抛出的位置重新采集组件栈,因此有层的 hoc 插槽之上的边界收到的内容错误,组件栈从 `HocOutletBoundary` 开始。
- 层如果自己在 `children` 外包错误边界,捕获到的是内部的 `HocContentError`,而不是原始的内容错误。
- frame 契约只在首页阶段成立:其他阶段根元素不参与层叠隔离,未经 portal 的 Conversation 浮层仍能盖住悬浮在中栏上方的 AppFrame 元素;首页阶段它们只在根元素内部比较层级。
- `HocOutlet` 与 Factory 局部组件一样用 `useMemo` 保持层组件身份;如果 React 将来会丢弃 memo 缓存,两处都需要改成显式缓存,因为这些组件是其内部内容的重新挂载边界。
- 其他 kind 仍以 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 为准;本 Note 新增第五种。
+1 -1
View File
@@ -6,4 +6,4 @@ The manual [Windows packaging workflow](workflows/windows-package.yml) uses the
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates.
The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
The manual Desktop publication workflow selects the original successful `package` job attempt independently of `upload-artifact` archival status, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
+51 -25
View File
@@ -3,11 +3,6 @@ name: Package CUA helper · macOS
on:
workflow_dispatch:
inputs:
source_ref:
description: '同仓库可信源码分支或 dsh-v* tag'
type: string
default: master
required: true
preset:
description: '机器上的签名配置'
type: choice
@@ -16,19 +11,29 @@ on:
required: true
workflow_call:
inputs:
source_ref:
reuse:
type: boolean
default: false
cua_run_id:
type: string
required: true
default: ''
preset:
type: string
default: production
outputs:
artifact:
description: '包含两个架构的已签名、公证 helper 归档'
value: ${{ jobs.package.outputs.artifact }}
artifact-id:
description: '包含两个架构的已签名、公证 helper 的产物编号'
value: ${{ jobs.package.outputs.artifact-id }}
run-id:
description: '产物所属的构建运行编号'
value: ${{ jobs.package.outputs.run-id }}
build-key:
description: '源码和构建配置的摘要'
value: ${{ jobs.package.outputs.build-key }}
permissions:
contents: read
actions: read
# Desktop uses the same keychain search list and optional system proxy transaction.
concurrency:
@@ -40,44 +45,65 @@ jobs:
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
timeout-minutes: 60
outputs:
artifact: ${{ steps.artifact.outputs.name }}
artifact-id: ${{ steps.retained.outputs.artifact-id || steps.upload.outputs.artifact-id }}
run-id: ${{ steps.retained.outputs.run-id || github.run_id }}
build-key: ${{ steps.build-inputs.outputs.build-key }}
env:
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
DSH_TELEMETRY_DISABLED: '1'
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
steps:
- uses: actions/checkout@v7.0.1
with:
ref: ${{ inputs.source_ref }}
persist-credentials: false
clean: true
- name: Validate source selection
- name: Validate run ref and preset
run: |
node -e 'const cp=require("node:child_process"); const fs=require("node:fs"); const ref=process.env.PACKAGE_SOURCE_REF;
if(ref.startsWith("refs/pull/") || /^[a-f0-9]{40}$/i.test(ref)) throw Error("Select a trusted repository branch or release tag");
node -e 'const fs=require("node:fs"); const ref=process.env.GITHUB_REF;
if(!ref || (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/"))) throw Error("Select a trusted repository branch or release tag");
const tag=ref.replace(/^refs\/tags\//, "");
if(tag.startsWith("dsh-v") && tag!=="dsh-v"+JSON.parse(fs.readFileSync("package.json","utf8")).version) throw Error("Release tag must match product version");
if(!["test","production"].includes(process.env.DSH_PACKAGING_PRESET)) throw Error("Invalid preset");'
- uses: pnpm/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
package-manager-cache: false
- name: Record CUA helper build inputs
id: build-inputs
run: node scripts/cua-helper/build-inputs.ts
- name: Find reusable CUA helper
if: inputs.reuse
id: retained
env:
GH_TOKEN: ${{ github.token }}
CUA_RUN_ID: ${{ inputs.cua_run_id }}
CUA_BUILD_KEY: ${{ steps.build-inputs.outputs.build-key }}
run: node scripts/cua-helper/select-artifact.ts
- uses: pnpm/action-setup@v5.0.0
if: steps.retained.outputs.artifact-id == ''
with:
dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }}
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
if: steps.retained.outputs.artifact-id == ''
run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
- name: Build and verify notarized applications
if: steps.retained.outputs.artifact-id == ''
env:
CUA_EXPECTED_BUILD_KEY: ${{ steps.build-inputs.outputs.build-key }}
run: |
mkdir -p dist
pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper
- name: Name retained artifact
id: artifact
run: echo "name=cua-helper-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
node --import tsx/esm scripts/cua-helper/package.ts --out dist/cua-helper
- uses: actions/upload-artifact@v7.0.1
if: steps.retained.outputs.artifact-id == ''
id: upload
with:
name: ${{ steps.artifact.outputs.name }}
name: cua-helper-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.build-inputs.outputs.build-key }}
path: dist/cua-helper/*
if-no-files-found: error
compression-level: 0
retention-days: 14
- name: Record newly built artifact
if: steps.retained.outputs.artifact-id == ''
env:
CUA_ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }}
run: |
printf '已构建 CUA helper,run ID `%s`,artifact ID `%s`。\n' "$GITHUB_RUN_ID" "$CUA_ARTIFACT_ID" >> "$GITHUB_STEP_SUMMARY"
+100
View File
@@ -9,6 +9,11 @@ on:
description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包'
type: string
required: true
upload_website:
description: '同时上传官网安装包并刷新缓存(仅 production);取消后仅执行版本与自动更新发布'
type: boolean
default: true
required: true
package_run_attempt:
description: '打包尝试次数:留空选择最新一次,且必须成功;失败时不会回退到旧包'
type: string
@@ -57,9 +62,13 @@ jobs:
route: ${{ steps.local.outputs.route }}
preset: ${{ steps.local.outputs.preset }}
approval: ${{ steps.check.outputs.approval }}
website-approval: ${{ steps.check.outputs.website-approval }}
website-destination: ${{ steps.check.outputs.website-destination }}
label: ${{ steps.check.outputs.label }}
env:
DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }}
DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }}
DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }}
DSH_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@v7.0.1
@@ -151,6 +160,7 @@ jobs:
DESKTOP_TAG_TOKEN: ${{ steps.app.outputs.token }}
run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --publish
- name: Retain publication audit
continue-on-error: true
if: always() && steps.publish.outputs.record != ''
uses: actions/upload-artifact@v7.0.1
with:
@@ -158,3 +168,93 @@ jobs:
path: ${{ steps.publish.outputs.record }}/
if-no-files-found: error
retention-days: 30
upload-website-installer:
needs: [route, prepare, publish]
if: inputs.upload_website && needs.prepare.outputs.preset != 'test'
runs-on: [self-hosted, '${{ needs.route.outputs.runner }}']
environment: desktop-production
timeout-minutes: 60
concurrency:
group: desktop-website-upload-${{ needs.prepare.outputs.website-destination }}
cancel-in-progress: false
env:
DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }}
DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }}
DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
clean: false
path: website-publisher
- uses: pnpm/action-setup@v5.0.0
with:
package_json_file: website-publisher/package.json
dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Check local publication configuration
working-directory: website-publisher
run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs
- run: pnpm install --frozen-lockfile
working-directory: website-publisher
- name: Upload website installer
id: website
working-directory: website-publisher
run: node --import tsx apps/desktop/scripts/publish-website-ci.ts upload
- name: Archive website publication diagnostics
if: always() && steps.website.outputs.record != ''
continue-on-error: true
uses: actions/upload-artifact@v7.0.1
with:
name: website-upload-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.website.outputs.record }}/
if-no-files-found: warn
retention-days: 30
purge-website-installer:
needs: [route, prepare, upload-website-installer]
if: inputs.upload_website && needs.prepare.outputs.preset != 'test'
runs-on: [self-hosted, '${{ needs.route.outputs.runner }}']
environment: desktop-production
timeout-minutes: 60
concurrency:
group: desktop-website-purge-${{ needs.prepare.outputs.website-destination }}
cancel-in-progress: false
env:
DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }}
DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }}
DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
clean: false
path: website-publisher
- uses: pnpm/action-setup@v5.0.0
with:
package_json_file: website-publisher/package.json
dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Check local publication configuration
working-directory: website-publisher
run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs
- run: pnpm install --frozen-lockfile
working-directory: website-publisher
- name: Recover and observe website cache purge
id: website
working-directory: website-publisher
run: node --import tsx apps/desktop/scripts/publish-website-ci.ts purge
- name: Archive website publication diagnostics
if: always() && steps.website.outputs.record != ''
continue-on-error: true
uses: actions/upload-artifact@v7.0.1
with:
name: website-purge-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.website.outputs.record }}/
if-no-files-found: warn
retention-days: 30
+49 -2
View File
@@ -57,6 +57,13 @@ concurrency:
jobs:
package:
outputs:
directory: ${{ steps.retained.outputs.directory }}
runner: ${{ steps.retained.outputs.runner }}
runner-name: ${{ steps.retained.outputs.runner-name }}
attempt: ${{ steps.retained.outputs.attempt }}
version: ${{ steps.settings.outputs.version }}
mode: ${{ steps.settings.outputs.mode }}
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
timeout-minutes: 180
defaults:
@@ -98,6 +105,7 @@ jobs:
if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref');
}
if(!fs.readFileSync('apps/desktop/scripts/prepare-macos-ci.mjs','utf8').includes('resolveDesktopBuildTarget')) throw Error('Source lacks Intel macOS CI support; select a newer source');
if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source');
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/action-setup@v5.0.0
with:
@@ -115,6 +123,10 @@ jobs:
run: arch -x86_64 /usr/bin/true
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
- name: Check original machine archival route
env:
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner
- name: Validate preset and resolve build version
id: settings
env:
@@ -154,10 +166,12 @@ jobs:
- name: Retain complete package outside the checkout
id: retained
env:
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts
- name: Retain packaging diagnostics
continue-on-error: true
if: always() && steps.checkout.outcome == 'success'
uses: actions/upload-artifact@v7.0.1
with:
@@ -166,13 +180,46 @@ jobs:
path: apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
upload-artifact:
needs: package
runs-on: [self-hosted, '${{ needs.package.outputs.runner }}']
timeout-minutes: 30
env:
DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }}
DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }}
DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
path: artifact-archiver
- uses: pnpm/action-setup@v5.0.0
with:
package_json_file: artifact-archiver/package.json
dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Configure persistent package caches
env:
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
run: |
echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV"
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
working-directory: artifact-archiver
- name: Verify original retained build before GitHub archival
working-directory: artifact-archiver
run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts
- name: Retain installers for the selected mode
if: success()
uses: actions/upload-artifact@v7.0.1
with:
name: macos-x64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
name: macos-x64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
path: ${{ needs.package.outputs.directory }}/*
compression-level: 0
if-no-files-found: error
retention-days: 14
overwrite: true
+49 -2
View File
@@ -57,6 +57,13 @@ concurrency:
jobs:
package:
outputs:
directory: ${{ steps.retained.outputs.directory }}
runner: ${{ steps.retained.outputs.runner }}
runner-name: ${{ steps.retained.outputs.runner-name }}
attempt: ${{ steps.retained.outputs.attempt }}
version: ${{ steps.settings.outputs.version }}
mode: ${{ steps.settings.outputs.mode }}
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
timeout-minutes: 180
defaults:
@@ -97,6 +104,7 @@ jobs:
const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim();
if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref');
}
if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source');
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/action-setup@v5.0.0
with:
@@ -112,6 +120,10 @@ jobs:
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
- name: Check original machine archival route
env:
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner
- name: Validate preset and resolve build version
id: settings
env:
@@ -151,10 +163,12 @@ jobs:
- name: Retain complete package outside the checkout
id: retained
env:
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts
- name: Retain packaging diagnostics
continue-on-error: true
if: always() && steps.checkout.outcome == 'success'
uses: actions/upload-artifact@v7.0.1
with:
@@ -163,13 +177,46 @@ jobs:
path: apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
upload-artifact:
needs: package
runs-on: [self-hosted, '${{ needs.package.outputs.runner }}']
timeout-minutes: 30
env:
DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }}
DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }}
DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
path: artifact-archiver
- uses: pnpm/action-setup@v5.0.0
with:
package_json_file: artifact-archiver/package.json
dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Configure persistent package caches
env:
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
run: |
echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV"
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
working-directory: artifact-archiver
- name: Verify original retained build before GitHub archival
working-directory: artifact-archiver
run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts
- name: Retain installers for the selected mode
if: success()
uses: actions/upload-artifact@v7.0.1
with:
name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
name: macos-arm64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
path: ${{ needs.package.outputs.directory }}/*
compression-level: 0
if-no-files-found: error
retention-days: 14
overwrite: true
+14 -33
View File
@@ -9,12 +9,7 @@ on:
workflow_dispatch:
inputs:
cua_run_id:
description: '已有 CUA 打包 run ID:留空重新打包;填写时复用同一源码和版本的已验证产物'
type: string
required: false
default: ''
cua_run_attempt:
description: 'CUA 打包 attempt:仅与 run ID 一起使用;留空选择最新一次,不回退到更早的成功构建'
description: 'CUA helper run ID,留空复用源码逐字节一致的产物,无匹配则构建'
type: string
required: false
default: ''
@@ -26,6 +21,7 @@ on:
permissions:
contents: read
actions: read
env:
PRIMARY_NODE_VERSION: '24'
@@ -33,18 +29,16 @@ env:
jobs:
cua-helper:
if: (startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check) && inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
if: startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check
uses: ./.github/workflows/cua-helper-package.yml
with:
source_ref: ${{ github.ref }}
reuse: true
preset: production
cua_run_id: ${{ inputs.cua_run_id }}
pack:
name: Pack npm tarballs
needs: cua-helper
if: >-
${{ !cancelled() && (needs.cua-helper.result == 'success'
|| (needs.cua-helper.result == 'skipped' && (inputs.cua_run_id != '' || inputs.cua_run_attempt != ''))) }}
runs-on: ubuntu-24.04
permissions:
contents: read
@@ -87,33 +81,20 @@ jobs:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }}
- name: Select retained CUA helper
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
id: retained-cua
env:
GH_TOKEN: ${{ github.token }}
run: pnpm exec tsx scripts/cua-helper/select-artifact.ts
- name: Download retained CUA helper
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
- name: Download selected CUA helper
uses: actions/download-artifact@v8.0.1
with:
artifact-ids: ${{ steps.retained-cua.outputs.artifact-id }}
run-id: ${{ inputs.cua_run_id }}
artifact-ids: ${{ needs.cua-helper.outputs.artifact-id }}
run-id: ${{ needs.cua-helper.outputs.run-id }}
github-token: ${{ github.token }}
path: dist/cua-helper
- name: Build
run: pnpm run build:official --serial
- name: Download freshly built CUA helper
if: inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
uses: actions/download-artifact@v8.0.1
with:
name: ${{ needs.cua-helper.outputs.artifact }}
path: dist/cua-helper
- name: Stage verified native helper applications
env:
CUA_EXPECTED_BUILD_KEY: ${{ needs.cua-helper.outputs.build-key }}
run: |
mkdir dist/cua-helper/extracted
tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted
@@ -121,13 +102,13 @@ jobs:
- name: Record CUA source and publication retry instructions
env:
CUA_RUN_ID: ${{ inputs.cua_run_id || github.run_id }}
CUA_RUN_ATTEMPT: ${{ steps.retained-cua.outputs.attempt || github.run_attempt }}
CUA_RUN_ID: ${{ needs.cua-helper.outputs.run-id }}
CUA_ARTIFACT_ID: ${{ needs.cua-helper.outputs.artifact-id }}
run: |
{
printf 'CUA helper: run `%s`, attempt `%s`.\n\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
printf '采用 CUA helper 构建 run ID `%s`,artifact ID `%s`。\n\n' "$CUA_RUN_ID" "$CUA_ARTIFACT_ID"
printf '%s\n\n' 'npm 部分发布失败时,只重跑 Publish to npm job,复用原 dsh-npm-tarballs;不要重跑全部 job。'
printf '若需新建发布运行,填写 `cua_run_id=%s`、`cua_run_attempt=%s`,并使用相同源码 commit 和版本。\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
printf '需要重新打包时,可留空自动查找,或填写 `cua_run_id=%s` 指定该 helper;源码和构建配置必须匹配。\n' "$CUA_RUN_ID"
} >> "$GITHUB_STEP_SUMMARY"
- name: Pack release tarballs
+65 -4
View File
@@ -63,6 +63,13 @@ concurrency:
jobs:
package:
outputs:
directory: ${{ steps.retained.outputs.directory }}
runner: ${{ steps.retained.outputs.runner }}
runner-name: ${{ steps.retained.outputs.runner-name }}
attempt: ${{ steps.retained.outputs.attempt }}
version: ${{ steps.settings.outputs.version }}
mode: ${{ steps.settings.outputs.mode }}
runs-on: dsh-win-package-trial
timeout-minutes: 120
defaults:
@@ -127,6 +134,7 @@ jobs:
const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim();
if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref');
}
if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source');
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/action-setup@v5.0.0
with:
@@ -158,6 +166,10 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm install --frozen-lockfile
exit $LASTEXITCODE
- name: Check original machine archival route
env:
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner
- name: Validate preset and resolve build version
id: settings
env:
@@ -183,10 +195,12 @@ jobs:
- name: Retain complete package outside the checkout
id: retained
env:
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts
- name: Retain packaging diagnostics
continue-on-error: true
if: always() && steps.checkout.outcome == 'success'
uses: actions/upload-artifact@v7.0.1
with:
@@ -195,23 +209,70 @@ jobs:
path: ${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
upload-artifact:
defaults:
run:
shell: pwsh
needs: package
runs-on: [self-hosted, '${{ needs.package.outputs.runner }}']
timeout-minutes: 30
env:
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }}
DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }}
DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
path: artifact-archiver
- uses: pnpm/action-setup@v5.0.0
with:
package_json_file: artifact-archiver/package.json
dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Configure persistent pnpm store
# PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory.
env:
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
run: |
$storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store'
$namespace = $env:PACKAGE_CACHE_NAMESPACE
if ($namespace) {
if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' }
if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' }
$storeRoot = Join-Path $storeRoot $namespace
$env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace
}
"DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV
"PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
- run: pnpm install --frozen-lockfile
working-directory: artifact-archiver
- name: Verify original retained build before GitHub archival
working-directory: artifact-archiver
run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts
- name: Retain verified unsigned installer
if: success() && inputs.signing == 'unsigned'
uses: actions/upload-artifact@v7.0.1
with:
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
name: windows-x64-unsigned-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
compression-level: 0
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
path: ${{ needs.package.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
overwrite: true
- name: Retain verified signed installer
if: success() && inputs.signing == 'signed'
uses: actions/upload-artifact@v7.0.1
with:
name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
name: windows-x64-signed-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
compression-level: 0
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
path: ${{ needs.package.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
overwrite: true
+4 -4
View File
@@ -24,11 +24,11 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: 253e17998951931c
zh: b582b377391d2ec2
en: e0a852fdda949c40
zh: b96c613022f6189e
/desktop-packaging/macos-self-hosted-packaging:
en: b7e03e2e9807a62c
zh: e5b0c43910e085d8
en: 239d5ab433020745
zh: e8916ba0f4682ae1
/desktop-packaging/unsigned-windows-test-installer:
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
+5 -1
View File
@@ -113,7 +113,9 @@ The `Package Desktop · Windows · x64` [manual workflow](../../../.github/workf
Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts.
Open the completed run in Actions and download its Artifacts. `windows-x64-signed-<version>-<run_id>-<run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
All Windows packaging modes, including test and unsigned, require the repository variable `DESKTOP_PUBLISH_RUNNERS`; configure the [original-machine route](../publishing/ci/README.md#machine-setup) before dispatch.
Open the completed run in Actions and download its Artifacts from the independent `upload-artifact` job. Installer names use the original `package_run_attempt`; diagnostic names use the current `run_attempt`. If archival fails after packaging succeeds, rerun only `upload-artifact` to reuse the retained installers. `windows-x64-signed-<version>-<run_id>-<package_run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<package_run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory.
@@ -127,6 +129,8 @@ Installer uploads store the already-compressed release files without ZIP recompr
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
Both macOS workflows require the repository variable `DESKTOP_PUBLISH_RUNNERS`, including test and unnotarized modes. Their independent `upload-artifact` job retains installer names with the original build attempt; if archival fails after packaging succeeds, rerun only that job. See [original-machine routing and recovery](../publishing/ci/README.md#recovery-and-records).
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields.
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
+5 -1
View File
@@ -113,7 +113,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。
在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed-<version>-<run_id>-<run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
所有 Windows 打包模式(含 test 和 unsigned)都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`;触发前配置[原机器路由](../publishing/ci/README.zh.md#machine-setup)。
在 Actions 打开完成的运行,下载独立 `upload-artifact` job 生成的 Artifacts。安装包名称使用原构建的 `package_run_attempt`,诊断名称使用当前 `run_attempt`。打包成功但归档失败时,仅重跑 `upload-artifact` 以复用保留的安装包。`windows-x64-signed-<version>-<run_id>-<package_run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<package_run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。
@@ -127,6 +129,8 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
两个 macOS 工作流都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`,包括 test 和未公证模式。独立 `upload-artifact` job 的安装包名称保留原构建 attempt;打包成功但归档失败时,仅重跑该 job。参见[原机器路由与恢复](../publishing/ci/README.zh.md#recovery-and-records)。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
+2 -2
View File
@@ -24,8 +24,8 @@
en: 4723059d6f16b25b
zh: 54e5afad8fff8eb6
/desktop-publication/fixed-installer-downloads:
en: ce8296f8f1ee881e
zh: 749ab63bd6ed4248
en: ed4dcced7bd4e7ba
zh: a9e7f5da9e39454f
/desktop-publication/upload-credentials:
en: 47442fb912fa4d5d
zh: a21a0d67cd01724b
+3 -1
View File
@@ -69,11 +69,13 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the
## Fixed installer downloads
Uploads set `Content-Disposition: attachment` with the verified installer basename as `filename`, preserving the complete version, platform and architecture. The fixed `dsh-latest-*` URL remains independent of nightly/stable update channels; no additional naming suffix is added. Verify CDN header forwarding and the browser save name after publication.
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically.
The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest <target>` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish fixed downloads or run this refresh.
The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest <target>` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The [CI publication workflow](ci/README.md#recovery-and-records) has separate website upload and observed-purge jobs.
<a id="credentials"></a>
+3 -1
View File
@@ -69,11 +69,13 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo
## 固定安装包下载
上传设置 `Content-Disposition: attachment`,`filename` 沿用经校验安装包的 basename,保留完整版本、平台和架构。固定 `dsh-latest-*` URL 与 nightly/stable 升级通道独立,不另加命名后缀。发布后需验证 CDN 响应头透传及浏览器保存文件名。
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交一次 EdgeOne URL 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。
命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest <target>` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。
命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest <target>` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。[CI 发布工作流](ci/README.zh.md#recovery-and-records)使用独立的官网上传和刷新观察 job。
<a id="credentials"></a>
+4 -4
View File
@@ -24,11 +24,11 @@
en: 0f43f2e193e95043
zh: 1c1efb062c06f7b8
/desktop-ci-publication/package-and-publish:
en: ee8d79ac75482502
zh: 1d986a76860ed262
en: 3f58698708671202
zh: be0ba215a67168d6
/desktop-ci-publication/recovery-and-records:
en: 4ff6f0673d057ca8
zh: 7171d060dd86db9a
en: 266afba2adeeadef
zh: 063114103585dae8
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
+13 -3
View File
@@ -68,11 +68,11 @@ Environment approval gates this workflow, not arbitrary code running under the s
## Package and publish
For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes.
For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. An archive-only rerun retains the original build attempt in artifact names, so this early check can miss it; local manifest qualification still rejects debugging builds before publication. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes.
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication.
Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt and its successful package job. An archival-only retry retains the original build attempt; a failed newer package job never falls back to an older success. An explicit attempt selects that build even when its separate GitHub archival failed. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication.
Before installing dependencies on the original runner, preparation and the approved publish job check that the selected `publishing/<preset>.json` is readable JSON. Missing configuration reports its path; restore it and retry publication with the same package. Full configuration and signature validation still run afterward. Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v<version>` at the recorded packaging commit; an existing tag must name the same commit.
@@ -80,7 +80,17 @@ Before installing dependencies on the original runner, preparation and the appro
## Recovery and records
After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, stable writes `stable.yml` / `stable-mac.yml`. Stable-channel binaries use a `stable/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit.
The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). All packaging modes, including test and unsigned builds, require `DESKTOP_PUBLISH_RUNNERS` before packaging; it routes archival to the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. If an archival retry stays queued without a runner, inspect the resolved runner label and upstream package outputs before treating the runner as offline. Missing outputs require operator recovery of the original retained directory and manifest; rerunning the whole workflow rebuilds the package and is not archival-only recovery. Verify output reuse in the Actions acceptance test.
Production COS publication enables website installer upload and cache refresh by default (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. Both production nightly and stable packages can replace the fixed website installer. Operators select the intended version; the fixed object has no downgrade protection, so rerunning an older publication can replace a newer installer. The pre-approval summary names the fixed website URL, selected version and lack of downgrade protection when website upload is selected. The existing version/update publication retains its own checks.
Set the repository variable `DOWNLOAD_PROD_EO_ZONE_ID` to the production EdgeOne zone. The production COS credential pair also needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks`. Website uploads serialize by bucket/target in Actions and by bucket/object on the original machine. Cache observation runs separately. Version publication, website upload and cache refresh each use the production approval Environment; later jobs and reruns may require another approval. GitHub concurrency retains at most one pending job per group, so a newer pending job can cancel an older pending job; inspect canceled jobs before rerunning them.
Website state lives in `upload-records/website-<publication run ID>-<target>/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. Website URLs and download filenames follow the [fixed-download rules](../README.md#fixed-downloads). Upload retries reconcile both bytes and `Content-Disposition`; identical bytes with a missing or different header receive one upload and readback. A completed journal with different remote bytes stops recovery and requires a newly approved publication. Completion evidence survives failed metadata repairs. Each new upload intent clears the previous purge record, so repaired metadata requires a new cache refresh; inspect EdgeOne history for the previous task ID and diagnostics. Unknown writes are read before any retry; CDN header forwarding requires real-environment validation. Rerun only the purge job to inspect its saved task when the original routing and preparation outputs remain available. With website upload enabled, website jobs skip only an explicitly selected test deployment. Each job requires its injected `route` or `selection` output for local preflight. Missing or changed website approval fails before upload or purge submission. Missing `website-destination` broadens both website concurrency groups. Missing `runner` output can prevent scheduling before preflight. Stop partial retries when outputs are missing; the workflow does not reconstruct them. Before restarting the publication workflow, inspect current remote state and obtain approval for its upload scope; this is not cache-only recovery. Do not rerun packaging to recover publication outputs. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. An unknown result with a task ID means submission was confirmed; without an ID it may never have been submitted. Inspect EdgeOne history before manual recovery, and a fresh publication run is one recovery option. Operators may instead use the documented [fixed-download purge command](../README.md#fixed-downloads); Windows operators use the existing DPAPI credential launcher described there. This submits only a fixed-URL purge: it does not observe completion or update this Actions run’s journal. Inspect the returned task in EdgeOne before considering recovery complete.
Purge observation waits up to five minutes at ten-second intervals, with one additional query after an unknown result or observation deadline. Confirmed failed, timeout or canceled tasks fail the job. An unresolved result exits successfully with an Actions warning and a prominent summary; its durable state remains unknown, not completed. Diagnostic artifact archival is best effort; local records remain the recovery source. Simulated-provider tests cover these paths; release operators must verify production COS/EdgeOne permissions, job-id filtering, time-range queries, task type and timestamp formats, cross-attempt Actions recovery, and actual CDN behavior before relying on recovery in production.
After resolving a failure, re-run the failed publishing job only when the upstream outputs required above remain available, keeping its prepared package attempt; otherwise stop partial retries. A new publication can explicitly select the original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, stable writes `stable.yml` / `stable-mac.yml`. Stable-channel binaries use a `stable/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit.
The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate.
+13 -3
View File
@@ -68,11 +68,11 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
## 打包与发布
发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。
发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。仅重跑归档时,artifact 名称保留原构建 attempt,早期检查可能未识别该包;本地清单资格校验仍会在发布前拒绝调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。
打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试及其成功的 package job;归档重跑继续使用原始构建 attempt,新的构建失败时不回退到较早成功构建。显式填写则选择对应构建,独立的 GitHub 归档失败不取消其资格。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。
在原 runner 安装依赖前,准备阶段和审批后的发布 job 都会检查所选 `publishing/<preset>.json` 是否为可读取的 JSON。配置缺失时会报告路径;补齐后可使用同一个包重试发布。之后仍会执行完整配置与签名校验。上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v<version>`;已有 tag 必须指向同一提交。
@@ -80,7 +80,17 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
## 恢复与记录
解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,stable 写入 `stable.yml` / `stable-mac.yml`。stable 二进制位于目标二进制前缀下的 `stable/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。
打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。所有打包模式(包括 test 和 unsigned)都要求在打包前配置 `DESKTOP_PUBLISH_RUNNERS`,用于将归档路由到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。归档重跑若一直排队且未分配 runner,应先检查解析出的 runner 标签和上游 package 输出,不能直接认定机器离线。输出缺失时需由操作者从原留存目录和 manifest 恢复文件;重跑整个 workflow 会重新构建,不属于仅归档恢复。Actions 验收须验证输出复用。
生产 COS 发布默认启用官网安装包上传和缓存刷新(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。production nightly 和 stable 包都可以替换官网固定安装包。操作者负责选择预期版本;固定对象没有防降级保护,重跑旧发布可能覆盖新安装包。勾选官网上传时,审批前摘要展示固定官网 URL、选定版本和无防降级保护的说明。原有版本与自动更新发布保留自身校验。
将仓库变量 `DOWNLOAD_PROD_EO_ZONE_ID` 配置为生产 EdgeOne 站点。生产 COS 凭据还需要 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。官网上传在 Actions 中按 bucket/平台互斥,在原机器上按 bucket/对象互斥;缓存观察单独运行。版本发布、官网上传和缓存刷新分别使用生产审批 Environment,后续 job 和重跑可能需要再次审批。GitHub 每个并发组最多保留一个排队 job,新排队 job 可能取消旧排队 job;重跑前应检查取消记录。
官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-<target>/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。官网 URL 和保存文件名遵循[固定下载规则](../README.zh.md#fixed-downloads)。上传重跑同时核对字节和 `Content-Disposition`;字节相同但响应头缺失或错误时,上传一次并读回验证。记录已完成但远端字节改变时停止恢复,需要重新审批发布。完成标记在元数据修复失败后仍保留。每次新的上传意图清除旧刷新记录,因此修复元数据后需重新刷新缓存;上一次任务 ID 和诊断需查 EdgeOne 历史。未知写入先读取再决定重试;CDN 响应头透传仍需实测。原机器路由和准备阶段输出完整时,仅重跑刷新 job 即可查询已保存任务。启用官网上传时,官网 job 仅跳过明确选择的 test 环境。各 job 的本地预检要求其注入的 `route` 或 `selection` 输出存在。官网审批缺失或变化时,在上传或提交刷新任务前失败。`website-destination` 缺失会扩大两个官网并发组的互斥范围。`runner` 输出缺失可能导致无法调度,尚未进入预检。输出缺失时停止局部重试,workflow 不重建这些输出。重新启动发布 workflow 前,先检查当前远端状态并确认上传范围的审批;这不属于仅缓存恢复。不要为恢复发布输出重跑打包。人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。unknown 带任务 ID 表示提交已确认;没有 ID 则可能从未提交。人工恢复前应检查 EdgeOne 历史,重新发起发布运行是恢复方式之一。操作者也可使用已有的[固定下载刷新命令](../README.zh.md#fixed-downloads);Windows 操作者使用该处说明的 DPAPI 凭据启动器。该命令只提交固定 URL 刷新,不观察完成状态,也不更新本次 Actions 运行的 journal。确认恢复完成前应在 EdgeOne 检查返回的任务。
刷新观察每十秒查询一次,最长五分钟;结果未知或观察超时后额外查询一次。明确的 failed、timeout、canceled 结果使 job 失败。仍无法确认时成功退出,并输出 Actions warning 和醒目摘要;持久化状态保持 unknown,不标为完成。诊断 artifact 尽力归档,本地记录仍是恢复依据。模拟服务测试覆盖这些路径;发布操作者必须在生产依赖恢复能力前验证 COS/EdgeOne 权限、job-id 筛选、时间范围查询、任务类型和时间戳格式、Actions 跨 attempt 恢复及实际 CDN 行为。
解决故障后,仅在上文要求的上游输出完整时重跑失败的发布 job,以保持准备阶段选定的打包尝试;否则停止局部重试。新发布可显式选择原打包运行和尝试。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,stable 写入 `stable.yml` / `stable-mac.yml`。stable 二进制位于目标二进制前缀下的 `stable/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。
同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。
@@ -0,0 +1,31 @@
/** Validate the original machine and immutable inventory before GitHub artifact archival. */
import { resolve } from 'node:path'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
import { desktopReleasesRoot } from './retain-desktop-ci.ts'
import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts'
/**
* Verify an original build without interpreting the archive job's attempt as a new build.
* @param env Original build outputs and current Actions machine identity.
* @returns Resolves after the retained inventory passes all digest checks.
*/
export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise<void> {
if (!env.RUNNER_NAME || env.RUNNER_NAME !== env.DESKTOP_PACKAGE_RUNNER) throw new Error('desktop archival: wrong original machine')
const root = desktopReleasesRoot(env)
const runId = env.GITHUB_RUN_ID || ''
const attempt = env.DESKTOP_PACKAGE_ATTEMPT || ''
const expected = retainedDesktopPath(root, runId, attempt)
if (env.DESKTOP_RETAINED_DIRECTORY !== expected) throw new Error('desktop archival: retained directory differs from original build')
const manifest = await verifyRetainedDesktopRelease(root, runId, attempt)
if (manifest.source.repository !== env.GITHUB_REPOSITORY || manifest.source.runnerName !== env.RUNNER_NAME) {
throw new Error('desktop archival: retained source does not match repository or machine')
}
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
try { await verifyDesktopArchive(process.env) }
catch (error) {
process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'artifact archival'))}\n`)
process.exitCode = 1
}
}
@@ -14,6 +14,13 @@ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64'
*/
export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): string
/**
* Resolve the production website installer URL shared by approval, upload and purge.
* @param target Supported Desktop target.
* @returns Fixed production download URL.
*/
export function desktopWebsiteInstallerUrl(target: DesktopAutoUpdateTarget): string
/** Public updater URL and object prefixes for one release target. */
export interface DesktopAutoUpdateConfig {
readonly environment: DesktopAutoUpdateEnvironment
@@ -46,6 +46,15 @@ export function desktopLatestInstallerFilename(target) {
return `dsh-latest-${windows ? 'windows' : 'macos'}-${target === 'mac-arm64' ? 'arm64' : 'x64'}.${windows ? 'exe' : 'dmg'}`
}
/**
* Resolve the production website installer URL shared by approval, upload and purge.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target Supported Desktop target.
* @returns {string} Fixed production download URL.
*/
export function desktopWebsiteInstallerUrl(target) {
return `${UPDATE_ENVIRONMENTS.production.fixedOrigin}/desktop/${desktopLatestInstallerFilename(target)}`
}
/**
* Resolve the update deployment, defaulting local release work to test.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
@@ -0,0 +1,9 @@
/** Read file-owned packaging settings without loading packaging dependencies. */
/**
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
* @param path - Selected file; relative file settings resolve beside it.
* @param accepts - Whether this file may declare a setting.
* @returns Validated file settings.
*/
export function readDesktopPackageSettings(path: string, accepts: (name: string) => boolean): Record<string, string>
@@ -0,0 +1,40 @@
/** Read file-owned packaging settings without loading packaging dependencies. */
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { parseEnv } from 'node:util'
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
/**
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
* @param {string} path Selected file; relative file settings resolve beside it.
* @param {(name: string) => boolean} accepts Whether this file may declare a setting.
* @returns {Record<string, string>} Validated file settings.
*/
export function readDesktopPackageSettings(path, accepts) {
let contents
try {
contents = readFileSync(path, 'utf8')
}
catch {
throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`)
}
let settings
try {
settings = parseEnv(contents.replace(/^\uFEFF/u, ''))
}
catch {
// Parser diagnostics can contain credential-bearing input.
throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
}
for (const name of Object.keys(settings)) {
if (!accepts(name)) {
throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
}
if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
}
for (const name of FILE_SETTINGS) {
if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
}
return settings
}
@@ -1,9 +1,9 @@
/** Select local test defaults or platform release settings without changing the caller's environment. */
import { accessSync, constants, readFileSync, statSync } from 'node:fs'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { accessSync, constants, statSync } from 'node:fs'
import { dirname, isAbsolute, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseEnv } from 'node:util'
import { readDesktopPackageSettings as readSettings } from './desktop-file-settings.mjs'
import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs'
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
import { createWindowsTokenSigner } from './windows-sign.mjs'
@@ -19,7 +19,6 @@ const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|M
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
/**
* Select local macOS test defaults without reading dotenv files or inheriting release settings.
@@ -101,40 +100,6 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
}
}
/**
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
* @param {string} path Selected file; relative file settings resolve beside it.
* @param {(name: string) => boolean} accepts Whether this file may declare a setting.
* @returns {NodeJS.ProcessEnv} Validated file settings.
*/
function readSettings(path, accepts) {
let contents
try {
contents = readFileSync(path, 'utf8')
}
catch {
throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`)
}
let settings
try {
settings = parseEnv(contents.replace(/^\uFEFF/u, ''))
}
catch {
// Parser diagnostics can contain credential-bearing input.
throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
}
for (const name of Object.keys(settings)) {
if (!accepts(name)) {
throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
}
if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
}
for (const name of FILE_SETTINGS) {
if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
}
return settings
}
function requireReadableFile(environment, name) {
try {
if (!statSync(environment[name]).isFile()) throw new Error('not a file')
@@ -1,5 +1,6 @@
/** Safe failure details shared by publication diagnostics and durable journals. */
import { ZodError } from 'zod'
import { DesktopPurgeExecutionError } from './desktop-purge-recovery.ts'
import { DesktopPublishError } from './desktop-publish.ts'
const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch',
@@ -39,12 +40,15 @@ export function desktopPublicationFailure(error: unknown, phase: string): {
...(typeof requestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(requestId) ? { requestId } : {}),
...(typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}) }
}
if (error instanceof DesktopPurgeExecutionError) return { phase, code: `purge-${error.status}`, message: error.message }
if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message }
if (error instanceof ZodError) return { phase, code: 'invalid-data',
message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` }
if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' }
const ownedMessage = /^(desktop (?:publish|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u
if (error instanceof Error && ownedMessage.test(error.message)) {
const ownedMessage =
/^(desktop (?:publish|archival|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u
const websiteMessage = /^website (?:publication|installer|cache purge):/u
if (error instanceof Error && (ownedMessage.test(error.message) || websiteMessage.test(error.message))) {
return { phase, code: 'validation', message: error.message }
}
const record = typeof error === 'object' && error !== null ? error : {}
@@ -1,6 +1,7 @@
/** Render the verified publication plan for GitHub's native deployment reviewers. */
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs'
import type { DesktopRetainedSource } from './desktop-release-store.ts'
import type { DesktopUploadPlan } from './desktop-upload-plan.ts'
@@ -18,10 +19,11 @@ function link(url: string, label = url): string { return `<a href="${text(url)}"
* @param plan Validated upload plan; YAML contents are the bytes that will be uploaded.
* @param approval Digest bound to the approved manifest and upload plan.
* File SHA-256 hex supports `shasum -a 256` on downloads; upload reconciliation separately uses SHA-512 base64.
* @param uploadWebsite Whether the selected operation also replaces the production website installer.
* @returns GitHub-flavored Markdown with final URLs and upload-byte fingerprints, without credentials or local paths.
*/
export async function desktopPublicationSummary(
source: DesktopRetainedSource, plan: DesktopUploadPlan, approval: string,
source: DesktopRetainedSource, plan: DesktopUploadPlan, approval: string, uploadWebsite: boolean,
): Promise<string> {
const origin = new URL(plan.publicUrl).origin
const url = (key: string) => `${origin}/${key.split('/').map(encodeURIComponent).join('/')}`
@@ -52,6 +54,8 @@ export async function desktopPublicationSummary(
row('升级 YAML', `${feeds.map(file => link(url(file.key))).join('<br>')}<br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。`),
row('打包来源', `${link(runUrl, `查看打包运行 #${source.runId} · 第 ${source.attempt} 次尝试`)}<br>源码:${code(`${source.sourceRef} @ ${source.commit}`)}<br>打包机器:${code(source.runnerName)}`),
row('签名与公证', plan.target === 'win-x64' ? 'EXE 签名校验通过' : 'DMG 校验通过<br>ZIP 内应用校验通过'),
...(uploadWebsite && plan.environment === 'production' ? [row('官网固定安装包',
`${link(desktopWebsiteInstallerUrl(plan.target))}<br>替换为版本 ${code(plan.version)};已有安装包会被替换,无防降级保护;官网上传与刷新在后续 job 中执行,可能需另行审批。`)] : []),
row('Git tag', tag), '', '### 批准后将执行', '',
'1. 重新校验产物与发布目标;若与审批摘要不一致则停止。',
'2. 上传缺少的文件,跳过内容一致的已有文件;发现内容冲突则停止。',
+5 -3
View File
@@ -49,8 +49,9 @@ export function desktopPublishCos(
})
try {
const cos = client()
await cosOperation(cos, 900_000, () => cos.getObject({ ...address, Output: output }))
return { size, sha512: sha512.digest('base64'), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) }
const response = await cosOperation(cos, 900_000, () => cos.getObject({ ...address, Output: output }))
return { size, sha512: sha512.digest('base64'),
...(typeof response.headers?.['content-disposition'] === 'string' ? { contentDisposition: response.headers['content-disposition'] } : {}), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) }
} catch (error) {
if (missing(error)) return null
throw new DesktopCosOperationError('GetObject', error)
@@ -71,7 +72,8 @@ export function desktopPublishCos(
try {
await cosOperation(cos, 900_000, () => cos.putObject({
...address, Body: body, ContentLength: digest.size, ContentType: artifact.contentType,
Headers: { 'Content-MD5': md5.digest('base64'), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) },
Headers: { 'Content-MD5': md5.digest('base64'),
...(artifact.contentDisposition ? { 'Content-Disposition': artifact.contentDisposition } : {}), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) },
}))
} catch (error) {
throw new DesktopCosOperationError('PutObject', error)
@@ -1,4 +1,4 @@
/** Validate a completed packaging attempt and route publication back to its original runner. */
/** Validate package-job success and route publication; attempt selection enforces run completion. */
import { z } from 'zod'
import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
@@ -6,7 +6,7 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio
const positive = z.number().int().positive()
const runSchema = z.object({
id: positive, run_attempt: positive, head_sha: z.string().regex(/^[a-f0-9]{40}$/u),
path: z.string(), conclusion: z.literal('success'), event: z.literal('workflow_dispatch'),
path: z.string(), event: z.literal('workflow_dispatch'),
repository: z.object({ full_name: z.string() }),
})
const requestSchema = z.object({ source: z.object({
+2 -2
View File
@@ -8,8 +8,8 @@ import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-
/** File identity computed from bytes, never inferred from COS ETags. */
export interface DesktopObjectDigest { readonly size: number; readonly sha512: string }
/** Remote object observation; channel objects additionally carry their exact YAML. */
export interface DesktopRemoteObject extends DesktopObjectDigest { readonly contents?: string }
/** Remote bytes, optional download filename header, and exact YAML for channel objects. */
export interface DesktopRemoteObject extends DesktopObjectDigest { readonly contents?: string; readonly contentDisposition?: string }
/** Transport owned by the caller holding the destination channel lock. */
export interface DesktopPublishTransport {
/** Read and hash remote bytes; only a confirmed missing object returns null. */
@@ -0,0 +1,149 @@
/** Resume a fixed-URL purge without repeating ambiguous submissions. */
import type { teo } from 'tencentcloud-sdk-nodejs-teo'
import type { DesktopPurgeConfig } from './desktop-edgeone.ts'
/** Non-secret evidence saved before submission and after every observation. */
export interface DesktopPurgeRecord {
readonly zoneId: string
readonly publicUrl: string
readonly submittedAt: string
readonly jobId?: string | undefined
readonly status: 'pending' | 'processing' | 'success' | 'failed' | 'timeout' | 'canceled' | 'unknown'
readonly observedAt?: string | undefined
readonly diagnostic?: string | undefined
}
/** SDK requests used by the recovery owner. */
export type DesktopPurgeRecoveryClient = Pick<InstanceType<typeof teo.v20220901.Client>, 'CreatePurgeTask' | 'DescribePurgeTasks'>
/** Explicit bounded observation policy; writes are never automatically retried. */
export interface DesktopPurgeRecoveryOptions {
readonly intervalMs: number
readonly waitMs: number
readonly allowResubmit: boolean
readonly now: () => number
readonly sleep: (milliseconds: number) => Promise<void>
readonly save: (record: DesktopPurgeRecord) => Promise<void>
}
/** A confirmed remote failure, distinct from an unconfirmed observation. */
export class DesktopPurgeExecutionError extends Error {
constructor(readonly status: 'failed' | 'timeout' | 'canceled') {
super(`website cache purge: ${status}; inspect the task before manually rerunning`)
}
}
function failed(status: DesktopPurgeRecord['status']): status is 'failed' | 'timeout' | 'canceled' {
return status === 'failed' || status === 'timeout' || status === 'canceled'
}
async function observe(record: DesktopPurgeRecord, client: DesktopPurgeRecoveryClient,
now: number, config: DesktopPurgeConfig): Promise<DesktopPurgeRecord> {
const observedAt = new Date(now).toISOString()
const unknown = (diagnostic: string): DesktopPurgeRecord => ({ ...record, status: 'unknown', observedAt, diagnostic })
const matches = []
try {
let offset = 0
do {
const response = await client.DescribePurgeTasks({
ZoneId: record.zoneId, Limit: 1000, Offset: offset,
...(record.jobId ? { Filters: [{ Name: 'job-id', Values: [record.jobId] }] } : {
StartTime: record.submittedAt,
EndTime: new Date(Math.min(now, Date.parse(record.submittedAt) + 120_000)).toISOString(),
}),
})
if (!Array.isArray(response.Tasks) || typeof response.TotalCount !== 'number') return unknown('invalid query response')
for (const task of response.Tasks) {
if (task.Target !== record.publicUrl || task.Type !== 'purge_url') continue
if (record.jobId ? task.JobId !== record.jobId
: !task.CreateTime || !Number.isFinite(Date.parse(task.CreateTime))
|| Date.parse(task.CreateTime) < Date.parse(record.submittedAt)
|| Date.parse(task.CreateTime) > Date.parse(record.submittedAt) + 120_000) continue
matches.push(task)
}
offset += response.Tasks.length
if (offset >= response.TotalCount) break
if (response.Tasks.length === 0 || offset >= 10_000) return unknown('task history could not be fully inspected')
} while (true)
} catch (error) { return unknown('task query failed; check EdgeOne history and DescribePurgeTasks permission') }
if (matches.length === 0) return unknown(record.jobId
? 'accepted task not found in query history; execution result unconfirmed'
: 'no matching submission found; purge may not have been submitted; inspect EdgeOne history before manual recovery')
if (matches.length !== 1) return unknown('multiple matching submissions found; cannot identify this submission')
const task = matches[0]!
if (!task.JobId || !/^[\w.-]{1,128}$/u.test(task.JobId)
|| task.JobId.includes(config.secretId) || task.JobId.includes(config.secretKey)) return unknown('query returned no usable task ID')
const status = task.Status
if (status !== 'processing' && status !== 'success' && status !== 'failed' && status !== 'timeout' && status !== 'canceled') {
return unknown('query returned an unknown task status')
}
return { zoneId: record.zoneId, publicUrl: record.publicUrl, submittedAt: record.submittedAt,
jobId: task.JobId, status, observedAt }
}
/**
* Submit at most once, or reconcile an earlier submission, then observe bounded completion.
* Unknown results receive one additional query and return unknown for warning-only Actions completion.
* @param config Validated destination and credentials; credentials never enter records.
* @param previous Durable record, or undefined for a new publication.
* @param client SDK client with bounded requests and no submission retry policy.
* @param options Clock, persistence, timing and explicit manual-resubmission authorization.
* @returns Successful or unconfirmed result; confirmed failures throw after durable recording.
*/
export async function recoverDesktopPurge(config: DesktopPurgeConfig, previous: DesktopPurgeRecord | undefined,
client: DesktopPurgeRecoveryClient, options: DesktopPurgeRecoveryOptions): Promise<DesktopPurgeRecord> {
if (options.intervalMs <= 0 || options.waitMs < 0) throw new Error('website cache purge: invalid observation budget')
if (previous && (previous.zoneId !== config.zoneId || previous.publicUrl !== config.publicUrl)) {
throw new Error('website cache purge: saved destination differs from selected publication')
}
let record = previous
let observedPrevious = false
if (record) {
record = await observe(record, client, options.now(), config)
observedPrevious = true
await options.save(record)
if (record.status === 'success') return record
if (failed(record.status)) {
if (!options.allowResubmit) throw new DesktopPurgeExecutionError(record.status)
record = undefined
observedPrevious = false
}
}
if (!record) {
record = { zoneId: config.zoneId, publicUrl: config.publicUrl,
submittedAt: new Date(Math.floor(options.now() / 1000) * 1000).toISOString(), status: 'pending' }
await options.save(record)
let response: Awaited<ReturnType<DesktopPurgeRecoveryClient['CreatePurgeTask']>> | undefined
try { response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) }
catch (error) { /* Submission may have reached EdgeOne; reconcile history without repeating the request. */ }
const jobId = response?.JobId
if (response?.FailedList?.length) {
record = { ...record, status: 'failed' }
} else if (typeof jobId === 'string' && /^[\w.-]{1,128}$/u.test(jobId)
&& !jobId.includes(config.secretId) && !jobId.includes(config.secretKey)) {
record = { ...record, status: 'processing', jobId }
} else {
record = { ...record, status: 'unknown', diagnostic: 'submission acceptance unconfirmed; checking task history' }
}
await options.save(record)
if (failed(record.status)) throw new DesktopPurgeExecutionError(record.status)
}
let current: DesktopPurgeRecord = record
const deadline = options.now() + options.waitMs
let extraQuery = false
while (true) {
const observed: DesktopPurgeRecord = observedPrevious ? current : await observe(current, client, options.now(), config)
observedPrevious = false
current = observed
await options.save(current)
if (current.status === 'success') return current
if (failed(current.status)) throw new DesktopPurgeExecutionError(current.status)
if (extraQuery) {
current = { ...current, status: 'unknown', diagnostic: current.diagnostic ?? (options.now() >= deadline ? 'observation deadline exceeded' : 'task still processing after an additional query') }
await options.save(current)
return current
}
if (current.status === 'unknown' || options.now() >= deadline) extraQuery = true
await options.sleep(options.intervalMs)
}
}
+16 -3
View File
@@ -121,13 +121,13 @@ export async function retainDesktopRelease(root: string, source: DesktopRetained
}
/**
* Read an attempt and rehash every retained file before a consumer uses it.
* Read and validate manifest identity without requiring unrelated retained files.
* @param root Persistent releases root.
* @param runId Expected source run ID.
* @param attempt Expected source attempt.
* @returns Parsed inventory; missing, linked, changed or mismatched files reject.
* @returns Parsed inventory; consumers must verify the files they use against their recorded digests.
*/
export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise<DesktopRetainedManifest> {
export async function readRetainedDesktopManifest(root: string, runId: string, attempt: string): Promise<DesktopRetainedManifest> {
const path = retainedDesktopPath(root, runId, attempt)
await directory(root)
await directory(join(root, runId))
@@ -137,6 +137,19 @@ export async function verifyRetainedDesktopRelease(root: string, runId: string,
const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')))
if (manifest.source.runId !== runId || manifest.source.attempt !== attempt) throw new Error('desktop release store: attempt identity mismatch')
if (new Set(manifest.files.map(file => file.name)).size !== manifest.files.length) throw new Error('desktop release store: duplicate file inventory')
return manifest
}
/**
* Verify every retained file against its immutable build manifest.
* @param root Persistent releases directory.
* @param runId Original packaging run.
* @param attempt Original packaging attempt, unchanged by archival reruns.
* @returns Manifest after all retained files pass size and hash verification.
*/
export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise<DesktopRetainedManifest> {
const manifest = await readRetainedDesktopManifest(root, runId, attempt)
const path = retainedDesktopPath(root, runId, attempt)
for (const file of manifest.files) {
const current = await fingerprint(join(path, file.name))
if (current.size !== file.size || current.sha512 !== file.sha512) throw new Error(`desktop release store: changed file ${file.name}`)
+4 -1
View File
@@ -33,6 +33,8 @@ export interface DesktopUploadArtifact {
readonly filename: string
readonly key: string
readonly contentType: string
/** Browser save name for fixed website downloads, preserving the validated installer basename. */
readonly contentDisposition?: string
readonly channelMetadata: boolean
/** Published YAML with normalized artifact URLs; binary bytes remain file-backed. */
readonly contents?: string
@@ -288,7 +290,8 @@ export async function createDesktopUploadPlan(
bucket: update.bucket,
secretIdEnvName: update.secretIdEnvName,
secretKeyEnvName: update.secretKeyEnvName,
artifacts: options.latest ? [{ ...installerArtifact, filename: latestFilename, key: latestKey }] : artifacts,
artifacts: options.latest ? [{ ...installerArtifact, filename: latestFilename, key: latestKey,
contentDisposition: `attachment; filename="${installerArtifact.filename}"` }] : artifacts,
...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {},
...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {},
}
+1 -1
View File
@@ -101,7 +101,7 @@ export async function uploadDesktopRelease(plan: DesktopUploadPlan, cos: COS, re
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 } })
Headers: { 'Content-MD5': artifact.md5, ...(artifact.contentDisposition ? { 'Content-Disposition': artifact.contentDisposition } : {}) } })
confirmedPuts++
stage = 'record-response'
recordPackagingEvent(directory, { type: 'put-confirmed', key, attempts: 1, ...receipt(response) })
@@ -0,0 +1,116 @@
/** Validate one production installer and hold its file handle through COS publication. */
import { createHash } from 'node:crypto'
import { open, lstat, type FileHandle } from 'node:fs/promises'
import { join } from 'node:path'
import type { DesktopRetainedManifest } from './desktop-release-store.ts'
import type { DesktopPublishRoute } from './desktop-publish-route.ts'
import { desktopLatestInstallerFilename, desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs'
import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from './desktop-cos.ts'
import { cosOperation } from './cos-operation.ts'
import type { DesktopObjectDigest, DesktopPublishTransport } from './desktop-publish.ts'
import { DesktopCosOperationError } from './desktop-publication-failure.ts'
import type { DesktopUploadPlan } from './desktop-upload-plan.ts'
/** Verified file and destination; the caller closes the handle after publication. */
export interface DesktopWebsiteInstaller {
readonly plan: DesktopUploadPlan
readonly digest: DesktopObjectDigest
readonly md5: string
readonly contentDisposition: string
readonly handle: FileHandle
}
/**
* Check production build identity and hash only the selected installer once.
* @param directory Retained build directory on the original runner.
* @param manifest Trusted retained build inventory matched against GitHub evidence.
* @param route Pinned build identity from the credential-free routing job.
* @param repository Repository expected by the workflow.
* @param bucket Approved production COS bucket.
* @returns An open verified installer; callers must close its handle in finally.
*/
export async function prepareWebsiteInstaller(directory: string, manifest: DesktopRetainedManifest,
route: DesktopPublishRoute, repository: string, bucket: string): Promise<DesktopWebsiteInstaller> {
const source = manifest.source
if (route.preset !== 'production' || source.preset !== 'production'
|| source.repository !== repository || source.runId !== route.runId || source.attempt !== route.attempt
|| source.commit !== route.commit || source.runnerName !== route.runnerName || source.target !== route.target
|| source.sourceRef !== route.sourceRef || source.channel !== route.channel
|| source.qualification !== (route.target === 'win-x64' ? 'signed' : 'notarized')
|| !/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef)) {
throw new Error('website installer: build identity or production qualification mismatch')
}
const windows = route.target === 'win-x64'
const filename = `deepseek-harness-${source.version}-${route.target}.${windows ? 'exe' : 'dmg'}`
const file = manifest.files.find(item => item.name === filename)
if (!file) throw new Error('website installer: selected installer is absent from build manifest')
const path = join(directory, file.name)
const info = await lstat(path)
if (!info.isFile() || info.isSymbolicLink()) throw new Error('website installer: expected a regular installer file')
const handle = await open(path, 'r')
try {
const sha512 = createHash('sha512')
const md5 = createHash('md5')
let size = 0
for await (const bytes of handle.createReadStream({ autoClose: false, start: 0 })) {
size += bytes.length
sha512.update(bytes)
md5.update(bytes)
}
const digest = { size, sha512: sha512.digest('base64') }
if (size !== file.size || digest.sha512 !== file.sha512) throw new Error('website installer: SHA-512 or size differs from build manifest')
const key = `desktop/${desktopLatestInstallerFilename(route.target)}`
const contentDisposition = `attachment; filename="${file.name}"`
return { handle, digest, contentDisposition, md5: md5.digest('base64'), plan: {
environment: 'production', target: route.target, version: source.version, commit: source.commit, dirty: false,
bucket, publicUrl: desktopWebsiteInstallerUrl(route.target),
secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY',
artifacts: [{ path, filename: file.name, key, channelMetadata: false,
contentDisposition,
contentType: windows ? 'application/vnd.microsoft.portable-executable' : 'application/x-apple-diskimage' }],
} }
} catch (error) {
await handle.close()
throw error
}
}
/**
* Reconcile an earlier upload or replace the fixed object once using the already verified handle.
* @param installer Open installer verified against the selected build.
* @param credentials Production upload credentials, never logged.
* @param transport Read-only reconciliation adapter using the same destination.
* @param record Durable event writer; intent must persist before the remote write.
* @param options Optional COS factory and durable completion evidence; completed uploads cannot replace different remote bytes.
* @returns Whether bytes were uploaded or an existing identical object was reused.
*/
export async function uploadWebsiteInstaller(installer: DesktopWebsiteInstaller, credentials: DesktopCosCredentials,
transport: Pick<DesktopPublishTransport, 'read'>,
record: (event: 'upload-intent' | 'uploaded') => Promise<void>,
options: { createClient?: typeof createDesktopCos; previouslyUploaded?: boolean } = {},
): Promise<'uploaded' | 'present'> {
const artifact = installer.plan.artifacts[0]!
const matches = (remote: DesktopObjectDigest | null) =>
remote?.size === installer.digest.size && remote.sha512 === installer.digest.sha512
const remote = await transport.read(artifact.key, false)
if (options.previouslyUploaded && !matches(remote)) throw new Error(`website installer: recorded upload no longer matches remote bytes (${remote === null ? 'object missing' : 'bytes changed'}); select and approve a new publication`)
if (matches(remote) && remote?.contentDisposition === installer.contentDisposition) {
await record('uploaded')
return 'present'
}
await record('upload-intent')
const body = installer.handle.createReadStream({ autoClose: false, start: 0 })
const cos = (options.createClient ?? createDesktopCos)(credentials)
try {
await cosOperation(cos, 900_000, () => cos.putObject({ Bucket: installer.plan.bucket, Region: DESKTOP_COS_REGION,
Key: artifact.key, Body: body, ContentLength: installer.digest.size, ContentType: artifact.contentType,
Headers: { 'Content-MD5': installer.md5, 'Content-Disposition': installer.contentDisposition },
}))
} catch (error) { throw new DesktopCosOperationError('PutObject', error) }
finally { body.destroy() }
const confirmed = await transport.read(artifact.key, false)
if (!matches(confirmed)) throw new Error(`website installer: remote bytes unconfirmed; expected ${JSON.stringify(installer.digest)}, observed ${JSON.stringify(confirmed)}; reconcile before retrying`)
if (confirmed?.contentDisposition !== installer.contentDisposition) throw new Error(`website installer: remote download filename unconfirmed; expected ${JSON.stringify(installer.contentDisposition)}, observed ${JSON.stringify(confirmed?.contentDisposition ?? null)}; inspect COS metadata before retrying`)
await record('uploaded')
return 'uploaded'
}
+7 -2
View File
@@ -14,6 +14,7 @@ import { publishDesktopRelease } from './desktop-publish.ts'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
import { desktopPublicationSummary } from './desktop-publication-summary.ts'
import { publishDesktopTag } from './desktop-publish-tag.ts'
import { websiteApproval } from './publish-website-ci.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(),
@@ -63,10 +64,14 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
const env = process.env
const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env)
if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required')
if (env.DESKTOP_UPLOAD_WEBSITE === 'true' && route.preset === 'production'
&& !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || '')) {
throw new Error('desktop publish: configure DOWNLOAD_PROD_EO_ZONE_ID before approving website upload')
}
if (mode === '--check') {
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`)
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `website-destination=${createHash('sha256').update(`${config.bucket}/${plan.target}`).digest('hex')}\nwebsite-approval=${websiteApproval(manifest, config.bucket)}\napproval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`)
if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_APPROVED_PACKAGE) await appendFile(env.GITHUB_STEP_SUMMARY,
await desktopPublicationSummary(manifest.source, plan, approval))
await desktopPublicationSummary(manifest.source, plan, approval, env.DESKTOP_UPLOAD_WEBSITE === 'true'))
if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT,
`app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`)
process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`)
+130
View File
@@ -0,0 +1,130 @@
/** Actions entrypoint for independently recoverable website upload and cache purge jobs. */
import { createHash, randomUUID } from 'node:crypto'
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { z } from 'zod'
import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts'
import { desktopReleasesRoot } from './retain-desktop-ci.ts'
import { readRetainedDesktopManifest, retainedDesktopPath } from './desktop-release-store.ts'
import { prepareWebsiteInstaller, uploadWebsiteInstaller } from './desktop-website-installer.ts'
import { desktopPublishCos } from './desktop-publish-cos.ts'
import { withDesktopChannelLock } from './desktop-publish-record.ts'
import { recoverDesktopPurge, type DesktopPurgeRecord } from './desktop-purge-recovery.ts'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
const routeSchema = z.object({ runId: z.string().regex(/^[1-9]\d*$/u), attempt: z.string().regex(/^[1-9]\d*$/u),
commit: z.string().regex(/^[a-f0-9]{40}$/u), runnerName: z.string(), runnerLabel: z.string(), sourceRef: z.string(),
channel: z.enum(['nightly', 'stable']), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.literal('production') })
const purgeSchema = z.object({ zoneId: z.string(), publicUrl: z.url(), submittedAt: z.iso.datetime(),
jobId: z.string().regex(/^[\w.-]{1,128}$/u).optional(),
status: z.enum(['pending', 'processing', 'success', 'failed', 'timeout', 'canceled', 'unknown']),
observedAt: z.iso.datetime().optional(), diagnostic: z.string().optional() })
const stateSchema = z.object({ approval: z.string(), upload: z.enum(['pending', 'upload-intent', 'uploaded']),
uploadConfirmed: z.boolean().optional(), purge: purgeSchema.optional(), purgeAttempt: z.string().optional() })
/**
* Bind website approval to the retained manifest and exact production bucket.
* @param manifest Retained build metadata already matched to GitHub evidence.
* @param bucket Production COS destination.
* @returns Non-secret digest passed through the production approval job.
*/
export function websiteApproval(manifest: unknown, bucket: string): string {
return createHash('sha256').update(JSON.stringify({ manifest, bucket })).digest('hex')
}
/**
* Upload a fixed installer or recover its cache purge, using a publication-run-owned journal.
* @param mode Explicit website operation; purge never opens or uploads installer bytes.
* @param env Actions identity, approved build and machine configuration.
* @returns Resolves on success or a prominently reported unknown purge result.
*/
export async function publishWebsiteCi(mode: 'upload' | 'purge', env: NodeJS.ProcessEnv): Promise<void> {
const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}'))
if (env.RUNNER_NAME !== route.runnerName || !/^[1-9]\d*$/u.test(env.GITHUB_RUN_ID || '')
|| !/^[1-9]\d*$/u.test(env.GITHUB_RUN_ATTEMPT || '')) throw new Error('website publication: invalid Actions identity or original machine')
const root = desktopReleasesRoot(env)
const manifest = await readRetainedDesktopManifest(root, route.runId, route.attempt)
const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, 'production')
const approval = websiteApproval(manifest, config.bucket)
if (!env.DESKTOP_WEBSITE_APPROVAL || env.DESKTOP_WEBSITE_APPROVAL !== approval) throw new Error('website publication: approved build or destination changed')
const zoneId = env.DOWNLOAD_PROD_EO_ZONE_ID
if (!zoneId || !/^zone-[a-z0-9]+$/u.test(zoneId)) throw new Error('website publication: configure DOWNLOAD_PROD_EO_ZONE_ID before uploading')
const directory = join(dirname(root), 'upload-records', `website-${env.GITHUB_RUN_ID}-${route.target}`)
await mkdir(directory, { recursive: true, mode: 0o700 })
const stateFile = join(directory, 'state.json')
let state: z.infer<typeof stateSchema> = { approval, upload: 'pending' }
try { state = stateSchema.parse(JSON.parse(await readFile(stateFile, 'utf8'))) }
catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error }
if (state.approval !== approval) throw new Error('website publication: journal belongs to another approved build')
const save = async () => {
const temporary = join(directory, `.state-${randomUUID()}.json`)
await writeFile(temporary, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600, flush: true })
await rename(temporary, stateFile)
}
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${directory}\n`)
const credentials = await readDesktopUploadCredentials(config)
if (mode === 'upload') {
const installer = await prepareWebsiteInstaller(retainedDesktopPath(root, route.runId, route.attempt), manifest, route,
env.GITHUB_REPOSITORY || '', config.bucket)
try {
const outcome = await withDesktopChannelLock(join(dirname(root), 'publish-locks'), `${config.bucket}/${installer.plan.artifacts[0]!.key}`, async () => {
const previouslyUploaded = state.uploadConfirmed === true || state.upload === 'uploaded'
return uploadWebsiteInstaller(installer, credentials, desktopPublishCos(installer.plan, credentials), async (event) => {
state = event === 'upload-intent'
? { approval: state.approval, upload: event, uploadConfirmed: previouslyUploaded }
: { ...state, upload: event, uploadConfirmed: true }
await save()
}, { previouslyUploaded })
})
const disposition = outcome === 'present' ? 'identical remote bytes and download filename confirmed; skipped' : 'uploaded'
const text = `Website installer ${disposition}: ${installer.plan.publicUrl}; version ${installer.plan.version}\n`
process.stdout.write(text)
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, text)
} finally { await installer.handle.close() }
return
}
if (state.upload !== 'uploaded') throw new Error('website publication: upload completion is not confirmed; recover the upload job first')
const { desktopWebsiteInstallerUrl } = await import('./desktop-auto-update-environment.mjs')
const publicUrl = desktopWebsiteInstallerUrl(route.target)
const { teo } = await import('tencentcloud-sdk-nodejs-teo')
const client = new teo.v20220901.Client({ credential: credentials, region: '',
profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } } })
const previousAttempt = state.purgeAttempt
const result = await recoverDesktopPurge({ ...credentials, zoneId, publicUrl }, state.purge, client, {
intervalMs: 10_000, waitMs: 300_000,
allowResubmit: previousAttempt !== undefined && previousAttempt !== env.GITHUB_RUN_ATTEMPT,
now: Date.now, sleep,
save: async (purge: DesktopPurgeRecord) => {
const accepted = purge.jobId && purge.jobId !== state.purge?.jobId
state = { ...state, purge, purgeAttempt: env.GITHUB_RUN_ATTEMPT }
await save()
if (accepted) process.stdout.write(`Website cache purge accepted: ${publicUrl}; task ${purge.jobId}\n`)
},
})
const details = `${publicUrl}\nTask: ${result.jobId ?? 'unknown'}\nObserved: ${result.observedAt}\n${result.diagnostic ?? ''}\n`
if (result.status === 'unknown') {
const acceptance = result.jobId ? '刷新已提交,执行结果未确认' : '刷新提交未确认,可能尚未提交'
process.stdout.write(`::warning title=官网缓存刷新结果未确认::官网资源已上传;${acceptance}。请核对 EdgeOne 任务记录后人工恢复。\n${details}`)
if (env.GITHUB_STEP_SUMMARY) {
let existing = ''
try { existing = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8') }
catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error }
await writeFile(env.GITHUB_STEP_SUMMARY, `# ⚠ 官网资源已上传,缓存刷新结果未确认\n\n${acceptance}\n\n${details}${existing ? `\n${existing}` : ''}`)
}
} else {
process.stdout.write(`Website cache purge completed\n${details}`)
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, `Website cache purge completed\n\n${details}`)
}
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
try {
const mode = process.argv[2]
if (process.argv.length !== 3 || (mode !== 'upload' && mode !== 'purge')) throw new Error('Usage: publish-website-ci.ts upload|purge')
await publishWebsiteCi(mode, process.env)
} catch (error) {
process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'website publication'))}\n`)
process.exitCode = 1
}
}
@@ -8,11 +8,11 @@ import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-
const positiveId = z.string().regex(/^[1-9]\d*$/u)
const inputsSchema = z.object({ package_run_id: positiveId, package_run_attempt: z.union([positiveId, z.literal('')]).optional() })
const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive(),
head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.literal('success'),
head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.string(),
event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)),
repository: z.object({ full_name: z.string() }) })
const jobsSchema = z.object({ jobs: z.array(z.object({
name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable(),
name: z.string(), run_attempt: z.number().int().positive(), runner_name: z.string().nullable(), conclusion: z.string().nullable(),
})) })
/**
@@ -29,12 +29,29 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r
const inputs = inputsSchema.parse(inputsValue)
const base = `repos/${repository}/actions/runs/${inputs.package_run_id}`
const attempt = inputs.package_run_attempt || String(z.object({ run_attempt: z.number().int().positive() }).parse(api(base)).run_attempt)
const run = runSchema.parse(api(`${base}/attempts/${attempt}`))
let run = runSchema.parse(api(`${base}/attempts/${attempt}`))
if (String(run.id) !== inputs.package_run_id || String(run.run_attempt) !== attempt || run.repository.full_name !== repository) {
throw new Error('desktop publish: selected run or attempt does not match GitHub evidence')
}
const jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs)
const packages = jobs.filter(job => job.name === 'package' && job.conclusion === 'success' && job.runner_name)
let jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs)
let packages = jobs.filter(job => job.name === 'package')
if (packages.length === 0 || packages.every(job => job.run_attempt < Number(attempt))) {
// An archival-only attempt owns no new build; select the latest actual package job, never a prior success over a failed build.
const history = z.array(jobsSchema).parse(api(`${base}/jobs?filter=all&per_page=100`, true)).flatMap(page => page.jobs)
.filter(job => job.name === 'package' && job.run_attempt <= Number(attempt))
.sort((left, right) => right.run_attempt - left.run_attempt)
const original = history[0]
if (!original || original.conclusion !== 'success') throw new Error('desktop publish: latest original package job was not successful')
const selected = run
run = runSchema.parse(api(`${base}/attempts/${original.run_attempt}`))
if (String(run.id) !== inputs.package_run_id || run.run_attempt !== original.run_attempt || run.repository.full_name !== repository
|| run.head_sha !== selected.head_sha || run.path !== selected.path) {
throw new Error('desktop publish: original build identity differs from GitHub evidence')
}
jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${original.run_attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs)
packages = jobs.filter(job => job.name === 'package')
}
packages = packages.filter(job => job.run_attempt === run.run_attempt && job.conclusion === 'success' && job.runner_name)
if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job')
const runnerName = packages[0]!.runner_name!
const routes = desktopPublishRunnersSchema.parse(routesValue)
+22 -2
View File
@@ -6,6 +6,7 @@ import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDeskt
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts'
import { validateDesktopBuildVersion } from './desktop-build-version.mjs'
import { desktopPublishRunnersSchema } from './desktop-publish-targets.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
function required(env: NodeJS.ProcessEnv, name: string): string {
@@ -26,6 +27,22 @@ export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string {
return root
}
/**
* Resolve the original machine's unique label before expensive packaging work.
* @param env Machine identity, target and administrator-owned runner mapping.
* @returns The exact label used by the later GitHub archival job.
*/
export function resolveDesktopRetentionRunner(env: NodeJS.ProcessEnv): string {
const target = resolveDesktopBuildTarget(env)
const routes = desktopPublishRunnersSchema.parse(JSON.parse(required(env, 'DESKTOP_PUBLISH_RUNNERS')))
const route = routes[required(env, 'RUNNER_NAME')]
if (!route || !route.target.includes(target)
|| new Set(Object.values(routes).map(item => item.label)).size !== Object.keys(routes).length) {
throw new Error('desktop retention: original machine requires a unique artifact routing label')
}
return route.label
}
/**
* Save the complete installer inventory for a successful package job, without reading signing secrets.
* @param env Actions identity, resolved package version and machine preset selection.
@@ -34,6 +51,7 @@ export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string {
*/
export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(import.meta.dirname, '..')): Promise<string> {
const target = resolveDesktopBuildTarget(env)
const runnerLabel = resolveDesktopRetentionRunner(env)
const windows = target === 'win-x64'
const platform = windows ? 'win32' : 'darwin'
const settings = loadDesktopPackageEnvironment(platform, env, appRoot, { unsigned: true })
@@ -66,7 +84,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(
names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform, source.channel))
}
const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name)))
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`)
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\nrunner=${runnerLabel}\nrunner-name=${source.runnerName}\nattempt=${source.attempt}\n`)
const nextStep = desktopPublishQualificationError(qualification)
?? `Publish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}`
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY,
@@ -75,5 +93,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
await retainDesktopCi(process.env)
if (process.argv[2] === '--check-runner' && process.argv.length === 3) resolveDesktopRetentionRunner(process.env)
else if (process.argv.length === 2) await retainDesktopCi(process.env)
else throw new Error('Usage: retain-desktop-ci.ts [--check-runner]')
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
desktopBuildRecordFilename,
desktopWebsiteInstallerUrl,
desktopUpdateMetadataFilename,
resolveDesktopAutoUpdateConfig,
resolveDesktopAutoUpdateEnvironment,
@@ -120,6 +121,13 @@ describe('desktop auto-update environment', () => {
})
})
it.each([
['mac-arm64', 'dsh-latest-macos-arm64.dmg'],
['mac-x64', 'dsh-latest-macos-x64.dmg'],
['win-x64', 'dsh-latest-windows-x64.exe'],
] as const)('uses the production origin for website target %s', (target, filename) => {
expect(desktopWebsiteInstallerUrl(target)).toBe(`https://download.deepseek.com/desktop/${filename}`)
})
it.each([['darwin', 'arm64', 'mac-arm64', 'stable-mac.yml'], ['darwin', 'x64', 'mac-x64', 'stable-mac.yml'], ['win32', 'x64', 'win-x64', 'stable.yml']] as const)
('routes stable metadata and binaries separately for %s %s', (platform, arch, target, filename) => {
@@ -9,7 +9,7 @@ import { retainDesktopRelease, verifyRetainedDesktopRelease } from '../scripts/d
function fixture() {
const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), status: 'completed', conclusion: 'success',
event: 'workflow_dispatch', path: '.github/workflows/macos-package.yml', repository: { full_name: 'owner/repo' } }
const jobs = [{ name: 'package', runner_name: 'mini', conclusion: 'success' }]
const jobs = [{ name: 'package', run_attempt: 2, runner_name: 'mini', conclusion: 'success' }]
const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } }
const calls: string[] = []
const api = (path: string, paginate?: boolean) => {
@@ -39,6 +39,7 @@ it('routes a Windows package through its own configured machine target', () => {
it('selects an explicit old attempt without consulting the latest attempt', () => {
const f = fixture()
f.run.run_attempt = 1
f.jobs[0]!.run_attempt = 1
selectDesktopPackage('owner/repo', { package_run_id: '42', package_run_attempt: '1' }, f.routes, f.api)
expect(f.calls).not.toContain('repos/owner/repo/actions/runs/42')
expect(f.calls).toContain('repos/owner/repo/actions/runs/42/attempts/1')
@@ -47,8 +48,8 @@ it('selects an explicit old attempt without consulting the latest attempt', () =
it.each(['failed', 'cancelled', 'running', 'workflow', 'repository', 'attempt', 'run', 'job', 'ambiguous', 'route', 'label'])(
'rejects %s evidence without falling back to a different attempt', (kind) => {
const f = fixture()
if (kind === 'failed') f.run.conclusion = 'failure'
if (kind === 'cancelled') f.run.conclusion = 'cancelled'
if (kind === 'failed') { f.run.conclusion = 'failure'; f.jobs[0]!.conclusion = 'failure' }
if (kind === 'cancelled') { f.run.conclusion = 'cancelled'; f.jobs[0]!.conclusion = 'cancelled' }
if (kind === 'running') f.run.status = 'in_progress'
if (kind === 'workflow') f.run.path = '.github/workflows/ci.yml'
if (kind === 'repository') f.run.repository.full_name = 'other/repo'
@@ -135,3 +136,24 @@ it('routes both Mac architectures to one machine without authorizing an unlisted
expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, { mini: { label: 'dsh-mini', target } }, f.api)).toThrow()
}
})
it.each([false, true])('keeps original build after archival retry with reused job listed: %s', (includesReused) => {
const f = fixture()
const selected = selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, (path) => {
if (path.endsWith('/42')) return { run_attempt: 3 }
if (path.endsWith('/attempts/3')) return { ...f.run, run_attempt: 3 }
if (path.endsWith('/attempts/3/jobs?per_page=100')) return [{ jobs: [{ name: 'upload-artifact', run_attempt: 3, runner_name: 'mini', conclusion: 'success' }, ...(includesReused ? f.jobs : [])] }]
if (path.includes('filter=all')) return [{ jobs: [{ ...f.jobs[0], run_attempt: 2 }] }]
if (path.endsWith('/attempts/2')) return { ...f.run, conclusion: 'failure' }
return [{ jobs: f.jobs }]
})
expect(selected.run.run_attempt).toBe(2)
expect(selected.runnerName).toBe('mini')
})
it('accepts a successful package when only archival failed', () => {
const f = fixture()
f.run.conclusion = 'failure'
f.jobs.push({ name: 'upload-artifact', run_attempt: 3, runner_name: 'mini', conclusion: 'failure' })
expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api).run.run_attempt).toBe(2)
})
@@ -44,3 +44,15 @@ it('omits malformed provider receipt fields and never copies raw errors into COS
expect(failure).not.toHaveProperty('httpStatus')
expect(JSON.stringify(failure)).not.toContain('fixture-secret')
})
it('prints structured archival failure through the real CLI', () => {
const result = spawnSync(process.execPath, ['--import', 'tsx/esm',
fileURLToPath(new URL('../scripts/archive-desktop-ci.ts', import.meta.url))], {
encoding: 'utf8', timeout: 30_000, env: { ...process.env, RUNNER_NAME: '', DESKTOP_PACKAGE_RUNNER: '' },
})
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status).toBe(1)
expect(JSON.parse(result.stderr)).toEqual({ phase: 'artifact archival', code: 'validation',
message: 'desktop archival: wrong original machine' })
})
@@ -39,6 +39,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu
}))
const env = {
DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test',
DESKTOP_PUBLISH_RUNNERS: JSON.stringify({ 'fixture-runner': { label: 'dsh-fixture', target: [target] } }),
DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64',
PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false',
GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner',
@@ -52,7 +53,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu
expect(prepared.plan.version).toBe('1.0.0')
expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true)
expect(signatureChecks).toBe(1)
const summary = await desktopPublicationSummary(prepared.manifest.source, prepared.plan, 'c'.repeat(64))
const summary = await desktopPublicationSummary(prepared.manifest.source, prepared.plan, 'c'.repeat(64), false)
await expect(summary).toMatchFileSnapshot(`./expected/publication-${target}.md`)
const feed = prepared.plan.artifacts.find(file => file.channelMetadata)!
expect(summary).toContain(createHash('sha256').update(feed.contents!).digest('hex'))
@@ -21,7 +21,7 @@ const plan: DesktopUploadPlan = {
}
it('shows production destinations and tag after approval with upload-byte fingerprints', async () => {
const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64))
const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64), false)
await expect(summary).toMatchFileSnapshot('./expected/publication-production.md')
expect(summary).toContain('/stable/deepseek-harness-1.0.0-win-x64.exe')
expect(summary).toContain('https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml')
@@ -32,7 +32,7 @@ it('shows production destinations and tag after approval with upload-byte finger
})
it('escapes runner display text without adding Markdown rows or HTML elements', async () => {
const summary = await desktopPublicationSummary({ ...source, runnerName: 'runner|\n<script>`&"__win__![x](https://example.com/x.png)' }, plan, 'c'.repeat(64))
const summary = await desktopPublicationSummary({ ...source, runnerName: 'runner|\n<script>`&"__win__![x](https://example.com/x.png)' }, plan, 'c'.repeat(64), false)
expect(summary).toContain('runner&#124;&#10;&#60;script&#62;&#96;&#38;&#34;')
expect(summary).not.toContain('<script>')
expect(summary).toContain('&#95;&#95;win&#95;&#95;&#33;&#91;x&#93;&#40;https&#58;//example.com/x.png&#41;')
@@ -42,5 +42,15 @@ it('fails instead of rendering success when an upload file cannot be read', asyn
await expect(desktopPublicationSummary(source, { ...plan,
artifacts: [{ path: '/missing-desktop-summary-artifact/installer.exe', filename: 'installer.exe',
key: 'installer.exe', contentType: 'application/octet-stream', channelMetadata: false }],
}, 'c'.repeat(64))).rejects.toThrow()
}, 'c'.repeat(64), false)).rejects.toThrow()
})
it('shows the fixed website replacement before production approval only when selected', async () => {
const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64), true)
await expect(summary).toMatchFileSnapshot('./expected/publication-production-website.md')
expect(summary).toContain('download.deepseek.com/desktop/dsh-latest-windows-x64.exe')
expect(summary).toContain('无防降级保护')
expect(await desktopPublicationSummary(source, plan, 'c'.repeat(64), false)).not.toContain('官网固定安装包')
expect(await desktopPublicationSummary({ ...source, preset: 'test', testReleaseId: 'a'.repeat(32) },
{ ...plan, environment: 'test' }, 'c'.repeat(64), true)).not.toContain('官网固定安装包')
})
@@ -6,12 +6,13 @@ import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
import { answer, cosError, startCosLoopback } from './cos-loopback.ts'
import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts'
it('uses overwrite prevention, streams verified bytes, and hashes remote readback through the SDK', async () => {
it.each([true, false])('preserves download headers and verified bytes with overwrite prevention %s', async (forbidOverwrite) => {
const objects = new Map<string, Buffer>()
const server = await startCosLoopback((request, response) => {
if (request.path.includes('versioning')) { answer(response, 403, cosError('AccessDenied')); return }
if (request.method === 'PUT') {
expect(request.headers['x-cos-forbid-overwrite']).toBe('true')
expect(request.headers['x-cos-forbid-overwrite']).toBe(forbidOverwrite ? 'true' : undefined)
expect(request.headers['content-disposition']).toBe('attachment; filename="file.exe"')
objects.set(request.path, request.body)
answer(response)
} else {
@@ -21,7 +22,7 @@ it('uses overwrite prevention, streams verified bytes, and hashes remote readbac
}
})
try {
const artifact = { path: '', filename: 'file.exe', key: 'bin/file.exe', contents: 'fixture bytes', contentType: 'application/octet-stream', channelMetadata: false }
const artifact = { path: '', filename: 'file.exe', key: 'bin/file.exe', contents: 'fixture bytes', contentType: 'application/octet-stream', channelMetadata: !forbidOverwrite, contentDisposition: 'attachment; filename="file.exe"' }
const plan: DesktopUploadPlan = {
environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000', publicUrl: 'https://example.com/',
secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact],
@@ -31,10 +32,10 @@ it('uses overwrite prevention, streams verified bytes, and hashes remote readbac
server.redirect(client)
return client
})
expect(await transport.read(artifact.key, false)).toBeNull()
expect(await transport.read(artifact.key, !forbidOverwrite)).toBeNull()
const digest = await desktopArtifactDigest(artifact)
await transport.put(artifact, digest, true)
expect(await transport.read(artifact.key, false)).toEqual(digest)
await transport.put(artifact, digest, forbidOverwrite)
expect(await transport.read(artifact.key, !forbidOverwrite)).toEqual({ ...digest, ...(!forbidOverwrite ? { contents: 'fixture bytes' } : {}) })
await expect(transport.read('elsewhere/file.exe', false)).rejects.toThrow(/outside/u)
expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1)
expect(server.requests.map(request => request.method)).toEqual(['GET', 'PUT', 'GET'])
@@ -43,3 +43,8 @@ it('runs the local preflight before dependency installation in both self-hosted
expect(section.indexOf('node apps/desktop/scripts/preflight-desktop-publish-local.mjs')).toBeLessThan(section.indexOf('pnpm install --frozen-lockfile'))
}
})
it('rejects missing website preparation outputs before reading local configuration', async () => {
await expect(preflightDesktopPublishLocal({ DSH_PACKAGING_CONFIG_DIR: tmpdir(), DESKTOP_PUBLISH_ROUTE: '' }))
.rejects.toThrow('selected package must identify a positive run ID and attempt')
})
@@ -46,7 +46,6 @@ it('fails closed when a publishing route or its unique label is missing', () =>
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, duplicates)).toThrow(/unique runner label/u)
})
it('keeps the packaged source separate from the workflow commit', () => {
const f = fixture()
f.source.commit = 'b'.repeat(40)
@@ -88,3 +87,21 @@ it('matches Intel artifacts to the Intel workflow on a machine serving both arch
f.source.target = 'mac-arm64'
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u)
})
it('accepts a successful package when the attempt failed during archival', () => {
const f = fixture()
f.run.conclusion = 'failure'
f.jobs.push({ ...f.jobs[0]!, name: 'upload-artifact', conclusion: 'failure' })
expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes))
.toMatchObject({ runId: '42', attempt: '2' })
})
it.each([null, undefined])('routes from successful package evidence without a run conclusion (%s)', (conclusion) => {
const f = fixture()
const run = { ...f.run, conclusion }
expect(resolveDesktopPublishRoute('owner/repo', run, { source: f.source }, f.jobs, f.routes))
.toMatchObject({ runId: '42', attempt: '2', runnerLabel: 'dsh-mini' })
f.jobs[0]!.conclusion = 'failure'
expect(() => resolveDesktopPublishRoute('owner/repo', run, { source: f.source }, f.jobs, f.routes))
.toThrow('single successful packaging runner')
})
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest'
import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts'
import { recoverDesktopPurge, type DesktopPurgeRecord, type DesktopPurgeRecoveryOptions } from '../scripts/desktop-purge-recovery.ts'
const config = { zoneId: 'zone-test', publicUrl: 'https://download.deepseek.com/desktop/test.dmg', secretId: 'secret-id', secretKey: 'secret-key' }
const submittedAt = '2026-10-30T00:00:00.000Z'
function fixture() {
let clock = Date.parse(submittedAt)
const records: DesktopPurgeRecord[] = []
const options: DesktopPurgeRecoveryOptions = {
intervalMs: 10, waitMs: 20, allowResubmit: false, now: () => clock,
sleep: async (ms) => { clock += ms }, save: async (record) => { records.push(record) },
}
const task = (status: string, jobId = 'job-1') => ({ TotalCount: 1, Tasks: [{ JobId: jobId, Status: status,
Target: config.publicUrl, Type: 'purge_url', CreateTime: submittedAt }] })
const client = { CreatePurgeTask: vi.fn(async () => ({ JobId: 'job-1' })), DescribePurgeTasks: vi.fn(async () => task('success')) }
return { options, records, task, client }
}
describe('website purge recovery', () => {
it('saves intent before submitting once and confirms completion', async () => {
const f = fixture()
f.client.CreatePurgeTask.mockImplementation(async () => {
expect(f.records.at(-1)?.status).toBe('pending')
return { JobId: 'job-1' }
})
expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).status).toBe('success')
expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce()
expect(f.records.map(r => r.status)).toEqual(['pending', 'processing', 'success'])
})
it('queries an existing successful task without submitting again', async () => {
const f = fixture()
const previous: DesktopPurgeRecord = { ...config, submittedAt, jobId: 'job-1', status: 'unknown' }
expect((await recoverDesktopPurge(config, previous, f.client, f.options)).status).toBe('success')
expect(f.client.CreatePurgeTask).not.toHaveBeenCalled()
})
it('reconciles a lost submission response using exact history', async () => {
const f = fixture()
f.client.CreatePurgeTask.mockRejectedValue(new Error(config.secretKey))
expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).jobId).toBe('job-1')
expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce()
expect(f.client.DescribePurgeTasks.mock.calls[0]).toBeDefined()
expect(JSON.stringify(f.records)).not.toContain(config.secretKey)
})
it('queries once more after uncertainty and preserves an unknown result', async () => {
const f = fixture()
f.client.DescribePurgeTasks.mockRejectedValue(new Error(config.secretKey))
const result = await recoverDesktopPurge(config, undefined, f.client, f.options)
expect(result.status).toBe('unknown')
expect(f.client.DescribePurgeTasks).toHaveBeenCalledTimes(2)
expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce()
expect(JSON.stringify(f.records)).not.toContain(config.secretKey)
})
it('does not claim deadline expiry when the extra query resumes processing', async () => {
const f = fixture()
f.client.DescribePurgeTasks.mockRejectedValueOnce(new Error('transient')).mockResolvedValue(f.task('processing'))
const result = await recoverDesktopPurge(config, undefined, f.client, f.options)
expect(result.diagnostic).toBe('task still processing after an additional query')
expect(result.status).toBe('unknown')
expect(f.client.DescribePurgeTasks).toHaveBeenCalledTimes(2)
expect(f.options.now()).toBeLessThan(Date.parse(submittedAt) + f.options.waitMs)
})
it('bounds processing observations and makes one final query', async () => {
const f = fixture()
f.client.DescribePurgeTasks.mockResolvedValue(f.task('processing'))
expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).status).toBe('unknown')
expect(f.client.DescribePurgeTasks).toHaveBeenCalledTimes(4)
})
it.each(['failed', 'timeout', 'canceled'])('fails for confirmed %s without retrying submission', async (status) => {
const f = fixture()
f.client.DescribePurgeTasks.mockResolvedValue(f.task(status))
await expect(recoverDesktopPurge(config, undefined, f.client, f.options)).rejects.toThrow(status)
expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce()
expect(f.records.at(-1)?.status).toBe(status)
const saved = f.records.at(-1)!
await recoverDesktopPurge(config, saved, f.client, f.options).catch((error: unknown) => {
expect(desktopPublicationFailure(error, 'purge')).toMatchObject({ code: `purge-${status}` })
})
})
it('allows one replacement only after a manual rerun confirms previous failure', async () => {
const f = fixture()
f.client.DescribePurgeTasks.mockResolvedValueOnce(f.task('failed')).mockResolvedValue(f.task('success'))
const previous: DesktopPurgeRecord = { zoneId: config.zoneId, publicUrl: config.publicUrl, submittedAt, jobId: 'old-job', status: 'failed' }
f.client.DescribePurgeTasks.mockReset().mockResolvedValueOnce(f.task('failed', 'old-job')).mockResolvedValue(f.task('success'))
expect((await recoverDesktopPurge(config, previous, f.client, { ...f.options, allowResubmit: true })).status).toBe('success')
expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce()
})
it('does not submit when intent persistence fails', async () => {
const f = fixture()
await expect(recoverDesktopPurge(config, undefined, f.client, { ...f.options,
save: async () => { throw new Error('disk full') },
})).rejects.toThrow('disk full')
expect(f.client.CreatePurgeTask).not.toHaveBeenCalled()
})
it('reports a possibly absent submission without resubmitting it', async () => {
const f = fixture()
f.client.CreatePurgeTask.mockRejectedValue(new Error('lost response'))
f.client.DescribePurgeTasks.mockResolvedValue({ TotalCount: 0, Tasks: [] })
const result = await recoverDesktopPurge(config, undefined, f.client, f.options)
expect(result.status).toBe('unknown')
expect(result.diagnostic).toContain('may not have been submitted')
expect(result.jobId).toBeUndefined()
expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce()
expect(f.client.DescribePurgeTasks).toHaveBeenCalledTimes(2)
})
it('does not mistake multiple matching tasks for confirmed completion', async () => {
const f = fixture()
f.client.CreatePurgeTask.mockRejectedValue(new Error('lost response'))
f.client.DescribePurgeTasks.mockResolvedValue({ TotalCount: 2, Tasks: [...f.task('success').Tasks, ...f.task('success', 'job-2').Tasks] })
expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).status).toBe('unknown')
expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce()
})
})
@@ -135,6 +135,7 @@ describe('desktop upload plan', () => {
expect(request.method).toBe('PUT')
expect(request.path).toBe(`/${artifact.key}`)
expect(request.body).toEqual(await readFile(artifact.path))
expect(request.headers['content-disposition']).toBe(`attachment; filename="deepseek-harness-1.2.3-${target}.${target === 'win-x64' ? 'exe' : 'dmg'}"`)
expect(JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))).toMatchObject({
success: true, confirmedPuts: 1, publicReadback: 'not-performed',
})
@@ -157,6 +158,7 @@ describe('desktop upload plan', () => {
expect(plan.artifacts[0]).toMatchObject({
path: join(paths.artifactsRoot, 'deepseek-harness-1.2.3-alpha.4-win-x64.exe'),
key: 'desktop/dsh-latest-windows-x64.exe', channelMetadata: false,
contentDisposition: 'attachment; filename="deepseek-harness-1.2.3-alpha.4-win-x64.exe"',
})
})
@@ -192,3 +192,13 @@ describe('release upload audit with the real COS SDK and an isolated loopback or
expect(await readFile(join(first, 'result.json'), 'utf8')).toBe(before)
})
})
it('sends the fixed-download save filename through the real COS SDK', async () => {
const f = await fixture('production')
const contentDisposition = 'attachment; filename="deepseek-harness-1.2.3-rc.5-win-x64.exe"'
const plan = { ...f.plan, artifacts: [{ ...f.plan.artifacts[0]!, key: 'desktop/dsh-latest-windows-x64.exe', contentDisposition }] }
const { cos, loopback } = await transport()
await uploadDesktopRelease(plan, cos, f.records)
expect(loopback.requests).toHaveLength(1)
expect(loopback.requests[0]!.headers['content-disposition']).toBe(contentDisposition)
})
@@ -0,0 +1,120 @@
import { createHash } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { createDesktopCos } from '../scripts/desktop-cos.ts'
import { desktopPublishCos } from '../scripts/desktop-publish-cos.ts'
import { answer, cosError, startCosLoopback } from './cos-loopback.ts'
import { prepareWebsiteInstaller, uploadWebsiteInstaller } from '../scripts/desktop-website-installer.ts'
import type { DesktopRetainedManifest } from '../scripts/desktop-release-store.ts'
import type { DesktopPublishRoute } from '../scripts/desktop-publish-route.ts'
const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
async function fixture(target: DesktopPublishRoute['target'] = 'mac-arm64') {
const directory = await mkdtemp(join(tmpdir(), 'website-installer-'))
roots.push(directory)
const bytes = Buffer.from('final installer bytes')
const route: DesktopPublishRoute = { runId: '12', attempt: '1', commit: 'a'.repeat(40), sourceRef: 'master',
channel: 'nightly', runnerName: 'mini', runnerLabel: 'dsh-mini', target, preset: 'production' }
const filename = `deepseek-harness-0.2.1-rc.5-${target}.${target === 'win-x64' ? 'exe' : 'dmg'}`
await writeFile(join(directory, filename), bytes)
const manifest: DesktopRetainedManifest = { schemaVersion: 1, source: {
runId: route.runId, attempt: route.attempt, commit: route.commit, sourceRef: route.sourceRef,
channel: route.channel, runnerName: route.runnerName, target: route.target, preset: route.preset, repository: 'owner/repo', workflowCommit: route.commit, productVersion: '0.2.1', version: '0.2.1-rc.5',
testReleaseId: null, qualification: target === 'win-x64' ? 'signed' : 'notarized',
}, files: [{ name: filename, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }] }
return { directory, bytes, route, manifest }
}
it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('accepts only the final %s installer without any update files or current checkout version', async (target) => {
const f = await fixture(target)
f.manifest = { ...f.manifest, files: [...f.manifest.files,
{ name: 'missing-update.zip', size: 1, sha512: 'unused' }, { name: 'nightly.yml', size: 1, sha512: 'unused' }] }
const installer = await prepareWebsiteInstaller(f.directory, f.manifest, f.route, 'owner/repo', 'production-123')
try {
expect(installer.plan.version).toBe('0.2.1-rc.5')
expect(installer.plan.artifacts).toHaveLength(1)
expect(installer.digest).toEqual({ size: f.bytes.length, sha512: f.manifest.files[0]!.sha512 })
} finally { await installer.handle.close() }
})
it('rejects changed installer bytes', async () => {
const f = await fixture()
await writeFile(join(f.directory, f.manifest.files[0]!.name), 'wrong bytes')
await expect(prepareWebsiteInstaller(f.directory, f.manifest, f.route, 'owner/repo', 'production-123')).rejects.toThrow('differs from build manifest')
})
it.each(['preset', 'qualification', 'attempt', 'commit', 'target', 'repository', 'runId', 'runnerName', 'sourceRef', 'channel'] as const)('rejects mismatched %s even when file bytes match', async (field) => {
const f = await fixture()
const source = { ...f.manifest.source }
if (field === 'preset') source.preset = 'test'
if (field === 'qualification') source.qualification = 'unnotarized'
if (field === 'attempt') source.attempt = '2'
if (field === 'commit') source.commit = 'b'.repeat(40)
if (field === 'target') source.target = 'mac-x64'
if (field === 'repository') source.repository = 'other/repo'
if (field === 'runId') source.runId = '13'
if (field === 'runnerName') source.runnerName = 'other'
if (field === 'sourceRef') source.sourceRef = 'dsh-v0.2.1'
if (field === 'channel') source.channel = 'stable'
await expect(prepareWebsiteInstaller(f.directory, { ...f.manifest, source }, f.route, 'owner/repo', 'production-123')).rejects.toThrow('identity or production qualification mismatch')
})
it.each(['absent', 'missing-header', 'wrong-header', 'header-not-retained'] as const)('reconciles %s after audit failure without repeating the fixed-object PUT', async (initial) => {
const f = await fixture()
const installer = await prepareWebsiteInstaller(f.directory, f.manifest, f.route, 'owner/repo', 'production-123')
let stored: Buffer | undefined = initial === 'absent' ? undefined : f.bytes
const disposition = `attachment; filename="${f.manifest.files[0]!.name}"`
let storedDisposition = initial === 'wrong-header' ? 'attachment; filename="old.dmg"' : undefined
const server = await startCosLoopback((request, response) => {
if (request.method === 'PUT') {
expect(request.headers['x-cos-forbid-overwrite']).toBeUndefined()
expect(request.headers['content-md5']).toBe(createHash('md5').update(f.bytes).digest('base64'))
expect(request.headers['content-disposition']).toBe(disposition)
storedDisposition = disposition
stored = request.body
answer(response)
} else if (stored) answer(response, 200, stored, storedDisposition && initial !== 'header-not-retained' ? { 'content-disposition': storedDisposition } : {})
else answer(response, 404, cosError('NoSuchKey'))
})
try {
const credentials = { secretId: 'fixture-id', secretKey: 'fixture-key' }
const createClient: typeof createDesktopCos = (credentials) => {
const client = createDesktopCos(credentials)
server.redirect(client)
return client
}
const transport = desktopPublishCos(installer.plan, credentials, createClient)
await expect(uploadWebsiteInstaller(installer, credentials, transport, async (event) => {
if (event === 'uploaded') throw new Error('audit unavailable')
}, { createClient })).rejects.toThrow(initial === 'header-not-retained' ? 'remote download filename unconfirmed' : 'audit unavailable')
if (initial === 'header-not-retained') {
expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1)
return
}
const events: string[] = []
expect(await uploadWebsiteInstaller(installer, credentials, transport, async (event) => { events.push(event) }, { createClient })).toBe('present')
expect(events).toEqual(['uploaded'])
expect(stored).toEqual(f.bytes)
expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1)
} finally { await installer.handle.close(); await server.close() }
})
it('does not replace changed bytes when recovering an already completed publication', async () => {
const f = await fixture()
const installer = await prepareWebsiteInstaller(f.directory, f.manifest, f.route, 'owner/repo', 'production-123')
try {
await expect(uploadWebsiteInstaller(installer, { secretId: 'fixture-id', secretKey: 'fixture-key' },
{ read: async () => ({ size: 1, sha512: 'different' }) }, async () => { throw new Error('unexpected write intent') },
{ createClient: () => { throw new Error('unexpected PUT') }, previouslyUploaded: true })).rejects.toThrow('recorded upload no longer matches')
} finally { await installer.handle.close() }
})
it('rejects an unsupported source ref even when route and manifest agree', async () => {
const f = await fixture()
const route = { ...f.route, sourceRef: 'feature/unapproved' }
const manifest = { ...f.manifest, source: { ...f.manifest.source, sourceRef: route.sourceRef } }
await expect(prepareWebsiteInstaller(f.directory, manifest, route, 'owner/repo', 'production-123')).rejects.toThrow('identity or production qualification mismatch')
})
@@ -0,0 +1,42 @@
## 发布前确认
核对以下信息后,点击 **Review deployments**,选择 <code>desktop-production</code> 并点击 **Approve and deploy**;信息有误请点击 **Reject**。
| 项目 | 发布信息 |
| --- | --- |
| 发布环境 | <code>production</code> |
| 版本号 | <code>1.0.0</code> |
| 升级通道 | <code>stable</code> |
| 平台 | <code>win-x64</code> |
| 下载地址预览 | <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
| 升级 YAML | <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml">https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml</a><br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |
| 打包来源 | <a href="https&#58;//github.com/owner/repo/actions/runs/20/attempts/3">查看打包运行 #20 · 第 3 次尝试</a><br>源码:<code>dsh-v1.0.0 @ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code><br>打包机器:<code>windows-publisher</code> |
| 签名与公证 | EXE 签名校验通过 |
| 官网固定安装包 | <a href="https&#58;//download.deepseek.com/desktop/dsh-latest-windows-x64.exe">https&#58;//download.deepseek.com/desktop/dsh-latest-windows-x64.exe</a><br>替换为版本 <code>1.0.0</code>;已有安装包会被替换,无防降级保护;官网上传与刷新在后续 job 中执行,可能需另行审批。 |
| Git tag | <code>desktop-v1.0.0</code> |
### 批准后将执行
1. 重新校验产物与发布目标;若与审批摘要不一致则停止。
2. 上传缺少的文件,跳过内容一致的已有文件;发现内容冲突则停止。
3. 更新上述升级 YAML,并保存发布记录。
4. 为实际打包提交创建上述 Git tag;若 tag 创建失败,记录“已发布,待补 tag”。
<details>
<summary>技术详情与文件清单</summary>
COS 存储桶:<code>production-1234567890</code>
审批摘要 SHA-256:<code>cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc</code>
共 2 个文件;大小及 SHA-256 对应实际上传字节,包括更新后的 YAML 内容。
| 文件 | 大小 | SHA-256 |
| --- | --- | --- |
| <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">deepseek-harness-1.0.0-win-x64.exe</a> | 15 字节 | <code>e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c</code> |
| <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml">stable.yml</a> | 15 字节 | <code>8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1</code> |
</details>
若仓库为公开仓库,此摘要中的下载路径及 test 发布的 release ID 也会公开。随机前缀不能替代访问鉴权。
@@ -0,0 +1,8 @@
# ⚠ 官网资源已上传,缓存刷新结果未确认
刷新已提交,执行结果未确认
https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg
Task: job-1
Observed: 2026-10-30T00:00:00.000Z
task query failed; check EdgeOne history and DescribePurgeTasks permission
@@ -0,0 +1,8 @@
# ⚠ 官网资源已上传,缓存刷新结果未确认
刷新提交未确认,可能尚未提交
https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg
Task: unknown
Observed: 2026-10-30T00:00:00.000Z
task query failed; check EdgeOne history and DescribePurgeTasks permission
@@ -0,0 +1,122 @@
import { createHash } from 'node:crypto'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import * as desktopCos from '../scripts/desktop-cos.ts'
import { answer, cosError, startCosLoopback } from './cos-loopback.ts'
import { teo } from 'tencentcloud-sdk-nodejs-teo'
import { publishWebsiteCi, websiteApproval } from '../scripts/publish-website-ci.ts'
vi.mock('node:timers/promises', () => ({ setTimeout: async () => {} }))
afterEach(() => vi.restoreAllMocks())
it.each(['accepted', 'unconfirmed', 'incomplete', 'wrong-journal', 'upload', 'put', 'uploaded', 'metadata', 'metadata-retry', 'stable', 'approval'] as const)('preserves publication recovery for %s', async (scenario) => {
const root = await mkdtemp(join(tmpdir(), 'website-ci-'))
try {
const config = join(root, 'packaging')
const records = join(root, 'upload-records', 'website-99-mac-arm64')
await mkdir(join(config, 'publishing'), { recursive: true })
await mkdir(join(root, 'releases', '12', '1'), { recursive: true })
await mkdir(records, { recursive: true })
const credentialsFile = join(root, 'credentials.json')
await writeFile(credentialsFile, JSON.stringify({ secretId: 'fixture-id', secretKey: 'fixture-key' }))
await writeFile(join(config, 'publishing', 'production.json'), JSON.stringify({
bucket: 'production-123', origin: 'https://download.deepseek.com', credentialsFile,
githubApp: { clientId: 'app', privateKeyFile: join(root, 'app.pem') },
}))
const route = { runId: '12', attempt: '1', commit: 'a'.repeat(40), sourceRef: 'master', channel: scenario === 'stable' ? 'stable' : 'nightly',
runnerName: 'mini', runnerLabel: 'dsh-mini', target: 'mac-arm64', preset: 'production' }
const manifest = { schemaVersion: 1, source: { repository: 'owner/repo', runId: '12', attempt: '1', commit: route.commit,
workflowCommit: route.commit, sourceRef: 'master', channel: route.channel, runnerName: 'mini', target: 'mac-arm64',
preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'notarized' },
files: [{ name: 'deepseek-harness-1.0.0-mac-arm64.dmg', size: 1, sha512: createHash('sha512').update('x').digest('base64') }] }
await writeFile(join(root, 'releases', '12', '1', 'manifest.json'), JSON.stringify(manifest))
const approval = websiteApproval(manifest, 'production-123')
await writeFile(join(records, 'state.json'), JSON.stringify({ approval, upload: 'uploaded' }))
if (scenario === 'metadata') await writeFile(join(records, 'state.json'), JSON.stringify({ approval, upload: 'uploaded', purgeAttempt: '1', purge: { zoneId: 'zone-fixture', publicUrl: 'https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg', submittedAt: '2026-10-30T00:00:00.000Z', jobId: 'old-job', status: 'success' } }))
if (scenario === 'metadata-retry') await writeFile(join(records, 'state.json'), JSON.stringify({ approval, upload: 'upload-intent', uploadConfirmed: true }))
const env = { RUNNER_NAME: 'mini', GITHUB_REPOSITORY: 'owner/repo', GITHUB_RUN_ID: '99',
GITHUB_RUN_ATTEMPT: '1', DSH_PACKAGING_CONFIG_DIR: config, DESKTOP_PUBLISH_ROUTE: JSON.stringify(route),
DESKTOP_WEBSITE_APPROVAL: approval, DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', GITHUB_OUTPUT: join(root, 'output') }
if (scenario === 'upload' || scenario === 'put' || scenario === 'uploaded' || scenario === 'metadata' || scenario === 'metadata-retry' || scenario === 'stable' || scenario === 'approval') {
await writeFile(join(root, 'releases', '12', '1', manifest.files[0]!.name), 'x')
if (scenario === 'upload' || scenario === 'put') await rm(join(records, 'state.json'))
let stored = scenario !== 'put'
let headerStored = scenario !== 'metadata'
const server = await startCosLoopback(async (request, response) => {
if (request.method === 'PUT') {
expect(request.body.toString()).toBe('x')
if (scenario === 'metadata') {
const state: unknown = JSON.parse(await readFile(join(records, 'state.json'), 'utf8'))
expect(state).toMatchObject({ upload: 'upload-intent', uploadConfirmed: true })
expect(state).not.toHaveProperty('purge')
expect(state).not.toHaveProperty('purgeAttempt')
}
expect(request.headers['content-disposition']).toBe(`attachment; filename="${manifest.files[0]!.name}"`)
stored = true
headerStored = true
answer(response)
return
}
if (!stored) answer(response, 404, cosError('NoSuchKey'))
else answer(response, 200, scenario === 'metadata-retry' ? 'y' : 'x', headerStored ? { 'content-disposition': `attachment; filename="${manifest.files[0]!.name}"` } : {})
})
const original = desktopCos.createDesktopCos
vi.spyOn(desktopCos, 'createDesktopCos').mockImplementation((credentials) => {
const client = original(credentials)
server.redirect(client)
return client
})
try {
if (scenario === 'metadata-retry') {
await expect(publishWebsiteCi('upload', env)).rejects.toThrow('recorded upload no longer matches')
expect(server.requests.map(request => request.method)).toEqual(['GET'])
} else if (scenario === 'approval') {
await expect(publishWebsiteCi('upload', { ...env, DESKTOP_WEBSITE_APPROVAL: 'changed' })).rejects.toThrow('approved build or destination changed')
expect(server.requests).toHaveLength(0)
} else {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
await publishWebsiteCi('upload', env)
expect(output.mock.calls.map(call => String(call[0])).join('')).toContain((scenario === 'put' || scenario === 'metadata')
? 'Website installer uploaded:' : 'identical remote bytes and download filename confirmed; skipped')
if (scenario === 'upload') expect(server.requests[0]!.path).toBe('/desktop/dsh-latest-macos-arm64.dmg')
expect(server.requests.map(request => request.method)).toEqual((scenario === 'put' || scenario === 'metadata') ? ['GET', 'PUT', 'GET'] : ['GET'])
expect(JSON.parse(await readFile(join(records, 'state.json'), 'utf8'))).toMatchObject({ approval, upload: 'uploaded' })
expect(await readFile(env.GITHUB_OUTPUT, 'utf8')).toContain(`record=${records}`)
if (scenario === 'metadata') {
const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'new-job' })
vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('unavailable'))
await publishWebsiteCi('purge', env)
expect(submit).toHaveBeenCalledOnce()
expect(JSON.parse(await readFile(join(records, 'state.json'), 'utf8'))).toMatchObject({ purge: { jobId: 'new-job' } })
}
}
} finally { await server.close() }
return
}
const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' })
if (scenario === 'incomplete' || scenario === 'wrong-journal') {
await writeFile(join(records, 'state.json'), JSON.stringify({ approval: scenario === 'wrong-journal' ? 'other' : approval, upload: 'upload-intent' }))
const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks')
await expect(publishWebsiteCi('purge', env)).rejects.toThrow(scenario === 'incomplete' ? 'upload completion is not confirmed' : 'journal belongs to another approved build')
expect(submit).not.toHaveBeenCalled()
expect(query).not.toHaveBeenCalled()
return
}
const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('fixture-key'))
if (scenario === 'unconfirmed') submit.mockRejectedValue(new Error('lost response'))
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-30T00:00:00.000Z'))
const summary = join(root, 'summary.md')
await publishWebsiteCi('purge', { RUNNER_NAME: 'mini', GITHUB_REPOSITORY: 'owner/repo', GITHUB_RUN_ID: '99',
GITHUB_RUN_ATTEMPT: '1', DSH_PACKAGING_CONFIG_DIR: config, DESKTOP_PUBLISH_ROUTE: JSON.stringify(route),
DESKTOP_WEBSITE_APPROVAL: approval, DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', GITHUB_STEP_SUMMARY: summary })
expect(submit).toHaveBeenCalledOnce()
expect(query).toHaveBeenCalledTimes(2)
expect(output.mock.calls.map(call => String(call[0])).join('')).toContain('::warning title=官网缓存刷新结果未确认::')
expect(JSON.parse(await readFile(join(records, 'state.json'), 'utf8'))).toMatchObject({ upload: 'uploaded', purge: { status: 'unknown' } })
await expect(await readFile(summary, 'utf8')).toMatchFileSnapshot(`./expected/website-purge-${scenario}.md`)
expect(await readFile(summary, 'utf8')).not.toContain('fixture-key')
} finally { await rm(root, { recursive: true, force: true }) }
})
+17 -2
View File
@@ -2,8 +2,9 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { desktopReleasesRoot, retainDesktopCi } from '../scripts/retain-desktop-ci.ts'
import { desktopReleasesRoot, retainDesktopCi, resolveDesktopRetentionRunner } from '../scripts/retain-desktop-ci.ts'
import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts'
import { verifyDesktopArchive } from '../scripts/archive-desktop-ci.ts'
import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts'
const roots: string[] = []
@@ -32,9 +33,15 @@ it.each([
DSH_DESKTOP_TARGET_ARCH: target === 'mac-arm64' ? 'arm64' : 'x64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0',
PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2',
GITHUB_STEP_SUMMARY: join(root, 'summary.md'),
DESKTOP_PUBLISH_RUNNERS: JSON.stringify({ dedicated: { label: 'dsh-dedicated', target: [target] } }),
GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated',
}
await retainDesktopCi(env, appRoot)
const directory = await retainDesktopCi(env, appRoot)
const archiveEnv = { ...env, GITHUB_RUN_ATTEMPT: '3', DESKTOP_RETAINED_DIRECTORY: directory,
DESKTOP_PACKAGE_ATTEMPT: '2', DESKTOP_PACKAGE_RUNNER: env.RUNNER_NAME }
await verifyDesktopArchive(archiveEnv)
await expect(verifyDesktopArchive({ ...archiveEnv, RUNNER_NAME: 'other' })).rejects.toThrow('wrong original machine')
await expect(verifyDesktopArchive({ ...archiveEnv, DESKTOP_PACKAGE_ATTEMPT: '3' })).rejects.toThrow('original build')
const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), '123', '2')
expect(manifest.files.map(file => file.name)).toEqual(names)
expect(manifest.source).toMatchObject({ target, qualification })
@@ -49,3 +56,11 @@ it.each([
await expect(summary).toMatchFileSnapshot(`./expected/retained-${qualification}.md`)
} else expect(summary).toContain('Publish separately with package_run_id=123 and package_run_attempt=2')
})
it('rejects missing or ambiguous archival routing before packaging starts', () => {
const env = { RUNNER_NAME: 'dedicated', DSH_DESKTOP_TARGET_PLATFORM: 'darwin', DSH_DESKTOP_TARGET_ARCH: 'arm64' }
expect(() => resolveDesktopRetentionRunner(env)).toThrow('DESKTOP_PUBLISH_RUNNERS is required')
expect(() => resolveDesktopRetentionRunner({ ...env, DESKTOP_PUBLISH_RUNNERS: JSON.stringify({
dedicated: { label: 'dsh-shared', target: 'mac-arm64' }, other: { label: 'dsh-shared', target: 'mac-arm64' },
}) })).toThrow('unique artifact routing label')
})
+201
View File
@@ -0,0 +1,201 @@
/** Keyless real-profile checks for the default official-home decoration and native input isolation. */
import { mkdir, mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
const artifacts = fileURLToPath(new URL('../../../.artifacts/home-whale/', import.meta.url))
describe('official home whale in the default web profile', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let consoleState: ReturnType<typeof watchConsole>
let captureDir: string
beforeAll(async () => {
await mkdir(artifacts, { recursive: true })
captureDir = await mkdtemp(join(artifacts, 'run-'))
scaffold = await launchWebScaffold({ firstUse: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
consoleState = watchConsole(page)
await page.goto(scaffold.authenticatedUrl)
await page.locator('[data-home-whale]').waitFor()
await page.locator('[data-composer-input]').waitFor()
}, 120_000)
afterAll(async () => {
try {
await browser?.close()
} finally {
await scaffold?.close()
}
})
it('alternates grayscale dot and code breaches on click and respects reduced motion', async () => {
const canvas = page.locator('[data-home-whale]')
const surface = page.locator('[data-conversation-content]')
const pixelSummary = () => canvas.evaluate((node: HTMLCanvasElement) => {
const values = node.getContext('2d')!.getImageData(0, 0, node.width, node.height).data
let saturated = 0
let opaque = 0
let nontransparent = 0
let contrast = 0
for (let i = 0; i < values.length; i += 4) {
const alpha = values[i + 3]! / 255
if (alpha > 0) nontransparent++
if (values[i + 3]! > 2) opaque++
contrast = Math.max(contrast, (255 - Math.min(values[i]!, values[i + 1]!, values[i + 2]!)) * alpha)
if (values[i + 3]! > 10 && values[i + 2]! - values[i]! > 8 && values[i + 2]! - values[i + 1]! > 4) saturated++
}
return { saturated, opaque, nontransparent, contrast }
})
expect(await canvas.evaluate(node => getComputedStyle(node).pointerEvents)).toBe('none')
expect(await canvas.getAttribute('aria-hidden')).toBe('true')
expect(await canvas.evaluate(node => getComputedStyle(node).filter)).toBe('grayscale(1)')
await expect.poll(async () => (await pixelSummary()).nontransparent).toBe(0)
await page.screenshot({ path: `${captureDir}/idle.png` })
const box = await surface.boundingBox()
expect(box).not.toBeNull()
const card = (await page.locator('[data-composer-card]').boundingBox())!
// The home above the input card, read from the composited page: it changes only if the canvas shows through.
const homeClip = { x: box!.x, y: box!.y, width: box!.width, height: card.y - box!.y }
const idleHome = await page.screenshot({ clip: homeClip })
const marginX = box!.x + 12
const marginY = card.y + card.height / 2
expect(await page.evaluate(({ x, y }) =>
document.elementFromPoint(x, y)?.closest('[data-composer-seat]') !== null,
{ x: marginX, y: marginY })).toBe(true)
await page.clock.install()
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000))
try {
await page.mouse.click(marginX, marginY)
await expect.poll(async () => {
await page.clock.runFor(100)
return (await pixelSummary()).opaque
}).toBeGreaterThan(300)
await page.clock.runFor(1500)
await page.screenshot({ path: `${captureDir}/ripple.png` })
await page.mouse.click(box!.x + box!.width * 0.78, box!.y + box!.height * 0.72)
expect((await pixelSummary()).opaque).toBeGreaterThan(0)
expect((await pixelSummary()).contrast).toBeLessThanOrEqual(100)
// The last visible pixels disappear before the playback deadline; finish the full timeline before the next click.
await page.clock.runFor(5700)
expect((await pixelSummary()).nontransparent).toBe(0)
await page.mouse.click(box!.x + box!.width * 0.78, box!.y + box!.height * 0.72)
await page.clock.runFor(1500)
expect((await pixelSummary()).opaque).toBeGreaterThan(300)
expect((await page.screenshot({ clip: homeClip })).equals(idleHome)).toBe(false)
await page.screenshot({ path: `${captureDir}/code.png` })
await page.clock.runFor(5700)
expect((await pixelSummary()).nontransparent).toBe(0)
await page.emulateMedia({ reducedMotion: 'reduce' })
const staticFrame = await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())
await page.mouse.click(box!.x + 100, box!.y + 150)
await page.clock.runFor(300)
expect(await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())).toBe(staticFrame)
} finally {
await page.emulateMedia({ reducedMotion: 'no-preference' })
await page.clock.resume()
}
})
it('keeps native draft editing independent of the decoration', async () => {
const input = page.locator('[data-composer-input]')
await input.fill('保留这个试用草稿')
const canvas = page.locator('[data-home-whale]')
const idleFrame = await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())
await input.click()
// Observe several normal animation frames while focus stays in the composer.
await page.waitForTimeout(300)
expect(await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL())).toBe(idleFrame)
expect(await input.innerText()).toBe('保留这个试用草稿')
await input.press('ControlOrMeta+A')
await input.press('Backspace')
})
it('paints the decoration between the Conversation background and the home content', async () => {
const order = await page.locator('[data-home-whale]').evaluate((node) => {
// The canvas's negative z-index resolves in the nearest ancestor that establishes a stacking context. Walk up to
// the document root and report the first one, which must be the home Conversation root that paints the background.
const stacking = (element: Element): boolean => {
const style = getComputedStyle(element)
return style.isolation === 'isolate' || (style.zIndex !== 'auto' && style.position !== 'static')
|| style.transform !== 'none' || style.opacity !== '1' || style.filter !== 'none'
}
let context: Element | null = node.parentElement
while (context !== null && context !== document.documentElement && !stacking(context)) context = context.parentElement
if (context === null || context === document.documentElement) return { found: false }
return {
found: true,
canvasZIndex: getComputedStyle(node).zIndex,
conversationPhase: context.getAttribute('data-phase'),
isolation: getComputedStyle(context).isolation,
paintsBackground: getComputedStyle(context).backgroundColor !== 'rgba(0, 0, 0, 0)',
}
})
expect(order).toEqual({
found: true, canvasZIndex: '-1', conversationPhase: 'hero', isolation: 'isolate', paintsBackground: true,
})
})
it('loads from the default composition without a separately enabled bundle', async () => {
await page.reload()
await page.locator('[data-home-whale]').waitFor()
const facts = await page.locator('[data-home-whale]').evaluate(node => ({
count: document.querySelectorAll('[data-home-whale]').length,
hiddenFromAssistiveTech: node.getAttribute('aria-hidden'),
pointerEvents: getComputedStyle(node).pointerEvents,
nativeComposer: document.querySelectorAll('[data-composer-seat]').length,
}))
expect(facts).toMatchInlineSnapshot(`
{
"count": 1,
"hiddenFromAssistiveTech": "true",
"nativeComposer": 1,
"pointerEvents": "none",
}
`)
expect(consoleState.pageErrors).toEqual([])
expect(consoleState.warnings).toEqual([])
})
it('keeps neutral dots visible in both themes and fits a narrow home', async () => {
const settings = scaffold.ctx.get('settings')!
const canvas = page.locator('[data-home-whale]')
const visibleDots = () => canvas.evaluate((node: HTMLCanvasElement) =>
node.getContext('2d')!.getImageData(0, 0, node.width, node.height).data.some((value, i) => i % 4 === 3 && value > 10))
const clickHome = async () => {
const box = (await page.locator('[data-conversation-content]').boundingBox())!
await page.mouse.click(box.x + box.width * 0.78, box.y + box.height * 0.18)
}
await settings.update('ui-theme', { preference: 'light' })
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBeNull()
const lightInk = await canvas.evaluate(node => getComputedStyle(node).color)
await clickHome()
await expect.poll(visibleDots).toBe(true)
try {
await settings.update('ui-theme', { preference: 'dark' })
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe('')
await expect.poll(() => canvas.evaluate(node => getComputedStyle(node).color)).not.toBe(lightInk)
// A click during playback is ignored and a fading breach can already read as blank, so keep clicking until a
// breach is visible after the theme switch.
await expect.poll(async () => { await clickHome(); return visibleDots() }, { timeout: 15_000 }).toBe(true)
await page.screenshot({ path: `${captureDir}/dark.png` })
await page.setViewportSize({ width: 390, height: 844 })
const box = (await canvas.boundingBox())!
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
} finally {
await page.setViewportSize({ width: 1440, height: 960 })
await settings.update('ui-theme', { preference: 'light' })
}
})
})
+1
View File
@@ -23,6 +23,7 @@
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/home-whale.e2e.ts",
"tests/sidebar-drawer.e2e.ts",
"tests/creation-recommendations.e2e.ts",
"tests/plugin-recommendations.e2e.ts",
+9 -6
View File
@@ -9,11 +9,14 @@
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
/web-client-slots/cardinality-and-scope:
en: cd17e3e0ff6e62ff
zh: 6e0db2cb16c41b73
en: 488f2f309af94398
zh: 7941265e4c545c21
/web-client-slots/cardinality-and-scope/hoc-layers:
en: e6862954bd8874af
zh: 0244a2d7bb0befdf
/web-client-slots/component-inputs:
en: 9cb70abb2e39b958
zh: cd4b6f1705d90e17
en: 278b2eee160be413
zh: d6e0fd1e9f4a4b27
/web-client-slots/framework-provided-hooks:
en: c9f8d836214623ae
zh: 76dec55006396535
@@ -24,5 +27,5 @@
en: e1d572c42e91d663
zh: 5a649dea2682be7f
/web-client-slots/extension-rules:
en: b2cce357141883ee
zh: 278080a5fb941189
en: e5fdcd6fafe4d161
zh: 7fd0c4374705b372
+16 -2
View File
@@ -61,11 +61,22 @@ The slot declaration fixes two independent axes.
| cardinality | `list` | Cells are addressed by required `id` and ordered by `order`, then registration order. |
| cardinality | `keyed` | The owner dispatches an `entryKey`; the matching cell renders with any key-specific props. |
| cardinality | `chain` | Each entry supplies a pure `select(owner)` function. The first non-null result in priority order renders and receives that result as `matched`; otherwise the owner fallback renders. |
| cardinality | `hoc` | Entries nest around the owner's `renderSlot(key, owner, { children })` content, lowest outermost; see [Hoc layers](#hoc-layers). |
| scope | `root` | One root-scoped component and store instance. |
| scope | `session-maybe` | Inherits the surrounding Provider binding but stays renderable without one; Session values are optional. |
| scope | `session` | Requires a resolved surrounding Provider binding and receives definite Session values. |
`priority` is a shadowing rank for `single`, `list`, and `keyed` cells and an election order for `chain`. Lower values run or render first. Ordinary additive contributions should choose a fresh list `id` or keyed `key`; intentionally reusing a shipped cell replaces its presentation.
`priority` is a shadowing rank for `single`, `list`, `keyed`, and `hoc` cells and an election order for `chain`. Lower values run or render first. Ordinary additive contributions should choose a fresh list or hoc `id`, or keyed `key`; intentionally reusing a shipped cell replaces its presentation.
<a id="hoc-layers"></a>
### Hoc layers
A hoc entry is a layer addressed by its required `id`. Layers nest by ascending `order`, then `priority`, then registration order, lowest outermost. Each layer receives the same owner props plus the next inner layer as `children`, the owner's content at the innermost, and renders it exactly once; owner props must not use the name `children`. Omitted `children` renders the layers around nothing, and with no live layer the content renders unchanged.
- Remounting: adding or removing a layer remounts the content inside it, including a crashed layer leaving the slot; a `session` or `session-maybe` layer also remounts it when its Session binding changes.
- Errors: a crashed layer renders its `children` and leaves the slot. An error thrown by the wrapped content passes every layer and is reported against none; a layer that wraps its `children` in its own error boundary catches it as an internal wrapper error rather than the original.
- Layout: the outlet inserts a `display: contents` `[data-slot]` element between the owner and its content, so owner selectors cannot rely on the content being a direct child. A layer must not create a box of its own — for example, use `display: contents` — or the content leaves its parent's layout.
The [context-menu plugin](../../packages/client/ui-context-menu/README.md) contributes `useContextMenu` through `provideRoot`; every scope receives the same plugin-owned trigger factory. Its prop and retained handlers retire when the provider unloads. Its root-scoped keyed `contextMenu.icon` Slot renders business-owned icons from action metadata.
@@ -81,6 +92,7 @@ A registered component receives inputs assembled at its binding site. Components
| private data, callbacks, and observable hooks | the registration's `inject` factory | `InjectFace<I>` |
| localized `t` function | the registration's `locale` namespace | `PropsLocale<N>` |
| selected chain value | the registration's `select` result | `matched` through `ComposedProps` |
| wrapped owner content | the `SlotMap` row's `hoc` kind | `children` through `ComposedProps` |
`SessionProvider` is also present in `PropsRenderSlots` when an entry declares a `session` or `session-maybe` child. With no `session` prop it inherits the surrounding binding; an explicit `SessionReference` or `undefined` overrides only that subtree. The Provider does not key its whole body. A strict `session` entry remounts when its binding generation changes. A blank `session-maybe` entry adopts its first binding without remounting, then remounts for a later generation or a return to absence.
@@ -182,6 +194,8 @@ Factory: conversation.content [ConversationContent]
├─ conversation.input.dock
│ └─ id=queue [QueueDock]
│ └─ calls Factory: conversation.user.text
├─ conversation.hero.frame [hoc]
│ └─ id=pixel-whale [PixelWhale]
├─ conversation.hero.brand.mark
├─ conversation.hero.workspace
│ └─ conversation.hero.workspace.directoryFlow
@@ -210,4 +224,4 @@ The generated [Client inspect catalog](../../packages/extensions/cordis-client-r
- Keep business and transport state in their owning Cordis services or Client models. Slot stores hold shared viewing and interaction state only.
- Keep observable source and snapshot identities stable between changes. Republish through the same source whenever its value changes.
- Pass JSON-compatible data and callbacks between UI domains. The `hooks` compartment is the sole exception for bare observables; React content travels through slots.
- Treat `single` and an occupied keyed cell as replacement points. Use list ids or an unoccupied key for additive extensions.
- Treat `single` and an occupied keyed cell as replacement points. Use fresh list or hoc ids, or an unoccupied key, for additive extensions.
+16 -2
View File
@@ -61,11 +61,22 @@ Slot 声明固定两个相互独立的维度。
| cardinality | `list` | cell 由必填 `id` 定址,先按 `order`、再按注册顺序排列。 |
| cardinality | `keyed` | owner 传入 `entryKey`;匹配 cell 以该 key 对应的 props 渲染。 |
| cardinality | `chain` | 每个 entry 提供纯 `select(owner)` 函数;按 priority 顺序遇到的第一个非 null 结果获选,并以 `matched` 传给组件;全部拒绝时渲染 owner fallback。 |
| cardinality | `hoc` | entry 层层包裹 owner 经 `renderSlot(key, owner, { children })` 传入的内容,最小者在最外层;见 [Hoc 层](#hoc-layers)。 |
| scope | `root` | 一个 root 作用域组件和 store 实例。 |
| scope | `session-maybe` | 继承外围 Provider binding,但没有 binding 时仍可渲染;Session 值是可选的。 |
| scope | `session` | 要求可解析的外围 Provider binding,并收到确定存在的 Session 值。 |
对于 `single`、`list` 和 `keyed` cell,`priority` 是遮蔽优先级;对于 `chain`,它是选举顺序。数值越小越先运行或渲染。普通增量贡献应选用新的 list `id` 或 keyed `key`;复用已有 cell 表示有意替换其展示。
对于 `single`、`list`、`keyed` 和 `hoc` cell,`priority` 是遮蔽优先级;对于 `chain`,它是选举顺序。数值越小越先运行或渲染。普通增量贡献应选用新的 list 或 hoc `id`,或 keyed `key`;复用已有 cell 表示有意替换其展示。
<a id="hoc-layers"></a>
### Hoc 层
hoc entry 是以必填 `id` 寻址的一层。各层依次按 `order`、`priority`、注册顺序升序嵌套,最小者在最外层。每层收到同一份 owner props,并以 `children` 收到内一层(最内层是 owner 的内容),且只渲染一次;owner props 不得使用 `children` 这个名字。省略 `children` 时各层包裹空内容;没有存活的层时原样渲染内容。
- 重新挂载:增减一层会让其内部内容重新挂载,崩溃的层退出 slot 也是如此;`session` 或 `session-maybe` 作用域的层在 Session binding 变化时也会让内部内容重新挂载。
- 错误:崩溃的层渲染它的 `children` 并退出 slot。被包裹内容抛出的错误穿过所有层,不记到任何层名下;层如果自己在 `children` 外包错误边界,捕获到的是内部包装错误而不是原始错误。
- 布局:outlet 会在 owner 与内容之间插入一个 `display: contents` 的 `[data-slot]` 元素,所以 owner 侧的选择器不能假定内容是直接子元素。层自身不得产生盒子——例如应使用 `display: contents`——否则内容会脱离父元素的布局。
<a id="component-inputs"></a>
@@ -83,6 +94,7 @@ Slot 声明固定两个相互独立的维度。
| 私有数据、callback 与 observable hook | 注册项的 `inject` factory | `InjectFace<I>` |
| 本地化 `t` 函数 | 注册项的 `locale` namespace | `PropsLocale<N>` |
| chain 选中的值 | 注册项的 `select` 结果 | 通过 `ComposedProps` 提供的 `matched` |
| 被包裹的 owner 内容 | `SlotMap` 条目的 `hoc` kind | 通过 `ComposedProps` 提供的 `children` |
当 entry 声明 `session` 或 `session-maybe` child 时,`PropsRenderSlots` 还会提供 `SessionProvider`。不传 `session` prop 时,它继承外围 binding;显式传入 `SessionReference` 或 `undefined` 时,只覆盖该子树。Provider 不为整个 body 设置 key。严格 `session` entry 在 binding generation 改变时重新挂载。空白 `session-maybe` entry 接受首个 binding 时不重新挂载,后续 generation 变化或回到缺失状态时才重新挂载。
@@ -184,6 +196,8 @@ Factory: conversation.content [ConversationContent]
├─ conversation.input.dock
│ └─ id=queue [QueueDock]
│ └─ calls Factory: conversation.user.text
├─ conversation.hero.frame [hoc]
│ └─ id=pixel-whale [PixelWhale]
├─ conversation.hero.brand.mark
├─ conversation.hero.workspace
│ └─ conversation.hero.workspace.directoryFlow
@@ -214,4 +228,4 @@ Factory: conversation.chat.reasoning.content [ReasoningContent / MarkdownText]
- 业务与传输状态留在所属 Cordis service 或 Client model 中。Slot store 只承载共享的视图与交互状态。
- 可观测 source 及其 snapshot identity 在值变化前保持稳定;值变化时通过同一个 source 发布。
- UI domain 之间只传 JSON 兼容数据和 callback。`hooks` compartment 是裸 observable 的唯一例外;React 内容通过 slot 传递。
- 将 `single` 和已有 occupant 的 keyed cell 视为替换点。增量扩展使用 list id 或尚未占用的 key。
- 将 `single` 和已有 occupant 的 keyed cell 视为替换点。增量扩展使用新的 list 或 hoc id,或尚未占用的 key。
+2 -2
View File
@@ -129,8 +129,8 @@
en: 07d595ce1f1a5184
zh: 07d595ce1f1a5184
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/apply-patch:
en: 882f2fa9fac236d6
zh: e8320796a5cb038f
en: 50b4939c9a1d96ac
zh: e733cc1156e2853c
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/edit:
en: 5b6a252adde51af6
zh: 7aa172a5d8b9cbd2
+2 -2
View File
@@ -1045,7 +1045,7 @@ Standalone view/create/unique literal replace/line insert tool over the filesyst
### `apply_patch`
Create, update, delete, or move UTF-8 text files with one Codex-style patch; prefer it over multiple edit calls when a change spans several files or separated regions.
Create, update, delete, or move UTF-8 text files with one patch; prefer it over multiple edit calls when a change spans several files or separated regions.
```json
{
@@ -1053,7 +1053,7 @@ Create, update, delete, or move UTF-8 text files with one Codex-style patch; pre
"properties": {
"input": {
"type": "string",
"description": "Codex patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory."
"description": "Patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory."
}
},
"required": [
+2 -2
View File
@@ -1051,7 +1051,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `apply_patch`
用一个 Codex 风格补丁新增、更新、删除或移动 UTF-8 文本文件;变更跨越多个文件或分离区域时,优先于多次 edit 调用。
用一个补丁新增、更新、删除或移动 UTF-8 文本文件;变更跨越多个文件或分离区域时,优先于多次 edit 调用。
```json
{
@@ -1059,7 +1059,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
"properties": {
"input": {
"type": "string",
"description": "Codex patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory."
"description": "Patch text: start with *** Begin Patch and end with *** End Patch. Use *** Add File: path with + lines; *** Update File: path, optionally followed by *** Move to: path, with @@ or @@ context and space-prefixed context, + added, or - removed lines; *** End of File anchors the last hunk to the file end. Use *** Delete File: path to delete. Paths are relative to the Session working directory."
}
},
"required": [
+1 -1
View File
@@ -339,7 +339,7 @@
- id: ui-chat
name: '@deepseek-ai/dsh-client-ui-chat'
# Official occupants for the generic sidebar and conversation brand slots.
# Official occupants for the sidebar brand slots and the home whale frame layer.
- id: ui-brand-official
name: '@deepseek-ai/dsh-client-ui-brand-official'
+1 -1
View File
@@ -82,7 +82,7 @@ function styleInjectionModule(
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-time|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-commands\/builtin$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|fs-patch|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-time|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-commands\/builtin$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
@@ -3,32 +3,35 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-brand-official/README.md
/:
en: a031cd88e552dc89
zh: 76c1af2393cb6983
en: a46a90b8cedd5c45
zh: 9b4f0d5fd9ffa945
/deepseek-ai-dsh-client-ui-brand-official:
en: 7cee1be833cea807
zh: 9953e1b38cb23d34
/deepseek-ai-dsh-client-ui-brand-official/summary:
en: cb6bf0a4eabd271b
zh: 181cf091ff44c194
en: 784b8216e88b0578
zh: 70165dea21d9a2ab
/deepseek-ai-dsh-client-ui-brand-official/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-brand-official/use-this-package:
en: 716dc268f98996db
zh: 7372a67ef81a0d5e
en: 8144a213ca978516
zh: 43dfefbd708d87bf
/deepseek-ai-dsh-client-ui-brand-official/use-this-package/choosing-the-profile:
en: 3f3f96d6adf98193
zh: 0b8691656a8532a0
en: 876e7a0e21eaa396
zh: 596fcdec565e3eae
/deepseek-ai-dsh-client-ui-brand-official/use-this-package/home-feedback:
en: f79f52c71a3d1491
zh: bda67c8b4050316a
/deepseek-ai-dsh-client-ui-brand-official/use-this-package/replacing-the-brand:
en: 6a923aa630d01d65
zh: 5df0c24eebd9d0d0
en: 3fd4b315b27cfa23
zh: 8f76a10d642a5811
/deepseek-ai-dsh-client-ui-brand-official/understand-the-implementation:
en: 4e4aa617edd7f4ad
zh: ea4e2d6d4a39535e
en: 4b59ceb74b5c9d1e
zh: c22bcc7d7344fe12
/deepseek-ai-dsh-client-ui-brand-official/further-exploration:
en: c9ae240d8e831cc0
zh: a7c9170bae79824f
en: 268cb9edf0c7d2f0
zh: 9b75285a61b44008
/deepseek-ai-dsh-client-ui-brand-official/model-experience:
en: 618008c7641070e7
zh: 7233febb4d92ccfb
@@ -36,8 +39,8 @@
en: ca75c51c89c2c9b0
zh: f57cc48a1b5cbfa9
/deepseek-ai-dsh-client-ui-brand-official/known-limitations-and-deferred-work:
en: 89e2b4bd095a0265
zh: a002f0c8a60267ad
en: b8f0ff2ff02bc0be
zh: 8f52f9dee0225fad
/deepseek-ai-dsh-client-ui-brand-official/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+16 -7
View File
@@ -1,5 +1,5 @@
---
description: "Official DeepSeek Harness brand occupants for the sidebar, active only in official builds; for users and maintainers choosing or replacing brand presentation."
description: "Official sidebar branding and the default click-triggered pixel whale on the home page."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
This package gives an `official` client build the DeepSeek Harness mark and name in the sidebar. Other build profiles keep the shell's fish mark and local-build label, while the conversation hero always uses the animated fish. Choose it for deployments branded as DeepSeek Harness; deployments with another identity should provide a replacement brand package. It has no runtime state and does not affect model requests.
This package supplies a click-triggered pixel whale on the blank home and gives an `official` client build the DeepSeek Harness sidebar artwork. The shipped Web and Desktop compositions load it by default. Other build profiles retain their existing branding. The hero mark stays on its animated fish fallback. The background owns only temporary canvas resources and adds no model requests.
## Table of Contents
@@ -25,15 +25,23 @@ This package gives an `official` client build the DeepSeek Harness mark and name
<a id="use-this-package"></a>
## Use this package
Mount this plugin in the browser roster of a deployment whose identity is DeepSeek's own, then build the client with the `official` profile so the occupants register.
The shipped Web and Desktop compositions already mount this plugin. Home feedback needs no extra installation or enablement; an `official` client build also registers the DeepSeek sidebar artwork.
### Choosing the profile
`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official mark and name in the sidebar; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The conversation hero shows the animated hero fish from `dsh-client-ui-conversation` regardless of profile, because that fallback is already the official mark. The plugin still loads and validates in both cases; only the registration is profile-gated.
`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official sidebar mark and name; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The conversation hero shows the animated hero fish from `dsh-client-ui-conversation` regardless of profile, because that fallback is already the official mark. The plugin still loads and validates in both cases; only the sidebar artwork registration is profile-gated; the home background is shared across build profiles.
### Home feedback
The default home stays clear until a primary click on empty space plays one light-gray whale breach. Successive completed clicks alternate between dots and code lettering, starting with dots. Both use the same large animated silhouette. The animation lasts 5.7 seconds, then clears. Repeated clicks leave the current playback uninterrupted. The whale's frame layer classifies clicks: blank space inside its frame triggers playback, including margins beside the input card, while input fields, controls, the input card, portaled overlays, and a click that ends a text selection never do. The canvas takes neither focus nor pointer events; the frame receives the clicks. On narrow homes the whale keeps a larger scale and is cropped at the canvas edges, without adding horizontal scrolling. Reduced-motion preference disables it, and leaving the blank home releases its animation and observers.
The whale uses very faint neutral theme ink, appearing as a subtle background texture; code lettering has more contrast than the dots. Water splashes retain fine particles with stronger ink, especially on the left during landing. A packaged frame atlas supplies the motion; the browser loads it once through the plugin asset route on the first eligible click. Code fills use dense 7px lettering drawn only from authored code slogans, and the moving silhouette clips text as it enters or leaves the water. Decode or pixel-read failures stop the decoration, leave home usable, and log a reload action.
The artwork values are fixed and the row takes no configuration. This is built-in presentation, without a separately installed bundle or Plugins-page toggle.
### Replacing the brand
A deployment with its own identity leaves this package out and composes another package that occupies the sidebar slots — and the hero slot, which this package leaves on its fallback. Occupying a slot is the only composition route; there is no brand configuration surface here.
A deployment with its own identity leaves this package out and composes another package that occupies the sidebar slots and the `conversation.hero.frame` layer — and the hero-mark slot, which this package leaves on its fallback. Occupying a slot is the only composition route; the artwork identity is not configurable.
-----
@@ -43,7 +51,7 @@ A deployment with its own identity leaves this package out and composes another
<details>
<summary>Implementation internals — click to expand</summary>
The two occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar declaration, so the set works whether this row activates before or after the declarer, withdraws both occupants when the declaration collapses, and leaves no partial brand mix during HMR. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system.
The two occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar declaration, so the set works whether this row activates before or after the declarer, withdraws both occupants when the declaration collapses, and leaves no partial brand mix during HMR. The independent `conversation.hero.frame` layer owns the canvas renderer and waits for its declaration through `ctx.slots.inject()`. While `hero` is true it paints the canvas at negative `z-index`, which the frame contract places above the Conversation background and below all Conversation content. It plays on primary clicks that reach its own frame outside controls and the composer card; clicks from portaled overlays and clicks that end a text selection inside the frame are ignored. It never changes the composer or conversation state. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The [frame atlas](src/client/assets/breach-atlas.webp) derives from the user-supplied [showcase video](https://yinghuo-hg.deepseek.com/wuyexinrui/dev-cpu/web-showcase/orca.mp4): 1.5–7.2 seconds at 24 frames per second, sampled to 192 × 108 cells and stored as lossless WebP. Its red and green channels encode dot radius and opacity at 32 levels; the renderer reads it once after the first eligible click. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system.
</details>
@@ -55,7 +63,7 @@ The two occupants install as one declaration-aware registration set: nested `ctx
Read these pages when the brand surface is not enough. They move from the slots this package occupies to the shell that renders them.
- [ui-sidebar](../ui-sidebar/README.md) — declares `sidebar.brand.mark` and `sidebar.brand.name` and renders their fallbacks.
- [ui-conversation](../ui-conversation/README.md) — declares `conversation.hero.brand.mark` in the hero.
- [ui-conversation](../ui-conversation/README.md) — declares `conversation.hero.brand.mark` in the hero and the `conversation.hero.frame` layer this package occupies.
- [Web client architecture](../../../docs/subsystems/web-client.md) — how browser plugin rows load and register slots.
-----
@@ -77,6 +85,7 @@ None; this package neither assembles nor sends a provider request.
These limits define how brand presentation is supplied. They are current package constraints, not a brand-design comparison or a task backlog.
- **One occupant set** — alternative presentation belongs in another Cordis package occupying the same slots.
- **Decorative rendering requires Canvas 2D** — unavailable contexts or reduced-motion preference leave the home clear.
- **The browser title is independent** — `DSH_CLIENT_TITLE` selects title text at build time rather than through a UI slot.
<a id="dev-note"></a>
+16 -7
View File
@@ -1,5 +1,5 @@
---
description: "面向侧栏的官方 DeepSeek Harness 品牌填充,仅在官方构建中生效;供选择或替换品牌呈现的用户与维护者阅读。"
description: "官方侧栏品牌与默认首页点击浮出的像素鲸鱼。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
本包让以 `official` profile 构建的客户端在侧栏显示 DeepSeek Harness 标志与名称。其他构建 profile 保留外壳的鱼形标志与本地构建标签,会话首屏则始终使用动画鱼。品牌为 DeepSeek Harness 的部署应选择本包;使用其他品牌的部署应提供替代品牌包。本包不保留运行时状态,也不影响模型请求。
本包在空白首页提供点击浮出的像素鲸鱼,并让以 `official` profile 构建的客户端显示 DeepSeek Harness 侧栏品牌图形。随产品提供的 Web 与 Desktop 组合默认加载本包。其他构建 profile 保留原有品牌。首屏标志仍使用动画鱼回退。背景只持有临时 Canvas 资源,不增加模型请求。
## 目录
@@ -25,15 +25,23 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
在采用 DeepSeek 自有品牌的部署中,将本插件挂载到浏览器插件名单,然后以 `official` profile 构建客户端,让填充得以注册。
随产品提供的 Web 与 Desktop 组合已挂载本插件。首页反馈不需要额外安装或启用;`official` 客户端构建还会注册 DeepSeek 侧栏图形。
### 选择 profile
`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建在侧栏显示官方标志与名称;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。会话首屏无论 profile 如何都显示来自 `dsh-client-ui-conversation` 的动画首屏鱼,因为这个回退本身就是官方标志。两种情况下插件都会照常加载并通过校验;只有注册受 profile 门控。
`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建显示官方侧栏标志和名称;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。会话首屏无论 profile 如何都显示来自 `dsh-client-ui-conversation` 的动画首屏鱼,因为这个回退本身就是官方标志。两种情况下插件都会照常加载并通过校验;只有侧栏图形注册受 profile 门控;各构建 profile 共用首页背景。
### 首页反馈
默认首页保持留白,主按钮点击空白处才播放一次浅灰色鲸鱼跃起。每次播放结束后的点击在点阵与代码填充之间交替,首次为点阵;两种填充使用相同的大幅动态轮廓。动画持续 5.7 秒,随后清空。连续点击不会打断当前播放。点击由鲸鱼的 frame 层自己判定:外框内部的留白可触发播放,包括输入卡片两侧的空白;输入框、控件、输入卡片、portal 出去的浮层以及结束文字选择的点击都不触发。Canvas 不获取焦点或指针事件,点击由外框接收。窄屏首页中的鲸鱼保留较大比例,在 Canvas 边缘裁切,不增加横向滚动。减少动态效果偏好会禁用它,离开空白首页时释放动画与观察器。
鲸鱼使用非常浅的主题中性色,呈现轻微的背景隐纹;代码文字的对比度略高于点阵。水花保留细小颗粒并使用较深的颜色,尤其是入水时左侧的水花。随包帧图提供动作,首次符合条件的点击时由浏览器经插件 asset 路由加载一次。代码填充使用密排的 7px 文字,内容只来自预设的代码标语,出水与入水时文本随动态轮廓裁切。解码或像素读取失败时停止装饰,首页仍可正常使用,并在日志中提示重新加载。
图形参数固定,本行不接受配置。这是内置呈现,没有需要单独安装的 Bundle 或插件页面开关。
### 替换品牌
自有身份的部署不组合本包,而是组合另一个占据侧栏 slot——以及本包留给回退的首屏 slot——的包。占据 slot 是唯一的组合路径;这里不存在任何品牌配置面。
自有身份的部署不组合本包,而是组合另一个占据侧栏 slot 和 `conversation.hero.frame` 层——以及本包留给回退的首屏标志 slot——的包。占据 slot 是唯一的组合路径;品牌图形身份不可配置。
-----
@@ -43,7 +51,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
两个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;声明消失时两个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是一个空 Loader 座位。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在 slot 系统之内。
两个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;声明消失时两个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。独立的 `conversation.hero.frame` 层持有 Canvas 绘制器,通过 `ctx.slots.inject()` 等待声明;`hero` 为 true 时它以负 `z-index` 绘制 Canvas,按 frame 层约定画在 Conversation 背景之上、所有 Conversation 内容之下。它在点击落到自身外框、且不在控件和输入卡片上时播放;portal 浮层的点击以及结束外框内文字选择的点击会被忽略。它不更改输入框或会话状态。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是空的 Loader 席位。[帧图](src/client/assets/breach-atlas.webp) 来自用户提供的[展示视频](https://yinghuo-hg.deepseek.com/wuyexinrui/dev-cpu/web-showcase/orca.mp4):截取 1.5–7.2 秒,以每秒 24 帧采样为 192 × 108 个单元,以无损 WebP 存储。红色与绿色通道以 32 级分别编码圆点半径和不透明度;绘制器在首次符合条件的点击后读取一次。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在 slot 系统之内。
</details>
@@ -55,7 +63,7 @@ kind: "package-reference"
当品牌面不够用时阅读以下页面。它们从本包占据的 slot 进入渲染这些 slot 的外壳。
- [ui-sidebar](../ui-sidebar/README.zh.md)——声明 `sidebar.brand.mark` 与 `sidebar.brand.name` 并渲染其回退。
- [ui-conversation](../ui-conversation/README.zh.md)——在首屏声明 `conversation.hero.brand.mark`。
- [ui-conversation](../ui-conversation/README.zh.md)——在首屏声明 `conversation.hero.brand.mark`,以及本包占用的 `conversation.hero.frame` 层。
- [Web 客户端架构](../../../docs/subsystems/web-client.zh.md)——浏览器插件行如何加载并注册 slot。
-----
@@ -77,6 +85,7 @@ kind: "package-reference"
这些限制界定了品牌呈现的供给方式。它们是当前包约束,不是品牌设计对比或任务积压。
- **只有一组填充**——替代呈现属于占据相同 slot 的另一个 Cordis 包。
- **装饰绘制需要 Canvas 2D**——上下文不可用或偏好减少动态效果时,首页保持留白。
- **浏览器标题独立**——`DSH_CLIENT_TITLE` 在构建时选择标题文本,而非通过 UI slot。
<a id="dev-note"></a>
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-brand-official",
"description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
"description": "Official DeepSeek Harness brand artwork and click-triggered home background",
"version": "0.2.2-alpha.3",
"publishConfig": {
"access": "public"
@@ -24,13 +24,15 @@
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
"./locale/*.json": "./locale/*.json",
"./assets/*": "./lib/assets/*"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-sidebar"
"@deepseek-ai/dsh-client-ui-sidebar",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
@@ -50,12 +52,15 @@
"@deepseek-ai/cordis": "workspace:~",
"@testing-library/react": "^16.3.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*"
},
"files": [
"locale/*.json",
"lib/index.js",
"lib/client.js",
"lib/assets",
"lib/types/**/*.d.ts"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

@@ -0,0 +1,20 @@
/* Layout-neutral: the wrapped scroll body stays a direct flex item of the Conversation body. */
.layer {
display: contents;
}
.canvas {
--home-whale-code-ink: var(--dsw-alias-label-primary);
/* The host isolates stacking at the Conversation root, so -1 lands above its background and below its content. */
position: absolute;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
display: block;
pointer-events: none;
opacity: 0.18;
color: var(--dsw-alias-label-secondary);
filter: grayscale(1);
font-family: var(--ds-font-family-code);
}
@@ -0,0 +1,53 @@
/** Home frame layer: a decorative canvas below the Conversation content, played by clicks on blank home space. */
import { useCallback, useRef, type MouseEvent, type ReactNode } from 'react'
import type { HeroFrameOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import atlasUrl from '../assets/breach-atlas.webp'
import { WHALE_ARTWORK } from './artwork.ts'
import { attachPixelWhale, type PixelWhaleHandle } from './renderer.ts'
import css from './PixelWhale.module.css'
/** Interactive descendants whose clicks belong to the control, not to blank home space. */
const CONTROLS = 'button, a, input, textarea, select, label, [contenteditable], [role="button"], [role="dialog"], [role="menu"], [role="listbox"], [data-composer-card]'
/** Frame owner state and the wrapped content. */
export type PixelWhaleProps = HeroFrameOwnerProps & { children: ReactNode }
/**
* Classify a click that bubbled to the frame. React bubbles portaled overlays through their React parent, so a
* target outside the frame's DOM subtree is excluded. A click that ends a text selection made inside the frame is
* not a blank click; a selection elsewhere in the document does not block playback.
* @param event - the click observed on the frame.
* @returns whether the click is a primary click on blank home space.
*/
function isBlankClick(event: MouseEvent<HTMLDivElement>): boolean {
// React reports element targets; text-node targets resolve to their parent element.
const target = event.target as Element
const frame = event.currentTarget
const selection = window.getSelection()
return event.button === 0
&& frame.contains(target)
&& target.closest(CONTROLS) === null
&& !(selection !== null && !selection.isCollapsed && frame.contains(selection.anchorNode))
}
/**
* Wrap the Conversation scroll body; while the home is shown, paint the whale canvas below the Conversation content.
* @param props - the home state and the wrapped scroll body.
* @returns a layout-neutral frame around the children.
*/
export function PixelWhale({ hero, children }: PixelWhaleProps) {
const whale = useRef<PixelWhaleHandle | undefined>(undefined)
const canvasRef = useCallback((canvas: HTMLCanvasElement | null) => {
whale.current?.dispose()
whale.current = canvas === null ? undefined : attachPixelWhale(canvas, WHALE_ARTWORK, atlasUrl)
}, [])
const onClick = (event: MouseEvent<HTMLDivElement>): void => {
if (whale.current !== undefined && isBlankClick(event)) whale.current.play()
}
return (
<div className={css.layer} onClick={onClick}>
{hero && <canvas ref={canvasRef} className={css.canvas} data-home-whale="" aria-hidden="true" />}
{children}
</div>
)
}
@@ -0,0 +1,30 @@
/** Fixed artwork choices for the official home whale. */
/** Visual parameters shared by the dot and code fills. */
export interface WhaleArtwork {
/** Maximum fill opacity, multiplied by each frame's water and highlight attenuation. */
activeOpacity: number
/** Word size in CSS pixels. */
fontSize: number
/** Authored artwork content, drawn verbatim as complete terms or sentences. */
phrases: readonly string[]
/** Maximum canvas redraw frequency. */
frameRate: number
/** One click's total lifetime in milliseconds. */
rippleDurationMs: number
}
/** The official artwork; these values are part of the design, not deployment settings. */
export const WHALE_ARTWORK: WhaleArtwork = {
activeOpacity: 0.65,
fontSize: 7,
phrases: [
'// Everything is a plugin', 'ctx.plugin(Agent)', 'await agent.run()',
'ctx.on("turn/end", next)', 'const tools = ctx.tools', 'await session.append(event)',
'return next()', 'ctx.effect(() => dispose)', 'const { skills } = ctx',
'yield tool.result', 'type Context = Harness', 'MCP.connect()',
'useSession()', 'const workspace = cwd', 'await runtime.start()', '// Creator Mode',
],
frameRate: 24,
rippleDurationMs: 5700,
}
@@ -0,0 +1,89 @@
/** Cached code lettering clipped by the same frames that drive the dotted whale. */
import type { WhaleArtwork } from './artwork.ts'
interface CodeFill {
resize(canvas: HTMLCanvasElement, width: number): void
paint(ctx: CanvasRenderingContext2D, pixels: Uint8ClampedArray, frame: number,
left: number, top: number, width: number, level: number): void
dispose(): void
}
/**
* Allocate lettering and alpha canvases for one home attachment.
* @param options - authored code phrases and font size.
* @param atlas - bundled frame geometry; green stores opacity.
* @returns a drawing cache, or undefined when Canvas 2D is unavailable.
*/
export function createCodeFill(
options: WhaleArtwork, atlas: { width: number; height: number; columns: number; frames: number },
): CodeFill | undefined {
const lettering = document.createElement('canvas')
const mask = document.createElement('canvas')
const ink = lettering.getContext('2d')
const matte = mask.getContext('2d')
if (ink === null || matte === null) return undefined
mask.width = atlas.width
mask.height = atlas.height
const alpha = matte.createImageData(atlas.width, atlas.height)
const stride = atlas.width * atlas.columns
function opacity(pixels: Uint8ClampedArray, frame: number, x: number, y: number): number {
const offsetX = (frame % atlas.columns) * atlas.width
const offsetY = Math.floor(frame / atlas.columns) * atlas.height
return pixels[((offsetY + y) * stride + offsetX + x) * 4 + 1] as number
}
return {
resize(canvas: HTMLCanvasElement, width: number): void {
const height = width * atlas.height / atlas.width
const dpr = Math.min(devicePixelRatio, 2)
lettering.width = Math.ceil(width * dpr)
lettering.height = Math.ceil(height * dpr)
ink.setTransform(dpr, 0, 0, dpr, 0, 0)
const style = getComputedStyle(canvas)
ink.fillStyle = style.getPropertyValue('--home-whale-code-ink')
ink.font = `500 ${options.fontSize}px ${style.fontFamily}`
ink.textBaseline = 'middle'
let seed = 0x9e3779b9
const random = () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
return seed / 0x1_0000_0000
}
const words: { text: string; x: number; y: number; room: number; alpha: number }[] = []
for (let y = options.fontSize; y < height; y += options.fontSize * 1.2) {
for (let x = -random() * options.fontSize * 10; x < width;) {
const text = options.phrases[Math.floor(random() * options.phrases.length)] as string
const room = ink.measureText(text).width
words.push({ text, x, y, room, alpha: 0.65 + random() * 0.35 })
x += room + options.fontSize * (0.35 + random() * 0.65)
}
}
for (const word of words) {
ink.globalAlpha = word.alpha
ink.fillText(word.text, word.x, word.y)
}
ink.globalAlpha = 1
},
paint(ctx: CanvasRenderingContext2D, pixels: Uint8ClampedArray, frame: number,
left: number, top: number, width: number, level: number): void {
for (let y = 0; y < atlas.height; y++) {
for (let x = 0; x < atlas.width; x++) {
alpha.data[(y * atlas.width + x) * 4 + 3] = opacity(pixels, frame, x, y)
}
}
matte.putImageData(alpha, 0, 0)
const height = width * atlas.height / atlas.width
ctx.globalAlpha = level
ctx.drawImage(lettering, left, top, width, height)
ctx.globalAlpha = 1
ctx.globalCompositeOperation = 'destination-in'
ctx.drawImage(mask, left, top, width, height)
ctx.globalCompositeOperation = 'source-over'
},
dispose(): void {
lettering.width = lettering.height = mask.width = mask.height = 0
},
}
}
@@ -0,0 +1,225 @@
/** Alternating dot and code breaches sharing one bundled animation atlas. */
import type { WhaleArtwork } from './artwork.ts'
import { createCodeFill } from './code-fill.ts'
// Geometry of assets/breach-atlas.webp: 137 frames at 192 × 108 cells, in twelve columns.
const ATLAS = { width: 192, height: 108, columns: 12, frames: 137 } as const
/** Control of one attached whale decoration. */
export interface PixelWhaleHandle {
/** Request one playback; ignored while playing, failed, hidden, or under reduced motion. */
play: () => void
/** Release frame work, atlas pixels, and observers; late atlas loads are ignored. */
dispose: () => void
}
const NO_WHALE: PixelWhaleHandle = { play: () => {}, dispose: () => {} }
/**
* Bind one noninteractive, initially transparent home decoration.
* Missing Canvas support returns a no-op handle, so home stays usable without decoration.
* An atlas failure stops decoration and logs a reload action.
* @param canvas - presentation-owned output, with its neutral color supplied by CSS.
* @param options - fixed opacity, lettering, frame rate, and duration artwork.
* @param atlasUrl - URL of the packaged frame atlas, loaded once on the first eligible playback.
* @returns the playback trigger and its teardown.
*/
export function attachPixelWhale(canvas: HTMLCanvasElement, options: WhaleArtwork, atlasUrl: string): PixelWhaleHandle {
const context = canvas.getContext('2d')
if (context === null) return NO_WHALE
const ctx = context
const image = new Image()
const media = matchMedia('(prefers-reduced-motion: reduce)')
let pixels: Uint8ClampedArray | undefined
let started: number | undefined
let loading = false
let disposed = false
let failed = false
let visible = true
let frame = 0
let layoutFrame = 0
let last = -Infinity
let width = 0
let height = 0
let sceneWidth = 0
let left = 0
let top = 0
let fill: 'pixels' | 'code' = 'code'
let code: ReturnType<typeof createCodeFill> | undefined
const moving = () => !media.matches
const drawable = () => !disposed && !document.hidden && visible && width > 0 && height > 0
function clear(): void {
ctx.clearRect(0, 0, width, height)
}
function stop(): void {
cancelAnimationFrame(frame)
frame = 0
started = undefined
clear()
}
function fail(error: unknown): void {
if (disposed) return
failed = true
stop()
console.warn('Home whale animation is unavailable; home remains usable. Reload the page to retry.', error)
}
function paint(now: number): void {
clear()
if (pixels === undefined || started === undefined) return
const progress = (now - started) / options.rippleDurationMs
if (progress >= 1) {
started = undefined
return
}
const index = Math.floor(progress * ATLAS.frames)
const offsetX = (index % ATLAS.columns) * ATLAS.width
const offsetY = Math.floor(index / ATLAS.columns) * ATLAS.height
const stride = ATLAS.width * ATLAS.columns
const cell = sceneWidth / ATLAS.width
const fade = Math.min(1, progress * 15, (1 - progress) * 10)
if (fill === 'code' && code !== undefined) {
code.paint(ctx, pixels, index, left, top, sceneWidth, Math.min(1, options.activeOpacity * 1.5) * fade)
return
}
for (let y = 0; y < ATLAS.height; y++) {
for (let x = 0; x < ATLAS.width; x++) {
const offset = ((offsetY + y) * stride + offsetX + x) * 4
const alpha = (pixels[offset + 1] as number) / 255
if (alpha < 0.015) continue
ctx.globalAlpha = alpha * options.activeOpacity * fade * 0.8
ctx.beginPath()
ctx.arc(left + (x + 0.5) * cell, top + (y + 0.5) * cell, (pixels[offset] as number) / 255 * cell, 0, Math.PI * 2)
ctx.fill()
}
}
ctx.globalAlpha = 1
}
function tick(now: number): void {
frame = 0
if (!drawable()) return
if (!moving()) { stop(); return }
if (now - last + 0.5 >= 1000 / options.frameRate) {
paint(now)
last = now
}
if (started !== undefined) frame = requestAnimationFrame(tick)
}
function resume(): void {
cancelAnimationFrame(frame)
frame = 0
if (disposed) return
if (!moving()) { stop(); return }
if (!drawable()) return
paint(performance.now())
last = performance.now()
if (started !== undefined) frame = requestAnimationFrame(tick)
}
function resize(): void {
width = canvas.clientWidth
height = canvas.clientHeight
const dpr = Math.min(devicePixelRatio, 2)
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.fillStyle = getComputedStyle(canvas).color
sceneWidth = Math.min(Math.max(width * 0.94, 720), height * 1.6, 1120) * 1.12
const sceneHeight = sceneWidth * ATLAS.height / ATLAS.width
left = (width - sceneWidth) / 2
top = height * 0.43 - sceneHeight / 2
if (code !== undefined && sceneWidth > 0) code.resize(canvas, sceneWidth)
resume()
}
// Layout redraws all code lettering, so bursts of size or theme changes collapse into one frame.
function scheduleResize(): void {
if (disposed || layoutFrame !== 0) return
layoutFrame = requestAnimationFrame(() => {
layoutFrame = 0
resize()
})
}
function begin(): void {
if (!moving() || !drawable()) return
if (fill === 'pixels') {
code ??= createCodeFill(options, ATLAS)
if (code === undefined) { fail(new Error('Home whale code Canvas context is unavailable')); return }
code.resize(canvas, sceneWidth)
}
fill = fill === 'pixels' ? 'code' : 'pixels'
started = performance.now()
resume()
}
image.onload = () => {
if (disposed) return
const source = document.createElement('canvas')
source.width = image.naturalWidth
source.height = image.naturalHeight
try {
if (source.width !== ATLAS.width * ATLAS.columns
|| source.height !== ATLAS.height * Math.ceil(ATLAS.frames / ATLAS.columns)) {
throw new Error('Home whale atlas dimensions do not match its frame geometry')
}
const reader = source.getContext('2d')
if (reader === null) { fail(new Error('Home whale atlas Canvas context is unavailable')); return }
reader.drawImage(image, 0, 0)
pixels = reader.getImageData(0, 0, source.width, source.height).data
} catch (error) {
fail(error)
return
} finally {
source.width = source.height = 0
image.removeAttribute('src')
}
begin()
}
image.onerror = () => { fail(new Error('Home whale atlas could not be decoded')) }
function play(): void {
if (failed || !moving() || !drawable() || started !== undefined) return
if (pixels !== undefined) { begin(); return }
if (loading) return
loading = true
image.src = atlasUrl
}
const sizeObserver = new ResizeObserver(scheduleResize)
const visibilityObserver = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? false
resume()
})
const themeObserver = new MutationObserver(scheduleResize)
sizeObserver.observe(canvas)
visibilityObserver.observe(canvas)
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'style'] })
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-ds-theme-source', 'style'] })
document.addEventListener('visibilitychange', resume)
media.addEventListener('change', resume)
resize()
const dispose = (): void => {
disposed = true
stop()
cancelAnimationFrame(layoutFrame)
layoutFrame = 0
image.onload = image.onerror = null
image.removeAttribute('src')
pixels = undefined
code?.dispose()
code = undefined
sizeObserver.disconnect()
visibilityObserver.disconnect()
themeObserver.disconnect()
document.removeEventListener('visibilitychange', resume)
media.removeEventListener('change', resume)
}
return { play, dispose }
}
@@ -1,19 +1,23 @@
/** Official DeepSeek Harness occupants for the generic browser-brand slots. */
/** Official DeepSeek Harness occupants: the profile-gated sidebar brand slots and the home whale frame layer. */
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PixelWhale } from './home-whale/PixelWhale.tsx'
import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx'
/** Required service: the UI slot registry. */
export const inject = ['slots']
/**
* Fill the sidebar brand slots as one declaration-aware registration set. The
* conversation hero stays on its declaring package's animated fish fallback,
* so the official build registers nothing there.
* Fill official sidebar artwork and frame the blank home with the whale decoration.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.slots.inject('conversation.hero.frame', () => ctx.slots.register({
name: 'conversation.hero.frame',
id: 'pixel-whale',
}, PixelWhale))
if (process.env.DSH_CLIENT_BUILD_PROFILE !== 'official') return
ctx.slots.inject('sidebar.brand.mark', () =>
ctx.slots.inject('sidebar.brand.name', function* () {
@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
@@ -10,11 +10,14 @@ import { apply as hostApply } from '../src/index.ts'
afterEach(() => {
cleanup()
vi.unstubAllEnvs()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
const HOLES = [
'sidebar.brand.mark',
'sidebar.brand.name',
'conversation.hero.frame',
] as const
const HERO_HOLE = 'conversation.hero.brand.mark'
@@ -25,7 +28,10 @@ async function bench(declare = true) {
const slots = ctx.get('slots') as SlotRegistry
const declareHoles = () => slots.register({
name: 'root',
children: Object.fromEntries([...HOLES, HERO_HOLE].map(name => [name, { kind: 'single', scope: 'root' }])),
children: Object.fromEntries([...HOLES, HERO_HOLE].map(name => [name, {
kind: name === 'conversation.hero.frame' ? 'hoc' : 'single',
scope: 'root',
}])),
} as never, () => null)
const disposeHoles = declare ? declareHoles() : undefined
return { ctx, slots, declareHoles, disposeHoles }
@@ -40,11 +46,13 @@ describe('official browser-brand plugin', () => {
expect(inject).toEqual(['slots'])
})
it('leaves every slot empty outside the official build profile', async () => {
it('keeps local sidebar branding and provides the same blank-home feedback', async () => {
vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'local')
const subject = await bench()
await subject.ctx.plugin({ inject: [...inject], apply }).await()
for (const hole of HOLES) expect(subject.slots.entries(hole)).toHaveLength(0)
expect(subject.slots.entries('sidebar.brand.mark')).toHaveLength(0)
expect(subject.slots.entries('sidebar.brand.name')).toHaveLength(0)
expect(subject.slots.entries('conversation.hero.frame')).toHaveLength(1)
})
it('fills declarations before or after apply and removes every occupant on teardown', async () => {
@@ -78,6 +86,21 @@ describe('official browser-brand plugin', () => {
expect(subject.slots.entries(HERO_HOLE)).toHaveLength(0)
})
it('registers the whale as a self-contained frame layer without replacing the hero mark', async () => {
vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'official')
const subject = await bench()
const fiber = subject.ctx.plugin({ inject: [...inject], apply })
try {
await fiber.await()
const [entry] = subject.slots.entries('conversation.hero.frame')
expect(entry?.options.id).toBe('pixel-whale')
expect(entry?.inject).toBeUndefined()
expect(subject.slots.entries(HERO_HOLE)).toHaveLength(0)
} finally {
await fiber.dispose()
}
})
it('renders the official name independently from both requested mark sizes', () => {
const name = render(<OfficialBrandName />)
expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24')
@@ -0,0 +1,320 @@
// @vitest-environment jsdom
import { cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WHALE_ARTWORK, type WhaleArtwork } from '../src/client/home-whale/artwork.ts'
import { attachPixelWhale } from '../src/client/home-whale/renderer.ts'
const releases: (() => void)[] = []
afterEach(() => {
for (const release of releases.splice(0)) release()
cleanup()
document.body.replaceChildren()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function scene(options: WhaleArtwork = WHALE_ARTWORK, missingContext = -1) {
let now = 0
let nextFrame = 1
const frames = new Map<number, FrameRequestCallback>()
const pixels = new Uint8ClampedArray(2304 * 1296 * 4)
for (let i = 0; i < 137; i++) {
const offset = (Math.floor(i / 12) * 108 * 2304 + (i % 12) * 192) * 4
pixels[offset] = 90
pixels[offset + 1] = 180
}
const drawing = () => ({
clearRect: vi.fn(), drawImage: vi.fn(), beginPath: vi.fn(), arc: vi.fn(), fill: vi.fn(), setTransform: vi.fn(),
fillText: vi.fn<(text: string, x: number, y: number) => void>(),
measureText: vi.fn((text: string) => ({ width: text.length * 6 }) as TextMetrics),
createImageData: vi.fn((size: number | ImageData, rows?: number) => {
const width = typeof size === 'number' ? size : size.width
const height = typeof size === 'number' ? rows ?? 0 : size.height
return { data: new Uint8ClampedArray(width * height * 4), width, height, colorSpace: 'srgb' as const }
}),
putImageData: vi.fn<(data: ImageData, x: number, y: number) => void>(),
getImageData: vi.fn(() => ({ data: pixels, width: 2304, height: 1296, colorSpace: 'srgb' as const })),
globalAlpha: 1,
})
const contexts: ReturnType<typeof drawing>[] = []
const canvases: HTMLCanvasElement[] = []
const image = document.createElement('img')
Object.defineProperties(image, { naturalWidth: { value: 2304, configurable: true }, naturalHeight: { value: 1296 } })
vi.stubGlobal('Image', function () { return image })
const media = Object.assign(new EventTarget(), { matches: false })
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
const disposers: ReturnType<typeof vi.fn>[] = []
let onResize = () => {}
let onTheme = () => {}
let onVisibility: (entries: { isIntersecting: boolean }[]) => void = () => {}
vi.spyOn(performance, 'now').mockImplementation(() => now)
const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function (this: HTMLCanvasElement) {
const context = drawing()
const partial: Partial<CanvasRenderingContext2D> = context
contexts.push(context)
canvases.push(this)
return contexts.length - 1 === missingContext ? null : partial as CanvasRenderingContext2D
})
vi.stubGlobal('devicePixelRatio', 3)
vi.stubGlobal('matchMedia', () => media)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextFrame++
frames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) })
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { onResize = callback; disposers.push(this.disconnect) }
observe() {}
disconnect = vi.fn()
})
vi.stubGlobal('IntersectionObserver', class {
constructor(callback: typeof onVisibility) { onVisibility = callback; disposers.push(this.disconnect) }
observe() {}
disconnect = vi.fn()
})
vi.stubGlobal('MutationObserver', class {
constructor(callback: () => void) { onTheme = callback; disposers.push(this.disconnect) }
observe() {}
disconnect = vi.fn()
})
const surface = document.createElement('div')
const canvas = document.createElement('canvas')
let width = 1200
Object.defineProperties(canvas, {
clientWidth: { get: () => width }, clientHeight: { get: () => 900 },
})
vi.spyOn(canvas, 'getBoundingClientRect').mockImplementation(() => new DOMRect(10, 20, width, 900))
surface.append(canvas)
document.body.append(surface)
const whale = attachPixelWhale(canvas, options, 'data:image/webp;base64,fixture')
const dispose = whale.dispose
releases.push(dispose)
const click = whale.play
function step(time: number) {
now = time
const pending = [...frames.values()]
frames.clear()
for (const callback of pending) callback(time)
}
return {
canvas, surface, contexts, canvases, image, media, hidden, disposers, frames,
dispose, click, step, getContext, pixels,
load: () => image.dispatchEvent(new Event('load')),
theme: () => { onTheme() },
width: (value: number) => { width = value; onResize() },
visibility: (entries: { isIntersecting: boolean }[]) => { onVisibility(entries) },
}
}
describe('official home pixel whale', () => {
it('alternates dots and code on completed clicks, without interrupting or automatically repeating', () => {
const s = scene()
const output = s.contexts[0]!
expect(s.image.hasAttribute('src')).toBe(false)
expect(s.frames.size).toBe(0)
s.click()
expect(s.image.getAttribute('src')).toBe('data:image/webp;base64,fixture')
s.click()
expect(s.frames.size).toBe(0)
s.load()
expect(s.canvases[1]!.width).toBe(0)
s.step(1500)
expect(output.arc).toHaveBeenCalled()
expect(output.globalAlpha).toBe(1)
s.click()
s.step(5750)
expect(s.frames.size).toBe(0)
const draws = output.arc.mock.calls.length
s.step(6000)
expect(output.arc).toHaveBeenCalledTimes(draws)
s.click()
s.step(6100)
expect(output.arc).toHaveBeenCalledTimes(draws)
expect(output.drawImage).toHaveBeenCalled()
expect(s.contexts[2]!.fillText).toHaveBeenCalled()
expect(s.contexts).toHaveLength(4)
s.step(12000)
expect(s.frames.size).toBe(0)
s.click(); s.step(13000)
expect(output.arc.mock.calls.length).toBeGreaterThan(draws)
s.dispose()
for (const canvas of s.canvases.slice(2)) expect(canvas.width).toBe(0)
})
it('fills the code whale only with authored snippets', () => {
const options = { ...WHALE_ARTWORK, phrases: ['ctx.effect(() => dispose)', 'await agent.run()'] }
const s = scene(options)
for (let y = 15; y < 80; y++) {
for (let x = 20; x < 165; x++) s.pixels[((3 * 108 + y) * 2304 + x) * 4 + 1] = 180
}
s.click(); s.load(); s.step(5800)
s.click(); s.step(7300)
const texts = s.contexts[2]!.fillText.mock.calls.map(call => call[0])
expect(texts.length).toBeGreaterThan(0)
expect(texts.every(text => options.phrases.includes(text))).toBe(true)
const mask = s.contexts[3]!.putImageData.mock.calls.at(-1)![0].data
expect(mask[3]).toBe(180)
expect(mask[7]).toBe(0)
const count = s.contexts[2]!.fillText.mock.calls.length
s.step(7400)
expect(s.contexts[2]!.fillText).toHaveBeenCalledTimes(count)
})
it('plays only when its handle requests it', () => {
const s = scene()
s.surface.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 200 }))
expect(s.image.hasAttribute('src')).toBe(false)
s.click()
expect(s.image.hasAttribute('src')).toBe(true)
})
it('keeps a large breach in narrow homes and clips both fills within the canvas', () => {
const s = scene()
s.width(390)
s.step(16)
s.click(); s.load(); s.step(1500)
const dot = s.contexts[0]!.arc.mock.calls.at(-1)!
expect(dot[0]).toBeLessThan(0)
expect(s.canvas.width).toBe(780)
s.step(5800); s.click(); s.step(7300)
const image = s.contexts[0]!.drawImage.mock.calls.at(-1)!
expect(image[1]).toBeLessThan(0)
expect(image[3]).toBeGreaterThan(390)
expect(s.canvas.width).toBe(780)
})
it('stops hidden work, expires elapsed animations, and clears when reduced motion is enabled', () => {
const s = scene()
s.click(); s.load(); s.step(100)
s.hidden.mockReturnValue(true)
s.step(200)
expect(s.frames.size).toBe(0)
s.hidden.mockReturnValue(false)
document.dispatchEvent(new Event('visibilitychange'))
expect(s.frames.size).toBe(1)
s.visibility([])
expect(s.frames.size).toBe(0)
s.visibility([{ isIntersecting: true }])
expect(s.frames.size).toBe(1)
s.media.matches = true
s.media.dispatchEvent(new Event('change'))
expect(s.frames.size).toBe(0)
s.click()
s.media.matches = false
s.media.dispatchEvent(new Event('change'))
expect(s.frames.size).toBe(0)
s.click()
s.media.matches = true
s.step(400)
expect(s.frames.size).toBe(0)
s.media.matches = false
s.click()
s.visibility([])
s.step(10000)
s.visibility([{ isIntersecting: true }])
expect(s.frames.size).toBe(0)
})
it('does not start a late load after motion is disabled, but allows a subsequent explicit click', () => {
const s = scene()
s.click()
s.media.matches = true
s.load()
expect(s.frames.size).toBe(0)
s.media.matches = false
s.click()
expect(s.frames.size).toBe(1)
})
it('redraws for theme and size changes without decoding the atlas again', () => {
const s = scene()
s.click(); s.load(); s.step(100)
const draws = s.contexts[0]!.arc.mock.calls.length
s.theme()
s.theme()
expect(s.contexts[0]!.arc.mock.calls.length).toBe(draws)
s.step(150)
expect(s.contexts[0]!.arc.mock.calls.length).toBeGreaterThan(draws)
s.width(0)
s.step(200)
expect(s.frames.size).toBe(0)
s.width(390)
expect(s.canvas.width).not.toBe(780)
s.step(250)
expect(s.canvas.width).toBe(780)
expect(s.contexts).toHaveLength(2)
})
it('throttles to the artwork frame rate and relays out the code fill on resize', () => {
const s = scene()
s.click(); s.load(); s.step(100)
const draws = s.contexts[0]!.arc.mock.calls.length
s.step(110)
expect(s.contexts[0]!.arc).toHaveBeenCalledTimes(draws)
s.step(5800); s.click(); s.step(7300)
const lettering = s.contexts[2]!
const layouts = lettering.setTransform.mock.calls.length
s.width(900)
s.step(7320)
expect(lettering.setTransform.mock.calls.length).toBeGreaterThan(layouts)
})
it('stops decoration when the code fill cannot allocate its canvases', () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
const s = scene(WHALE_ARTWORK, 2)
s.click(); s.load(); s.step(5800)
s.click(); s.step(7300)
expect(warning).toHaveBeenCalledOnce()
expect(s.frames.size).toBe(0)
})
it('cancels frames and rejects callbacks retained before disposal', () => {
const s = scene()
s.click()
const lateLoad = s.image.onload!
const lateError = s.image.onerror!
s.dispose()
lateLoad.call(s.image, new Event('load'))
lateError.call(s.image, new Event('error'))
s.theme()
s.visibility([{ isIntersecting: true }])
s.click()
expect(s.frames.size).toBe(0)
expect(s.contexts).toHaveLength(1)
expect(s.image.hasAttribute('src')).toBe(false)
for (const disconnect of s.disposers) expect(disconnect).toHaveBeenCalledOnce()
})
it('leaves home usable without Canvas through an inert handle', () => {
const s = scene(WHALE_ARTWORK, 0)
s.click()
s.dispose()
expect(s.frames.size).toBe(0)
expect(s.disposers).toHaveLength(0)
expect(s.image.hasAttribute('src')).toBe(false)
})
it.each(['decode', 'geometry', 'context', 'pixels'] as const)('contains %s failure and does not retry until remount', (failure) => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
const s = scene(WHALE_ARTWORK, failure === 'context' ? 1 : -1)
s.click()
if (failure === 'decode') s.image.dispatchEvent(new Event('error'))
else {
if (failure === 'geometry') Object.defineProperty(s.image, 'naturalWidth', { value: 1 })
if (failure === 'pixels') {
const original = s.getContext.getMockImplementation()!
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function (this: HTMLCanvasElement) {
const ctx = original.call(this, '2d') as CanvasRenderingContext2D
ctx.getImageData = () => { throw new Error('Unreadable atlas') }
return ctx
})
}
s.load()
}
expect(warning).toHaveBeenCalledOnce()
s.click(); s.step(100)
expect(s.frames.size).toBe(0)
expect(s.contexts[0]!.arc).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createPortal } from 'react-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WHALE_ARTWORK } from '../src/client/home-whale/artwork.ts'
import { PixelWhale } from '../src/client/home-whale/PixelWhale.tsx'
import { attachPixelWhale } from '../src/client/home-whale/renderer.ts'
vi.mock('../src/client/home-whale/renderer.ts', () => ({ attachPixelWhale: vi.fn() }))
const attach = vi.mocked(attachPixelWhale)
function handle() {
const whale = { play: vi.fn(), dispose: vi.fn() }
attach.mockReturnValueOnce(whale)
return whale
}
afterEach(() => {
vi.restoreAllMocks()
cleanup()
document.body.replaceChildren()
attach.mockReset()
})
describe('pixel whale frame layer', () => {
it('attaches the artwork to a canvas before its content and releases it on unmount', () => {
const whale = handle()
const view = render(<PixelWhale hero><p>content</p></PixelWhale>)
const canvas = view.container.querySelector('canvas')
expect(canvas?.getAttribute('aria-hidden')).toBe('true')
expect(canvas?.nextElementSibling?.textContent).toBe('content')
expect(attach).toHaveBeenCalledExactlyOnceWith(canvas, WHALE_ARTWORK, expect.stringContaining('breach-atlas'))
view.rerender(<PixelWhale hero><p>updated</p></PixelWhale>)
expect(attach).toHaveBeenCalledOnce()
view.unmount()
expect(whale.dispose).toHaveBeenCalledOnce()
})
it('wraps its content without decoration outside the blank home', () => {
const whale = handle()
const view = render(<PixelWhale hero><p>content</p></PixelWhale>)
view.rerender(<PixelWhale hero={false}><p>content</p></PixelWhale>)
expect(view.container.querySelector('canvas')).toBeNull()
expect(view.getByText('content')).toBeTruthy()
expect(whale.dispose).toHaveBeenCalledOnce()
fireEvent.click(view.getByText('content'))
expect(whale.play).not.toHaveBeenCalled()
})
it('plays only for primary clicks on blank space inside its own frame', () => {
const whale = handle()
const overlay = document.createElement('div')
document.body.append(overlay)
const view = render(
<PixelWhale hero>
<section data-testid="blank">
<button type="button">control</button>
<div data-composer-card=""><span>card</span></div>
{createPortal(<span>overlay</span>, overlay)}
</section>
</PixelWhale>,
)
fireEvent.click(view.getByText('control'))
fireEvent.click(view.getByText('card'))
fireEvent.click(view.getByText('overlay'))
fireEvent.click(view.getByTestId('blank'), { button: 2 })
expect(whale.play).not.toHaveBeenCalled()
fireEvent.click(view.getByTestId('blank'))
expect(whale.play).toHaveBeenCalledOnce()
})
it('does not play when the click ends a text selection made inside its frame', () => {
const whale = handle()
const outside = document.createElement('p')
outside.textContent = 'elsewhere'
document.body.append(outside)
const view = render(<PixelWhale hero><p data-testid="text">selectable headline</p></PixelWhale>)
const text = view.getByTestId('text')
const selection = window.getSelection()!
selection.selectAllChildren(text)
fireEvent.click(text)
expect(whale.play).not.toHaveBeenCalled()
// A selection left elsewhere in the document does not block playback.
selection.selectAllChildren(outside)
fireEvent.click(text)
expect(whale.play).toHaveBeenCalledOnce()
selection.removeAllRanges()
fireEvent.click(text)
expect(whale.play).toHaveBeenCalledTimes(2)
})
it('plays when the document exposes no selection', () => {
const whale = handle()
vi.spyOn(window, 'getSelection').mockReturnValue(null)
const view = render(<PixelWhale hero><p data-testid="text">headline</p></PixelWhale>)
fireEvent.click(view.getByTestId('text'))
expect(whale.play).toHaveBeenCalledOnce()
})
})
@@ -22,6 +22,9 @@
},
{
"path": "../ui-slots"
},
{
"path": "../ui-conversation/tsconfig.client.json"
}
]
}
@@ -21,8 +21,8 @@
en: cc4dafd1aae02468
zh: be6b67724b7ac4a3
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 04b3ff9e2bdd5eb2
zh: 38daec55d2e75ac3
en: c11cc27a337174f3
zh: a971ef53fa6046a2
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: 59cd63057dfb6eeb
zh: b2e2c86e810b7bb8
+3 -1
View File
@@ -68,6 +68,8 @@ The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinat
<a id="shell-and-standard-props"></a>
## Shell and standard props
The root-scoped `conversation.hero.frame` hoc Slot wraps the scroll body in every phase, so its layers never remount the composer when the home ends. Its owner props carry `hero`, true only for the blank home of a standalone Conversation. Layers keep the scroll body a direct flex item of the body, for example with `display: contents`. On the home, the Conversation root, which paints the background, isolates stacking: a layer decoration at negative `z-index` paints above that background and below all Conversation content, including composer takeovers. Other phases leave the root out of the stacking order, so unportaled Conversation popovers can still rise above AppFrame chrome that floats over the column. Layers own their own input handling. With no occupant, the body is unchanged.
Below 768px, Web view tabs align with the header's 20px left inset. Desktop and wider Web windows retain the additional 8px tab inset.
The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox.
@@ -106,7 +108,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis
The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer owns its full footer band, which is not a resize target; a captured drag continues under the band and under gutter tables, which cover its indicator. In an active transcript the composer rests at the scrollport floor even when no View fills the column, keeps one stacking rank in every state, above transcript sticky headers and the back-to-bottom control, so menus it opens upward cover that control, and never serves as a scroll-anchoring target. It keeps its own compositor layer whatever lies beneath it, which makes it the containing block for fixed-position descendants: contributions to `conversation.composer`, `conversation.composer.bar`, `conversation.composer.dock`, `conversation.input.dock`, and the other `conversation.input.*` slots must portal fixed overlays to the document body, for example with `Tooltip` or `Menu` `portal` or `createPortal` ([handle and composer styles](src/client/skeleton/ConversationRoot.module.css)).
Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The handle scrolls the first `[data-conversation-scroll]` element inside the body, which is the body's own scroll element even when frame layers wrap it. The sticky composer owns its full footer band, which is not a resize target; a captured drag continues under the band and under gutter tables, which cover its indicator. In an active transcript the composer rests at the scrollport floor even when no View fills the column, keeps one stacking rank in every state, above transcript sticky headers and the back-to-bottom control, so menus it opens upward cover that control, and never serves as a scroll-anchoring target. It keeps its own compositor layer whatever lies beneath it, which makes it the containing block for fixed-position descendants: contributions to `conversation.composer`, `conversation.composer.bar`, `conversation.composer.dock`, `conversation.input.dock`, and the other `conversation.input.*` slots must portal fixed overlays to the document body, for example with `Tooltip` or `Menu` `portal` or `createPortal` ([handle and composer styles](src/client/skeleton/ConversationRoot.module.css)).
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
+3 -1
View File
@@ -68,6 +68,8 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
<a id="shell-and-standard-props"></a>
## Shell 与标准 props
root 作用域的 hoc Slot `conversation.hero.frame` 在所有阶段都包裹滚动区,因此首页结束时各层不会让输入框重新挂载。它的所有者 props 提供 `hero`,仅在独立 Conversation 的空白首页时为 true。各层需让滚动区仍是 body 的直接 flex 子项,例如使用 `display: contents`。首页阶段,绘制背景的 Conversation 根元素隔离层叠上下文:层中负 `z-index` 的装饰画在该背景之上、所有 Conversation 内容之下,包括输入区的接管面板。其他阶段根元素不参与层叠隔离,未经 portal 的 Conversation 浮层仍能盖住悬浮在中栏上方的 AppFrame 元素。各层自行处理输入。没有占用者时,body 保持原样。
宽度小于 768px 时,Web 的 View 标签与页头的 20px 左内边距对齐。桌面客户端和较宽的 Web 窗口保留标签额外的 8px 左内边距。
共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。
@@ -106,7 +108,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会在该区带和沟槽中的表格下方继续,此时指示线被它们遮住。在活跃 transcript 中,即使没有 View 撑满整列,composer 也停在滚动区底部;它在所有状态下保持同一层级,高于 transcript 的吸顶标题与“回到底部”按钮,因此它向上打开的菜单会盖住该按钮;它也从不作为滚动锚定目标。无论下方是什么内容,它都保持自己的合成层,因此成为 fixed 定位后代的包含块:向 `conversation.composer`、`conversation.composer.bar`、`conversation.composer.dock`、`conversation.input.dock` 以及其他 `conversation.input.*` slot 贡献的内容,必须把 fixed 浮层 portal 到 document body,例如使用 `Tooltip` 或 `Menu` 的 `portal`,或 `createPortal`([拖动手柄与 composer 样式](src/client/skeleton/ConversationRoot.module.css))。
主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。拖拽条滚动的是 body 内第一个 `[data-conversation-scroll]` 元素;即使有 frame 层包在外面,它也是 body 自己的滚动区。粘滞 composer 拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会在该区带和沟槽中的表格下方继续,此时指示线被它们遮住。在活跃 transcript 中,即使没有 View 撑满整列,composer 也停在滚动区底部;它在所有状态下保持同一层级,高于 transcript 的吸顶标题与“回到底部”按钮,因此它向上打开的菜单会盖住该按钮;它也从不作为滚动锚定目标。无论下方是什么内容,它都保持自己的合成层,因此成为 fixed 定位后代的包含块:向 `conversation.composer`、`conversation.composer.bar`、`conversation.composer.dock`、`conversation.input.dock` 以及其他 `conversation.input.*` slot 贡献的内容,必须把 fixed 浮层 portal 到 document body,例如使用 `Tooltip` 或 `Menu` 的 `portal`,或 `createPortal`([拖动手柄与 composer 样式](src/client/skeleton/ConversationRoot.module.css))。
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
@@ -349,6 +349,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.hero.frame': { kind: 'hoc', scope: 'root' },
'conversation.hero.brand.mark': { kind: 'single', scope: 'root' },
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' },
@@ -283,6 +283,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/** Workspace picker shown by the blank-session Hero. */
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
/** Layers wrapping the Conversation scroll body; `hero` reports a blank, standalone home. */
'conversation.hero.frame': { kind: 'hoc'; scope: 'root'; owner: HeroFrameOwnerProps }
/** Brand mark shown before the blank-session headline. */
'conversation.hero.brand.mark': { kind: 'single'; scope: 'root'; owner: HeroBrandMarkOwnerProps }
/** Agent-preset control staged for a New Session. */
@@ -363,6 +365,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'conversation.composer': { kind: 'chain'; scope: 'session' }
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe' }
'conversation.input.dock': { kind: 'list'; scope: 'session' }
'conversation.hero.frame': { kind: 'hoc'; scope: 'root' }
'conversation.hero.brand.mark': { kind: 'single'; scope: 'root' }
'conversation.hero.workspace': { kind: 'single'; scope: 'root' }
'conversation.hero.agentPreset': { kind: 'single'; scope: 'session-maybe' }
@@ -616,7 +619,13 @@ export interface ComposerChainProps {
pendingInteraction: SessionPendingInteraction | undefined
}
/** Presentation props supplied to the blank-session brand mark. */
/** Conversation state exposed to scroll-body frame layers. */
export interface HeroFrameOwnerProps {
/** Whether the body shows the blank home of a standalone, non-embedded Conversation. */
hero: boolean
}
/** Brand-mark presentation selected by the blank home. */
export interface HeroBrandMarkOwnerProps {
/** Requested square edge in pixels. */
size: number
@@ -92,7 +92,7 @@ export type {
ConversationViewActionEntry, ConversationViewActionsOwnerProps, ConversationViewsProps,
ConversationWidthControlsInputProps, ConversationWidthControlsProps,
ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, HeroFrameOwnerProps, InputControlOwnerProps, InputZone,
MessageImageLabels, MessageImageLoader, MessageImageSource, MessageImagesContentInput, MessageImagesOwnerProps, UseConversation,
UserTextInput, UserTextInjected, UserTextNodeOwnerProps, UserTextNodeProps,
UseConversationViews,
@@ -332,7 +332,7 @@ export const zh = {
'tool.title.bash': '运行命令',
'tool.title.write': '写入',
'tool.title.edit': '编辑',
'tool.title.applyPatch': '应用补丁',
'tool.title.applyPatch': '编辑',
'tool.title.code': '代码',
'tool.title.generic': '工具调用',
'tool.title.inspect': '查询 Cordis 环境',
@@ -726,7 +726,7 @@ export const en = {
'tool.title.bash': 'Bash',
'tool.title.write': 'Write',
'tool.title.edit': 'Edit',
'tool.title.applyPatch': 'Apply patch',
'tool.title.applyPatch': 'Edit',
'tool.title.code': 'Code',
'tool.title.generic': 'Tool call',
'tool.title.inspect': 'Query Cordis environment',
@@ -1119,7 +1119,7 @@ export const ja = {
'tool.title.bash': 'Bash',
'tool.title.write': '書き込み',
'tool.title.edit': '編集',
'tool.title.applyPatch': 'パッチを適用',
'tool.title.applyPatch': '編集',
'tool.title.code': 'コード',
'tool.title.generic': 'ツール呼び出し',
'tool.title.inspect': 'Cordis 環境を照会',
@@ -1505,7 +1505,7 @@ export const es = {
'tool.title.bash': 'golpe',
'tool.title.write': 'escribir',
'tool.title.edit': 'Editar',
'tool.title.applyPatch': 'Aplicar parche',
'tool.title.applyPatch': 'Editar',
'tool.title.code': 'Código',
'tool.title.generic': 'llamada de herramienta',
'tool.title.inspect': 'Consulta entorno Cordis',
@@ -1898,7 +1898,7 @@ export const fr = {
'tool.title.bash': 'Frapper',
'tool.title.write': 'Écrire',
'tool.title.edit': 'Modifier',
'tool.title.applyPatch': 'Appliquer le patch',
'tool.title.applyPatch': 'Modifier',
'tool.title.code': 'Coder',
'tool.title.generic': "Appel d'outil",
'tool.title.inspect': "Interroger l'environnement Cordis",
@@ -2291,7 +2291,7 @@ export const de = {
'tool.title.bash': 'Bash',
'tool.title.write': 'Schreiben',
'tool.title.edit': 'Bearbeiten',
'tool.title.applyPatch': 'Patch anwenden',
'tool.title.applyPatch': 'Bearbeiten',
'tool.title.code': 'Code',
'tool.title.generic': 'Werkzeugaufruf',
'tool.title.inspect': 'Fragen Sie die Cordis-Umgebung ab',
@@ -2684,7 +2684,7 @@ export const ru = {
'tool.title.bash': 'Баш',
'tool.title.write': 'Написать',
'tool.title.edit': 'Редактировать',
'tool.title.applyPatch': 'Применить патч',
'tool.title.applyPatch': 'Редактировать',
'tool.title.code': 'Код',
'tool.title.generic': 'Вызов инструмента',
'tool.title.inspect': 'Запрос среды Cordis',
@@ -3077,7 +3077,7 @@ export const pt = {
'tool.title.bash': 'Bash',
'tool.title.write': 'Escreva',
'tool.title.edit': 'Editar',
'tool.title.applyPatch': 'Aplicar patch',
'tool.title.applyPatch': 'Editar',
'tool.title.code': 'Código',
'tool.title.generic': 'Chamada de ferramenta',
'tool.title.inspect': 'Consultar ambiente Cordis',
@@ -195,10 +195,15 @@ export function ConversationContent(props: ConversationContentProps) {
data-conversation-region="chat"
data-content-phase={phase}
>
<div className={css.scrollBody} data-conversation-scroll="">
{sessionId === undefined ? null : <Views />}
{composerSeat}
</div>
{/* The frame wraps the scroll body in every phase, so leaving the home keeps the composer mounted. */}
{renderSlot('conversation.hero.frame', { hero: hero && props.variant !== 'embedded' }, {
children: (
<div className={css.scrollBody} data-conversation-scroll="">
{sessionId === undefined ? null : <Views />}
{composerSeat}
</div>
),
})}
<WidthControls container={body} phase={phase} />
</div>
)
@@ -7,6 +7,13 @@
background: var(--dsw-alias-bg-base);
}
/* `conversation.hero.frame` contract on the home: layer decorations at negative z-index paint above this
background and below all Conversation content. Other phases keep the root out of the stacking order, so
unportaled Conversation popovers still rise above AppFrame chrome that floats over the column. */
.root[data-phase='hero'] {
isolation: isolate;
}
/* 76px with its rule: the height of the Sidebar's tab strip and header row
(ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules
meet at the column edge. The rows add up to it — 10px top inset, the 30px
@@ -106,8 +106,8 @@ function WidthHandle(props: {
const body = event.currentTarget.parentElement
/* v8 ignore next -- a width handle renders only inside the Conversation body. */
if (body === null) return
const scrollport = body.querySelector<HTMLElement>(':scope > [data-conversation-scroll]')
/* v8 ignore next -- the Conversation body always contains its direct scroll element. */
const scrollport = body.querySelector<HTMLElement>('[data-conversation-scroll]')
/* v8 ignore next -- the Conversation body always contains its scroll element. */
if (scrollport === null) return
if (event.ctrlKey || event.deltaY === 0) return
scrollport.scrollBy({ top: wheelDeltaY(event, scrollport) })
@@ -27,14 +27,13 @@ import { ConversationMainPanel } from '../src/client/skeleton/ConversationMainPa
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
import { conversationPhase } from '../src/client/contract/snapshot.ts'
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx'
import './control-row-dom.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type {
ComposerBarOwnerProps, ConversationContentInputProps, ConversationContentProps,
ConversationHeaderLineageOwnerProps, ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, ConversationSlotProps,
ConversationViewActionsOwnerProps, ConversationViewsProps,
ConversationViewActionsOwnerProps, ConversationViewsProps, HeroFrameOwnerProps,
} from '../src/client/contract/slots.ts'
import type { ViewTab } from '../src/client/contract/views.ts'
@@ -201,14 +200,20 @@ function mount(
/** Owner share handed to the two composer tool-row seats, per render. */
const seatOwners: { key: string; owner: unknown }[] = []
let pickerOwner: unknown
const frameOwners: HeroFrameOwnerProps[] = []
let actionsOwner: ConversationViewActionsOwnerProps | undefined
const setPinned = vi.fn<(id: string, pinned: boolean) => void>()
const renderSlot = ((key: string, owner: object, opts?: { only?: string; fallback?: ReactNode }) => {
const renderSlot = ((key: string, owner: object, opts?: { only?: string; fallback?: ReactNode; children?: ReactNode }) => {
slotCalls.push(key)
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
seatOwners.push({ key, owner })
}
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.hero.frame') {
// A hoc slot with no layers renders its content unchanged.
frameOwners.push(owner as HeroFrameOwnerProps)
return <div data-testid="hero-frame">{opts?.children}</div>
}
if (key === 'conversation.session.header.views.actions') {
actionsOwner = owner as ConversationViewActionsOwnerProps
return <button type="button" role="menuitem" onClick={actionsOwner.onDismiss}>Header view action</button>
@@ -401,6 +406,7 @@ function mount(
return {
view, store, wiring, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open,
pickerOwner: () => pickerOwner,
frameOwner: () => frameOwners.at(-1),
setPinned,
actionsOwner: () => {
if (actionsOwner === undefined) throw new Error('View actions not rendered')
@@ -412,8 +418,8 @@ function mount(
describe('Hero chrome', () => {
it('renders the English preview badge through the hero locale seat', () => {
const renderSlot = vi.fn<HeroShellProps['renderSlot']>(() => null)
const view = render(<HeroShell t={makeTranslate(en, commonEn)} renderSlot={renderSlot} />)
const renderSlot = vi.fn<(key: string, owner: object, opts?: { fallback?: ReactNode }) => ReactNode>(() => null)
const view = render(<HeroShell t={makeTranslate(en, commonEn)} renderSlot={renderSlot as never} />)
expect(view.getByText('Into the Unknown')).toBeTruthy()
expect(view.getByText('Preview')).toBeTruthy()
expect(renderSlot).toHaveBeenCalledOnce()
@@ -652,6 +658,8 @@ describe('ConversationRoot resident composer', () => {
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
const b = mount(sessionSnapshotOf({ blank: true }))
const frame = b.view.getByTestId('hero-frame')
expect(b.frameOwner()).toEqual({ hero: true })
const before = b.view.getByRole('textbox')
act(() => { b.wiring.setDraft('kept across flip') })
// First message landed: content exists, phase leaves blank. Composer
@@ -665,9 +673,19 @@ describe('ConversationRoot resident composer', () => {
expect(b.store.store.getSnapshot().draft).toEqual({ text: 'kept across flip', references: [] })
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByTestId('hero-headline')).toBeNull()
// The frame wraps the scroll body in every phase, so leaving the home does not remount it.
expect(b.view.getByTestId('hero-frame')).toBe(frame)
expect(frame.contains(after)).toBe(true)
expect(b.frameOwner()).toEqual({ hero: false })
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
it('wraps the scroll body in the hero frame', () => {
const b = mount(sessionSnapshotOf({ blank: true }))
const scroll = b.view.container.querySelector('[data-conversation-scroll]')
expect(scroll?.parentElement).toBe(b.view.getByTestId('hero-frame'))
})
it('keeps the Chat fallback selected by id when a view is inserted before it', () => {
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
@@ -1002,16 +1020,6 @@ describe('ConversationRoot resident composer', () => {
fireEvent.wheel(handle, { deltaY: 0, deltaMode: 0 })
fireEvent.wheel(handle, { ctrlKey: true, deltaY: 120, deltaMode: 0 })
expect(scrollBy).toHaveBeenCalledTimes(calls)
scrollport.removeAttribute('data-conversation-scroll')
const nestedScrollport = document.createElement('div')
nestedScrollport.setAttribute('data-conversation-scroll', '')
const nestedScrollBy = vi.fn()
Object.defineProperty(nestedScrollport, 'scrollBy', { value: nestedScrollBy, configurable: true })
scrollport.append(nestedScrollport)
fireEvent.wheel(handle, { deltaY: 120, deltaMode: 0 })
expect(scrollBy).toHaveBeenCalledTimes(calls)
expect(nestedScrollBy).not.toHaveBeenCalled()
})
it('does not check capture, measure, style, or schedule width-handle moves before dragging', () => {
@@ -8,7 +8,8 @@ import {
} from 'react'
import {
SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
type ChainRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace, type RenderFactorySlot, type RenderOpts,
type ChainRenderOpts, type HocRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace,
type RenderFactorySlot, type RenderOpts,
type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
type SlotEntryDef, type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type SlotSpec, type StandardSourceBinding,
type StoredEntry, type StoredFactory, type Translate,
@@ -31,7 +32,10 @@ interface BoundSlotInject {
readonly slotHookFactories?: SlotHookFactories | undefined
}
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
/** Type-erased outlet options: every kind's dispatch fields, validated by the typed render shares. */
type OutletOpts = RenderOpts & ChainRenderOpts & Partial<HocRenderOpts>
type RenderSlotBinding = (key: string, owner: object, opts?: OutletOpts) => ReactNode
type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
@@ -360,7 +364,7 @@ function useLocaleRevision(face: LocaleFace | undefined): number {
/**
* Entry-identity React keys for entry boundaries. An outlet renders one
* winner per position (single/keyed/list cell head, chain election) through
* winner per position (single/keyed/list/hoc cell head, chain election) through
* an error boundary; without a key, a boundary that failed on entry A would
* survive a winner change (re-election, shadowing fallback after an
* abdication, HMR re-registration) and keep a healthy entry B blacked out.
@@ -392,6 +396,39 @@ function sessionGenerationKeyOf(binding: ScopedStandardSourceBinding): number {
return key
}
/**
* An error thrown by the content a hoc layer wraps. Layer boundaries pass it
* through untouched, so content failures never retire or blame a layer; the
* hoc outlet rethrows the original error to the boundaries above it.
*/
class HocContentError extends Error {
readonly original: unknown
constructor(original: unknown) {
super('hoc slot content failed', { cause: original })
this.original = original
}
}
/** Wrap the content inside one hoc layer: its errors leave as {@link HocContentError}. */
class HocContentBoundary extends Component<{ children: ReactNode }> {
static getDerivedStateFromError(error: unknown): never {
throw error instanceof HocContentError ? error : new HocContentError(error)
}
override render(): ReactNode {
return this.props.children
}
}
/** Surround a hoc slot's composed layers: content errors continue upward as their original error. */
class HocOutletBoundary extends Component<{ children: ReactNode }> {
static getDerivedStateFromError(error: unknown): never {
throw error instanceof HocContentError ? error.original : error
}
override render(): ReactNode {
return this.props.children
}
}
/**
* Per-entry isolation: one registrant crashing (component render or inject
* factory) must not take down siblings. Assembly errors (missing providers)
@@ -400,14 +437,21 @@ function sessionGenerationKeyOf(binding: ScopedStandardSourceBinding): number {
* seam); for shadowing kinds the report abdicates the entry, the outlet
* re-renders onto the cell's next survivor, and this boundary's crash face
* only shows until that re-render lands (permanently once the cell is dry —
* the outlet then owns the crash face).
* the outlet then owns the crash face). A hoc layer supplies `crashed`, its
* inner content, which replaces the crash face so the failed layer is skipped.
*/
class SlotErrorBoundary extends Component<
{ slotKey: string; anchor?: SlotAnchorTag; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean }
{
slotKey: string
anchor?: SlotAnchorTag
crashed?: ReactNode
onEntryError: (error: unknown) => void
children: ReactNode
}, { failed: boolean }
> {
override state = { failed: false }
static getDerivedStateFromError(error: unknown): { failed: boolean } {
if (error instanceof SlotAssemblyError) throw error
if (error instanceof SlotAssemblyError || error instanceof HocContentError) throw error
return { failed: true }
}
override componentDidCatch(error: unknown): void {
@@ -416,7 +460,9 @@ class SlotErrorBoundary extends Component<
}
override render(): ReactNode {
const Anchor = this.props.anchor ?? 'div'
if (this.state.failed) return <Anchor data-slot-error={this.props.slotKey} />
if (this.state.failed) {
return this.props.crashed === undefined ? <Anchor data-slot-error={this.props.slotKey} /> : this.props.crashed
}
return this.props.children
}
}
@@ -600,7 +646,7 @@ function standardKit(
* the registration and renderSlot seams already proved their contracts.
*/
function ContextualEntry({
slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext,
slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext, children,
}: {
slotKey: string
Comp: FC<InjectedProps>
@@ -611,6 +657,7 @@ function ContextualEntry({
ownerProps: object
hookContext: unknown
hasHookContext: boolean
children?: ReactNode
}) {
const contextual = useMemo(
() => {
@@ -621,7 +668,7 @@ function ContextualEntry({
},
[hasHookContext, hookContext, slotInjected.slotHookFactories, slotKey, standard],
)
return <Comp {...kit} {...injected} {...slotInjected.props} {...contextual} {...ownerProps} />
return <Comp {...kit} {...injected} {...slotInjected.props} {...contextual} {...ownerProps}>{children}</Comp>
}
function renderEntry(
@@ -634,9 +681,11 @@ function renderEntry(
ownerProps: object,
hookContext: unknown,
hasHookContext: boolean,
children: ReactNode | undefined,
): ReactNode {
// `children` is a hoc layer's inner content; other kinds pass undefined.
if (slotInjected.slotHookFactories === undefined) {
return <Comp {...kit} {...injected} {...slotInjected.props} {...ownerProps} />
return <Comp {...kit} {...injected} {...slotInjected.props} {...ownerProps}>{children}</Comp>
}
return (
<ContextualEntry
@@ -649,42 +698,43 @@ function renderEntry(
ownerProps={ownerProps}
hookContext={hookContext}
hasHookContext={hasHookContext}
/>
>
{children}
</ContextualEntry>
)
}
function SessionEntry({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
/** Props shared by the scope-specific Entry Components; `children` is a hoc layer's inner content. */
interface EntryProps {
entry: StoredEntry
ownerProps: object
binding: StandardSourceBinding & { readonly key: string }
slotKey: string
slotInjected: BoundSlotInject
hookContext: unknown
hasHookContext: boolean
children?: ReactNode
}
function SessionEntry({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext, children }: EntryProps & {
binding: StandardSourceBinding & { readonly key: string }
}) {
const host = useHost()
const rootBinding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const { kit, standard, actions } = standardKit(host, entry, 'session', rootBinding, binding)
const injected = cachedSessionInject(entry, binding, actions)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext, children)
}
function SessionMaybeEntryBody({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
entry: StoredEntry
ownerProps: object
binding: StandardSourceBinding
slotKey: string
slotInjected: BoundSlotInject
hookContext: unknown
hasHookContext: boolean
}) {
function SessionMaybeEntryBody({
entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext, children,
}: EntryProps & { binding: StandardSourceBinding }) {
const host = useHost()
const rootBinding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', rootBinding, binding)
const injected = cachedSessionMaybeInject(entry, binding, actions)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext, children)
}
/**
@@ -700,14 +750,7 @@ function SessionMaybeEntryBody({ entry, ownerProps, binding, slotKey, slotInject
* that must SURVIVE a switch belongs in session-bound sources (machine,
* store, hooks) — the existing layering rule, now load-bearing.
*/
function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: {
entry: StoredEntry
ownerProps: object
slotKey: string
slotInjected: BoundSlotInject
hookContext: unknown
hasHookContext: boolean
}) {
function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext, children }: EntryProps) {
const binding = useScopeBinding()
const epoch = useMaybeIncarnation(binding)
return (
@@ -720,7 +763,9 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
slotInjected={slotInjected}
hookContext={hookContext}
hasHookContext={hasHookContext}
/>
>
{children}
</SessionMaybeEntryBody>
)
}
@@ -764,20 +809,13 @@ function useMaybeIncarnation(binding: StandardSourceBinding): number {
return epoch
}
function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: {
entry: StoredEntry
ownerProps: object
slotKey: string
slotInjected: BoundSlotInject
hookContext: unknown
hasHookContext: boolean
}) {
function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext, children }: EntryProps) {
const host = useHost()
const rootBinding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const { kit, standard, actions } = standardKit(host, entry, 'root', rootBinding, undefined)
const injected = cachedRootInject(entry, actions)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext, children)
}
interface FactoryOccurrenceValue {
@@ -1026,14 +1064,11 @@ function FactoryOutlet({ name, inputProps, slots: selected = EMPTY_FACTORY_SELEC
)
}
function StrictSessionEntry({ slotKey, anchor, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: {
slotKey: string
function StrictSessionEntry({
slotKey, anchor, crashed, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError, children,
}: EntryProps & {
anchor: SlotAnchorTag
entry: StoredEntry
ownerProps: object
slotInjected: BoundSlotInject
hookContext: unknown
hasHookContext: boolean
crashed?: ReactNode
onEntryError: (error: unknown) => void
}) {
const binding = useScopeBinding()
@@ -1044,7 +1079,13 @@ function StrictSessionEntry({ slotKey, anchor, entry, ownerProps, slotInjected,
// Per-session remount rides this key; per-entry remount rides the outer
// element's entry-identity key (the outlet's guarded() call).
return (
<SlotErrorBoundary slotKey={slotKey} anchor={anchor} key={sessionGenerationKeyOf(scopedBinding)} onEntryError={onEntryError}>
<SlotErrorBoundary
slotKey={slotKey}
anchor={anchor}
crashed={crashed}
key={sessionGenerationKeyOf(scopedBinding)}
onEntryError={onEntryError}
>
<SessionEntry
entry={entry}
ownerProps={ownerProps}
@@ -1053,11 +1094,129 @@ function StrictSessionEntry({ slotKey, anchor, entry, ownerProps, slotInjected,
slotInjected={slotInjected}
hookContext={hookContext}
hasHookContext={hasHookContext}
/>
>
{children}
</SessionEntry>
</SlotErrorBoundary>
)
}
/** Props of one hoc layer Component: the owner share and the content it wraps. */
interface HocLayerProps {
owner: object
children: ReactNode
}
/** Render the content inside one hoc layer: the next inner layer, or the owner's content itself at the innermost layer. */
type RenderHocInner = (owner: object, children: ReactNode) => ReactNode
/** The innermost step: the owner's content unchanged, with no Component of its own. */
const renderHocContent: RenderHocInner = (_owner, children) => children
/**
* Build one hoc layer Component. Scope, entry, and crash reporting are fixed
* here; a render passes only the owner share and the next inner layer. A
* crashed layer renders that inner content and abdicates.
* @param host - registry host owning the ledger.
* @param slotKey - the hoc slot.
* @param spec - its live declaration.
* @param entry - the layer's registration.
* @param renderInner - renders the content inside this layer.
* @returns the layer Component.
*/
function hocLayer(
host: SlotRendererHost,
slotKey: string,
spec: SlotSpec<SlotEntryDef>,
entry: StoredEntry,
renderInner: RenderHocInner,
): FC<HocLayerProps> {
const anchor: SlotAnchorTag = spec.inline === true ? 'span' : 'div'
const slotInjected = cachedSlotInject(spec.inject)
const onEntryError = (error: unknown): void => {
host.reportEntryError(slotKey, entry, error, { abdicate: true })
}
let Layer: FC<HocLayerProps>
switch (spec.scope) {
case 'session':
Layer = ({ owner, children }) => {
const inner = <HocContentBoundary>{renderInner(owner, children)}</HocContentBoundary>
return (
<StrictSessionEntry
slotKey={slotKey} anchor={anchor} crashed={inner} onEntryError={onEntryError}
entry={entry} ownerProps={owner} slotInjected={slotInjected} hookContext={undefined} hasHookContext={false}
>
{inner}
</StrictSessionEntry>
)
}
break
case 'session-maybe':
Layer = ({ owner, children }) => {
const inner = <HocContentBoundary>{renderInner(owner, children)}</HocContentBoundary>
return (
<SlotErrorBoundary slotKey={slotKey} anchor={anchor} crashed={inner} onEntryError={onEntryError}>
<SessionMaybeEntry
entry={entry} ownerProps={owner} slotKey={slotKey} slotInjected={slotInjected} hookContext={undefined} hasHookContext={false}
>
{inner}
</SessionMaybeEntry>
</SlotErrorBoundary>
)
}
break
case 'root':
Layer = ({ owner, children }) => {
const inner = <HocContentBoundary>{renderInner(owner, children)}</HocContentBoundary>
return (
<SlotErrorBoundary slotKey={slotKey} anchor={anchor} crashed={inner} onEntryError={onEntryError}>
<RootEntry
entry={entry} ownerProps={owner} slotKey={slotKey} slotInjected={slotInjected} hookContext={undefined} hasHookContext={false}
>
{inner}
</RootEntry>
</SlotErrorBoundary>
)
}
break
}
return Layer
}
/**
* Compose a hoc slot's live layers, lowest `order` outermost. Winners arrive
* in ledger order (priority first), so nesting re-sorts by `order`. Each layer
* adds exactly one Component; with no layer the result renders the owner's
* content directly.
* @param host - registry host owning the ledger.
* @param slotKey - the hoc slot.
* @param spec - its live declaration.
* @returns the composed render function; its layer Components are the layer set's identity.
*/
function composeHocLayers(host: SlotRendererHost, slotKey: string, spec: SlotSpec<SlotEntryDef>): RenderHocInner {
const layers = [...host.entriesOfSlot(slotKey)].sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
return layers.reduceRight<RenderHocInner>((renderInner, entry) => {
const Layer = hocLayer(host, slotKey, spec, entry, renderInner)
return (owner, children) => <Layer owner={owner}>{children}</Layer>
}, renderHocContent)
}
/** Hoc dispatch: recomposes only when the slot's ledger version or declaration changes. */
function HocOutlet({ slotKey, spec, ownerProps, children }: {
slotKey: string
spec: SlotSpec<SlotEntryDef>
ownerProps: object
children: ReactNode
}) {
const host = useHost()
const version = host.getVersion(slotKey)
// `version` keys the ledger read inside composeHocLayers; a new layer set yields new layer Components and remounts
// the content.
const renderLayers = useMemo(() => composeHocLayers(host, slotKey, spec), [host, slotKey, spec, version])
if (renderLayers === renderHocContent) return children
return <HocOutletBoundary>{renderLayers(ownerProps, children)}</HocOutletBoundary>
}
/**
* Anchor style shared by every outlet wrapper: `display:contents` keeps the
* wrapper out of layout (grid/flex parents see the slot's own children), so
@@ -1069,7 +1228,7 @@ const ANCHOR_STYLE = { display: 'contents' } as const
function SlotOutlet({ slotKey, ownerProps, opts, declaredInline }: {
slotKey: string
ownerProps: object
opts?: (RenderOpts & ChainRenderOpts) | undefined
opts?: OutletOpts | undefined
declaredInline: boolean
}) {
const host = useHost()
@@ -1099,12 +1258,12 @@ function SlotOutlet({ slotKey, ownerProps, opts, declaredInline }: {
)
}
/** Kind dispatch behind the outlet anchor (single/keyed/list/chain, fallbacks, crash faces). */
/** Kind dispatch behind the outlet anchor (single/keyed/list/chain/hoc, fallbacks, crash faces). */
function renderOutletContent(
host: SlotRendererHost,
slotKey: string,
ownerProps: object,
opts: (RenderOpts & ChainRenderOpts) | undefined,
opts: OutletOpts | undefined,
scopeBinding: StandardSourceBinding,
spec: SlotSpec<SlotEntryDef>,
): ReactNode {
@@ -1115,6 +1274,9 @@ function renderOutletContent(
if (spec.scope === 'session' && scopeBinding.key === undefined) {
throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
}
if (spec.kind === 'hoc') {
return <HocOutlet slotKey={slotKey} spec={spec} ownerProps={ownerProps}>{opts?.children ?? null}</HocOutlet>
}
const entries = host.entriesOf(slotKey)
const slotInjected = cachedSlotInject(spec.inject)
@@ -0,0 +1,205 @@
// @vitest-environment jsdom
/**
* Hoc slots over the real ui-slots SlotCore: nesting order, owner and
* children delivery, remount semantics, crash skipping, and every scope.
*/
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { Component, useEffect, useState, type ReactNode } from 'react'
import {
SlotCore, type PropsRenderSlots, type ScopedStandardSourceBinding, type SlotRendererHost,
type SlotScopeAdapter, type StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'spec.hoc': { kind: 'hoc'; scope: 'root'; owner: { tone: string } }
'spec.hoc.maybe': { kind: 'hoc'; scope: 'session-maybe' }
'spec.hoc.session': { kind: 'hoc'; scope: 'session' }
}
}
type Frame = PropsRenderSlots<'spec.hoc' | 'spec.hoc.maybe' | 'spec.hoc.session'>
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
/** Passthrough host over the real core whose current scope is one live Session. */
function hostOver(core: SlotCore): SlotRendererHost {
const rootBinding: StandardSourceBinding = { key: undefined, hooks: {}, keyedHooks: {}, props: {} }
const session = { key: 'S1', ctx: new Context(), hooks: {}, keyedHooks: {}, props: {} } as ScopedStandardSourceBinding
const source = <T,>(value: T) => ({ getSnapshot: () => value, subscribe: () => () => {} })
const sessionSource = source<StandardSourceBinding>(session)
const adapter: SlotScopeAdapter = {
current: sessionSource,
bindingSource: () => sessionSource,
renderArea: (_binding, { children }) => <>{children}</>,
}
return {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: key => core.getVersion(key),
entriesOf: key => core.entries(key),
entriesOfSlot: key => core.entriesOfSlot(key),
reportEntryError: (key, entry, error, info) => { core.reportEntryError(key, entry, error, info) },
reportFactoryError: () => {},
specOf: key => core.specDynamic(key),
isLive: entry => core.isLive(entry),
storeOf: () => undefined,
factoryStoreOf: () => undefined,
retainFactoryOccurrence: () => () => {},
subscribeFactory: () => () => {},
getFactoryVersion: () => 0,
factoryOf: () => undefined,
isFactoryLive: () => false,
root: source(rootBinding),
scopeRevision: source(0),
scope: () => adapter,
}
}
const flush = () => act(async () => { await Promise.resolve() })
const mounts = vi.fn()
function Content({ label = 'content' }: { label?: string }) {
useEffect(() => { mounts(label) }, [label])
return <p data-testid={label}>{label}</p>
}
/** A layer that wraps its children in a section carrying its id and the owner's tone. */
function layer(id: string) {
return ({ tone, children }: { tone?: string; children: ReactNode }) => (
<section data-layer={id} data-tone={tone}>{children}</section>
)
}
let setTone: (tone: string) => void = () => {}
/** Register the root declaring the hoc slots, render the root-scope hoc around Content, and mount. */
function mount(core: SlotCore, body?: (renderSlot: Frame['renderSlot']) => ReactNode) {
core.register({
name: 'root',
children: {
'spec.hoc': { kind: 'hoc', scope: 'root' },
'spec.hoc.maybe': { kind: 'hoc', scope: 'session-maybe' },
'spec.hoc.session': { kind: 'hoc', scope: 'session' },
},
}, function Root(props: Frame) {
const [tone, set] = useState('calm')
setTone = set
return <>{body?.(props.renderSlot) ?? props.renderSlot('spec.hoc', { tone }, { children: <Content /> })}</>
} as never)
return render(<>{createSlotRenderer().renderRoot(hostOver(core), {})}</>)
}
describe('hoc slots', () => {
it('renders the content unchanged without layers, then nests layers lowest order outermost', async () => {
const core = new SlotCore()
const view = mount(core)
expect(view.getByTestId('content')).toBeTruthy()
expect(view.container.querySelector('section')).toBeNull()
core.register({ name: 'spec.hoc', id: 'inner', order: 10 }, layer('inner') as never)
core.register({ name: 'spec.hoc', id: 'outer' }, layer('outer') as never)
await flush()
const outer = view.container.querySelector('section[data-layer="outer"]')
const inner = outer?.querySelector(':scope > section[data-layer="inner"]')
expect(inner?.querySelector(':scope > p')?.textContent).toBe('content')
expect(outer?.getAttribute('data-tone')).toBe('calm')
expect(inner?.getAttribute('data-tone')).toBe('calm')
})
it('updates owner props without remounting the content, and remounts it when the layer set changes', async () => {
const core = new SlotCore()
mounts.mockClear()
const view = mount(core)
core.register({ name: 'spec.hoc', id: 'outer' }, layer('outer') as never)
await flush()
mounts.mockClear()
act(() => { setTone('calm') })
expect(mounts).not.toHaveBeenCalled()
act(() => { setTone('loud') })
expect(view.container.querySelector('section')?.getAttribute('data-tone')).toBe('loud')
expect(mounts).not.toHaveBeenCalled()
const dispose = core.register({ name: 'spec.hoc', id: 'inner', order: 10 }, layer('inner') as never)
await flush()
expect(mounts).toHaveBeenCalledOnce()
dispose()
await flush()
expect(view.container.querySelector('section[data-layer="inner"]')).toBeNull()
expect(view.getByTestId('content')).toBeTruthy()
})
it('skips a crashed layer: its content still renders and the layer leaves the slot', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const core = new SlotCore()
const view = mount(core)
core.register({ name: 'spec.hoc', id: 'outer' }, layer('outer') as never)
core.register({ name: 'spec.hoc', id: 'broken', order: 10 }, (() => { throw new Error('layer failed') }) as never)
await flush()
await flush()
expect(view.container.querySelector('section[data-layer="outer"] p')?.textContent).toBe('content')
expect(view.container.querySelector('[data-slot-error]')).toBeNull()
expect(core.entriesOfSlot('spec.hoc').map(entry => entry.options.id)).toEqual(['outer'])
expect(error).toHaveBeenCalled()
})
it('passes content errors upward without blaming or removing any layer', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const core = new SlotCore()
const reported = vi.fn()
core.onEntryError(reported)
let failing = false
function Fragile() {
if (failing) throw new Error('content failed')
return <p data-testid="fragile">fragile</p>
}
const caught = vi.fn()
class Catch extends Component<{ children: ReactNode }, { error: unknown }> {
override state: { error: unknown } = { error: undefined }
static getDerivedStateFromError(error: unknown) { return { error } }
override componentDidCatch(error: unknown) { caught(error) }
override render() { return this.state.error === undefined ? this.props.children : <p data-testid="host-fallback" /> }
}
const view = mount(core, renderSlot => <Catch>{renderSlot('spec.hoc', { tone: 'calm' }, { children: <Fragile /> })}</Catch>)
core.register({ name: 'spec.hoc', id: 'outer' }, layer('outer') as never)
core.register({ name: 'spec.hoc', id: 'inner', order: 10 }, layer('inner') as never)
await flush()
failing = true
act(() => { setTone('loud') })
await flush()
expect(view.getByTestId('host-fallback')).toBeTruthy()
expect(caught).toHaveBeenCalledWith(expect.objectContaining({ message: 'content failed' }))
expect(reported).not.toHaveBeenCalled()
expect(core.entriesOfSlot('spec.hoc').map(entry => entry.options.id)).toEqual(['outer', 'inner'])
})
it('renders its layers around nothing when the owner omits children', async () => {
const core = new SlotCore()
const view = mount(core, renderSlot => renderSlot('spec.hoc', { tone: 'calm' }))
core.register({ name: 'spec.hoc', id: 'outer' }, layer('outer') as never)
await flush()
expect(view.container.querySelector('section[data-layer="outer"]')?.childNodes).toHaveLength(0)
})
it('wraps content in session-maybe and strict session layers', async () => {
const core = new SlotCore()
const view = mount(core, renderSlot => (
<>
{renderSlot('spec.hoc.maybe', {}, { children: <Content label="maybe-content" /> })}
{renderSlot('spec.hoc.session', {}, { children: <Content label="session-content" /> })}
</>
))
core.register({ name: 'spec.hoc.maybe', id: 'maybe' }, layer('maybe') as never)
core.register({ name: 'spec.hoc.session', id: 'session' }, layer('session') as never)
await flush()
expect(view.container.querySelector('section[data-layer="maybe"] p')?.textContent).toBe('maybe-content')
expect(view.container.querySelector('section[data-layer="session"] p')?.textContent).toBe('session-content')
})
})
@@ -21,8 +21,8 @@
en: 57942270e23d3a8e
zh: 6617124e652b3f67
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
en: e3ca76083e667293
zh: 4b52ffa2848d9f87
en: 26c4fa4d4bbaa6ac
zh: e889b67cb167092b
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
en: 595499992ceea693
zh: 83c6147c46a80dca
@@ -65,7 +65,7 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` s
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a cool light-grey background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport.
Initial reads and renderer preparation, including Office conversion, share a centered 28px ongoing `StateDot` with the visible localized status “Rendering document...”. The status exposes the same accessible name and respects reduced-motion preferences. Additional text pages keep a compact inline spinner while loaded content stays visible. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF and converted Word/PowerPoint pages form a vertical sequence with 12px page gaps and a 12px outer inset showing the theme-specific document background; fit width reserves this inset, and pages render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Selections use translucent blue in both themes while the canvas retains the document text; blank line breaks remain unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
Initial reads and renderer preparation, including Office conversion, share a centered 28px ongoing `StateDot` with the visible localized status “Rendering document...”. The status exposes the same accessible name and respects reduced-motion preferences. Additional text pages keep a compact inline spinner while loaded content stays visible. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF and converted Word/PowerPoint pages form a vertical sequence with 12px page gaps and a 12px outer inset showing the theme-specific document background; fit width reserves this inset, and pages render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Each text layer is laid out detached and attached once complete, so its text measurement does not restyle the whole document; a page’s text becomes selectable when its layer completes. Selections use translucent blue in both themes while the canvas retains the document text; blank line breaks remain unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
A leading YAML frontmatter block, opened by `---` on the first line and closed by `---` or `...`, renders above the Markdown body as a key/value list. String values show their resolved text, including folded and multiline strings; other scalars, lists, mappings, and aliases keep their authored YAML. Invalid YAML and non-mapping documents show the block verbatim, and a block without content shows nothing. An unterminated block remains Markdown. The YAML parser loads in the package-local `client.frontmatter-fields.js` chunk only when a document has frontmatter; the verbatim block stands in while it loads or if it fails to load. The source file is unchanged.

Some files were not shown because too many files have changed in this diff Show More