mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge master into personalized pages experiment
This commit is contained in:
commit
d28efd25a2
267 files changed
+8066
-1557
No files matched your search
+4
-4
@@ -54,8 +54,8 @@
|
||||
en: 7865e159528e2d30
|
||||
zh: 1399d54632f4d926
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream:
|
||||
en: 41d7ac1faf2448d7
|
||||
zh: 6d523895a9abae68
|
||||
en: a928a63e8c36177c
|
||||
zh: c943c1fdc8c4bb45
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face:
|
||||
en: 7be28cd7e19e5377
|
||||
zh: 30b5aee957271d6c
|
||||
@@ -81,8 +81,8 @@
|
||||
en: 89452c3c3f042e75
|
||||
zh: 9bcad00a60c65303
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
|
||||
en: 048067eb02e6bf8a
|
||||
zh: 12508b59f6b206b8
|
||||
en: 2279a8c153cc5b89
|
||||
zh: 63c0b6462722f0a3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
|
||||
en: 1e254cec680b1a37
|
||||
zh: 06a31e057ba965aa
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| Behavior | Detail |
|
||||
|---|---|
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts |
|
||||
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
|
||||
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` |
|
||||
| Endpoint actions only | `event:<name>` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
|
||||
@@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
|
||||
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
|
||||
| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
|
||||
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec |
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
|
||||
| 行为 | 说明 |
|
||||
|---|---|
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 |
|
||||
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
|
||||
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 |
|
||||
| 只含端点 action | `event:<name>` action 不进入快照;前端只据端点 action 决定入口显隐 |
|
||||
@@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
|
||||
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
|
||||
| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
|
||||
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Bilingual-pair consistency record for 2026-10-21-when-access.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-10-21-when-access.md
|
||||
/agent-note-whenaccess-registration-that-follows-access:
|
||||
en: 2ef7cfef514c138a
|
||||
zh: 064e3aecbfafcd9c
|
||||
/agent-note-whenaccess-registration-that-follows-access/problem:
|
||||
en: 4f88d3ee7868eaa5
|
||||
zh: 72a4ce2dde13bef7
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess:
|
||||
en: c168de20989cf9a4
|
||||
zh: bf3b0361beea083d
|
||||
/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
|
||||
en: 73577ed282c656f3
|
||||
zh: 2f60a6824218e693
|
||||
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
|
||||
en: ddc432b693608163
|
||||
zh: 620294d8812b0a31
|
||||
/agent-note-whenaccess-registration-that-follows-access/consequences:
|
||||
en: de44fa92c49bc335
|
||||
zh: 34436fe971ab013f
|
||||
/agent-note-whenaccess-registration-that-follows-access/related:
|
||||
en: 89073ffbfcff0641
|
||||
zh: 736a180d13a08981
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: whenAccess registration that follows access
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-21-when-access.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering:
|
||||
|
||||
| Where | Existing capability | Gap |
|
||||
|---|---|---|
|
||||
| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically |
|
||||
| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable<boolean>` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand |
|
||||
|
||||
Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change.
|
||||
|
||||
## Decision
|
||||
|
||||
### whenAccess
|
||||
|
||||
The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook:
|
||||
|
||||
| Interface | Provider | Matching Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`.
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads |
|
||||
| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration |
|
||||
| Stream failure | A non-owner's answer turns false and the installed effects dispose |
|
||||
| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask |
|
||||
| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### Plugin migration
|
||||
|
||||
| Plugin | Change | Action |
|
||||
|---|---|---|
|
||||
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` |
|
||||
| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` |
|
||||
| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
|
||||
| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change.
|
||||
|
||||
**Trim the plugin list per connection**: a plugin cannot reload at once when access changes.
|
||||
|
||||
**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest.
|
||||
|
||||
**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds.
|
||||
|
||||
## Consequences
|
||||
|
||||
| Consequence | Note |
|
||||
|---|---|
|
||||
| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration |
|
||||
| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads |
|
||||
| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI |
|
||||
| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path |
|
||||
|
||||
## Related
|
||||
|
||||
- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: 按访问状态注册的 whenAccess
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-21-when-access.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染:
|
||||
|
||||
| 位置 | 现有能力 | 缺口 |
|
||||
|---|---|---|
|
||||
| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 |
|
||||
| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable<boolean>` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 |
|
||||
|
||||
没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。
|
||||
|
||||
## 决策
|
||||
|
||||
### whenAccess
|
||||
|
||||
JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供:
|
||||
|
||||
| 接口 | 提供者 | 对应 Hook |
|
||||
|---|---|---|
|
||||
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
|
||||
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
|
||||
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
|
||||
|
||||
```ts ignore-check
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
|
||||
/** The global-resource member; the Session and Workspace versions take the resource id first. */
|
||||
export interface WhenAccess {
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call `action`
|
||||
* on the resource: run it when the answer becomes true, dispose its effects in
|
||||
* reverse order when it becomes false, and run it again on the next true.
|
||||
* @returns disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
```
|
||||
|
||||
`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 |
|
||||
| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 |
|
||||
| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 |
|
||||
| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 |
|
||||
| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
|
||||
|
||||
### 插件迁移
|
||||
|
||||
| 插件 | 改法 | action |
|
||||
|---|---|---|
|
||||
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` |
|
||||
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` |
|
||||
| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
|
||||
| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
|
||||
| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。
|
||||
|
||||
**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
|
||||
|
||||
**插件包清单声明访问性**:在包清单上定义访问控制概念。
|
||||
|
||||
**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。
|
||||
|
||||
## 后果
|
||||
|
||||
| 后果 | 说明 |
|
||||
|---|---|
|
||||
| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 |
|
||||
| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 |
|
||||
| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 |
|
||||
| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 |
|
||||
|
||||
## 相关
|
||||
|
||||
- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。
|
||||
@@ -104,7 +104,7 @@ async function main(): Promise<void> {
|
||||
if (process.connected) process.send?.({ type: 'platform-session', session })
|
||||
})
|
||||
const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`)
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
|
||||
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) })
|
||||
}
|
||||
|
||||
/** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 5f2d25a1d8472647
|
||||
zh: 2acb5c5d7d1917e2
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: 24387a07ae4eb8e7
|
||||
zh: 0119352685cf273c
|
||||
en: bb35a022c37955c9
|
||||
zh: ab2ef22d398d83a0
|
||||
/deepseek-harness-desktop/key-technical-decisions:
|
||||
en: 3724c53da4f7647b
|
||||
zh: 76ae549d43c99bb4
|
||||
@@ -57,14 +57,14 @@
|
||||
en: 9b90a2651a6d2b52
|
||||
zh: 86d4d9c4358aad80
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: 87db37529b51da62
|
||||
zh: 4fc0d752e437e7d6
|
||||
en: 67bf0a18faa7aa76
|
||||
zh: 782e14ce62bb939f
|
||||
/deepseek-harness-desktop/updates/local-desktop-settings:
|
||||
en: 2e57457c44c85b5f
|
||||
zh: 5b8510a3dc4c6c9d
|
||||
/deepseek-harness-desktop/updates/mandatory-update-policy:
|
||||
en: 54596bef0706d305
|
||||
zh: 48737302dcf3c939
|
||||
en: 182a9c6705213e13
|
||||
zh: 681762f83490f4c6
|
||||
/deepseek-harness-desktop/updates/local-updater-qualification:
|
||||
en: 68ec2f8bd5763ea6
|
||||
zh: 23b932dfe8503602
|
||||
@@ -72,8 +72,8 @@
|
||||
en: 8264b135871e8978
|
||||
zh: 0939aafb1b2961fc
|
||||
/deepseek-harness-desktop/known-limitations:
|
||||
en: c63d4dc7a9104954
|
||||
zh: a4c676e5b46a6ac0
|
||||
en: b1eeadfefb338056
|
||||
zh: 28683fbdf1e98685
|
||||
/deepseek-harness-desktop/dev-note:
|
||||
en: 8e26b460ba31f21b
|
||||
zh: fbcd0307b3867125
|
||||
@@ -70,7 +70,7 @@ Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and th
|
||||
|
||||
Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. Before the first hide, the shared update-dialog overlay explains that running tasks continue and the window can be reopened from the system tray. Confirm hides the window and writes `background-close-confirmed` under Electron userData; Escape, dismissal, or dialog failure keeps the window visible and does not record acknowledgement. Repeated close requests focus the existing shell dialog. In-place updates retain the marker and uninstall removes it. The old `background-notice-shown` marker does not suppress this confirmation. Closing the window sends no system notification. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon.
|
||||
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quit caused by closing the welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
|
||||
|
||||
The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation.
|
||||
|
||||
@@ -271,9 +271,11 @@ On Windows, the downloaded-update confirmation explains that the application clo
|
||||
|
||||
On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window.
|
||||
|
||||
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
|
||||
Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. The Windows caption lies outside the view; its native and product Web controls remain operable. Caption clicks can toggle the sidebar, create or reuse a blank Session, and switch the underlying page. Application menu actions such as About or CLI command management can replace and cancel the current ordinary prompt; this does not approve installation or clear mandatory policy. Product keyboard shortcuts and editing-key forwarding remain blocked while the view is attached.
|
||||
|
||||
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
|
||||
Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline.
|
||||
|
||||
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page.
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
|
||||
|
||||
@@ -330,10 +332,12 @@ When permitted, login is offered during user-initiated checks and the packaged a
|
||||
|
||||
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
|
||||
|
||||
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
|
||||
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay uses an isolated shell-origin WebContentsView inside its native owner. It fades in and out in place, retains its backdrop across update-state changes, and blocks product keyboard input. Loading or updating the view never shows or activates a separate native window; returning to an already focused owner focuses the view. Closing its native owner follows the [normal window-close behavior](#closing-the-window-and-quitting). View loading failures retain policy and input blocking without changing the owner title. A native recovery dialog appears only while the owner is visible and focused, showing the policy explanation and available target version. Open download page uses the policy URL after the existing origin validation and is omitted when no approved URL is available; Quit uses normal application exit confirmation. No Cancel button is offered; non-action dismissals retain blocking and reopen recovery. Hiding the owner, policy clearance, successful loading, and disposal dismiss the recovery dialog; obsolete responses have no effect. This recovery covers the isolated view, not the Windows embedded frame. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
|
||||
|
||||
Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
|
||||
|
||||
Selecting Check for Updates while mandatory policy is already blocking focuses its existing interface. If a manual check finishes with mandatory policy active, it also restores, shows, and focuses the owner even after the user has switched applications, minimized, or hidden it. This explicit foreground request is retained independently of the view carrier; the ordinary-result path does not make that request. Focusing the interface does not authorize downloading, installation, or task stopping. The asynchronous mandatory-result foreground path has not received a dedicated background/fullscreen manual qualification.
|
||||
|
||||
Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required.
|
||||
|
||||
### Local updater qualification
|
||||
@@ -375,7 +379,7 @@ When an expired account returns to Welcome, the main process retains a one-time
|
||||
|
||||
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
|
||||
|
||||
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
|
||||
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Update policy and ordinary update prompts refuse Platform admission, including a pending open whose storage cleanup completes after blocking starts. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
|
||||
|
||||
Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin, token, and resolved language.
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会
|
||||
|
||||
Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。首次隐藏前复用更新弹窗,显示“正在运行的任务不会中断,可在系统托盘中重新打开窗口”和“确认”按钮。确认后隐藏窗口,并在 Electron userData 下写入 `background-close-confirmed`;Esc、关闭弹窗或加载失败均保持主窗口可见,不记录确认。重复关闭请求会聚焦已有壳弹窗。覆盖更新保留标记,卸载删除标记。旧的 `background-notice-shown` 标记不会跳过此确认。关闭窗口不发送系统通知。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。
|
||||
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
|
||||
强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。
|
||||
|
||||
@@ -271,9 +271,11 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
|
||||
|
||||
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
|
||||
|
||||
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
|
||||
普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 顶栏位于视图覆盖范围外,其中的原生及产品 Web 控件仍可操作。点击顶栏可切换侧栏、创建或复用空 Session,并改变底层页面。“关于”或 CLI 命令管理等应用菜单操作可替换并取消当前普通提示,但不代表批准安装,也不会清除强更策略。视图挂载期间仍阻断产品键盘快捷键和编辑按键转发。
|
||||
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
|
||||
普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。
|
||||
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
|
||||
|
||||
@@ -330,10 +332,12 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett
|
||||
|
||||
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
|
||||
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层使用原生窗口内、隔离的壳静态源 WebContentsView,原位渐入渐出,在更新状态切换时保留蒙层并拦截产品键盘输入。加载或更新视图不会显示或激活独立原生窗口;回到已获焦点的父窗口时聚焦视图。关闭原生父窗口遵循[常规关窗行为](#closing-the-window-and-quitting)。视图加载失败保留策略与输入阻断,不改写父窗口标题。仅当所属窗口可见且有焦点时显示原生恢复提示,包含策略说明与可用目标版本。打开下载页面使用现有来源校验后的策略 URL,缺少获准地址时不显示该按钮;退出走正常应用退出确认。不提供取消按钮,非操作性关闭保留阻断并重新显示恢复提示。隐藏窗口、策略解除、加载成功或释放控制器会撤销提示,过期响应无效。此恢复覆盖独立视图,不包含 Windows 内嵌 frame。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
|
||||
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
|
||||
|
||||
已处于强更阻断状态时,选择“检查更新”会聚焦现有界面。手动检查完成时若强更策略已生效,也会恢复、显示并聚焦所属窗口,即使用户已切换应用、最小化或隐藏窗口。此显式置前行为独立于视图承载方式保留;普通检查结果路径不发起该请求。聚焦界面不代表批准下载、安装或停止任务。异步强更结果置前路径尚未单独完成后台/全屏人工验证。
|
||||
|
||||
后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。
|
||||
|
||||
### 本地 updater 验证
|
||||
@@ -375,7 +379,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
|
||||
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
|
||||
|
||||
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
|
||||
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。强更策略或普通更新提示阻止打开 Platform 视图;清理存储期间开始阻断时,待打开视图也不会挂载。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
|
||||
|
||||
私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin、token 和已解析的语言。
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
#status, #version { margin-top: 12px; color: #555962; }
|
||||
#status, #version { margin-top: 12px; color: var(--dsw-alias-label-secondary); }
|
||||
#status:empty, #version:empty { display: none; }
|
||||
#error { margin-top: 12px; color: #d93025; }
|
||||
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; }
|
||||
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; }
|
||||
#error { margin-top: 12px; color: var(--dsw-alias-state-error-primary); }
|
||||
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/20px monospace; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; resize: vertical; }
|
||||
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: var(--dsw-alias-label-secondary); background: transparent; }
|
||||
#update, #refresh { border-radius: 100px; }
|
||||
#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; }
|
||||
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; }
|
||||
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
|
||||
#copy-message { color: #d93025 !important; }
|
||||
#fallback { border-top: 1px solid var(--dsw-alias-border-l2); margin-top: 12px; padding-top: 12px; }
|
||||
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
|
||||
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: var(--dsw-alias-link); font-size: 12px; }
|
||||
#copy-message { color: var(--dsw-alias-state-error-primary) !important; }
|
||||
#manual-copy { display: block; margin-top: 8px; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: var(--dsw-alias-border-l2); accent-color: var(--dsw-alias-button-primary-fill); }
|
||||
|
||||
#progress::-webkit-progress-bar { background: #e6e7eb; }
|
||||
#progress::-webkit-progress-value { background: #0f1115; }
|
||||
#progress::-moz-progress-bar { background: #0f1115; }
|
||||
#progress::-webkit-progress-bar { background: var(--dsw-alias-border-l2); }
|
||||
#progress::-webkit-progress-value { background: var(--dsw-alias-button-primary-fill); }
|
||||
#progress::-moz-progress-bar { background: var(--dsw-alias-button-primary-fill); }
|
||||
@media (hover: hover) {
|
||||
#actions button:not(.primary):hover:not(:disabled), #copy:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'">
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="design-platform.css">
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
<link rel="stylesheet" href="mandatory-update.css">
|
||||
</head>
|
||||
<body class="update-dialog">
|
||||
<script src="shell-theme.js"></script>
|
||||
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
|
||||
<header><h1 id="title"></h1></header>
|
||||
<p id="detail"></p>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Shell documents follow Electron's app-resolved palette before displaying their content. */
|
||||
const palette = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const applyPalette = () => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) }
|
||||
applyPalette()
|
||||
palette.addEventListener('change', applyPalette)
|
||||
window.addEventListener('pagehide', () => { palette.removeEventListener('change', applyPalette) }, { once: true })
|
||||
@@ -1,31 +1,37 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color-scheme: light dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: #0f1115;
|
||||
background: transparent;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; }
|
||||
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); }
|
||||
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); }
|
||||
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; background: transparent; color: var(--dsw-alias-label-primary); color-scheme: light; }
|
||||
body[data-ds-dark-theme] { color-scheme: dark; }
|
||||
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: var(--dsw-alias-bg-mask-1); }
|
||||
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: var(--dsw-alias-bg-layer-1); box-shadow: none; }
|
||||
body[data-ds-dark-theme] main { box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); }
|
||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
||||
main:focus { outline: none; }
|
||||
h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; }
|
||||
p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; }
|
||||
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; }
|
||||
.primary { background: #0f1115; color: #fff; }
|
||||
.secondary { background: #f5f5f5; color: inherit; }
|
||||
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: var(--dsw-alias-bg-layer-1); }
|
||||
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; color: inherit; cursor: pointer; }
|
||||
.primary { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted); }
|
||||
|
||||
.secondary { background: var(--dsw-alias-button-primary-dimmed); color: inherit; }
|
||||
.close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; }
|
||||
.close img { width: 14px; height: 14px; }
|
||||
button:hover { filter: brightness(0.95); }
|
||||
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
|
||||
.close::before { content: ""; width: 14px; height: 14px; background: currentColor; mask: url("update-close.svg") center / contain no-repeat; }
|
||||
|
||||
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 3px; }
|
||||
details { margin-top: 12px; font-size: 13px; line-height: 20px; }
|
||||
summary { cursor: pointer; color: #5b6472; }
|
||||
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
summary { cursor: pointer; color: var(--dsw-alias-label-secondary); }
|
||||
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
[hidden] { display: none !important; }
|
||||
body.update-dialog { opacity: 0; transition: opacity 150ms ease; }
|
||||
body.update-dialog.visible { opacity: 1; }
|
||||
@starting-style { body.update-dialog.visible { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } }
|
||||
@media (hover: hover) {
|
||||
.primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
|
||||
.secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'">
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="design-platform.css">
|
||||
<link rel="stylesheet" href="update-dialog.css">
|
||||
</head>
|
||||
<body class="update-dialog">
|
||||
<script src="shell-theme.js"></script>
|
||||
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
|
||||
<header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
|
||||
<header><h1 id="title"></h1><button id="close" class="close" type="button"></button></header>
|
||||
<p id="detail"></p>
|
||||
<details id="technical-details" hidden>
|
||||
<summary id="technical-details-label"></summary>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<script src="../lib/welcome/welcome.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<script src="shell-theme.js"></script>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,7 +29,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi
|
||||
directories: { ...config.directories, output: join(run.root, version, 'installer') },
|
||||
extraMetadata: { ...config.extraMetadata, name: `dsh-update-test-${run.id}`, version, main: 'qualification-bootstrap.mjs' },
|
||||
files: [
|
||||
{ from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] },
|
||||
{ from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'lib/welcome/design-platform.css', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] },
|
||||
'package.json',
|
||||
...config.files.filter((file): file is string => typeof file === 'string' && file.startsWith('!')),
|
||||
{ from: dsh, to: 'dsh', filter: ['**/*'] },
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function prepareInstalledUpdateApplication(manifest: string, source
|
||||
const modules = (await readdir(join(source, 'lib'), { withFileTypes: true }))
|
||||
.filter(entry => /\.(?:js|cjs)$/u.test(entry.name))
|
||||
if (modules.some(entry => !entry.isFile())) throw new Error('installed update: application modules must be regular files')
|
||||
const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer')].sort()
|
||||
const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer'), 'lib/welcome/design-platform.css'].sort()
|
||||
for (const required of ['lib/main.js', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', 'lib/preload-update-dialog.cjs']) {
|
||||
if (!paths.includes(required)) throw new Error('installed update: rebuild Desktop before freezing application files')
|
||||
}
|
||||
@@ -76,7 +76,8 @@ export async function verifyInstalledUpdateApplication(root: string): Promise<st
|
||||
if (typeof entry !== 'object' || entry === null || !('path' in entry) || !('sha256' in entry)
|
||||
|| typeof entry.path !== 'string' || typeof entry.sha256 !== 'string'
|
||||
|| entry.path.includes('\\') || entry.path.includes(':') || entry.path.split('/').some(part => ['', '.', '..'].includes(part))
|
||||
|| !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path)
|
||||
|| (entry.path !== 'lib/welcome/design-platform.css'
|
||||
&& !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path))
|
||||
|| !/^[a-f0-9]{64}$/u.test(entry.sha256) || paths.has(entry.path)) {
|
||||
throw new Error('installed update: invalid application file inventory')
|
||||
}
|
||||
@@ -87,7 +88,7 @@ export async function verifyInstalledUpdateApplication(root: string): Promise<st
|
||||
}
|
||||
const actual = await applicationFiles(join(directory, 'files'))
|
||||
if (actual.length !== paths.size || actual.some(path => !paths.has(path))
|
||||
|| ['lib/main.js', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) {
|
||||
|| ['lib/main.js', 'lib/welcome/design-platform.css', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) {
|
||||
throw new Error('installed update: frozen application inventory has missing or additional files')
|
||||
}
|
||||
return join(directory, 'files')
|
||||
|
||||
@@ -12,12 +12,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const palette = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const syncPalette = (): void => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) }
|
||||
syncPalette()
|
||||
palette.addEventListener('change', syncPalette)
|
||||
window.addEventListener('pagehide', () => { palette.removeEventListener('change', syncPalette) }, { once: true })
|
||||
|
||||
const container = document.getElementById('root')
|
||||
if (container === null) throw new Error('desktop welcome: missing React root')
|
||||
const root = createRoot(container)
|
||||
|
||||
@@ -150,6 +150,8 @@ export const en = {
|
||||
mandatoryPage: 'Download from the official website',
|
||||
mandatoryCopy: 'Copy download link',
|
||||
mandatoryPageFailed: 'Could not open the official download page. Copy the link and open it in your browser.',
|
||||
mandatoryViewDownload: 'Open download page',
|
||||
mandatoryViewPageFailed: 'Could not open the download page. Please try again.',
|
||||
mandatoryActionFailed: 'The action failed. Please try again. You can use the app again after the update is complete.',
|
||||
mandatoryReady: 'Update ready',
|
||||
mandatoryVersion: 'New version: {version}',
|
||||
@@ -322,6 +324,8 @@ export const zh = {
|
||||
mandatoryPage: '前往官网下载',
|
||||
mandatoryCopy: '复制下载链接',
|
||||
mandatoryPageFailed: '无法打开官网下载页面,请复制链接后在浏览器中打开。',
|
||||
mandatoryViewDownload: '打开下载页面',
|
||||
mandatoryViewPageFailed: '无法打开下载页面,请重试',
|
||||
mandatoryActionFailed: '操作失败,请重试。完成更新后才能继续使用应用。',
|
||||
mandatoryReady: '更新已准备就绪',
|
||||
mandatoryVersion: '新版本:{version}',
|
||||
@@ -492,6 +496,8 @@ export const ja = {
|
||||
mandatoryPage: '公式サイトからダウンロード',
|
||||
mandatoryCopy: 'ダウンロードリンクをコピー',
|
||||
mandatoryPageFailed: '公式ダウンロードページを開けませんでした。リンクをコピーしてブラウザで開いてください。',
|
||||
mandatoryViewDownload: 'ダウンロードページを開く',
|
||||
mandatoryViewPageFailed: 'ダウンロードページを開けませんでした。もう一度お試しください。',
|
||||
mandatoryActionFailed: '操作に失敗しました。もう一度お試しください。アップデートが完了すると、アプリを再度使用できます。',
|
||||
mandatoryReady: 'アップデートの準備完了',
|
||||
mandatoryVersion: '新しいバージョン: {version}',
|
||||
|
||||
@@ -442,7 +442,8 @@ async function main(): Promise<void> {
|
||||
return next.promise
|
||||
}
|
||||
const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs'),
|
||||
() => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin')
|
||||
() => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin',
|
||||
owner => isMandatory() || updateOverlays.input(owner).blocked)
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
@@ -668,7 +669,8 @@ async function main(): Promise<void> {
|
||||
protocol.handle(SCHEME, (request) => {
|
||||
const url = new URL(request.url)
|
||||
// Shell-owned documents live in the application bundle and never pass through the Host.
|
||||
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
|
||||
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(),
|
||||
url.pathname === '/design-platform.css' ? 'lib/welcome' : 'renderer'))
|
||||
if (url.hostname === 'app') {
|
||||
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
|
||||
|| ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Shell-owned modal policy UI; only explicit actions authorize downloads or browser navigation. */
|
||||
|
||||
import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
||||
import { app, type BrowserWindow, clipboard, dialog, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
|
||||
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
|
||||
import type { DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
import type { DesktopUpdateOverlay, DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
import { DesktopUpdateAttention } from './update-attention.ts'
|
||||
|
||||
/** A renderer action never carries a URL or authorizes a different version. */
|
||||
@@ -47,13 +47,15 @@ export interface MandatoryUpdateWindowOptions {
|
||||
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
|
||||
const page = 'dsh-app://shell/mandatory-update.html'
|
||||
|
||||
/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */
|
||||
/** Shell-owned update presentation inside the native owner; policy and installation approval remain main-owned. */
|
||||
export class DesktopMandatoryUpdateWindow {
|
||||
private readonly embedded = process.platform === 'win32'
|
||||
private embeddedParent: BrowserWindow | undefined
|
||||
private embeddedBlocking = false
|
||||
private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() }
|
||||
private window: BrowserWindow | undefined
|
||||
private window: DesktopUpdateOverlay | undefined
|
||||
private surfaceParent: BrowserWindow | undefined
|
||||
private recovery: { load: () => Promise<void>; dismiss: () => void; dispose: () => void } | undefined
|
||||
private closing: ReturnType<typeof setTimeout> | undefined
|
||||
private disposed = false
|
||||
private error: string | undefined
|
||||
@@ -110,7 +112,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}
|
||||
|
||||
/** Active modal used as the owner of shell installation-confirmation dialogs. */
|
||||
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window }
|
||||
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.surfaceParent }
|
||||
|
||||
/**
|
||||
* @param version - Updater-owned target, already downloaded and verified.
|
||||
@@ -145,6 +147,15 @@ export class DesktopMandatoryUpdateWindow {
|
||||
/** Publish current status, create the block immediately, or close it only after policy clearance. */
|
||||
sync(): void {
|
||||
if (this.disposed) return
|
||||
if (!this.embedded && this.window !== undefined && this.surfaceParent !== this.options.parent()) {
|
||||
this.recovery?.dispose()
|
||||
this.recovery = undefined
|
||||
this.finishConfirmation(false)
|
||||
const previous = this.window
|
||||
this.window = undefined
|
||||
this.surfaceParent = undefined
|
||||
previous.destroy()
|
||||
}
|
||||
if (this.embedded) {
|
||||
const parent = this.options.parent()
|
||||
if (parent !== this.embeddedParent) {
|
||||
@@ -158,6 +169,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
if (parent === undefined || parent.isDestroyed()) return
|
||||
}
|
||||
if (!this.options.policy().blocking) {
|
||||
this.recovery?.dismiss()
|
||||
this.finishConfirmation(false)
|
||||
this.attention.reset()
|
||||
this.clearNavigation()
|
||||
@@ -168,7 +180,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
window.webContents.send(MANDATORY_IPC.state, this.view())
|
||||
this.closing = setTimeout(() => {
|
||||
this.closing = undefined
|
||||
if (this.window === window) this.window = undefined
|
||||
if (this.window === window) { this.window = undefined; this.surfaceParent = undefined }
|
||||
window.destroy()
|
||||
}, 150)
|
||||
}
|
||||
@@ -190,24 +202,23 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}
|
||||
if (this.window === undefined) {
|
||||
const parent = this.options.parent()
|
||||
if (parent === undefined) return
|
||||
const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
|
||||
if (parent === undefined || parent.isDestroyed()) return
|
||||
const window = this.options.overlays.create(parent, this.options.preload)
|
||||
this.window = window
|
||||
window.setMenu(null)
|
||||
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
|
||||
window.on('closed', () => { if (this.window === window) this.window = undefined })
|
||||
this.surfaceParent = parent
|
||||
window.webContents.once('destroyed', () => {
|
||||
if (this.window === window) {
|
||||
this.window = undefined; this.surfaceParent = undefined
|
||||
this.finishConfirmation(false)
|
||||
if (!this.disposed) queueMicrotask(() => { this.sync() })
|
||||
}
|
||||
})
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
|
||||
window.webContents.on('render-process-gone', () => {
|
||||
if (!this.disposed) void window.loadURL(page).catch(() => {
|
||||
// A failed recovery keeps the parent blocked and leaves application exit available.
|
||||
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
|
||||
})
|
||||
})
|
||||
void window.loadURL(page).catch(() => {
|
||||
// The parent stays modal-blocked if its dedicated recovery document cannot load.
|
||||
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
|
||||
})
|
||||
window.webContents.on('will-redirect', (event) => { event.preventDefault() })
|
||||
const recovery = this.attachRecovery(window, parent)
|
||||
this.recovery = recovery
|
||||
void recovery.load()
|
||||
}
|
||||
this.window.webContents.send(MANDATORY_IPC.state, this.view())
|
||||
}
|
||||
@@ -219,14 +230,15 @@ export class DesktopMandatoryUpdateWindow {
|
||||
if (parent?.isDestroyed()) return
|
||||
if (parent?.isMinimized()) parent.restore()
|
||||
parent?.show()
|
||||
if (this.embedded) parent?.focus()
|
||||
this.window?.show()
|
||||
parent?.focus()
|
||||
this.window?.focus()
|
||||
}
|
||||
|
||||
/** Detach IPC and release the modal during shutdown, including after the main window closes. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.recovery?.dispose()
|
||||
this.recovery = undefined
|
||||
clearTimeout(this.closing)
|
||||
this.closing = undefined
|
||||
this.finishConfirmation(false)
|
||||
@@ -240,6 +252,93 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}
|
||||
this.window?.destroy()
|
||||
this.window = undefined
|
||||
this.surfaceParent = undefined
|
||||
}
|
||||
|
||||
private attachRecovery(surface: DesktopUpdateOverlay, parent: BrowserWindow): {
|
||||
load: () => Promise<void>
|
||||
dismiss: () => void
|
||||
dispose: () => void
|
||||
} {
|
||||
let released = false
|
||||
let failed = false
|
||||
let attempt = 0
|
||||
let prompt: AbortController | undefined
|
||||
const valid = (): boolean => !released && !this.disposed && this.options.policy().blocking
|
||||
&& this.options.parent() === parent && this.window === surface && !surface.isDestroyed() && !parent.isDestroyed()
|
||||
const dismiss = (): void => { prompt?.abort() }
|
||||
const show = (): void => {
|
||||
if (!valid() || !failed || prompt !== undefined || !parent.isVisible() || !parent.isFocused()) return
|
||||
const controller = new AbortController()
|
||||
prompt = controller
|
||||
const messages = this.options.locale.messages
|
||||
const policy = this.options.policy()
|
||||
const update = this.options.update()
|
||||
const destination = desktopPolicyPage(policy.page, this.options.allowedPageOrigins)
|
||||
const actions: readonly ('page' | 'quit')[] = destination === undefined ? ['quit'] : ['page', 'quit']
|
||||
const labels = { page: messages.mandatoryViewDownload, quit: messages.quit }
|
||||
const detail = [policy.detail ?? messages.mandatoryDetail,
|
||||
...('version' in update ? [messages.mandatoryVersion.replace('{version}', update.version)] : []),
|
||||
...(this.navigation?.page === 'failed' ? [messages.mandatoryViewPageFailed] : [])].join('\n\n')
|
||||
void dialog.showMessageBox(parent, { type: 'error', title: messages.mandatoryTitle,
|
||||
message: policy.title ?? messages.mandatoryTitle, detail,
|
||||
buttons: actions.map(action => labels[action]), defaultId: 0, cancelId: -1,
|
||||
noLink: true, signal: controller.signal }).then(({ response }) => {
|
||||
if (prompt === controller) prompt = undefined
|
||||
if (controller.signal.aborted) { show(); return }
|
||||
if (!valid() || !failed) return
|
||||
if (actions[response] === 'quit') app.quit()
|
||||
else if (actions[response] === 'page') {
|
||||
if (destination !== desktopPolicyPage(this.options.policy().page, this.options.allowedPageOrigins)) { show(); return }
|
||||
void this.navigate('page').then(() => { if (this.navigation?.page === 'failed') show() })
|
||||
.catch((error: unknown) => { console.error('desktop policy: recovery download page failed', error) })
|
||||
} else show()
|
||||
}).catch((error: unknown) => {
|
||||
if (prompt === controller) prompt = undefined
|
||||
console.error('desktop policy: native update recovery failed', error)
|
||||
})
|
||||
}
|
||||
const load = async (): Promise<void> => {
|
||||
if (!valid()) return
|
||||
const revision = ++attempt
|
||||
failed = false
|
||||
dismiss()
|
||||
try {
|
||||
await surface.loadURL(page)
|
||||
} catch (error) {
|
||||
if (!valid() || revision !== attempt) return
|
||||
console.error('desktop policy: mandatory update view failed to load', error)
|
||||
failed = true
|
||||
show()
|
||||
return
|
||||
}
|
||||
if (!valid() || revision !== attempt) return
|
||||
failed = false
|
||||
dismiss()
|
||||
this.sync()
|
||||
}
|
||||
const loaded = (): void => {
|
||||
if (!valid()) return
|
||||
failed = false
|
||||
dismiss()
|
||||
}
|
||||
const crashed = (): void => { void load() }
|
||||
const dispose = (): void => {
|
||||
if (released) return
|
||||
released = true
|
||||
dismiss()
|
||||
parent.off('focus', show)
|
||||
parent.off('hide', dismiss)
|
||||
surface.webContents.off('did-finish-load', loaded)
|
||||
surface.webContents.off('render-process-gone', crashed)
|
||||
surface.webContents.off('destroyed', dispose)
|
||||
}
|
||||
parent.on('focus', show)
|
||||
parent.on('hide', dismiss)
|
||||
surface.webContents.on('did-finish-load', loaded)
|
||||
surface.webContents.on('render-process-gone', crashed)
|
||||
surface.webContents.once('destroyed', dispose)
|
||||
return { load, dismiss, dispose }
|
||||
}
|
||||
|
||||
private view(): MandatoryUpdateView {
|
||||
|
||||
@@ -51,9 +51,10 @@ export class DesktopPlatformView {
|
||||
* @param preload - bundled sandboxed Platform preload path.
|
||||
* @param getLocale - current resolved Desktop language.
|
||||
* @param platform - operating system this shell runs on, reported to Platform.
|
||||
* @param blocked - Whether shell policy or an update prompt prevents opening a product view.
|
||||
*/
|
||||
constructor(private readonly preload: string, private readonly getLocale: () => PlatformLocale,
|
||||
private readonly platform: 'darwin' | 'win32') {}
|
||||
private readonly platform: 'darwin' | 'win32', private readonly blocked: (owner: PlatformOwner) => boolean) {}
|
||||
|
||||
/** @param next - private Host credentials; identity enrichment preserves an already open temporary document. */
|
||||
setSession(next: PlatformSession | null): void {
|
||||
@@ -75,9 +76,11 @@ export class DesktopPlatformView {
|
||||
* @param page - explicit supported Platform page.
|
||||
* @param bounds - owned renderer rectangle.
|
||||
* @returns when loading finishes, or without a document when superseded or the owner closes or navigates.
|
||||
* @throws When a shell update prompt blocks admission, including after storage cleanup.
|
||||
*/
|
||||
async open(owner: PlatformOwner, page: 'usage' | 'top-up', bounds: PlatformBounds): Promise<void> {
|
||||
if (this.disposed) throw new Error('Platform view disposed')
|
||||
if (this.blocked(owner)) throw new Error('Platform view blocked by update prompt')
|
||||
this.close()
|
||||
const account = this.account
|
||||
if (account === null) throw new Error('Platform account unavailable')
|
||||
@@ -104,6 +107,7 @@ export class DesktopPlatformView {
|
||||
// Sanitize persisted authentication even after an unclean previous process exit.
|
||||
const failure = await this.cleanStorage(browserSession)
|
||||
if (generation !== this.generation) return
|
||||
if (this.blocked(owner)) { this.close(); throw new Error('Platform view blocked by update prompt') }
|
||||
if (failure !== null) {
|
||||
this.close()
|
||||
throw failure.error
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */
|
||||
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
import type { DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
import type { DesktopUpdateOverlays, DesktopUpdateOverlay } from './update-overlay.ts'
|
||||
|
||||
/** Channels available only to the isolated update-dialog document. */
|
||||
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
|
||||
@@ -40,13 +40,17 @@ const page = 'dsh-app://shell/update-dialog.html'
|
||||
export class DesktopUpdateDialog {
|
||||
private disposed = false
|
||||
private revision = 0
|
||||
private window: BrowserWindow | undefined
|
||||
private view: DesktopUpdateOverlay | undefined
|
||||
private parent: BrowserWindow | undefined
|
||||
private closing: ReturnType<typeof setTimeout> | undefined
|
||||
private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
|
||||
private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
|
||||
|
||||
/** Focus the current explanation or confirmation without replacing it or granting permission. */
|
||||
focus(): void { this.active?.window.focus() }
|
||||
focus(): void {
|
||||
if (this.active === undefined || this.parent?.isDestroyed() !== false) return
|
||||
this.parent.focus()
|
||||
this.view?.webContents.focus()
|
||||
}
|
||||
|
||||
/** Whether a shell prompt is awaiting a response. */
|
||||
get isOpen(): boolean { return this.active !== undefined }
|
||||
@@ -54,7 +58,7 @@ export class DesktopUpdateDialog {
|
||||
/**
|
||||
* @param preload - Bundled isolated preload.
|
||||
* @param locale - Shell-owned copy or a reader of the current UI language.
|
||||
* @param overlays - Application-owned overlay creation and input tracking.
|
||||
* @param overlays - Application-owned view creation and input tracking.
|
||||
*/
|
||||
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale),
|
||||
private readonly overlays: Pick<DesktopUpdateOverlays, 'create'>) {
|
||||
@@ -87,9 +91,9 @@ export class DesktopUpdateDialog {
|
||||
this.active?.finish(this.active.view.cancelId, true)
|
||||
clearTimeout(this.closing)
|
||||
this.closing = undefined
|
||||
const existing = this.window
|
||||
const window = existing ?? this.overlays.create(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
|
||||
this.window = window
|
||||
const existing = this.view
|
||||
const surface = existing ?? this.overlays.create(parent, this.preload)
|
||||
this.view = surface
|
||||
this.parent = parent
|
||||
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
|
||||
detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose,
|
||||
@@ -100,20 +104,23 @@ export class DesktopUpdateDialog {
|
||||
if (this.active?.view !== view) return
|
||||
this.active = undefined
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
if (!retain && !window.isDestroyed()) {
|
||||
window.webContents.send(UPDATE_DIALOG_IPC.changed, null)
|
||||
if (!retain && !surface.webContents.isDestroyed()) {
|
||||
surface.webContents.send(UPDATE_DIALOG_IPC.changed, null)
|
||||
this.closing = setTimeout(() => { this.close() }, 150)
|
||||
}
|
||||
resolve({ response, checkboxChecked: false })
|
||||
}
|
||||
this.active = { window, view, finish }
|
||||
this.active = { view, finish }
|
||||
options.signal?.addEventListener('abort', abort, { once: true })
|
||||
if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
|
||||
const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } }
|
||||
window.once('closed', failed)
|
||||
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
|
||||
window.webContents.once('render-process-gone', failed)
|
||||
void window.loadURL(page).catch(failed)
|
||||
if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
|
||||
const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } }
|
||||
surface.webContents.once('destroyed', failed)
|
||||
surface.webContents.once('render-process-gone', failed)
|
||||
surface.webContents.once('preload-error', failed)
|
||||
surface.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
|
||||
surface.webContents.on('will-redirect', (event) => { event.preventDefault() })
|
||||
surface.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
void surface.loadURL(page).catch(failed)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,20 +140,16 @@ export class DesktopUpdateDialog {
|
||||
private close(): void {
|
||||
clearTimeout(this.closing)
|
||||
this.closing = undefined
|
||||
const window = this.window
|
||||
this.window = undefined
|
||||
const surface = this.view
|
||||
this.view = undefined
|
||||
this.parent = undefined
|
||||
if (window !== undefined && !window.isDestroyed()) {
|
||||
// Release Windows modality before destruction so another application cannot become active.
|
||||
if (process.platform === 'win32' && window.isVisible()) window.hide()
|
||||
window.destroy()
|
||||
}
|
||||
surface?.destroy()
|
||||
}
|
||||
|
||||
private owned(event: IpcMainInvokeEvent): void {
|
||||
const window = this.window
|
||||
if (window === undefined || event.sender !== window.webContents
|
||||
|| event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) {
|
||||
const surface = this.view
|
||||
if (surface === undefined || event.sender !== surface.webContents
|
||||
|| event.senderFrame !== surface.webContents.mainFrame || event.senderFrame.url !== page) {
|
||||
throw new Error('desktop update: rejected unowned dialog renderer')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,83 @@
|
||||
/** Shell-owned modal windows cover the parent's content without replacing its native window controls. */
|
||||
import { BrowserWindow } from 'electron'
|
||||
/** Isolated update documents share their owner's native window and input lifetime. */
|
||||
import { WebContentsView, type BrowserWindow, type WebContents, type View } from 'electron'
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
||||
|
||||
const unblockedInput = { revision: 0, blocked: false } as const
|
||||
/** Main-owned document with no independent native window or activation state. */
|
||||
export interface DesktopUpdateOverlay {
|
||||
readonly webContents: WebContents
|
||||
/** Focus the document only while its owner already has focus. */
|
||||
focus(): void
|
||||
/** Release the view, input interception, and owner listeners. */
|
||||
destroy(): void
|
||||
/** @returns Whether the document has been destroyed. */
|
||||
isDestroyed(): boolean
|
||||
/** @param url - Shell-owned document URL. @returns Completion of document loading. */
|
||||
loadURL(url: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Tracks application-owned update overlays and their parent input state. */
|
||||
type UpdateOwner = Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed' | 'isFocused'>
|
||||
& Pick<EventEmitter, 'on' | 'once' | 'off'> & {
|
||||
readonly webContents: Pick<EventEmitter, 'prependListener' | 'off'> & Pick<WebContents, 'focus'>
|
||||
readonly contentView: Pick<View, 'addChildView' | 'removeChildView'>
|
||||
}
|
||||
|
||||
/** Tracks isolated update views and each owner's keyboard-blocking revision. */
|
||||
export class DesktopUpdateOverlays {
|
||||
private readonly inputStates = new WeakMap<BrowserWindow, { revision: number; active: number; readonly blocked: boolean }>()
|
||||
private readonly inputs = new WeakMap<object, { revision: number; active: number; readonly blocked: boolean }>()
|
||||
|
||||
/**
|
||||
* @param parent - Product window whose input may belong to an update dialog.
|
||||
* @returns Current blocking state; its revision changes whenever an overlay opens or closes.
|
||||
*/
|
||||
input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } {
|
||||
return this.inputStates.get(parent) ?? unblockedInput
|
||||
/** @param parent - Product window. @returns Blocking state and attachment/release revision. */
|
||||
input(parent: Pick<BrowserWindow, 'isDestroyed'>): { readonly revision: number; readonly blocked: boolean } {
|
||||
const state = this.inputs.get(parent)
|
||||
return { revision: state?.revision ?? 0, blocked: state?.blocked ?? false }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parent - Product window whose content is blocked while the overlay is open.
|
||||
* @param preload - Isolated shell-only preload.
|
||||
* @param title - Localized window title.
|
||||
* @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets.
|
||||
* @returns A transparent child that follows its parent's bounds and visibility after loading and releases its listeners on close.
|
||||
* @param parent - Native owner; its caption remains operable.
|
||||
* @param preload - Isolated shell preload.
|
||||
* @returns Document attached inside the owner, without showing or activating a native window.
|
||||
*/
|
||||
create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
|
||||
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
|
||||
skipTaskbar: true, hasShadow: false, title,
|
||||
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
|
||||
})
|
||||
const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
|
||||
this.inputStates.set(parent, inputState)
|
||||
inputState.active++; inputState.revision++
|
||||
window.once('closed', () => { inputState.active--; inputState.revision++ })
|
||||
// macOS native modals animate the entire viewport as a sheet.
|
||||
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
|
||||
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
|
||||
if (!nativeModal && process.platform === 'darwin') {
|
||||
parent.on('focus', focus)
|
||||
// Shell dialogs block input before product shortcut listeners can dispatch it.
|
||||
parent.webContents.prependListener('before-input-event', blockInput)
|
||||
window.once('closed', () => {
|
||||
parent.off('focus', focus)
|
||||
if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput)
|
||||
})
|
||||
create(parent: UpdateOwner, preload: string): DesktopUpdateOverlay {
|
||||
const view = new WebContentsView({ webPreferences: {
|
||||
preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true,
|
||||
} })
|
||||
const ownerContents = parent.webContents
|
||||
const contents = view.webContents
|
||||
const input = this.inputs.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
|
||||
this.inputs.set(parent, input)
|
||||
input.active++; input.revision++
|
||||
const resize = (): void => {
|
||||
const { width, height } = parent.getContentBounds()
|
||||
const top = process.platform === 'win32' ? WINDOWS_TITLEBAR_HEIGHT : 0
|
||||
view.setBounds({ x: 0, y: top, width, height: Math.max(0, height - top) })
|
||||
}
|
||||
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
|
||||
parent.on('move', follow)
|
||||
parent.on('resize', follow)
|
||||
window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) })
|
||||
let ready = false
|
||||
const show = (): void => {
|
||||
if (ready && !window.isDestroyed() && !parent.isDestroyed() && parent.isVisible()) window.show()
|
||||
const focus = (): void => {
|
||||
if (!parent.isDestroyed() && parent.isFocused() && !contents.isDestroyed()) contents.focus()
|
||||
}
|
||||
parent.on('show', show)
|
||||
window.once('closed', () => { parent.off('show', show) })
|
||||
window.once('ready-to-show', () => { ready = true; show() })
|
||||
window.setMenu(null)
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
return window
|
||||
const block = (event: Electron.Event): void => { event.preventDefault(); focus() }
|
||||
const destroy = (): void => { if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false }) }
|
||||
const release = (): void => {
|
||||
parent.off('resize', resize)
|
||||
parent.off('focus', focus)
|
||||
parent.off('closed', destroy)
|
||||
ownerContents.off('before-input-event', block)
|
||||
input.active--; input.revision++
|
||||
if (!parent.isDestroyed()) {
|
||||
parent.contentView.removeChildView(view)
|
||||
if (parent.isFocused()) ownerContents.focus()
|
||||
}
|
||||
}
|
||||
view.setBackgroundColor('#00000000')
|
||||
parent.contentView.addChildView(view)
|
||||
resize()
|
||||
parent.on('resize', resize)
|
||||
parent.on('focus', focus)
|
||||
parent.once('closed', destroy)
|
||||
ownerContents.prependListener('before-input-event', block)
|
||||
contents.once('destroyed', release)
|
||||
contents.on('did-finish-load', focus)
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
return { webContents: contents, focus, destroy, isDestroyed: () => contents.isDestroyed(),
|
||||
loadURL: url => contents.loadURL(url) }
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,20 @@
|
||||
en: 94559f431b0b2151
|
||||
zh: 1a22166a2bc59686
|
||||
/desktop-local-update-verification/table-of-contents:
|
||||
en: a5edee063916cd27
|
||||
zh: 7a5286350ff09e58
|
||||
en: 86afdf78fa613876
|
||||
zh: 66fad143d99e999f
|
||||
/desktop-local-update-verification/update-dialog-themes:
|
||||
en: d086c3b38ce623f1
|
||||
zh: 8327c31092f5f174
|
||||
/desktop-local-update-verification/native-overlay-visibility:
|
||||
en: cff2c259a195bb57
|
||||
zh: fa03ff3fdc32a346
|
||||
en: 6f4de4503748f3e2
|
||||
zh: 648370375f16e463
|
||||
/desktop-local-update-verification/manual-walkthrough:
|
||||
en: c4b4ee8763d8464a
|
||||
zh: ee2159bc08966592
|
||||
/desktop-local-update-verification/evidence:
|
||||
en: 331a6709d0f7d03f
|
||||
zh: 98499192ef9d3a81
|
||||
en: dcb2473264c825d4
|
||||
zh: cebb2832efa15974
|
||||
/desktop-local-update-verification/open-verification:
|
||||
en: 20e9ea47acb1ff50
|
||||
zh: acce24b56d57e12c
|
||||
@@ -10,11 +10,24 @@ Installed qualification can opt into `DSH_DESKTOP_UPDATE_JOURNAL_DIR`, an absolu
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Update dialog themes](#verification-theme)
|
||||
- [Native overlay visibility](#verification-overlay)
|
||||
- [Evidence](#verification-evidence)
|
||||
- [Manual walkthrough](#verification-interactive)
|
||||
- [Open verification](#verification-open)
|
||||
|
||||
<a id="verification-theme"></a>
|
||||
|
||||
## Update dialog themes
|
||||
|
||||
After `pnpm --dir apps/desktop run build`, run the real Electron palette fixture on macOS:
|
||||
|
||||
```sh
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-theme.mjs
|
||||
```
|
||||
|
||||
The fixture creates a private profile, loads the production shell HTML, sandboxed preloads, renderer scripts, and bundled design tokens, and substitutes only fixed IPC status responses. Its 12 cases cover ordinary and mandatory documents starting in light/dark mode, switching while open, and following system appearance. It checks transparent document backgrounds, surface/text/button/mask colors, the code/address surface, the dark-only 1 CSS px inset outline, and the ordinary close icon, then writes four screenshots and `result.json` to `.desktop-build/qualification/update-theme-*`. It does not start a Host, download updates, run an installer, or qualify Windows native rendering. The first-close notice shares the ordinary document's CSS and theme script.
|
||||
|
||||
<a id="verification-overlay"></a>
|
||||
|
||||
## Native overlay visibility
|
||||
@@ -26,7 +39,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
|
||||
```
|
||||
|
||||
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It compares native visibility, unfiltered parent content, and listener cleanup with the owner-local expected output for parent hide/show and document readiness while the parent is hidden. It uses no network, product login, or dsh Host. This qualifies native window restoration rather than the full first-login flow.
|
||||
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It checks visible and hidden owners, absence of native child windows, retention of a separate control window’s focus after owner visibility notifications, and view/input cleanup including owner destruction. It uses no network, product login, or dsh Host. It qualifies the in-window carrier, not the full first-login or installation flow.
|
||||
|
||||
<a id="verification-interactive"></a>
|
||||
|
||||
@@ -49,8 +62,8 @@ Packaging supervision fixtures control their Git metadata and use real file hash
|
||||
| Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass |
|
||||
| Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass |
|
||||
| Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup |
|
||||
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
|
||||
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
|
||||
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; focused main-entry tests verify close-to-hide without quitting or stopping the Host, with policy retained |
|
||||
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, theme-resolved primary button, translucent backdrop without parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
|
||||
| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload |
|
||||
| Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking |
|
||||
| Host task protection | The actual controller with substituted composition detects running agents, queued turns/steps, and global and agent jobs; API reads do not warn. Admission locking returns 503 for new requests, drains existing requests, and rechecks tasks; unlock restores admission |
|
||||
@@ -63,7 +76,7 @@ Chromium headless shell revision 1228 is installed in the ignored `.desktop-buil
|
||||
|
||||
The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written.
|
||||
|
||||
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results.
|
||||
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. The recorded Windows run passes ten scenarios, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. The macOS workspace path has not completed qualification; these Windows results do not cover it.
|
||||
|
||||
The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact.
|
||||
|
||||
|
||||
@@ -10,11 +10,24 @@
|
||||
|
||||
## 目录
|
||||
|
||||
- [更新弹窗主题](#verification-theme)
|
||||
- [原生浮层可见性](#verification-overlay)
|
||||
- [验证证据](#verification-evidence)
|
||||
- [手动演练](#verification-interactive)
|
||||
- [待验证事项](#verification-open)
|
||||
|
||||
<a id="verification-theme"></a>
|
||||
|
||||
## 更新弹窗主题
|
||||
|
||||
执行 `pnpm --dir apps/desktop run build` 后,在 macOS 上运行真实 Electron 配色夹具:
|
||||
|
||||
```sh
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-theme.mjs
|
||||
```
|
||||
|
||||
夹具创建独立 profile,加载生产壳 HTML、sandboxed preload、渲染脚本及打包的 design token,仅替换固定 IPC 状态响应。12 个用例覆盖普通和强更文档的浅色/暗色初始状态、打开时切换及跟随系统。它检查文档背景透明、卡片/文字/按钮/遮罩配色、代码/地址表面、仅暗色显示的 1 CSS px 内描边及普通弹窗关闭图标,并将四张截图和 `result.json` 写入 `.desktop-build/qualification/update-theme-*`。它不启动 Host、不下载更新、不运行安装器,也不验证 Windows 原生渲染。首次关闭提示共用普通文档的 CSS 和主题脚本。
|
||||
|
||||
<a id="verification-overlay"></a>
|
||||
|
||||
## 原生浮层可见性
|
||||
@@ -26,7 +39,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
|
||||
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
|
||||
```
|
||||
|
||||
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它对照所属测试目录中的预期输出,检查父窗口隐藏/显示、父窗口隐藏期间文档就绪两种顺序中的原生可见性、父页面不受模糊影响及监听清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖原生窗口恢复,不覆盖完整的首次登录流程。
|
||||
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它检查可见和隐藏的父窗口、不创建原生子窗口、父窗口可见性通知后独立对照窗口仍保留焦点,以及销毁父窗口时的视图和输入清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖窗内承载方式,不覆盖完整的首次登录或安装流程。
|
||||
|
||||
<a id="verification-interactive"></a>
|
||||
|
||||
@@ -49,8 +62,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
|
||||
| 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 |
|
||||
| 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 |
|
||||
| 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 |
|
||||
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
|
||||
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
|
||||
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向主入口测试验证关闭时隐藏、不退出也不停止 Host,并保留策略 |
|
||||
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、随主题变化的主按钮、不模糊父页面的半透明遮罩、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
|
||||
| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 |
|
||||
| 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 |
|
||||
| Host 任务保护 | 替换组合环境后的真实控制器识别运行中的 agent、排队的 turn/step、全局和 agent job;API 读取不触发警告。请求准入锁定向新请求返回 503,等待已有请求结束并复查任务;解锁恢复准入 |
|
||||
@@ -63,7 +76,7 @@ Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/p
|
||||
|
||||
[构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。
|
||||
|
||||
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。
|
||||
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。已有 Windows 运行记录中十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。macOS 工作区路径尚未完成验证;这些 Windows 结果不覆盖它。
|
||||
|
||||
[Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。
|
||||
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
[
|
||||
{
|
||||
"initiallyVisible": true,
|
||||
"loaded": { "parent": true, "overlay": true, "filter": "none" },
|
||||
"restored": { "parent": true, "overlay": true, "filter": "none" },
|
||||
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
|
||||
},
|
||||
{
|
||||
"initiallyVisible": false,
|
||||
"loaded": { "parent": false, "overlay": false, "filter": "none" },
|
||||
"restored": { "parent": true, "overlay": true, "filter": "none" },
|
||||
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
|
||||
},
|
||||
{ "initiallyVisible": true, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
|
||||
{ "initiallyVisible": false, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
|
||||
{ "parentDestroyed": true, "overlayDestroyed": true }
|
||||
]
|
||||
+12
-9
@@ -17,6 +17,7 @@ import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig } from '../../
|
||||
import { DesktopMandatoryUpdateWindow } from '../../lib/types/mandatory-update-window.js'
|
||||
import { DesktopUpdatePreparationError } from '../../lib/types/update-error.js'
|
||||
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
import { qualifyUpdateDialogs } from './update-dialogs.mjs'
|
||||
|
||||
const root = process.env.DSH_LOCAL_UPDATE_TEST_ROOT
|
||||
@@ -230,9 +231,9 @@ async function main() {
|
||||
protocol.handle('dsh-app', async request => {
|
||||
if (new URL(request.url).hostname === 'app') return new Response('<!doctype html><title>Updater qualification</title>', { headers: { 'content-type': 'text/html' } })
|
||||
const path = new URL(request.url).pathname.slice(1)
|
||||
assert.ok(['mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
|
||||
assert.ok(['design-platform.css', 'shell-theme.js', 'mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path))
|
||||
const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html'
|
||||
return new Response(await readFile(new URL(`../../renderer/${path}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
return new Response(await readFile(new URL(`${path === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${path}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
})
|
||||
const parent = new BrowserWindow({ show: true, width: 900, height: 650, webPreferences: { preload: fileURLToPath(new URL('../../lib/preload-app.cjs', import.meta.url)), sandbox: true, contextIsolation: true } })
|
||||
await parent.loadURL('dsh-app://app/')
|
||||
@@ -244,6 +245,7 @@ async function main() {
|
||||
() => { modal?.sync() }, undefined,
|
||||
() => ({ version: '1.0.0', locale: 'zh-CN', timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 }))
|
||||
modal = new DesktopMandatoryUpdateWindow({
|
||||
overlays: new DesktopUpdateOverlays(),
|
||||
preload: fileURLToPath(new URL('../../lib/preload-mandatory.cjs', import.meta.url)),
|
||||
locale: resolveDesktopLocale('zh-CN'), allowedPageOrigins: config.allowedPageOrigins,
|
||||
parent: () => parent, policy: () => policy.state, update: () => f.coordinator.state,
|
||||
@@ -268,11 +270,12 @@ async function main() {
|
||||
await policy.check('launch')
|
||||
const window = modal.confirmationWindow
|
||||
assert.ok(window)
|
||||
const contents = process.platform === 'win32' ? await mandatoryFrame(parent) : window.webContents
|
||||
const contents = process.platform === 'win32' ? await mandatoryFrame(parent)
|
||||
: parent.contentView.children.at(-1).webContents
|
||||
assert.equal(window.isModal(), false)
|
||||
assert.equal(parent.isEnabled(), true)
|
||||
window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
|
||||
window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
|
||||
contents.on('console-message', event => { console.log('mandatory renderer:', event.message) })
|
||||
contents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) })
|
||||
async function until(expression) {
|
||||
try { return await contents.executeJavaScript(`new Promise((resolve, reject) => {
|
||||
const observer = new MutationObserver(check);
|
||||
@@ -325,7 +328,7 @@ async function main() {
|
||||
await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled")
|
||||
assert.equal(f.installations.length, 0)
|
||||
let capture
|
||||
try { capture = await window.webContents.capturePage() }
|
||||
try { capture = await contents.capturePage() }
|
||||
catch (error) { screenshot = { captured: false, reason: String(error) } }
|
||||
if (capture !== undefined) {
|
||||
await writeFile(join(root, 'mandatory-update.png'), capture.toPNG())
|
||||
@@ -348,15 +351,15 @@ async function main() {
|
||||
assert.equal(await contents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true)
|
||||
}
|
||||
const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png'
|
||||
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
|
||||
await writeFile(join(root, name), (await contents.capturePage()).toPNG())
|
||||
dialogScreenshots.push(name)
|
||||
}
|
||||
assert.equal(policy.state.blocking, true)
|
||||
assert.equal(f.installations.length, 0)
|
||||
server.policy('clear')
|
||||
const closed = process.platform === 'win32' ? undefined : once(window, 'closed')
|
||||
const closed = process.platform === 'win32' ? undefined : once(contents, 'destroyed')
|
||||
await policy.check('manual', true)
|
||||
if (closed) { await closed; assert.equal(window.isDestroyed(), true) }
|
||||
if (closed) { await closed; assert.equal(contents.isDestroyed(), true) }
|
||||
else {
|
||||
const deadline = Date.now() + 10000
|
||||
while (!contents.detached && Date.now() < deadline) await delay(20)
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ async function run() {
|
||||
await app.whenReady()
|
||||
const { DesktopPlatformView } = await import(pathToFileURL(viewModule).href)
|
||||
const owner = new BrowserWindow({ show: false, width: 900, height: 700 })
|
||||
const manager = new DesktopPlatformView('', () => 'en_US', process.platform === 'win32' ? 'win32' : 'darwin')
|
||||
const manager = new DesktopPlatformView('', () => 'en_US', process.platform === 'win32' ? 'win32' : 'darwin', () => false)
|
||||
const account = userId => ({ origin: base, token: 'fixture-secret', userId })
|
||||
const states = []
|
||||
const record = async name => {
|
||||
|
||||
+19
-13
@@ -1,12 +1,12 @@
|
||||
/** Real isolated update dialogs; installer handoff is recorded by the caller's inert updater. */
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
import assert from 'node:assert/strict'
|
||||
import { once } from 'node:events'
|
||||
import { access, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { app, BrowserWindow, protocol } from 'electron'
|
||||
import { app, BrowserWindow, nativeTheme, protocol } from 'electron'
|
||||
import { DesktopUpdateDialog } from '../../lib/types/update-dialog.js'
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
import { DesktopBackgroundNotice } from '../../lib/types/background-notice.js'
|
||||
import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js'
|
||||
|
||||
@@ -21,15 +21,17 @@ async function rendered(window) {
|
||||
|
||||
/** Exercise cancellation and explicit installation through the production preload and renderer. */
|
||||
export async function qualifyUpdateDialogs(root, fixture) {
|
||||
const theme = nativeTheme.themeSource
|
||||
nativeTheme.themeSource = 'light'
|
||||
const locale = resolveDesktopLocale('zh-CN')
|
||||
const messages = locale.messages
|
||||
const screenshots = []
|
||||
protocol.handle('dsh-app', async request => {
|
||||
const name = new URL(request.url).pathname.slice(1)
|
||||
assert.ok(['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name))
|
||||
assert.ok(['design-platform.css', 'shell-theme.js', 'update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name))
|
||||
const mime = name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css'
|
||||
: name.endsWith('.svg') ? 'image/svg+xml' : 'text/html'
|
||||
return new Response(await readFile(new URL(`../../renderer/${name}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
return new Response(await readFile(new URL(`${name === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${name}`, import.meta.url)), { headers: { 'content-type': mime } })
|
||||
})
|
||||
const parent = new BrowserWindow({ show: true, width: 900, height: 650 })
|
||||
const dialogs = new DesktopUpdateDialog(fileURLToPath(new URL('../../lib/preload-update-dialog.cjs', import.meta.url)), locale, new DesktopUpdateOverlays())
|
||||
@@ -48,9 +50,10 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], cancelId: 1,
|
||||
}
|
||||
f.restart(async () => (await dialogs.show(parent, options)).response === 0)
|
||||
const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const created = once(app, 'web-contents-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const pending = f.coordinator.install(available.version)
|
||||
const [, window] = await created
|
||||
const [, contents] = await created
|
||||
const window = { webContents: contents, isDestroyed: () => contents.isDestroyed() }
|
||||
await once(window.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
|
||||
await rendered(window)
|
||||
const view = await window.webContents.executeJavaScript(`({
|
||||
@@ -76,7 +79,7 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
const name = active ? 'update-active-tasks.png' : 'update-ready.png'
|
||||
await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG())
|
||||
screenshots.push(name)
|
||||
const closed = once(window, 'closed', { signal: AbortSignal.timeout(10_000) })
|
||||
const closed = once(window.webContents, 'destroyed', { signal: AbortSignal.timeout(10_000) })
|
||||
if (active) {
|
||||
await window.webContents.executeJavaScript("setTimeout(() => document.querySelector('.primary').click(), 0); undefined")
|
||||
} else {
|
||||
@@ -89,11 +92,12 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
assert.equal(f.installations.length, active ? 1 : 0)
|
||||
}
|
||||
assert.deepEqual(f.installations, [[true, true]])
|
||||
const created = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const created = once(app, 'web-contents-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const diagnostics = 'exit 0; shutdown acknowledged false\n' + 'at internal/diagnostic/path\n'.repeat(1000)
|
||||
const pending = dialogs.show(parent, { title: messages.updateFailedTitle, message: messages.updateStopFailed,
|
||||
technicalDetails: diagnostics })
|
||||
const [, errorWindow] = await created
|
||||
const [, errorContents] = await created
|
||||
const errorWindow = { webContents: errorContents }
|
||||
await once(errorWindow.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
|
||||
await rendered(errorWindow)
|
||||
assert.equal(await errorWindow.webContents.executeJavaScript("document.getElementById('technical-details').open"), false)
|
||||
@@ -116,17 +120,18 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
errorWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
|
||||
assert.equal((await pending).response, 0)
|
||||
dialogs.cancel()
|
||||
await once(errorWindow, 'closed', { signal: AbortSignal.timeout(10_000) })
|
||||
await once(errorWindow.webContents, 'destroyed', { signal: AbortSignal.timeout(10_000) })
|
||||
const markerPath = join(root, 'background-close-confirmed')
|
||||
const notice = new DesktopBackgroundNotice({ markerPath, locale: () => locale,
|
||||
show: options => dialogs.show(parent, options), focus: () => dialogs.focus() })
|
||||
try {
|
||||
for (const confirm of [false, true]) {
|
||||
parent.show()
|
||||
const createdNotice = once(app, 'browser-window-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const createdNotice = once(app, 'web-contents-created', { signal: AbortSignal.timeout(10_000) })
|
||||
const hidden = Promise.withResolvers()
|
||||
notice.close(() => { parent.hide(); hidden.resolve() })
|
||||
const [, noticeWindow] = await createdNotice
|
||||
const [, noticeContents] = await createdNotice
|
||||
const noticeWindow = { webContents: noticeContents }
|
||||
await once(noticeWindow.webContents, 'did-finish-load', { signal: AbortSignal.timeout(10_000) })
|
||||
await rendered(noticeWindow)
|
||||
assert.equal(parent.isVisible(), true)
|
||||
@@ -136,7 +141,7 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
detailHidden: document.getElementById('detail').hidden,
|
||||
})`), JSON.parse(await readFile(new URL('../expected/background-close-confirmation.json', import.meta.url), 'utf8')))
|
||||
await assert.rejects(access(markerPath))
|
||||
const closedNotice = once(noticeWindow, 'closed', { signal: AbortSignal.timeout(10_000) })
|
||||
const closedNotice = once(noticeWindow.webContents, 'destroyed', { signal: AbortSignal.timeout(10_000) })
|
||||
if (confirm) {
|
||||
const name = 'background-close-confirmation.png'
|
||||
await noticeWindow.webContents.executeJavaScript(`Promise.all(document.getAnimations().map(animation => animation.finished))`)
|
||||
@@ -159,5 +164,6 @@ export async function qualifyUpdateDialogs(root, fixture) {
|
||||
dialogs.dispose()
|
||||
parent.destroy()
|
||||
protocol.unhandle('dsh-app')
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
}
|
||||
+37
-72
@@ -1,4 +1,4 @@
|
||||
/** Native overlay visibility qualification without a Host, network, or user profile. */
|
||||
/** Native-owner visibility and focus qualification for isolated in-window update views. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { once } from 'node:events'
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
@@ -7,88 +7,53 @@ import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
|
||||
|
||||
const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url))
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const root = mkdtempSync(join(directory, 'update-overlay-'))
|
||||
mkdirSync(join(root, 'profile'))
|
||||
app.setPath('userData', join(root, 'profile'))
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
async function main() {
|
||||
await app.whenReady()
|
||||
const cases = []
|
||||
const windows = []
|
||||
const closeWindows = () => {
|
||||
for (const window of [...windows].reverse()) if (!window.isDestroyed()) window.destroy()
|
||||
}
|
||||
let phase = 'application ready'
|
||||
const deadline = setTimeout(() => {
|
||||
console.error(`Update overlay qualification exceeded 30 seconds: ${phase}`)
|
||||
closeWindows()
|
||||
app.exit(1)
|
||||
}, 30_000)
|
||||
|
||||
async function qualify(initiallyVisible, closeParentFirst = false) {
|
||||
const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 })
|
||||
windows.push(parent)
|
||||
phase = `parent load (${initiallyVisible})`
|
||||
await parent.loadURL('data:text/html,<h1>Update overlay visibility</h1><input aria-label="Keyboard probe">')
|
||||
const initialShowListeners = parent.listenerCount('show')
|
||||
const overlay = new DesktopUpdateOverlays().create(parent, undefined, 'Overlay qualification', false)
|
||||
windows.push(overlay)
|
||||
const ready = once(overlay, 'ready-to-show')
|
||||
const initiallyShown = initiallyVisible ? once(overlay, 'show') : undefined
|
||||
phase = `overlay load (${initiallyVisible})`
|
||||
await overlay.loadURL('data:text/html,<h1>Update confirmation</h1>')
|
||||
phase = `overlay ready (${initiallyVisible})`
|
||||
await ready
|
||||
await initiallyShown
|
||||
const filter = () => parent.webContents.executeJavaScript('getComputedStyle(document.body).filter')
|
||||
const loaded = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: await filter() }
|
||||
if (initiallyVisible) {
|
||||
const hidden = once(parent, 'hide')
|
||||
parent.hide()
|
||||
await hidden
|
||||
}
|
||||
const shown = once(parent, 'show')
|
||||
phase = `overlay restored (${initiallyVisible})`
|
||||
parent.show()
|
||||
await shown
|
||||
const restoredFilter = await filter()
|
||||
const restored = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: restoredFilter }
|
||||
phase = `overlay release (${initiallyVisible})`
|
||||
if (closeParentFirst) {
|
||||
parent.destroy()
|
||||
if (!overlay.isDestroyed()) overlay.destroy()
|
||||
cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() })
|
||||
return
|
||||
}
|
||||
overlay.destroy()
|
||||
const released = { filter: await filter(), showListenersRestored: parent.listenerCount('show') === initialShowListeners,
|
||||
keyboardListeners: parent.webContents.listenerCount('before-input-event') }
|
||||
parent.hide()
|
||||
parent.show()
|
||||
cases.push({ initiallyVisible, loaded, restored, released })
|
||||
parent.destroy()
|
||||
}
|
||||
|
||||
const deadline = setTimeout(() => { console.error('Overlay qualification deadline exceeded'); app.exit(1) }, 30000)
|
||||
try {
|
||||
await app.whenReady()
|
||||
await qualify(true)
|
||||
await qualify(false)
|
||||
await qualify(true, true)
|
||||
await writeFile(join(root, 'result.json'), `${JSON.stringify(cases, null, 2)}\n`)
|
||||
const expected = JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8'))
|
||||
assert.deepEqual(cases, expected)
|
||||
console.log(`Update overlay qualification passed: ${root}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
for (const initiallyVisible of [true, false]) {
|
||||
const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 })
|
||||
windows.push(parent)
|
||||
await parent.loadURL('data:text/html,<h1>Update owner</h1>')
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
const overlay = overlays.create(parent, undefined)
|
||||
await overlay.loadURL('data:text/html,<h1>Isolated update</h1>')
|
||||
assert.equal(parent.getChildWindows().length, 0)
|
||||
assert.equal(parent.contentView.children.length, 1)
|
||||
const control = new BrowserWindow({ show: true, width: 300, height: 200 })
|
||||
windows.push(control)
|
||||
await control.loadURL('data:text/html,<h1>Focus control</h1>')
|
||||
const focused = control.isFocused() ? undefined : once(control, 'focus')
|
||||
control.focus(); await focused
|
||||
parent.emit('show')
|
||||
assert.equal(control.isFocused(), true)
|
||||
assert.equal(parent.isFocused(), false)
|
||||
const destroyed = once(overlay.webContents, 'destroyed')
|
||||
overlay.destroy(); await destroyed
|
||||
cases.push({ initiallyVisible, childWindows: parent.getChildWindows().length, controlKeepsFocus: control.isFocused(),
|
||||
viewsReleased: parent.contentView.children.length === 0, input: overlays.input(parent) })
|
||||
parent.destroy(); control.destroy()
|
||||
}
|
||||
const parent = new BrowserWindow({ show: false })
|
||||
windows.push(parent)
|
||||
const overlay = new DesktopUpdateOverlays().create(parent, undefined)
|
||||
const destroyed = once(overlay.webContents, 'destroyed')
|
||||
parent.destroy(); await destroyed
|
||||
cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() })
|
||||
assert.deepEqual(cases, JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8')))
|
||||
await writeFile(join(root, 'result.json'), JSON.stringify(cases, null, 2) + '\n')
|
||||
console.log(`Update view qualification passed: ${root}`)
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
closeWindows()
|
||||
app.exit(process.exitCode ?? 0)
|
||||
for (const window of windows) if (!window.isDestroyed()) window.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch(error => { console.error(error); app.exit(1) })
|
||||
void main().then(() => app.exit(0), error => { console.error(error); app.exit(1) })
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/** Real Chromium qualification of shared shell tokens and app-resolved palette changes. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, protocol } from 'electron'
|
||||
import { resolveDesktopLocale } from '../../lib/types/locale.js'
|
||||
import { UPDATE_DIALOG_IPC } from '../../lib/types/update-dialog.js'
|
||||
import { MANDATORY_IPC } from '../../lib/types/mandatory-update-ipc.js'
|
||||
|
||||
const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url))
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const root = mkdtempSync(join(directory, 'update-theme-'))
|
||||
app.setPath('userData', root)
|
||||
protocol.registerSchemesAsPrivileged([{ scheme: 'dsh-app', privileges: { standard: true, secure: true, supportFetchAPI: true } }])
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
async function main() {
|
||||
await app.whenReady()
|
||||
const original = nativeTheme.themeSource
|
||||
const locale = resolveDesktopLocale('en')
|
||||
const results = []
|
||||
const windows = []
|
||||
const deadline = setTimeout(() => { console.error('Theme qualification deadline exceeded'); app.exit(1) }, 30000)
|
||||
protocol.handle('dsh-app', async request => {
|
||||
const name = new URL(request.url).pathname.slice(1)
|
||||
const file = new URL(`${name === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${name}`, import.meta.url)
|
||||
const mime = name.endsWith('.css') ? 'text/css' : name.endsWith('.js') ? 'text/javascript'
|
||||
: name.endsWith('.svg') ? 'image/svg+xml' : 'text/html'
|
||||
return new Response(await readFile(file), { headers: { 'content-type': mime } })
|
||||
})
|
||||
ipcMain.handle(UPDATE_DIALOG_IPC.status, () => ({ revision: 1, locale: locale.id,
|
||||
title: 'Check for updates', message: 'Update ready', detail: 'Shell palette qualification',
|
||||
buttons: ['Install', 'Later'], cancelId: 1, closeLabel: 'Close', technicalDetails: 'Details', technicalDetailsLabel: 'Details' }))
|
||||
ipcMain.handle(MANDATORY_IPC.status, () => ({ locale, policy: { blocking: true, checking: false },
|
||||
update: { phase: 'error', failedOperation: 'download', version: '1.0.0', technicalDetails: 'Details' }, deferred: false }))
|
||||
try {
|
||||
assert.equal(await readFile(new URL('../../lib/welcome/design-platform.css', import.meta.url), 'utf8'),
|
||||
await readFile(new URL('../../../../packages/client/ui-theme/src/styles/design-platform.css', import.meta.url), 'utf8'))
|
||||
for (const document of ['update-dialog', 'mandatory-update']) {
|
||||
for (const initial of ['light', 'dark']) {
|
||||
nativeTheme.themeSource = initial
|
||||
const window = new BrowserWindow({ show: true, width: 600, height: 650,
|
||||
webPreferences: { preload: fileURLToPath(new URL(`../../lib/preload-${document === 'update-dialog' ? 'update-dialog' : 'mandatory'}.cjs`, import.meta.url)),
|
||||
contextIsolation: true, sandbox: true, nodeIntegration: false } })
|
||||
windows.push(window)
|
||||
// Palette checks exclude the user's pointer so hover cannot select an interaction color.
|
||||
window.setIgnoreMouseEvents(true)
|
||||
await window.loadURL(`dsh-app://shell/${document}.html`)
|
||||
for (const source of [initial, initial === 'light' ? 'dark' : 'light', 'system']) {
|
||||
nativeTheme.themeSource = source
|
||||
const dark = nativeTheme.shouldUseDarkColors
|
||||
const colors = await window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
|
||||
const dark = ${dark};
|
||||
const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Palette did not settle')); }, 10000);
|
||||
const observer = new MutationObserver(check);
|
||||
function check() {
|
||||
if (!document.body.classList.contains('visible') || document.body.hasAttribute('data-ds-dark-theme') !== dark) return;
|
||||
observer.disconnect(); clearTimeout(deadline);
|
||||
const card = getComputedStyle(document.querySelector('main'));
|
||||
const primary = getComputedStyle(document.querySelector('.primary'));
|
||||
resolve({ dark, surface: card.backgroundColor, label: card.color, primary: primary.backgroundColor,
|
||||
primaryLabel: primary.color, boxShadow: card.boxShadow,
|
||||
diagnostic: getComputedStyle(document.querySelector('pre, #address')).backgroundColor, body: getComputedStyle(document.body).backgroundColor,
|
||||
mask: getComputedStyle(document.body, '::before').backgroundColor,
|
||||
close: document.querySelector('.close') ? getComputedStyle(document.querySelector('.close'), '::before').backgroundColor : null });
|
||||
}
|
||||
observer.observe(document.body, { attributes: true }); check();
|
||||
})`)
|
||||
const { boxShadow, ...palette } = colors
|
||||
if (dark) assert.match(boxShadow, /0px 0px 0px 1px inset$/)
|
||||
else assert.equal(boxShadow, 'none')
|
||||
assert.deepEqual(palette, { dark, surface: dark ? 'rgb(35, 35, 36)' : 'rgb(255, 255, 255)',
|
||||
label: dark ? 'rgb(249, 250, 251)' : 'rgb(15, 17, 21)',
|
||||
primary: dark ? 'rgb(249, 250, 251)' : 'rgb(15, 17, 21)',
|
||||
primaryLabel: dark ? 'rgb(53, 54, 56)' : 'rgb(255, 255, 255)',
|
||||
diagnostic: dark ? 'rgb(27, 27, 28)' : 'rgb(249, 250, 251)', body: 'rgba(0, 0, 0, 0)',
|
||||
mask: dark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.24)',
|
||||
close: document === 'update-dialog' ? dark ? 'rgb(249, 250, 251)' : 'rgb(15, 17, 21)' : null })
|
||||
results.push({ document, initial, source, ...colors })
|
||||
if (source === initial) {
|
||||
await window.webContents.executeJavaScript('Promise.all(document.getAnimations().map(animation => animation.finished))')
|
||||
await writeFile(join(root, `${document}-${initial}.png`), (await window.webContents.capturePage()).toPNG())
|
||||
}
|
||||
}
|
||||
window.destroy()
|
||||
}
|
||||
}
|
||||
await writeFile(join(root, 'result.json'), JSON.stringify(results, null, 2) + '\n')
|
||||
console.log(`Update theme qualification passed (${results.length} cases): ${root}`)
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
for (const window of windows) if (!window.isDestroyed()) window.destroy()
|
||||
ipcMain.removeHandler(UPDATE_DIALOG_IPC.status)
|
||||
ipcMain.removeHandler(MANDATORY_IPC.status)
|
||||
protocol.unhandle('dsh-app')
|
||||
nativeTheme.themeSource = original
|
||||
}
|
||||
}
|
||||
void main().then(() => app.exit(0), error => { console.error(error); app.exit(1) })
|
||||
+18
-5
@@ -6,7 +6,7 @@ import { registerHooks } from 'node:module'
|
||||
import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { app, BrowserWindow, Menu } from 'electron'
|
||||
import { app, BrowserWindow, Menu, webContents } from 'electron'
|
||||
import updaterModule from 'electron-updater'
|
||||
import { createUpdateServer } from './update-server.mjs'
|
||||
import { fixture } from './workspace-update-adapters.mjs'
|
||||
@@ -100,6 +100,11 @@ async function windowAt(url) {
|
||||
if (process.platform === 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
|
||||
return mandatoryFrameDriver(await windowAt('dsh-app://app/'))
|
||||
}
|
||||
if (process.platform !== 'win32' && url === 'dsh-app://shell/mandatory-update.html') {
|
||||
let contents
|
||||
await waitFor(() => { contents = webContents.getAllWebContents().find(candidate => candidate.getURL() === url); return contents !== undefined }, 'mandatory view document')
|
||||
return { webContents: contents, isDestroyed: () => contents.isDestroyed() }
|
||||
}
|
||||
const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url)
|
||||
if (existing) return existing
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -161,11 +166,19 @@ async function clickText(window, label) {
|
||||
await waitFor(async () => window.isDestroyed()
|
||||
|| await window.webContents.executeJavaScript("document.getElementById('title').textContent") !== title, `dialog response: ${label}`)
|
||||
}
|
||||
const dialogDrivers = new WeakMap()
|
||||
async function dialogWith(message) {
|
||||
let found
|
||||
await waitFor(async () => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.webContents.getURL() !== 'dsh-app://shell/update-dialog.html') continue
|
||||
for (const contents of webContents.getAllWebContents()) {
|
||||
if (contents.getURL() !== 'dsh-app://shell/update-dialog.html') continue
|
||||
let window = dialogDrivers.get(contents)
|
||||
if (!window) {
|
||||
const owner = BrowserWindow.fromWebContents(contents)
|
||||
window = { webContents: contents, isDestroyed: () => contents.isDestroyed(),
|
||||
isVisible: () => owner?.isVisible() ?? false, isFocused: () => contents.isFocused(), isEnabled: () => owner?.isEnabled() ?? true }
|
||||
dialogDrivers.set(contents, window)
|
||||
}
|
||||
try {
|
||||
const ready = await window.webContents.executeJavaScript(`document.getElementById('title')?.textContent === ${JSON.stringify(message)} && !!document.querySelector('#actions button')`)
|
||||
if (ready) { found = window; return true }
|
||||
@@ -249,7 +262,7 @@ async function qualify() {
|
||||
await press(mainWindow, `document.querySelector('button[data-error="true"]')`)
|
||||
await server.arrived()
|
||||
await documentReady(mainWindow, `document.querySelector('button[aria-disabled="true"]')`)
|
||||
assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
assert.equal(webContents.getAllWebContents().some(contents => contents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
await screenshot(mainWindow, 'downloading.png')
|
||||
server.release()
|
||||
const confirmation = desktopUpdateReadyConfirmation(messages, '0.1.6-nightly.1', process.platform)
|
||||
@@ -353,7 +366,7 @@ async function qualify() {
|
||||
await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window for recovery')
|
||||
await press(forcedRecovery, `document.getElementById('update')`)
|
||||
await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`)
|
||||
assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
assert.equal(webContents.getAllWebContents().some(contents => contents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
|
||||
const forcedHost = fixture.host
|
||||
await control('hold-shutdown')
|
||||
await press(forcedRecovery, `document.getElementById('update')`)
|
||||
|
||||
@@ -35,6 +35,8 @@ async function fixture<T>(body: (manifest: string, source: string) => Promise<T>
|
||||
await prepareInstalledUpdateBootstrap(manifest)
|
||||
const source = join(root, 'app')
|
||||
await mkdir(join(source, 'lib'), { recursive: true })
|
||||
await mkdir(join(source, 'lib/welcome'), { recursive: true })
|
||||
await writeFile(join(source, 'lib/welcome/design-platform.css'), 'body { --fixture-token: white; }\n')
|
||||
await mkdir(join(source, 'renderer'))
|
||||
for (const file of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
|
||||
await writeFile(join(source, 'lib', file), '// inert fixture\n')
|
||||
@@ -57,19 +59,23 @@ describe('installed-update application inputs and builder configuration', () =>
|
||||
.toMatchObject({ dependenciesFrozen: false, signed: false, bootTested: false })
|
||||
const directory = await verifyInstalledUpdateApplication(join(manifest, '..'))
|
||||
expect((await readdir(directory)).sort()).toEqual(['installed-update-identity.mjs', 'lib', 'qualification-bootstrap.mjs', 'renderer'])
|
||||
expect(await readFile(join(directory, 'lib/welcome/design-platform.css'), 'utf8')).toBe('body { --fixture-token: white; }\n')
|
||||
await writeFile(join(source, 'lib/main.js'), '// later checkout\n')
|
||||
expect(await readFile(join(directory, 'lib/main.js'), 'utf8')).toBe('// inert fixture\n')
|
||||
await expect(prepareInstalledUpdateApplication(manifest, source)).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['modified', 'added', 'missing'] as const)('rejects %s frozen application bytes', async (failure) => {
|
||||
it.each([
|
||||
['modified', 'lib/main.js'], ['added', 'lib/unrecorded.js'], ['missing', 'lib/main.js'],
|
||||
['modified', 'lib/welcome/design-platform.css'], ['missing', 'lib/welcome/design-platform.css'],
|
||||
] as const)('rejects %s frozen application bytes in %s', async (failure, file) => {
|
||||
await fixture(async (manifest, source) => {
|
||||
await prepareInstalledUpdateApplication(manifest, source)
|
||||
const root = join(manifest, '..')
|
||||
const directory = await verifyInstalledUpdateApplication(root)
|
||||
if (failure === 'missing') await rm(join(directory, 'lib/main.js'))
|
||||
else await writeFile(join(directory, failure === 'added' ? 'lib/unrecorded.js' : 'lib/main.js'), '// invalid\n')
|
||||
if (failure === 'missing') await rm(join(directory, file))
|
||||
else await writeFile(join(directory, file), '// invalid\n')
|
||||
await expect(verifyInstalledUpdateApplication(root)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,8 @@ async function fixture(body: (context: {
|
||||
await prepareInstalledUpdateBootstrap(manifest)
|
||||
const source = join(root, 'source')
|
||||
await mkdir(join(source, 'lib'), { recursive: true })
|
||||
await mkdir(join(source, 'lib/welcome'), { recursive: true })
|
||||
await writeFile(join(source, 'lib/welcome/design-platform.css'), 'body { --fixture-token: white; }\n')
|
||||
await mkdir(join(source, 'renderer'))
|
||||
for (const name of ['main.js', 'preload-app.cjs', 'preload-mandatory.cjs', 'preload-update-dialog.cjs']) {
|
||||
await writeFile(join(source, 'lib', name), '// inert fixture\n')
|
||||
@@ -104,7 +106,7 @@ describe('installed update archive contents', () => {
|
||||
it.each(versions)('reads real ASAR and runtime inventories for %s without claiming installation', async (version) => {
|
||||
await fixture(async ({ manifest, payload }) => {
|
||||
expect(await verifyInstalledUpdatePackageContent(manifest, version, payload, publisher)).toMatchObject({
|
||||
version, applicationFiles: 7, dependenciesFrozen: false, installed: false,
|
||||
version, applicationFiles: 8, dependenciesFrozen: false, installed: false,
|
||||
resignedExecutables: [join(payload, 'resources/app.asar.unpacked/dsh/tool.exe')],
|
||||
})
|
||||
}, version)
|
||||
|
||||
@@ -18,17 +18,21 @@ const overlays = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
class Window extends EventEmitter {
|
||||
private destroyed = false
|
||||
readonly webContents = { setWindowOpenHandler: vi.fn() }
|
||||
readonly webContents = Object.assign(new EventEmitter(), { setWindowOpenHandler: vi.fn(),
|
||||
focus: vi.fn(), isDestroyed: () => this.destroyed, loadURL: vi.fn(async () => {}),
|
||||
close: () => { this.destroyed = true; this.webContents.emit('destroyed') },
|
||||
})
|
||||
readonly focus = vi.fn()
|
||||
readonly show = vi.fn()
|
||||
readonly setBounds = vi.fn()
|
||||
readonly setBackgroundColor = vi.fn()
|
||||
setMenu() {}
|
||||
isDestroyed() { return this.destroyed }
|
||||
destroy() { this.destroyed = true; this.emit('closed') }
|
||||
destroy() { this.destroyed = true; this.webContents.emit('destroyed') }
|
||||
}
|
||||
return { Window }
|
||||
})
|
||||
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
|
||||
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, WebContentsView: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
|
||||
setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() },
|
||||
}) } }))
|
||||
@@ -89,7 +93,7 @@ async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
|
||||
let current: WindowFixture | undefined = window
|
||||
const updateMenu = vi.fn()
|
||||
const updateOverlays = new DesktopUpdateOverlays()
|
||||
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window as BrowserWindow))
|
||||
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window))
|
||||
keyboard.attach(current)
|
||||
onTestFinished(() => { keyboard.dispose() })
|
||||
const handlers = new Map<string, (event: InvokeFixture, ...args: unknown[]) => unknown>(
|
||||
@@ -108,12 +112,12 @@ function updateOverlayFixture(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
onTestFinished(() => { platform.mockRestore() })
|
||||
const parent: WindowFixture & Pick<BrowserWindow, 'getContentBounds'> = Object.assign(f.window, {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 900, height: 650 }),
|
||||
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
|
||||
})
|
||||
Object.assign(f.contents, { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) })
|
||||
return () => {
|
||||
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload', 'Update', false)
|
||||
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload')
|
||||
onTestFinished(() => { if (!overlay.isDestroyed()) overlay.destroy() })
|
||||
expect(vi.spyOn(overlay, 'show')).not.toHaveBeenCalled()
|
||||
return overlay
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const harness = await vi.hoisted(async () => {
|
||||
const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() })
|
||||
class FakeWindow extends EventEmitter {
|
||||
destroyed = false
|
||||
readonly contentView = { addChildView: vi.fn(), removeChildView: vi.fn() }
|
||||
readonly urls: string[] = []
|
||||
readonly webContents = Object.assign(new EventEmitter(), {
|
||||
id: 42,
|
||||
@@ -129,6 +130,18 @@ const harness = await vi.hoisted(async () => {
|
||||
if (event.preventDefault.mock.calls.length === 0) this.destroy()
|
||||
}
|
||||
}
|
||||
const views: FakeView[] = []
|
||||
class FakeView extends FakeWindow {
|
||||
constructor() {
|
||||
super({ show: false })
|
||||
windows.pop()
|
||||
views.push(this)
|
||||
Object.assign(this.webContents, {
|
||||
loadURL: (url: string) => this.loadURL(url),
|
||||
close: () => { this.destroy(); this.webContents.emit('destroyed') },
|
||||
})
|
||||
}
|
||||
}
|
||||
class FakeHost {
|
||||
readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false)
|
||||
readonly inspectQuit = vi.fn(async () => ({ activeTasks: false, scheduledTasks: false }))
|
||||
@@ -191,7 +204,7 @@ const harness = await vi.hoisted(async () => {
|
||||
const shellDialog = { isOpen: false, focus: vi.fn() }
|
||||
return {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
windows, views, hosts, handlers, app, FakeWindow, FakeView, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
@@ -238,7 +251,8 @@ const harness = await vi.hoisted(async () => {
|
||||
set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value },
|
||||
reset() {
|
||||
accountListener = undefined
|
||||
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
||||
windows.length = 0
|
||||
views.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
|
||||
trays.length = 0
|
||||
backgroundNotice.markerPath = undefined
|
||||
shellDialog.isOpen = false
|
||||
@@ -277,6 +291,7 @@ vi.mock('../src/policy-test-auth.ts', () => ({ DesktopPolicyTestAuth: class {
|
||||
vi.mock('electron', () => ({
|
||||
app: harness.app,
|
||||
BrowserWindow: harness.FakeWindow,
|
||||
WebContentsView: harness.FakeView,
|
||||
dialog: harness.dialog,
|
||||
shell: { openExternal: harness.openExternal },
|
||||
nativeTheme: harness.nativeTheme,
|
||||
@@ -330,6 +345,7 @@ vi.mock('../src/host-process.ts', async importOriginal => ({
|
||||
}))
|
||||
vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class {
|
||||
get isOpen() { return harness.shellDialog.isOpen }
|
||||
input() { return { revision: 0, blocked: false } }
|
||||
show(owner: { options: { modal?: boolean } }, options: unknown) {
|
||||
return (owner.options.modal ? harness.dialog.showMessageBox(owner, options)
|
||||
: harness.dialog.showMessageBox(options)) as Promise<Electron.MessageBoxReturnValue>
|
||||
@@ -485,7 +501,7 @@ describe('desktop main startup', () => {
|
||||
for (const [name, mime] of [
|
||||
['update-dialog.html', 'text/html'], ['update-dialog.css', 'text/css'], ['update-dialog.js', 'text/javascript'],
|
||||
['mandatory-update.html', 'text/html'], ['mandatory-update.css', 'text/css'], ['mandatory-update.js', 'text/javascript'],
|
||||
['update-close.svg', 'image/svg+xml'],
|
||||
['update-close.svg', 'image/svg+xml'], ['shell-theme.js', 'text/javascript'],
|
||||
] as const) {
|
||||
const response = await handler(new Request(`dsh-app://shell/${name}`))
|
||||
expect(response.status).toBe(200)
|
||||
@@ -514,6 +530,9 @@ describe('desktop main startup', () => {
|
||||
await handler(request)
|
||||
expect(serveWebDocument).toHaveBeenLastCalledWith(request, join('desktop-test-app', 'renderer'))
|
||||
}
|
||||
const tokens = new Request('dsh-app://shell/design-platform.css')
|
||||
await handler(tokens)
|
||||
expect(serveWebDocument).toHaveBeenLastCalledWith(tokens, join('desktop-test-app', 'lib/welcome'))
|
||||
expect(forwardWebRequest).not.toHaveBeenCalled()
|
||||
expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404)
|
||||
})
|
||||
@@ -1280,6 +1299,27 @@ describe('desktop main startup', () => {
|
||||
expect(harness.trays[0]!.setContextMenu.mock.calls.length).toBe(relabels + 1)
|
||||
})
|
||||
|
||||
it('hides a macOS owner with a mandatory view without quitting or stopping the Host', async () => {
|
||||
vi.stubGlobal('process', { ...process, platform: 'darwin' })
|
||||
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
|
||||
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
|
||||
} })))
|
||||
const host = await readyWorkspace()
|
||||
await harness.policyBlocked.promise
|
||||
const window = harness.windows[0]!
|
||||
const hides = window.hide.mock.calls.length
|
||||
window.close()
|
||||
expect(window.hide).toHaveBeenCalledTimes(hides + 1)
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
expect(harness.app.quit).not.toHaveBeenCalled()
|
||||
expect(harness.backgroundNotice.close).not.toHaveBeenCalled()
|
||||
expect(host.inspectQuit).not.toHaveBeenCalled()
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
harness.app.emit('activate')
|
||||
expect(window.show).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the workspace visible while acknowledgement is pending and ignores a destroyed window', async () => {
|
||||
await readyWorkspace()
|
||||
const window = harness.windows[0]!
|
||||
@@ -1797,7 +1837,7 @@ describe('desktop main startup', () => {
|
||||
const host = await readyForUpdate()
|
||||
if (mandatory) await harness.policyBlocked.promise
|
||||
const window = harness.windows[0]!
|
||||
const dialogOwner = mandatory ? harness.windows.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
|
||||
const dialogOwner = mandatory ? harness.views.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
|
||||
const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') }
|
||||
window.fullscreen = true
|
||||
window.setFullScreen.mockImplementation(() => {})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow, WebContents, WebFrameMain } from 'electron'
|
||||
import type { BrowserWindow, MessageBoxOptions, WebContents, WebFrameMain } from 'electron'
|
||||
import { DesktopMandatoryUpdateWindow, type MandatoryUpdateView } from '../src/mandatory-update-window.ts'
|
||||
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
|
||||
import { resolveDesktopLocale } from '../src/locale.ts'
|
||||
@@ -8,14 +8,16 @@ import type { DesktopPolicyState } from '../src/mandatory-update-policy.ts'
|
||||
import type { DesktopUpdateState } from '../src/ipc.ts'
|
||||
|
||||
const native = vi.hoisted(() => ({ handlers: new Map<string, (...args: unknown[]) => unknown>(),
|
||||
open: vi.fn<(...args: unknown[]) => Promise<void>>(), write: vi.fn(), read: vi.fn(), quit: vi.fn() }))
|
||||
open: vi.fn<(...args: unknown[]) => Promise<void>>(), write: vi.fn(), read: vi.fn(), quit: vi.fn(),
|
||||
messageBox: vi.fn<(parent: BrowserWindow, options: MessageBoxOptions) => Promise<{ response: number }>>() }))
|
||||
vi.mock('electron', () => ({ ipcMain: {
|
||||
handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler),
|
||||
removeHandler: (channel: string) => native.handlers.delete(channel),
|
||||
}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
|
||||
}, dialog: { showMessageBox: native.messageBox }, app: { quit: native.quit }, shell: { openExternal: native.open },
|
||||
clipboard: { writeText: native.write, readText: native.read } }))
|
||||
|
||||
type WindowFixture = EventEmitter & Pick<BrowserWindow, 'setMenu' | 'setTitle' | 'loadURL' | 'destroy' | 'isDestroyed'
|
||||
| 'isFocused' | 'isMinimized' | 'focus' | 'show' | 'restore'> & {
|
||||
| 'isVisible' | 'isFocused' | 'isMinimized' | 'focus' | 'show' | 'restore'> & {
|
||||
webContents: EventEmitter & Pick<WebContents, 'send' | 'setWindowOpenHandler'> & { mainFrame: Pick<WebFrameMain, 'url'> }
|
||||
}
|
||||
|
||||
@@ -26,27 +28,33 @@ function fakeWindow() {
|
||||
send: vi.fn(), setWindowOpenHandler: vi.fn() })
|
||||
return Object.assign(new EventEmitter(), { webContents: contents, setMenu: vi.fn(), setTitle: vi.fn(),
|
||||
loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: (): boolean => false,
|
||||
isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
|
||||
isVisible: (): boolean => true, isFocused: (): boolean => true,
|
||||
isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() })
|
||||
}
|
||||
afterEach(() => { ui?.dispose(); vi.restoreAllMocks(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() })
|
||||
function setup(platform: NodeJS.Platform = 'darwin') {
|
||||
function setup(platform: NodeJS.Platform = 'darwin', beforeSync?: (surface: ReturnType<typeof fakeWindow>) => void) {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
native.messageBox.mockImplementation(() => new Promise(() => {}))
|
||||
window = fakeWindow()
|
||||
if (platform === 'win32') window.webContents.mainFrame.url = 'dsh-app://app/'
|
||||
const owner = platform === 'win32' ? window : fakeWindow()
|
||||
owner.webContents.mainFrame.url = 'dsh-app://app/'
|
||||
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
|
||||
let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' }
|
||||
const install = vi.fn(async () => update)
|
||||
const createOverlay = (): WindowFixture => window
|
||||
const parent = createOverlay as () => BrowserWindow
|
||||
ui = new DesktopMandatoryUpdateWindow({ overlays: { create: parent }, preload: 'owned', locale: resolveDesktopLocale('zh'),
|
||||
const create = createOverlay as () => BrowserWindow
|
||||
const currentParent = (): WindowFixture => platform === 'win32' ? window : owner
|
||||
const parent = currentParent as () => BrowserWindow
|
||||
ui = new DesktopMandatoryUpdateWindow({ overlays: { create }, preload: 'owned', locale: resolveDesktopLocale('zh'),
|
||||
allowedPageOrigins: ['https://downloads.example.com'], parent,
|
||||
policy: () => policy, update: () => update, refresh: async () => {}, download: async () => update, install })
|
||||
beforeSync?.(window)
|
||||
ui.sync()
|
||||
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
const view = () => native.handlers.get(MANDATORY_IPC.status)!(event) as MandatoryUpdateView
|
||||
const action = (name: string, version: string = '1.0.1-nightly.1', revision = view().confirmation?.revision) =>
|
||||
native.handlers.get(MANDATORY_IPC.action)!(event, name, version, revision)
|
||||
return { view, action, install, policy(next: DesktopPolicyState) { policy = next; ui!.sync() },
|
||||
return { view, action, install, owner, policy(next: DesktopPolicyState) { policy = next; ui!.sync() },
|
||||
update(next: DesktopUpdateState) { update = next; ui!.sync() } }
|
||||
}
|
||||
|
||||
@@ -140,15 +148,6 @@ it('rejects same-URL child frames and removes quit from renderer privileges', ()
|
||||
expect(() => f.action('quit')).toThrow(/invalid action/)
|
||||
})
|
||||
|
||||
it('exits the application when the mandatory window is closed without clearing the policy', () => {
|
||||
const f = setup()
|
||||
const event = { preventDefault: vi.fn() }
|
||||
window.emit('close', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(native.quit).toHaveBeenCalledOnce()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
|
||||
it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => {
|
||||
vi.useFakeTimers()
|
||||
const f = setup()
|
||||
@@ -212,3 +211,219 @@ it.each(['darwin', 'win32'] as const)('includes the installation wait notice onl
|
||||
? '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。'
|
||||
: '更新期间应用将暂时关闭,完成后会自动打开。')
|
||||
})
|
||||
|
||||
it('updates the same macOS view in the background without showing or focusing the owner', () => {
|
||||
const f = setup()
|
||||
f.update({ phase: 'downloading', version: '1.0.1-nightly.1', percent: 35 })
|
||||
f.update({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'download' })
|
||||
expect(f.owner).not.toBe(window)
|
||||
expect(f.owner.show).not.toHaveBeenCalled()
|
||||
expect(f.owner.focus).not.toHaveBeenCalled()
|
||||
expect(window.loadURL).toHaveBeenCalledOnce()
|
||||
expect(ui!.confirmationWindow).toBe(f.owner)
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels outstanding approval when the mandatory view is destroyed without clearing policy', async () => {
|
||||
const f = setup()
|
||||
const approval = ui!.confirm('1.0.1-nightly.1', true)
|
||||
window.webContents.emit('destroyed')
|
||||
expect(await approval).toBe(false)
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['initial', 'recovery'] as const)('keeps the native owner title unchanged after %s view loading fails', async (stage) => {
|
||||
const failure = new Error('view load failed')
|
||||
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup('darwin', (surface) => {
|
||||
if (stage === 'initial') surface.loadURL.mockRejectedValueOnce(failure)
|
||||
})
|
||||
expect(f.owner).not.toBe(window)
|
||||
if (stage === 'recovery') {
|
||||
window.loadURL.mockRejectedValueOnce(failure)
|
||||
window.webContents.emit('render-process-gone')
|
||||
}
|
||||
await expect(window.loadURL.mock.results.at(-1)!.value).rejects.toBe(failure)
|
||||
expect(f.owner.setTitle).not.toHaveBeenCalled()
|
||||
expect(f.owner.show).not.toHaveBeenCalled()
|
||||
expect(f.owner.focus).not.toHaveBeenCalled()
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(report).toHaveBeenCalledWith(expect.stringContaining('mandatory update view'), failure)
|
||||
f.policy({ blocking: false, checking: false })
|
||||
ui!.dispose()
|
||||
expect(f.owner.setTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers native recovery until foreground and opens the policy download page without installing', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup('darwin')
|
||||
await Promise.resolve()
|
||||
vi.spyOn(f.owner, 'isFocused').mockReturnValue(false)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local load failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
expect(native.messageBox).not.toHaveBeenCalled()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
vi.spyOn(f.owner, 'isFocused').mockReturnValue(true)
|
||||
f.owner.emit('focus')
|
||||
f.owner.emit('focus')
|
||||
expect(native.messageBox).toHaveBeenCalledOnce()
|
||||
expect(native.messageBox).toHaveBeenCalledWith(f.owner, expect.objectContaining({
|
||||
message: '请更新后继续使用', cancelId: -1,
|
||||
buttons: ['打开下载页面', '退出'],
|
||||
}))
|
||||
expect(native.messageBox.mock.calls[0]![1].detail).toContain('1.0.1-nightly.1')
|
||||
answer.resolve({ response: 0 })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(window.loadURL).toHaveBeenCalledTimes(2)
|
||||
expect(native.open).toHaveBeenCalledWith('https://downloads.example.com/desktop')
|
||||
expect(f.install).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['clearance', 'dispose', 'hide'] as const)('ignores native recovery responses after %s', async (reason) => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup('darwin', (surface) => { surface.loadURL.mockRejectedValueOnce(new Error('local load failed')) })
|
||||
await Promise.resolve()
|
||||
const options = native.messageBox.mock.calls[0]![1]
|
||||
expect(options.signal!.aborted).toBe(false)
|
||||
if (reason === 'clearance') f.policy({ blocking: false, checking: false })
|
||||
else if (reason === 'dispose') ui!.dispose()
|
||||
else f.owner.emit('hide')
|
||||
expect(options.signal!.aborted).toBe(true)
|
||||
expect(f.owner.setTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the normal quit entry only for an explicit native recovery exit', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
const f = setup('darwin')
|
||||
await Promise.resolve()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
answer.resolve({ response: 1 })
|
||||
await Promise.resolve()
|
||||
expect(native.quit).toHaveBeenCalledOnce()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(f.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains blocking and reopens native recovery after a non-action dismissal', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
answer.resolve({ response: -1 })
|
||||
await Promise.resolve()
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(window.loadURL).toHaveBeenCalledTimes(2)
|
||||
expect(native.messageBox).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a late quit response after policy clearance', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: false, checking: false })
|
||||
answer.resolve({ response: 1 })
|
||||
await Promise.resolve()
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
it.each([undefined, 'https://unapproved.example.com/download'])('omits native download action for unavailable destination %s', async (page) => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: true, checking: false, ...(page === undefined ? {} : { page }) })
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
expect(native.messageBox.mock.calls[0]![1].buttons).toEqual(['退出'])
|
||||
expect(native.messageBox.mock.calls[0]![1].cancelId).toBe(-1)
|
||||
expect(native.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not open a changed policy URL using approval for the previous destination', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: true, checking: false, page: 'https://downloads.example.com/new' })
|
||||
answer.resolve({ response: 0 })
|
||||
await Promise.resolve()
|
||||
expect(native.open).not.toHaveBeenCalled()
|
||||
expect(native.messageBox).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
|
||||
it('reports a browser-opening failure in the native fallback without claiming download success', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
native.open.mockRejectedValueOnce(new Error('browser unavailable'))
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
answer.resolve({ response: 0 })
|
||||
await vi.waitFor(() => { expect(native.messageBox).toHaveBeenCalledTimes(2) })
|
||||
expect(native.messageBox.mock.calls[1]![1].detail).toContain('无法打开下载页面,请重试')
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
expect(f.install).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
it('uses the configured policy title as the native headline without repeating it in detail', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
f.policy({ blocking: true, checking: false, title: 'Configured title', detail: 'Configured detail' })
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
const options = native.messageBox.mock.calls[0]![1]
|
||||
expect(options.message).toBe('Configured title')
|
||||
expect(options.detail).toBe('Configured detail\n\n新版本:1.0.1-nightly.1')
|
||||
})
|
||||
|
||||
|
||||
it('rechecks recovery after an aborted native prompt finishes following a foreground return', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const f = setup()
|
||||
await Promise.resolve()
|
||||
const answer = Promise.withResolvers<{ response: number }>()
|
||||
native.messageBox.mockReturnValueOnce(answer.promise)
|
||||
window.loadURL.mockRejectedValueOnce(new Error('local recovery failed'))
|
||||
window.webContents.emit('render-process-gone')
|
||||
await Promise.resolve()
|
||||
f.owner.emit('hide')
|
||||
f.owner.emit('focus')
|
||||
expect(native.messageBox).toHaveBeenCalledOnce()
|
||||
answer.resolve({ response: 1 })
|
||||
await Promise.resolve()
|
||||
expect(native.messageBox).toHaveBeenCalledTimes(2)
|
||||
expect(native.quit).not.toHaveBeenCalled()
|
||||
expect(f.view().policy.blocking).toBe(true)
|
||||
})
|
||||
@@ -46,13 +46,14 @@ afterEach(() => {
|
||||
vi.clearAllMocks(); vi.unstubAllEnvs()
|
||||
})
|
||||
function setup() {
|
||||
let blocked = false
|
||||
const removeChildView = vi.fn()
|
||||
const owner = Object.assign(new EventEmitter(), {
|
||||
webContents: new EventEmitter(), contentView: { addChildView: vi.fn(), removeChildView }, isDestroyed: () => false,
|
||||
})
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => 'en_US', 'darwin')
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => 'en_US', 'darwin', () => blocked)
|
||||
manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' })
|
||||
return { manager, owner, removeChildView }
|
||||
return { manager, owner, removeChildView, block: () => { blocked = true } }
|
||||
}
|
||||
|
||||
/** Invoke the first registered header interceptor for one request. */
|
||||
@@ -154,7 +155,7 @@ it('samples the language and UTC offset on every Platform request', async () =>
|
||||
const owner = Object.assign(new EventEmitter(), {
|
||||
webContents: new EventEmitter(), contentView: { addChildView: vi.fn(), removeChildView: vi.fn() }, isDestroyed: () => false,
|
||||
})
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'darwin')
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'darwin', () => false)
|
||||
manager.setSession({ origin: 'https://platform.deepseek.com', userId: null, token: 'fixture-secret' })
|
||||
await manager.open(owner, 'usage', bounds)
|
||||
const offset = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(480)
|
||||
@@ -314,7 +315,7 @@ it('does not reveal a pending view after the owner reloads or remove a replaceme
|
||||
it('bootstraps the current language and updates an open view without reloading', async () => {
|
||||
const { owner } = setup()
|
||||
let locale: 'en_US' | 'zh_CN' = 'zh_CN'
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'win32')
|
||||
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale, 'win32', () => false)
|
||||
manager.setSession({ origin: 'https://platform.deepseek.com', userId: 'fixture-user' as AccountUserId, token: 'fixture-secret' })
|
||||
manager.notifyLocaleChanged()
|
||||
await manager.open(owner, 'usage', bounds)
|
||||
@@ -490,3 +491,25 @@ it('keeps an open temporary document when its credential gains a stable identity
|
||||
expect(browserSession().partition).toMatch(/^persist:/)
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('refuses Platform admission while an update prompt is blocking', async () => {
|
||||
const f = setup()
|
||||
f.block()
|
||||
await expect(f.manager.open(f.owner, 'usage', { x: 0, y: 0, width: 400, height: 300 })).rejects.toThrow(/blocked/)
|
||||
expect(state.views).toHaveLength(0)
|
||||
expect(state.sessions).toHaveLength(0)
|
||||
await f.manager.dispose()
|
||||
})
|
||||
|
||||
it('refuses a pending Platform view if update blocking starts during storage cleanup', async () => {
|
||||
const f = setup()
|
||||
const pending = f.manager.open(f.owner, 'usage', { x: 0, y: 0, width: 400, height: 300 })
|
||||
expect(state.sessions).toHaveLength(1)
|
||||
f.block()
|
||||
await expect(pending).rejects.toThrow(/blocked/)
|
||||
expect(state.views).toHaveLength(0)
|
||||
expect(f.owner.contentView.addChildView).not.toHaveBeenCalled()
|
||||
expect(f.owner.listenerCount('closed')).toBe(0)
|
||||
await f.manager.dispose()
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInContext } from 'node:vm'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
it.each(['welcome', 'update-dialog', 'mandatory-update'].flatMap(document => [false, true].map(dark => ({ document, dark }))))(
|
||||
'$document applies the initial app palette and follows changes (dark=$dark)', ({ document, dark }) => {
|
||||
const dom = new JSDOM(readFileSync(new URL(`../renderer/${document}.html`, import.meta.url), 'utf8'), { runScripts: 'outside-only' })
|
||||
const palette = Object.assign(new dom.window.EventTarget(), { matches: dark, media: '(prefers-color-scheme: dark)',
|
||||
onchange: null, addListener: vi.fn(), removeListener: vi.fn() })
|
||||
const remove = vi.spyOn(palette, 'removeEventListener')
|
||||
dom.window.matchMedia = vi.fn(() => palette)
|
||||
try {
|
||||
expect(dom.window.document.querySelectorAll('script[src="shell-theme.js"]')).toHaveLength(1)
|
||||
runInContext(readFileSync(new URL('../renderer/shell-theme.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
|
||||
expect(dom.window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)')
|
||||
expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(dark)
|
||||
palette.matches = !dark
|
||||
palette.dispatchEvent(new dom.window.Event('change'))
|
||||
expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark)
|
||||
dom.window.dispatchEvent(new dom.window.Event('pagehide'))
|
||||
expect(remove).toHaveBeenCalledWith('change', expect.any(Function))
|
||||
palette.matches = dark
|
||||
palette.dispatchEvent(new dom.window.Event('change'))
|
||||
expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark)
|
||||
} finally { dom.window.close() }
|
||||
})
|
||||
@@ -1,233 +1,215 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts'
|
||||
import { DesktopUpdateDialog, UPDATE_DIALOG_IPC, type UpdateDialogOptions } from '../src/update-dialog.ts'
|
||||
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
|
||||
import { resolveDesktopLocale, type DesktopLocale } from '../src/locale.ts'
|
||||
|
||||
const fixture = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
||||
const windows: FakeWindow[] = []
|
||||
class FakeWindow extends EventEmitter {
|
||||
const views: FakeView[] = []
|
||||
const load = { failure: false }
|
||||
class FakeParent extends EventEmitter {
|
||||
destroyed = false
|
||||
readonly webContents = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
mainFrame: { url: 'dsh-app://shell/update-dialog.html' },
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
insertCSS: vi.fn(async () => 'blur'),
|
||||
removeInsertedCSS: vi.fn(async () => {}),
|
||||
})
|
||||
readonly focus = vi.fn()
|
||||
readonly show = vi.fn()
|
||||
readonly hide = vi.fn()
|
||||
readonly setBounds = vi.fn()
|
||||
readonly loadURL = vi.fn(async () => {})
|
||||
constructor(readonly options: unknown) { super(); windows.push(this) }
|
||||
readonly isFocused = vi.fn(() => true)
|
||||
readonly contentView = { addChildView: vi.fn(), removeChildView: vi.fn() }
|
||||
readonly webContents = Object.assign(new EventEmitter(), { focus: vi.fn() })
|
||||
getContentBounds() { return { x: 10, y: 20, width: 900, height: 650 } }
|
||||
isDestroyed() { return this.destroyed }
|
||||
isVisible() { return true }
|
||||
destroy() { this.destroyed = true; this.emit('closed') }
|
||||
setMenu() {}
|
||||
}
|
||||
return { handlers, windows, FakeWindow }
|
||||
class FakeView {
|
||||
destroyed = false
|
||||
readonly webContents = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(), focus: vi.fn(),
|
||||
mainFrame: { url: 'dsh-app://shell/update-dialog.html' },
|
||||
setWindowOpenHandler: vi.fn<(handler: () => { action: 'deny' }) => void>(),
|
||||
isDestroyed: () => this.destroyed,
|
||||
loadURL: vi.fn(async () => { if (load.failure) throw new Error('load failed'); this.webContents.emit('did-finish-load') }),
|
||||
close: vi.fn(() => { this.destroyed = true; this.webContents.emit('destroyed') }),
|
||||
})
|
||||
readonly setBackgroundColor = vi.fn()
|
||||
readonly setBounds = vi.fn()
|
||||
constructor(readonly options: unknown) { views.push(this) }
|
||||
}
|
||||
return { handlers, views, FakeParent, FakeView, load }
|
||||
})
|
||||
vi.mock('electron', () => ({ BrowserWindow: fixture.FakeWindow, ipcMain: {
|
||||
vi.mock('electron', () => ({ WebContentsView: fixture.FakeView, ipcMain: {
|
||||
handle: (name: string, fn: (...args: unknown[]) => unknown) => fixture.handlers.set(name, fn),
|
||||
removeHandler: (name: string) => fixture.handlers.delete(name),
|
||||
} }))
|
||||
|
||||
let dialogs: DesktopUpdateDialog | undefined
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
dialogs?.dispose()
|
||||
dialogs = undefined
|
||||
for (const window of fixture.windows) if (!window.isDestroyed()) window.destroy()
|
||||
fixture.windows.length = 0
|
||||
vi.useRealTimers()
|
||||
fixture.views.length = 0
|
||||
fixture.handlers.clear()
|
||||
fixture.load.failure = false
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLocale('zh-CN')) {
|
||||
const parent = new fixture.FakeWindow({})
|
||||
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, new DesktopUpdateOverlays())
|
||||
const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, {
|
||||
message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }),
|
||||
const parent = new fixture.FakeParent()
|
||||
const owner = parent as unknown as BrowserWindow
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, overlays)
|
||||
const show = (signal?: AbortSignal, options: Partial<UpdateDialogOptions> = {}) => dialogs!.show(owner, {
|
||||
message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...options, ...(signal === undefined ? {} : { signal }),
|
||||
})
|
||||
const invoke = (channel: string, ...args: unknown[]) => {
|
||||
const window = fixture.windows.at(-1)!
|
||||
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
const view = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number }
|
||||
return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [view.revision, ...args] : args))
|
||||
const view = fixture.views.at(-1)!
|
||||
const event = { sender: view.webContents, senderFrame: view.webContents.mainFrame }
|
||||
const state = fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event) as { revision: number }
|
||||
return fixture.handlers.get(channel)!(event, ...(channel === UPDATE_DIALOG_IPC.respond ? [state.revision, ...args] : args))
|
||||
}
|
||||
return { parent, show, invoke }
|
||||
return { parent, owner, show, invoke, overlays }
|
||||
}
|
||||
|
||||
it('accepts only a displayed choice from its own main frame and retains cancellation outside the button list', async () => {
|
||||
it('accepts only a displayed choice from its isolated shell frame', async () => {
|
||||
const f = setup()
|
||||
expect(dialogs!.isOpen).toBe(false)
|
||||
const pending = f.show()
|
||||
const view = fixture.views[0]!
|
||||
expect(dialogs!.isOpen).toBe(true)
|
||||
const window = fixture.windows.at(-1)!
|
||||
expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ closeLabel: '关闭', buttons: ['安装并重启'], cancelId: 1 })
|
||||
const respond = fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!
|
||||
expect(() => respond({ sender: {}, senderFrame: window.webContents.mainFrame }, 0)).toThrow(/unowned/)
|
||||
expect(() => respond({ sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } }, 0)).toThrow(/unowned/)
|
||||
expect(() => respond({ sender: f.parent.webContents, senderFrame: view.webContents.mainFrame }, 0)).toThrow(/unowned/)
|
||||
expect(() => respond({ sender: view.webContents, senderFrame: { ...view.webContents.mainFrame } }, 0)).toThrow(/unowned/)
|
||||
for (const index of [-1, 2, '0', 0.5, NaN]) expect(() => f.invoke(UPDATE_DIALOG_IPC.respond, index)).toThrow(/invalid/)
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 1)
|
||||
expect(await pending).toEqual({ response: 1, checkboxChecked: false })
|
||||
expect(dialogs!.isOpen).toBe(false)
|
||||
const next = f.show()
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
|
||||
expect((await next).response).toBe(0)
|
||||
})
|
||||
|
||||
it('releases visible Windows modality before destroying a dismissed dialog', async () => {
|
||||
vi.useFakeTimers()
|
||||
it('attaches within its owner and resizes below the Windows caption without a native child window', async () => {
|
||||
const f = setup()
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const destroy = vi.spyOn(window, 'destroy').mockImplementation(() => {
|
||||
expect(window.hide).toHaveBeenCalledTimes(process.platform === 'win32' ? 1 : 0)
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
})
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
|
||||
await pending
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('follows the parent geometry and removes listeners when closed or replaced', async () => {
|
||||
const f = setup()
|
||||
const first = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
expect(window.options).toMatchObject({ frame: false, transparent: true, modal: process.platform !== 'darwin', width: 900, height: 650 })
|
||||
window.emit('ready-to-show')
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
const view = fixture.views[0]!
|
||||
expect(f.parent.contentView.addChildView).toHaveBeenCalledWith(view)
|
||||
expect(view.options).toMatchObject({ webPreferences: { preload: 'preload-update-dialog.cjs', sandbox: true, contextIsolation: true } })
|
||||
expect(view.setBackgroundColor).toHaveBeenCalledWith('#00000000')
|
||||
const top = process.platform === 'win32' ? 40 : 0
|
||||
expect(view.setBounds).toHaveBeenCalledWith({ x: 0, y: top, width: 900, height: 650 - top })
|
||||
f.parent.emit('resize')
|
||||
expect(window.setBounds).toHaveBeenCalledWith(f.parent.getContentBounds())
|
||||
const next = f.show()
|
||||
expect((await first).response).toBe(1)
|
||||
expect(f.parent.listenerCount('resize')).toBe(1)
|
||||
fixture.windows.at(-1)!.destroy()
|
||||
expect((await next).response).toBe(1)
|
||||
expect(f.parent.listenerCount('resize')).toBe(0)
|
||||
expect(f.parent.listenerCount('move')).toBe(0)
|
||||
expect(f.parent.webContents.insertCSS).not.toHaveBeenCalled()
|
||||
expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels on abort, renderer failure, disposal, or an already-closed parent', async () => {
|
||||
const f = setup()
|
||||
const controller = new AbortController()
|
||||
const pending = f.show(controller.signal)
|
||||
controller.abort()
|
||||
expect((await pending).response).toBe(1)
|
||||
expect((await f.show(controller.signal)).response).toBe(1)
|
||||
const crashing = f.show()
|
||||
fixture.windows.at(-1)!.webContents.emit('render-process-gone')
|
||||
expect((await crashing).response).toBe(1)
|
||||
const stopping = f.show()
|
||||
dialogs!.dispose()
|
||||
expect((await stopping).response).toBe(1)
|
||||
expect(fixture.handlers.size).toBe(0)
|
||||
const count = fixture.windows.length
|
||||
expect((await f.show()).response).toBe(1)
|
||||
expect(fixture.windows).toHaveLength(count)
|
||||
expect(view.setBounds).toHaveBeenCalledTimes(2)
|
||||
f.parent.destroy()
|
||||
expect((await f.show()).response).toBe(1)
|
||||
expect((await pending).response).toBe(1)
|
||||
expect(view.webContents.close).toHaveBeenCalledOnce()
|
||||
expect(f.parent.listenerCount('resize')).toBe(0)
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
})
|
||||
|
||||
it('denies navigation away from the owned document', async () => {
|
||||
it('updates a background result in the same view without activating its owner', async () => {
|
||||
const f = setup()
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const event = { preventDefault: vi.fn() }
|
||||
window.webContents.emit('will-navigate', event, 'https://example.com')
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
window.webContents.emit('will-navigate', event, 'dsh-app://shell/update-dialog.html')
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('supplies localized disclosure copy without putting diagnostics in the ordinary detail', async () => {
|
||||
const f = setup()
|
||||
const pending = dialogs!.show(f.parent as unknown as BrowserWindow, {
|
||||
message: '未能安全停止任务', technicalDetails: 'exit 0; shutdown acknowledged false',
|
||||
})
|
||||
expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ detail: '',
|
||||
technicalDetails: 'exit 0; shutdown acknowledged false', technicalDetailsLabel: '查看技术详情' })
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('replaces content without releasing the backdrop and rejects a response from the previous prompt', async () => {
|
||||
const f = setup()
|
||||
const abort = new AbortController()
|
||||
const first = f.show(abort.signal)
|
||||
const window = fixture.windows.at(-1)!
|
||||
f.parent.isFocused.mockReturnValue(false)
|
||||
const first = f.show()
|
||||
await Promise.resolve()
|
||||
const view = fixture.views[0]!
|
||||
const old = f.invoke(UPDATE_DIALOG_IPC.status) as { revision: number }
|
||||
const next = f.show()
|
||||
const next = f.show(undefined, { message: '已是最新版本' })
|
||||
expect((await first).response).toBe(1)
|
||||
expect(fixture.windows).toHaveLength(2)
|
||||
expect(window.loadURL).toHaveBeenCalledOnce()
|
||||
expect(window.webContents.send.mock.calls).toEqual([[UPDATE_DIALOG_IPC.changed, f.invoke(UPDATE_DIALOG_IPC.status)]])
|
||||
expect(f.parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
abort.abort()
|
||||
expect(fixture.views).toHaveLength(1)
|
||||
expect(f.parent.contentView.addChildView).toHaveBeenCalledOnce()
|
||||
expect(view.webContents.loadURL).toHaveBeenCalledOnce()
|
||||
expect(f.parent.focus).not.toHaveBeenCalled()
|
||||
expect(view.webContents.focus).not.toHaveBeenCalled()
|
||||
expect(view.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, expect.objectContaining({ message: '已是最新版本' }))
|
||||
expect(() => fixture.handlers.get(UPDATE_DIALOG_IPC.respond)!({
|
||||
sender: window.webContents, senderFrame: window.webContents.mainFrame,
|
||||
sender: view.webContents, senderFrame: view.webContents.mainFrame,
|
||||
}, old.revision, 0)).toThrow(/stale/)
|
||||
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
|
||||
expect((await next).response).toBe(0)
|
||||
})
|
||||
|
||||
it('fades out before releasing the backdrop and cancels pending removal when another prompt opens', async () => {
|
||||
it('releases keyboard blocking only after fade-out and retains it across replacement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const f = setup()
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 0, blocked: false })
|
||||
const first = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const view = fixture.views[0]!
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 1, blocked: true })
|
||||
dialogs!.cancel()
|
||||
await first
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
expect(window.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null)
|
||||
expect(view.webContents.send).toHaveBeenLastCalledWith(UPDATE_DIALOG_IPC.changed, null)
|
||||
const next = f.show()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(window.isDestroyed()).toBe(false)
|
||||
expect(view.destroyed).toBe(false)
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 1, blocked: true })
|
||||
dialogs!.cancel()
|
||||
await next
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(window.isDestroyed()).toBe(true)
|
||||
expect(f.parent.listenerCount('focus')).toBe(0)
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
expect(view.destroyed).toBe(true)
|
||||
expect(f.parent.contentView.removeChildView).toHaveBeenCalledWith(view)
|
||||
expect(f.overlays.input(f.owner)).toEqual({ revision: 2, blocked: false })
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redirects focus without a native sheet', async () => {
|
||||
it('blocks owner input and restores dialog focus only when that owner is focused', async () => {
|
||||
const f = setup()
|
||||
f.parent.isFocused.mockReturnValue(false)
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
await Promise.resolve()
|
||||
const view = fixture.views[0]!
|
||||
const event = { preventDefault: vi.fn() }
|
||||
f.parent.webContents.emit('before-input-event', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(view.webContents.focus).not.toHaveBeenCalled()
|
||||
f.parent.isFocused.mockReturnValue(true)
|
||||
f.parent.emit('focus')
|
||||
expect(window.focus).toHaveBeenCalledTimes(2)
|
||||
expect(view.webContents.focus).toHaveBeenCalledOnce()
|
||||
dialogs!.focus()
|
||||
expect(f.parent.focus).toHaveBeenCalledOnce()
|
||||
dialogs!.dispose()
|
||||
await pending
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
expect(f.parent.listenerCount('focus')).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the current locale for each presentation', async () => {
|
||||
it('cancels on abort, renderer failure, failed load, disposal, or a destroyed owner', async () => {
|
||||
const f = setup()
|
||||
const abort = new AbortController()
|
||||
const pending = f.show(abort.signal)
|
||||
abort.abort()
|
||||
expect((await pending).response).toBe(1)
|
||||
expect((await f.show(abort.signal)).response).toBe(1)
|
||||
const crashing = f.show()
|
||||
fixture.views.at(-1)!.webContents.emit('render-process-gone')
|
||||
expect((await crashing).response).toBe(1)
|
||||
fixture.load.failure = true
|
||||
expect((await f.show()).response).toBe(1)
|
||||
fixture.load.failure = false
|
||||
const stopping = f.show()
|
||||
dialogs!.dispose()
|
||||
expect((await stopping).response).toBe(1)
|
||||
expect(fixture.handlers.size).toBe(0)
|
||||
expect((await f.show()).response).toBe(1)
|
||||
f.parent.destroy()
|
||||
expect((await f.show()).response).toBe(1)
|
||||
})
|
||||
|
||||
it('denies foreign navigation, redirects, and new windows', async () => {
|
||||
const f = setup()
|
||||
const pending = f.show()
|
||||
const contents = fixture.views[0]!.webContents
|
||||
const event = { preventDefault: vi.fn() }
|
||||
contents.emit('will-navigate', event, 'https://example.com')
|
||||
contents.emit('will-navigate', event, 'dsh-app://shell/update-dialog.html')
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
contents.emit('will-redirect', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(2)
|
||||
expect(contents.setWindowOpenHandler.mock.calls[0]![0]()).toEqual({ action: 'deny' })
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('reads the current locale and keeps technical diagnostics separate from ordinary detail', async () => {
|
||||
let locale = resolveDesktopLocale('en')
|
||||
const f = setup(() => locale)
|
||||
for (const language of ['zh-CN', 'en']) {
|
||||
locale = resolveDesktopLocale(language)
|
||||
const pending = f.show()
|
||||
const window = fixture.windows.at(-1)!
|
||||
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
expect(fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event)).toMatchObject({
|
||||
locale: locale.id, closeLabel: locale.messages.updateClose,
|
||||
technicalDetailsLabel: locale.messages.updateTechnicalDetails,
|
||||
})
|
||||
const pending = f.show(undefined, { technicalDetails: 'exit 0; shutdown acknowledged false' })
|
||||
expect(f.invoke(UPDATE_DIALOG_IPC.status)).toMatchObject({ locale: locale.id, closeLabel: locale.messages.updateClose,
|
||||
detail: '', technicalDetails: 'exit 0; shutdown acknowledged false', technicalDetailsLabel: locale.messages.updateTechnicalDetails })
|
||||
dialogs!.cancel()
|
||||
await pending
|
||||
}
|
||||
|
||||
@@ -1,160 +1,83 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
|
||||
|
||||
const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() }))
|
||||
vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } }))
|
||||
|
||||
const native = vi.hoisted(() => ({ create: vi.fn<(options: object) => object>() }))
|
||||
vi.mock('electron', () => ({ WebContentsView: function (options: object) { return native.create(options) } }))
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function visibilityFixture(visible = true) {
|
||||
const visibility = { visible }
|
||||
const parent: ParentFixture & Pick<BrowserWindow, 'isVisible'> = Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: (): boolean => false,
|
||||
isVisible: () => visibility.visible,
|
||||
function fixture() {
|
||||
let destroyed = false
|
||||
const parent = Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 50, y: 40, width: 900, height: 650 }), isDestroyed: () => destroyed,
|
||||
isFocused: vi.fn(() => false), focus: vi.fn(), show: vi.fn(),
|
||||
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
|
||||
webContents: Object.assign(new EventEmitter(), { focus: vi.fn() }),
|
||||
destroy() { destroyed = true; parent.emit('closed') },
|
||||
})
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
destroyed: false,
|
||||
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
|
||||
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => window.destroyed,
|
||||
})
|
||||
native.create.mockReturnValue(window)
|
||||
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
|
||||
return { parent, window, visibility }
|
||||
}
|
||||
|
||||
it('restores a ready overlay each time its parent is shown and releases visibility ownership on close', async () => {
|
||||
const { parent, window, visibility } = visibilityFixture()
|
||||
window.emit('ready-to-show')
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
for (let index = 0; index < 2; index++) {
|
||||
visibility.visible = false
|
||||
parent.emit('hide')
|
||||
visibility.visible = true
|
||||
parent.emit('show')
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(window.show).toHaveBeenCalledTimes(3)
|
||||
await Promise.resolve()
|
||||
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
expect(parent.listenerCount('show')).toBe(0)
|
||||
expect(parent.webContents.insertCSS).not.toHaveBeenCalled()
|
||||
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
parent.emit('show')
|
||||
expect(window.show).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('waits for both a visible parent and a ready document, in either order', async () => {
|
||||
for (const readyFirst of [true, false]) {
|
||||
const { parent, window, visibility } = visibilityFixture(false)
|
||||
if (readyFirst) window.emit('ready-to-show')
|
||||
else { visibility.visible = true; parent.emit('show') }
|
||||
expect(window.show).not.toHaveBeenCalled()
|
||||
if (readyFirst) { visibility.visible = true; parent.emit('show') }
|
||||
else window.emit('ready-to-show')
|
||||
await Promise.resolve()
|
||||
expect(window.show).toHaveBeenCalledOnce()
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not show or change parent styles when closed before its document is ready', async () => {
|
||||
const { parent, window, visibility } = visibilityFixture(false)
|
||||
window.destroyed = true
|
||||
window.emit('closed')
|
||||
window.emit('ready-to-show')
|
||||
visibility.visible = true
|
||||
parent.emit('show')
|
||||
await Promise.resolve()
|
||||
expect(window.show).not.toHaveBeenCalled()
|
||||
expect(parent.listenerCount('show')).toBe(0)
|
||||
expect(parent.webContents.insertCSS).not.toHaveBeenCalled()
|
||||
expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases a macOS overlay after its parent has already been destroyed', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const { parent, window } = visibilityFixture()
|
||||
await Promise.resolve()
|
||||
vi.spyOn(parent, 'isDestroyed').mockReturnValue(true)
|
||||
Object.defineProperty(parent, 'webContents', { get() { throw new Error('Object has been destroyed') } })
|
||||
window.destroyed = true
|
||||
expect(() => window.emit('closed')).not.toThrow()
|
||||
for (const event of ['focus', 'move', 'resize', 'show']) expect(parent.listenerCount(event)).toBe(0)
|
||||
})
|
||||
|
||||
type ParentFixture = EventEmitter & Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed'> & {
|
||||
webContents: EventEmitter & Pick<WebContents, 'insertCSS' | 'removeInsertedCSS'>
|
||||
}
|
||||
|
||||
it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const parent: ParentFixture = Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: () => false,
|
||||
})
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
|
||||
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
|
||||
})
|
||||
native.create.mockReturnValue(window)
|
||||
try {
|
||||
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
|
||||
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
|
||||
const event = { preventDefault: vi.fn() }
|
||||
parent.webContents.emit('before-input-event', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(window.focus).toHaveBeenCalledOnce()
|
||||
window.emit('closed')
|
||||
expect(parent.listenerCount('focus')).toBe(0)
|
||||
expect(parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
} finally { platform.mockRestore() }
|
||||
})
|
||||
|
||||
it('blocks each parent until its last owned overlay closes and invalidates each transition', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
onTestFinished(() => { platform.mockRestore() })
|
||||
const parent = (): ParentFixture => Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: () => false,
|
||||
})
|
||||
const firstParent = parent() as BrowserWindow
|
||||
const secondParent = parent() as BrowserWindow
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
const isolated = new DesktopUpdateOverlays()
|
||||
native.create.mockImplementation(() => {
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
|
||||
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
|
||||
const views: ReturnType<typeof view>[] = []
|
||||
function view() {
|
||||
let closed = false
|
||||
const webContents = Object.assign(new EventEmitter(), { focus: vi.fn(), isDestroyed: () => closed,
|
||||
loadURL: vi.fn(async () => {}), setWindowOpenHandler: vi.fn(),
|
||||
close: vi.fn(() => { if (!closed) { closed = true; webContents.emit('destroyed') } }),
|
||||
})
|
||||
onTestFinished(() => { window.emit('closed') })
|
||||
return window
|
||||
})
|
||||
expect(overlays.input(firstParent)).toEqual({ revision: 0, blocked: false })
|
||||
const first = overlays.create(firstParent, 'owned', 'Update required', false)
|
||||
const second = overlays.create(firstParent, 'owned', 'Confirm installation', false)
|
||||
const other = overlays.create(secondParent, 'owned', 'Update required', false)
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 2, blocked: true })
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
|
||||
expect(isolated.input(firstParent)).toEqual({ revision: 0, blocked: false })
|
||||
first.emit('closed')
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 3, blocked: true })
|
||||
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(1)
|
||||
second.emit('closed')
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 4, blocked: false })
|
||||
expect(firstParent.listenerCount('move')).toBe(0)
|
||||
expect(firstParent.listenerCount('resize')).toBe(0)
|
||||
expect(firstParent.listenerCount('focus')).toBe(0)
|
||||
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
|
||||
other.emit('closed')
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 2, blocked: false })
|
||||
return { get webContents() { if (closed) throw new Error('Destroyed native view'); return webContents },
|
||||
setBounds: vi.fn(), setBackgroundColor: vi.fn() }
|
||||
}
|
||||
native.create.mockImplementation(() => { const next = view(); views.push(next); return next })
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
const owner = parent
|
||||
return { parent, owner, views, overlays, create: () => overlays.create(owner, 'isolated-preload') }
|
||||
}
|
||||
|
||||
it('never shows or activates its native owner on visibility or background loading', () => {
|
||||
const f = fixture(); const overlay = f.create()
|
||||
expect(f.parent.contentView.addChildView).toHaveBeenCalledWith(f.views[0])
|
||||
f.parent.emit('show'); f.parent.emit('hide'); f.parent.emit('show')
|
||||
overlay.webContents.emit('did-finish-load')
|
||||
expect(f.parent.show).not.toHaveBeenCalled()
|
||||
expect(f.parent.focus).not.toHaveBeenCalled()
|
||||
expect(f.views[0]!.webContents.focus).not.toHaveBeenCalled()
|
||||
expect(f.parent.listenerCount('show')).toBe(0)
|
||||
overlay.destroy()
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32'] as const)('fits inside the owner with native caption clearance on %s', (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
const f = fixture(); const overlay = f.create(); const top = platform === 'win32' ? 40 : 0
|
||||
expect(f.views[0]!.setBounds).toHaveBeenCalledWith({ x: 0, y: top, width: 900, height: 650 - top })
|
||||
f.parent.emit('resize'); expect(f.views[0]!.setBounds).toHaveBeenCalledTimes(2)
|
||||
overlay.destroy()
|
||||
expect(f.parent.listenerCount('resize')).toBe(0)
|
||||
})
|
||||
|
||||
it('blocks keyboard input and redirects focus only while the owner is focused', () => {
|
||||
const f = fixture(); const overlay = f.create(); const event = { preventDefault: vi.fn() }
|
||||
f.parent.webContents.emit('before-input-event', event)
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(f.views[0]!.webContents.focus).not.toHaveBeenCalled()
|
||||
f.parent.isFocused.mockReturnValue(true); f.parent.emit('focus')
|
||||
expect(f.views[0]!.webContents.focus).toHaveBeenCalledOnce()
|
||||
overlay.destroy()
|
||||
expect(f.parent.webContents.focus).toHaveBeenCalledOnce()
|
||||
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps input blocked until every view is released and changes the shortcut revision', () => {
|
||||
const f = fixture(); const first = f.create(); const second = f.create()
|
||||
expect(f.overlays.input(f.owner)).toEqual({ blocked: true, revision: 2 })
|
||||
first.destroy(); expect(f.overlays.input(f.owner)).toEqual({ blocked: true, revision: 3 })
|
||||
first.destroy(); second.destroy()
|
||||
expect(f.overlays.input(f.owner)).toEqual({ blocked: false, revision: 4 })
|
||||
expect(new DesktopUpdateOverlays().input(f.owner)).toEqual({ blocked: false, revision: 0 })
|
||||
})
|
||||
|
||||
it('closes the view after owner destruction without accessing destroyed native contents', () => {
|
||||
const f = fixture(); const overlay = f.create()
|
||||
Object.defineProperty(f.parent, 'webContents', { get() { throw new Error('Destroyed owner') } })
|
||||
expect(() => { f.parent.destroy() }).not.toThrow()
|
||||
expect(overlay.isDestroyed()).toBe(true)
|
||||
expect(f.parent.listenerCount('focus')).toBe(0)
|
||||
expect(f.parent.listenerCount('closed')).toBe(0)
|
||||
expect(f.parent.contentView.removeChildView).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -54,9 +54,9 @@ export default defineConfig([
|
||||
await build({
|
||||
configFile: false,
|
||||
plugins: [{
|
||||
name: 'desktop-brand-font',
|
||||
name: 'desktop-shell-assets',
|
||||
async generateBundle() {
|
||||
for (const name of ['brand-font.css', 'montserrat-regular.woff2', 'montserrat-light.woff2', 'montserrat-medium.woff2', 'Montserrat-OFL.txt']) {
|
||||
for (const name of ['design-platform.css', 'brand-font.css', 'montserrat-regular.woff2', 'montserrat-light.woff2', 'montserrat-medium.woff2', 'Montserrat-OFL.txt']) {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: name,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# NavScenario: first run bash to
|
||||
|
||||
Session file:
|
||||
|
||||
```text
|
||||
{{sessionFile}}
|
||||
```
|
||||
|
||||
> NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.
|
||||
|
||||
<details><summary>3 previous messages</summary>
|
||||
|
||||
**Tool: `bash`**
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "echo NAVIGATION_OK",
|
||||
"description": "Print NAVIGATION_OK"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
NAVIGATION_OK
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-a.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-a.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # alpha nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-b.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-b.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # beta nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
FIRST_DONE
|
||||
|
||||
> Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.
|
||||
|
||||
## Navigation Summary
|
||||
|
||||
- alpha nav
|
||||
- beta nav
|
||||
|
||||
```
|
||||
echo WATERFALL
|
||||
```
|
||||
@@ -6,7 +6,7 @@
|
||||
// under the standard discipline: turn 1 produces a bash call plus two
|
||||
// parallel reads in one assistant message (tool-call density for the
|
||||
// trajectory ledger/timing lanes), turn 2 a markdown-rich reply.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page, Response } from 'playwright'
|
||||
@@ -14,7 +14,7 @@ import { chromium } from 'playwright'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -32,6 +32,7 @@ const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
|
||||
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
|
||||
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
|
||||
const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
|
||||
@@ -424,6 +425,66 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('copies the complete Session as Markdown from the Session Header', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
expect(await page.evaluate(() => typeof ClipboardItem)).toBe('function')
|
||||
expect(await page.evaluate(() => typeof navigator.clipboard.write)).toBe('function')
|
||||
await page.evaluate(() => {
|
||||
const clipboard = navigator.clipboard
|
||||
const originalWrite = clipboard.write.bind(clipboard)
|
||||
Reflect.set(globalThis, '__dshClipboardWrites', 0)
|
||||
Object.defineProperty(clipboard, 'write', {
|
||||
configurable: true,
|
||||
value: (...args: Parameters<typeof originalWrite>) => {
|
||||
Reflect.set(globalThis, '__dshClipboardWrites', Number(Reflect.get(globalThis, '__dshClipboardWrites')) + 1)
|
||||
return originalWrite(...args)
|
||||
},
|
||||
})
|
||||
})
|
||||
const markdownResponse = page.waitForResponse(response =>
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 })
|
||||
const copyButton = page.getByRole('button', { name: 'More actions' })
|
||||
await copyButton.click()
|
||||
await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click()
|
||||
const response = await markdownResponse
|
||||
expect(response.status()).toBe(200)
|
||||
expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8')
|
||||
await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1)
|
||||
|
||||
const sourcePath = (await scaffold.ctx.sessionPersistence.stat(SessionId(SEED_ID)))?.location?.path
|
||||
if (sourcePath === undefined) throw new Error('seeded Session has no materialized artifact location')
|
||||
expect((await stat(sourcePath)).isFile()).toBe(true)
|
||||
expect(sourcePath.startsWith(`${scaffold.persistenceRoot}/`)).toBe(true)
|
||||
const markdown = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``)
|
||||
const normalized = markdown
|
||||
.split(sourcePath).join('{{sessionFile}}')
|
||||
.split(scaffold.workspaceCwd).join('{{cwd}}')
|
||||
// compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one.
|
||||
await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE)
|
||||
expect(normalized).toContain('Session file:\n\n```text\n{{sessionFile}}\n```')
|
||||
expect(normalized).toContain('# NavScenario: first run bash to')
|
||||
expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK')
|
||||
expect(normalized).toContain('<details><summary>3 previous messages</summary>')
|
||||
expect(normalized).toContain('**Tool: `bash`**')
|
||||
expect(normalized).toContain('NAVIGATION_OK')
|
||||
expect(normalized).toContain('FIRST_DONE')
|
||||
expect(normalized).toContain('Navigation Summary')
|
||||
expect(normalized).not.toContain('The user wants me to follow')
|
||||
expect(normalized).not.toContain('{{system}}')
|
||||
expect(normalized).not.toContain('request/header')
|
||||
expect(normalized.endsWith('\n')).toBe(true)
|
||||
expect(normalized.endsWith('\n\n')).toBe(false)
|
||||
|
||||
await copyButton.click()
|
||||
expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false)
|
||||
await page.keyboard.press('Escape')
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
await ensureSeedOpen(page)
|
||||
@@ -561,7 +622,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md'])
|
||||
await assertFixtureInventory(CODE_CARD_DIR, [
|
||||
'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md',
|
||||
])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md', 'timing.expected.md',
|
||||
|
||||
@@ -68,9 +68,43 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls:
|
||||
})
|
||||
}
|
||||
|
||||
// The options carry long descriptions on purpose: the squeeze assertion below
|
||||
// needs option copy that WRAPS, which is the only text layout that reproduces a
|
||||
// collapsed row painting its copy outside its own box.
|
||||
/**
|
||||
* Read how the composer draws one long-copy element: the lines it shows,
|
||||
* whether CSS clipped it, whether its text overflows the line budget, and what
|
||||
* its toggle does while that toggle is visible.
|
||||
* @param element - a question heading or an option description.
|
||||
* @returns the drawn state of the copy.
|
||||
*/
|
||||
function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } {
|
||||
const style = getComputedStyle(element)
|
||||
const toggle = element.tagName === 'H2'
|
||||
? element.nextElementSibling
|
||||
: element.closest('[data-option-row]')?.querySelector(':scope > button:not([role])')
|
||||
return {
|
||||
// A clipped box keeps its full budget and gives the dropped line back through its bottom margin.
|
||||
lines: Math.round((element.getBoundingClientRect().height + parseFloat(style.marginBottom)) / parseFloat(style.lineHeight)),
|
||||
clipped: style.maskImage !== 'none',
|
||||
overflows: element.scrollHeight > element.clientHeight + 0.5,
|
||||
toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible'
|
||||
? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand'
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether an expanded copy's toggle sits below the copy's last text line.
|
||||
* @param toggle - the copy's toggle button.
|
||||
* @param copy - the expanded question heading or option description.
|
||||
* @returns whether the toggle starts at or below the bottom of the text; false without the copy.
|
||||
*/
|
||||
function belowText(toggle: HTMLElement, copy: Element | null): boolean {
|
||||
if (copy === null) return false
|
||||
const textBottom = copy.getBoundingClientRect().bottom - parseFloat(getComputedStyle(copy).paddingBottom)
|
||||
return toggle.getBoundingClientRect().top >= textBottom - 0.5
|
||||
}
|
||||
|
||||
// The options carry descriptions on purpose: the squeeze assertion below
|
||||
// verifies that each description keeps to its two-line budget in the card scrollport.
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -338,53 +372,85 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
// This golden owns the stable question surface; the answered-state
|
||||
// golden below owns the resulting transcript.
|
||||
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes =>
|
||||
nodes.map(node => ({
|
||||
label: node.getAttribute('aria-label'),
|
||||
description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/)
|
||||
.filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '),
|
||||
})))
|
||||
await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE)
|
||||
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
}
|
||||
|
||||
// Squeezed card: the option rows are the capped card's scroll content, so
|
||||
// shrinking the seat must push overflow into the option list, never
|
||||
// collapse a row below the height its own copy needs — a collapsed row
|
||||
// paints its centered copy outside the row box, over the title and the
|
||||
// neighbouring rows. Measured on the live composer at seat heights that
|
||||
// force the cap, then restored for the answer gesture below. Replay only:
|
||||
// record mode must reach the recording write below, not abort on layout.
|
||||
// Squeezed card: the question and compact option rows share one scrollport,
|
||||
// while navigation and submission stay outside it. Measured on the live
|
||||
// composer at seat heights that force the cap, then restored for the answer
|
||||
// gesture below. Replay only: record mode must reach the recording write
|
||||
// below, not abort on layout.
|
||||
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
if (MODE !== 'record') {
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
const scrollStates: boolean[] = []
|
||||
for (const height of [520, 440, 380]) {
|
||||
await page.setViewportSize({ width: 900, height })
|
||||
await page.setViewportSize({ width: 640, height })
|
||||
const squeeze = await composer.evaluate((card) => {
|
||||
// Role/ARIA selectors, not the CSS-module class names: the built
|
||||
// client hashes those.
|
||||
const rows = [...card.querySelectorAll<HTMLElement>(
|
||||
'[role="radio"], [role="checkbox"], [aria-expanded]',
|
||||
)]
|
||||
const rows = [...card.querySelectorAll<HTMLElement>('[data-option-row]')]
|
||||
const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
|
||||
const box = row.getBoundingClientRect()
|
||||
const inner = child.getBoundingClientRect()
|
||||
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
|
||||
})))
|
||||
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const title = card.querySelector('h2')
|
||||
return {
|
||||
rows: rows.length,
|
||||
spill: Math.max(...spill),
|
||||
// Wrapped option text is what overflows a collapsed row, and a
|
||||
// scrolling list proves the seat is genuinely capped. Without both,
|
||||
// the spill assertion would hold vacuously.
|
||||
wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
|
||||
scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
|
||||
titleInScrollport: list?.contains(title) === true,
|
||||
}
|
||||
})
|
||||
expect(squeeze.rows).toBeGreaterThan(0)
|
||||
expect(squeeze.wrappedRows).toBeGreaterThan(0)
|
||||
expect(squeeze.scrolls).toBe(true)
|
||||
for (const description of await composer.locator('[data-option-description]').all()) {
|
||||
const copy = await description.evaluate(readCopy)
|
||||
expect(copy.lines).toBeLessThanOrEqual(2)
|
||||
expect(copy.clipped).toBe(copy.overflows)
|
||||
expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined)
|
||||
}
|
||||
scrollStates.push(squeeze.scrolls)
|
||||
expect(squeeze.titleInScrollport).toBe(true)
|
||||
expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true)
|
||||
// Sub-pixel tolerance: every row's copy stays inside its border box.
|
||||
expect(squeeze.spill).toBeLessThan(0.6)
|
||||
}
|
||||
await page.setViewportSize(original)
|
||||
expect(scrollStates).toContain(true)
|
||||
await page.setViewportSize(originalViewport)
|
||||
}
|
||||
|
||||
// The narrowest supported card clips Blue's description to one line; its
|
||||
// toggle expands the text in place without choosing Blue.
|
||||
await page.setViewportSize({ width: 390, height: 700 })
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
const blueText = composer.locator('[data-option-description]').first()
|
||||
const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`)
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
// A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it.
|
||||
expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true })
|
||||
expect(await composer.locator('textarea').evaluate((field) => {
|
||||
const row = field.closest('[class*="customRow"]')
|
||||
if (!(row instanceof HTMLElement)) return false
|
||||
const box = row.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2)
|
||||
return hit !== null && row.contains(hit)
|
||||
})).toBe(true)
|
||||
await blueToggle.click()
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await blueToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
await blueToggle.click()
|
||||
// A collapsed copy's scroll timeline reports the overflow from the next frame.
|
||||
await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await page.setViewportSize(originalViewport)
|
||||
|
||||
// Multi-line custom answer: the field is a textarea whose hidden mirror
|
||||
// owns the box height, so a soft-wrapped or line-broken draft GROWS the
|
||||
// field instead of scrolling one line, and Shift+Enter breaks the line
|
||||
@@ -415,7 +481,6 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await custom.fill('')
|
||||
}
|
||||
|
||||
const blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
await blue.click()
|
||||
await custom.fill('Include accessibility notes')
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
@@ -536,6 +601,436 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => {
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'reasoning',
|
||||
header: '确认思考等级',
|
||||
question: '选择 HF provider 的思考等级?',
|
||||
options: [
|
||||
{ label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' },
|
||||
{ label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' },
|
||||
{ label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' },
|
||||
{ label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let completed = false
|
||||
try {
|
||||
await page.setViewportSize({ width: 1328, height: 720 })
|
||||
await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor()
|
||||
expect(await composer.getByRole('heading').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0)
|
||||
const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => {
|
||||
const label = row.querySelector<HTMLElement>('[data-option-line] > span:first-child')
|
||||
const description = row.querySelector<HTMLElement>('[data-option-description]')
|
||||
if (label === null || description === null) return null
|
||||
const style = getComputedStyle(description)
|
||||
return {
|
||||
sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
maskImage: style.maskImage,
|
||||
}
|
||||
}))
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows).toEqual(Array.from({ length: 4 }, () => ({
|
||||
sameLine: true, fontSize: '13px', lineHeight: '20px', maskImage: 'none',
|
||||
})))
|
||||
await composer.getByRole('radio', { name: '中(medium)' }).click()
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } })
|
||||
completed = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
|
||||
if (completed) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy'))
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ')
|
||||
const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.'
|
||||
const description = Array.from({ length: 48 }, (_, index) =>
|
||||
`Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ')
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'release',
|
||||
header: 'Release decision',
|
||||
question,
|
||||
detail,
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: 'Explicit install', description },
|
||||
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
|
||||
{ label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' },
|
||||
],
|
||||
}, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let bodyCompleted = false
|
||||
try {
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const heading = composer.getByRole('heading', { name: question })
|
||||
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
|
||||
has: page.getByRole('checkbox', { name: label, exact: true }),
|
||||
}).locator('[data-option-description]')
|
||||
const toggleFor = async (copy: Locator) =>
|
||||
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
|
||||
const readMetrics = () => composer.evaluate((card) => {
|
||||
const scroll = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const title = card.querySelector<HTMLElement>('h2')
|
||||
const submit = [...card.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Next')
|
||||
if (scroll === null || title === null || submit === undefined) return null
|
||||
const submitBox = submit.getBoundingClientRect()
|
||||
return {
|
||||
footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
|
||||
scrolls: scroll.scrollHeight > scroll.clientHeight,
|
||||
titleInScrollport: scroll.contains(title),
|
||||
}
|
||||
})
|
||||
// Cover common desktop sizes plus the CSS-pixel equivalents of a
|
||||
// progressively zoomed 640x420 window without changing browser-global zoom.
|
||||
const scrollStates: boolean[] = []
|
||||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 900, height: 600 },
|
||||
{ width: 640, height: 420 },
|
||||
{ width: 512, height: 336 },
|
||||
{ width: 427, height: 280 },
|
||||
{ width: 390, height: 700 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport)
|
||||
await expect.poll(readMetrics).not.toBeNull()
|
||||
const metrics = await readMetrics()
|
||||
expect(metrics?.footerFits).toBe(true)
|
||||
expect(metrics?.titleInScrollport).toBe(true)
|
||||
scrollStates.push(metrics?.scrolls ?? false)
|
||||
// Past its three-line budget the question keeps two lines; past its
|
||||
// two-line budget a description keeps one. Copy within budget shows in full.
|
||||
await expect.poll(() => heading.evaluate(readCopy))
|
||||
.toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Explicit install').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(await descriptionOf('Keep the default edge').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
for (const copy of await composer.locator('[data-option-description]').all()) {
|
||||
const drawn = await copy.evaluate(readCopy)
|
||||
expect(drawn.clipped).toBe(drawn.overflows)
|
||||
expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined)
|
||||
expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2)
|
||||
}
|
||||
if (viewport.width === 512) {
|
||||
expect(await descriptionOf('Move the package').evaluate(readCopy))
|
||||
.toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
|
||||
}
|
||||
const textMetrics = await composer.locator('[data-option-description]').evaluateAll(nodes => nodes.map((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
const rect = node.getBoundingClientRect()
|
||||
return { fontSize: style.fontSize, lineHeight: style.lineHeight,
|
||||
fitsViewport: rect.left >= 0 && rect.right <= innerWidth }
|
||||
}))
|
||||
expect(textMetrics).toEqual(Array.from({ length: 3 }, () => ({
|
||||
fontSize: '13px', lineHeight: '20px', fitsViewport: true,
|
||||
})))
|
||||
}
|
||||
expect(scrollStates).toContain(false)
|
||||
expect(scrollStates).toContain(true)
|
||||
|
||||
// A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail.
|
||||
expect(await composer.getByText(detail, { exact: true }).evaluate((text) => {
|
||||
const box = text.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(box.left + 8, box.top + 3)
|
||||
return hit !== null && text.contains(hit)
|
||||
})).toBe(true)
|
||||
|
||||
// The question expands in place; its choices move down inside the
|
||||
// scrollport while navigation and submission stay reachable.
|
||||
const questionToggle = await toggleFor(heading)
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Expand')
|
||||
expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id'))
|
||||
await questionToggle.click()
|
||||
expect(await questionToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse')
|
||||
const expandedQuestion = await heading.evaluate(readCopy)
|
||||
expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedQuestion.lines).toBeGreaterThan(3)
|
||||
expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true)
|
||||
expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true })
|
||||
await questionToggle.click()
|
||||
await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
const detailText = composer.getByText(detail, { exact: true })
|
||||
expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true)
|
||||
await detailText.scrollIntoViewIfNeeded()
|
||||
expect(await detailText.isVisible()).toBe(true)
|
||||
expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true)
|
||||
|
||||
// A description expands in place without choosing its option, and its
|
||||
// toggle stays described by the text it controls.
|
||||
const explicit = composer.getByRole('checkbox', { name: 'Explicit install' })
|
||||
const explicitText = descriptionOf('Explicit install')
|
||||
const descriptionToggle = await toggleFor(explicitText)
|
||||
const descriptionId = await explicitText.getAttribute('id')
|
||||
expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId)
|
||||
expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand')
|
||||
await descriptionToggle.click()
|
||||
expect(await explicit.getAttribute('aria-checked')).toBe('false')
|
||||
const expandedDescription = await explicitText.evaluate(readCopy)
|
||||
expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' })
|
||||
expect(expandedDescription.lines).toBeGreaterThan(2)
|
||||
expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true)
|
||||
expect(await explicitText.evaluate((text) => {
|
||||
const row = text.closest('[data-option-row]') as HTMLElement
|
||||
return [...row.querySelectorAll<HTMLElement>('*')].every(child =>
|
||||
child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5)
|
||||
})).toBe(true)
|
||||
|
||||
// The toggle's icon keeps non-text contrast on the card in both themes.
|
||||
const palette: string[] = []
|
||||
const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme')
|
||||
try {
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark)
|
||||
const colors = await descriptionToggle.evaluate((toggle) => {
|
||||
const card = toggle.closest('section')
|
||||
return card === null ? null : { text: getComputedStyle(toggle).color, surface: getComputedStyle(card).backgroundColor }
|
||||
})
|
||||
if (colors === null) throw new Error('The question card is unavailable')
|
||||
const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? []
|
||||
const luminance = (color: string) => channels(color).reduce((total, channel, index) => {
|
||||
const normalized = channel / 255
|
||||
const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
|
||||
return total + linear * [0.2126, 0.7152, 0.0722][index]!
|
||||
}, 0)
|
||||
const foreground = luminance(colors.text)
|
||||
const background = luminance(colors.surface)
|
||||
expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05))
|
||||
.toBeGreaterThanOrEqual(3)
|
||||
palette.push(colors.surface)
|
||||
}
|
||||
expect(palette[0]).not.toBe(palette[1])
|
||||
} finally {
|
||||
await page.evaluate((previous) => {
|
||||
if (previous === null) document.body.removeAttribute('data-ds-dark-theme')
|
||||
else document.body.setAttribute('data-ds-dark-theme', previous)
|
||||
}, originalDarkTheme)
|
||||
}
|
||||
await descriptionToggle.click()
|
||||
await expect.poll(() => explicitText.evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
await composer.getByRole('checkbox', { name: 'Explicit install' }).click()
|
||||
const answer = composer.getByRole('textbox')
|
||||
await answer.fill('Keep the migration steps explicit.')
|
||||
await answer.press('Enter')
|
||||
await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor()
|
||||
expect(await answer.evaluate(node => document.activeElement === node)).toBe(true)
|
||||
await page.keyboard.type('Release owner')
|
||||
expect(await answer.inputValue()).toBe('Release owner')
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({
|
||||
value: {
|
||||
answers: [{
|
||||
id: 'release',
|
||||
selected: ['Explicit install'],
|
||||
custom: 'Keep the migration steps explicit.',
|
||||
}, { id: 'followup', selected: [], custom: 'Release owner' }],
|
||||
},
|
||||
})
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
bodyCompleted = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)])
|
||||
if (bodyCompleted) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width'))
|
||||
const sessionId = answeredSession
|
||||
expect(sessionId).toBeDefined()
|
||||
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
||||
expect(agent).toBeDefined()
|
||||
// Sized for the chat-width drag range: the question takes three lines or
|
||||
// fewer in the widest chat column and more in the narrowest, and the staged
|
||||
// description takes two lines or fewer and more.
|
||||
const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?`
|
||||
const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.'
|
||||
const explicit = Array.from({ length: 24 }, (_, index) =>
|
||||
`Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ')
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent: agent as NonNullable<typeof agent>,
|
||||
signal: controller.signal,
|
||||
questions: [{
|
||||
id: 'rollout',
|
||||
header: 'Rollout decision',
|
||||
question,
|
||||
options: [
|
||||
{ label: 'Explicit install', description: explicit },
|
||||
{ label: 'Stage the rollout', description: staged },
|
||||
{ label: 'Keep the default edge', description: 'Keep the current setup.' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
|
||||
const composer = page.locator('[data-question-key]')
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
let bodyCompleted = false
|
||||
try {
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
await composer.waitFor({ timeout: 30_000 })
|
||||
const heading = composer.getByRole('heading', { name: question })
|
||||
const descriptionOf = (label: string) => composer.locator('[data-option-row]', {
|
||||
has: page.getByRole('radio', { name: label, exact: true }),
|
||||
}).locator('[data-option-description]')
|
||||
const toggleFor = async (copy: Locator) =>
|
||||
composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`)
|
||||
// Settled layout: no AppFrame column transition in flight, then every
|
||||
// collapsed copy drawn as its own overflow decides, the expanded copy
|
||||
// still expanded, and the actions reachable.
|
||||
const settle = async () => {
|
||||
await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false)
|
||||
const read = async () => {
|
||||
const card = await composer.locator('section').evaluate((section) => {
|
||||
const scroll = section.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
const submit = [...section.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent === 'Submit')
|
||||
const submitBox = submit?.getBoundingClientRect()
|
||||
return {
|
||||
width: Math.round(section.getBoundingClientRect().width),
|
||||
actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5,
|
||||
titleInScrollport: scroll?.contains(section.querySelector('h2')) === true,
|
||||
}
|
||||
})
|
||||
return {
|
||||
card,
|
||||
question: await heading.evaluate(readCopy),
|
||||
explicit: await descriptionOf('Explicit install').evaluate(readCopy),
|
||||
staged: await descriptionOf('Stage the rollout').evaluate(readCopy),
|
||||
short: await descriptionOf('Keep the default edge').evaluate(readCopy),
|
||||
}
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const state = await read()
|
||||
const decided = (copy: Awaited<ReturnType<typeof read>>['question'], budget: number) =>
|
||||
copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined)
|
||||
&& copy.lines <= (copy.clipped ? budget - 1 : budget)
|
||||
return state.card.actionsReachable && state.card.titleInScrollport
|
||||
&& decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2)
|
||||
&& !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse'
|
||||
}).toBe(true)
|
||||
return await read()
|
||||
}
|
||||
// The right transcript handle widens the content column by twice its
|
||||
// outward travel. The composer seat covers the handle's lower part, so
|
||||
// the gesture grabs it beside the transcript.
|
||||
const dragChatWidth = async (travel: number) => {
|
||||
const box = await page.locator('[data-width-handle="right"]').boundingBox()
|
||||
if (box === null) throw new Error('The chat width handle is not rendered')
|
||||
const x = box.x + box.width / 2
|
||||
const y = box.y + 120
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await page.mouse.move(x + travel, y, { steps: 8 })
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded copy is the user's choice and survives every width change below.
|
||||
const explicitToggle = await toggleFor(descriptionOf('Explicit install'))
|
||||
await explicitToggle.click()
|
||||
expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await dragChatWidth(-400)
|
||||
const narrow = await settle()
|
||||
expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
|
||||
await dragChatWidth(600)
|
||||
const wide = await settle()
|
||||
expect(wide.card.width).toBeGreaterThan(narrow.card.width)
|
||||
expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined })
|
||||
expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined })
|
||||
expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined })
|
||||
|
||||
// The right sidebar narrows the conversation column, and collapsing it restores the wide state.
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
const withSidebar = await settle()
|
||||
expect(withSidebar.card.width).toBeLessThan(wide.card.width)
|
||||
await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
|
||||
expect(await settle()).toEqual(wide)
|
||||
|
||||
// A smaller window clamps the dragged width; restoring the window restores it.
|
||||
await page.setViewportSize({ width: 900, height: 700 })
|
||||
const smallWindow = await settle()
|
||||
expect(smallWindow.card.width).toBeLessThan(wide.card.width)
|
||||
expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' })
|
||||
expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
expect(await settle()).toEqual(wide)
|
||||
|
||||
await explicitToggle.click()
|
||||
await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy))
|
||||
.toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' })
|
||||
await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click()
|
||||
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
||||
expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } })
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
bodyCompleted = true
|
||||
} finally {
|
||||
controller.abort()
|
||||
// The dragged width is a persisted preference; dropping it and resizing republishes the default.
|
||||
const cleanup = await Promise.allSettled([
|
||||
outcome,
|
||||
page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') })
|
||||
.then(() => page.setViewportSize(original)),
|
||||
])
|
||||
if (bodyCompleted) {
|
||||
const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
|
||||
|
||||
@@ -224,6 +224,8 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
let hostClock: MockInstance<typeof Date.now> | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const requestInputs: string[][] = []
|
||||
let firstRequestSignal: AbortSignal | undefined
|
||||
const questionStarted = Promise.withResolvers<undefined>()
|
||||
const releaseQuestion = Promise.withResolvers<undefined>()
|
||||
const FOLLOWUP = 'Also acknowledge this second message.'
|
||||
const RESPONSE = "Great, let's move forward. BANANA!"
|
||||
@@ -239,9 +241,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
message.role === 'user'
|
||||
? message.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: []))
|
||||
if (requestInputs.length === 1) firstRequestSignal = options.signal
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise
|
||||
if (requestInputs.length === 1 && chunk.type === 'block-start') {
|
||||
questionStarted.resolve(undefined)
|
||||
await releaseQuestion.promise
|
||||
}
|
||||
}
|
||||
}, { prepend: true })
|
||||
browser = await chromium.launch()
|
||||
@@ -271,11 +277,13 @@ describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop m
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await questionStarted.promise
|
||||
await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1)
|
||||
await input.fill(STEER)
|
||||
await input.press('Meta+Enter')
|
||||
await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Stop generating', exact: true }).click()
|
||||
await expect.poll(() => firstRequestSignal?.aborted).toBe(true)
|
||||
releaseQuestion.resolve(undefined)
|
||||
await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1)
|
||||
expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0)
|
||||
|
||||
@@ -1722,7 +1722,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2906,7 +2906,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-session-log-export`
|
||||
|
||||
- `inject`: `commands` · `connection`
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
|
||||
@@ -1724,7 +1724,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2908,7 +2908,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-session-log-export`
|
||||
|
||||
- `inject`: `commands` · `connection`
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts)
|
||||
- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
|
||||
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
|
||||
@@ -82,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `creation-recommendations`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
|
||||
@@ -84,7 +84,7 @@
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `creation-recommendations`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
@@ -1454,6 +1454,7 @@ flowchart TD
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_approval
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
@@ -1814,5 +1815,5 @@ flowchart TD
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -1456,6 +1456,7 @@ flowchart TD
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_approval
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
@@ -1816,5 +1817,5 @@ flowchart TD
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -9,8 +9,8 @@
|
||||
en: cc0f91dc4bb82605
|
||||
zh: 81d5031752c6ea74
|
||||
/vendored-package-rescope/what-the-rename-does-not-touch:
|
||||
en: e781d7e1b46c0974
|
||||
zh: 9796fb884c25d850
|
||||
en: 9bb7d09f8cccf695
|
||||
zh: 2a8f3f60ae5116f3
|
||||
/vendored-package-rescope/what-your-code-has-to-change:
|
||||
en: 50a48dfda6cea731
|
||||
zh: 55028c5c04270eff
|
||||
|
||||
+3
-1
@@ -28,7 +28,9 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@
|
||||
- **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`.
|
||||
- **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`.
|
||||
- **Upstream runtime identifiers**, such as Schemastery's `Symbol.for('schemastery')` and its `vendor:` metadata field.
|
||||
- **Prose outside `docs/`.** `vendor/*/README.md`, package READMEs, and Agent Notes keep the names they were written with; a bare `cordis` there can also be the Python SDK's option name or an agent-preset id. Inside `docs/`, prose and every Markdown fence follow the rename.
|
||||
- **Product identifiers.** Files in `GENERIC_SKIPS` keep preset IDs, event names, directory group names, and locale or input-source keys for the listed upstream name. Source imports, exports, module declarations and JSDoc import types still change in both directions, including in JavaScript/TypeScript fences. YAML plugin `name:` values also change. Other string uses of the listed name stay intact; other library names remain checked.
|
||||
- **Rewrite fixtures.** `scripts/rescope-vendor.spec.ts` and `scripts/gen-third-party-notices.spec.ts` keep both package spellings as test data.
|
||||
- **Prose outside `docs/`.** `vendor/*/README.md`, package READMEs, and Agent Notes keep the names they were written with; a bare `cordis` there can also be the Python SDK's option name or an agent-preset id. Inside `docs/`, prose and Markdown fences follow the token rule except for the audited identifiers above.
|
||||
|
||||
## What your code has to change
|
||||
|
||||
|
||||
+3
-1
@@ -28,7 +28,9 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R
|
||||
- **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。
|
||||
- **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。
|
||||
- **上游运行时标识符**,例如 Schemastery 的 `Symbol.for('schemastery')` 及其 `vendor:` 元数据字段。
|
||||
- **`docs/` 之外的散文。** `vendor/*/README.md`、各包 README 与 Agent Note 保留写作当时的名字;那里的裸 `cordis` 也可能是 Python SDK 的选项名或某个 agent-preset 的 id。`docs/` 之内,散文与所有 Markdown 围栏都跟着改。
|
||||
- **产品标识符。** `GENERIC_SKIPS` 列出的文件保留所列上游名称对应的 preset ID、事件名、目录分组名,以及 locale 或输入来源键。同一文件中的 import、export、模块声明和 JSDoc import 类型仍会双向改名,JavaScript/TypeScript 围栏内也如此。YAML 插件的 `name:` 值同样改名。所列名称的其他字符串用法保持不变,其他库名仍受检查。
|
||||
- **改名测试夹具。** `scripts/rescope-vendor.spec.ts` 和 `scripts/gen-third-party-notices.spec.ts` 保留两种包名拼写作为测试数据。
|
||||
- **`docs/` 之外的散文。** `vendor/*/README.md`、各包 README 与 Agent Note 保留写作当时的名字;那里的裸 `cordis` 也可能是 Python SDK 的选项名或某个 agent-preset 的 id。`docs/` 之内的散文和 Markdown 围栏按 token 规则改名,上述经过核对的标识符除外。
|
||||
|
||||
## 你的代码要改什么
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
/session-persistence/crash-recovery-preserves-an-interrupted-turn:
|
||||
en: 534044a0ce482d52
|
||||
zh: d2e8a7dfeb066946
|
||||
/session-persistence/sessionlocation-refusal-diagnostics-artifact-target:
|
||||
en: 886589f052a916d7
|
||||
zh: 2ca126aa700c933c
|
||||
/session-persistence/sessionlocation-per-session-artifact-metadata:
|
||||
en: e310ff426d848262
|
||||
zh: 8a7667349b0549a7
|
||||
/session-persistence/sessionheader-metadata-beside-the-log:
|
||||
en: 5795ae03d2d163cd
|
||||
zh: a46ceeb22cfbb6a1
|
||||
|
||||
@@ -113,16 +113,16 @@ Repair therefore writes only under write ownership: a live session's write handl
|
||||
|
||||
Read-only observation is `open(id, 'read')`: the handle serves validated contiguous prefix slices, never a torn tail, and repeated reads on one handle never observe an older state than a prior read. There is no persistence-side prepared-Session cache: session-query owns its cold-read cache, keying one balanced cold Session per id on the `stat().revision` change token and re-reading only when the token changes. The [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) owns this lifecycle; the archived [Session preparation record](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md) documents the original publication-boundary `SessionPreparation` decision.
|
||||
|
||||
## `SessionLocation` — refusal-diagnostics artifact target
|
||||
## `SessionLocation` — per-Session artifact metadata
|
||||
|
||||
`SessionLocation` is not a consumer-facing query: log access goes through a session handle's `read`. It survives only as refusal diagnostics, letting a `SessionFormatUnsupportedError` name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing.
|
||||
`SessionLocation` is observational metadata for diagnostics and export; log access still goes through a session handle's `read`. Materialized persistence snapshots carry the selected artifact path, and a `SessionFormatUnsupportedError` can name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A backend-resolved, per-session local artifact location. Carried only by
|
||||
* refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can
|
||||
* find the raw log a build refused to interpret; it is not a consumer-facing
|
||||
* query — log access goes through a session handle's `read`.
|
||||
* A backend-resolved, per-Session local artifact path used by observational
|
||||
* snapshots and refusal diagnostics. It names a backend target for the Session
|
||||
* and does not authorize reads; log access still goes through a session
|
||||
* handle's `read`. It is not persisted Session content.
|
||||
*/
|
||||
interface SessionLocation {
|
||||
/** Backend-specific artifact kind, for example `jsonl`. */
|
||||
@@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot {
|
||||
readonly eventCount?: number
|
||||
/** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */
|
||||
readonly sizeBytes?: number
|
||||
/**
|
||||
* Backend-resolved location of the selected materialized per-Session
|
||||
* artifact, for diagnostics and export. Absent when the backend has no
|
||||
* materialized per-Session file, including an unmaterialized pending Session.
|
||||
*/
|
||||
readonly location?: SessionLocation
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -113,16 +113,16 @@ interface SessionHandle extends AsyncDisposable {
|
||||
|
||||
只读观察即 `open(id, 'read')`:句柄提供经过验证的连续前缀切片,绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态。持久化侧不存在已准备 Session 缓存:session-query 拥有自己的冷读缓存,按 `stat().revision` 变更令牌为每个 id 缓存一个已配平的冷 Session,仅在令牌变化时重新读取。该生命周期由[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)定义;已归档的 [Session 准备阶段记录](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md)记载了发布边界 `SessionPreparation` 最初的决策。
|
||||
|
||||
## `SessionLocation`——拒绝诊断的产物目标
|
||||
## `SessionLocation`——每一 Session 的产物元数据
|
||||
|
||||
`SessionLocation` 不是面向消费者的查询:日志访问走会话句柄的 `read`。它仅作为拒绝诊断存在,使 `SessionFormatUnsupportedError` 能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。
|
||||
`SessionLocation` 用于诊断与导出的观察元数据;日志访问仍走会话句柄的 `read`。已物化的持久化快照携带选中产物路径,`SessionFormatUnsupportedError` 也能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A backend-resolved, per-session local artifact location. Carried only by
|
||||
* refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can
|
||||
* find the raw log a build refused to interpret; it is not a consumer-facing
|
||||
* query — log access goes through a session handle's `read`.
|
||||
* A backend-resolved, per-Session local artifact path used by observational
|
||||
* snapshots and refusal diagnostics. It names a backend target for the Session
|
||||
* and does not authorize reads; log access still goes through a session
|
||||
* handle's `read`. It is not persisted Session content.
|
||||
*/
|
||||
interface SessionLocation {
|
||||
/** Backend-specific artifact kind, for example `jsonl`. */
|
||||
@@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot {
|
||||
readonly eventCount?: number
|
||||
/** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */
|
||||
readonly sizeBytes?: number
|
||||
/**
|
||||
* Backend-resolved location of the selected materialized per-Session
|
||||
* artifact, for diagnostics and export. Absent when the backend has no
|
||||
* materialized per-Session file, including an unmaterialized pending Session.
|
||||
*/
|
||||
readonly location?: SessionLocation
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision
|
||||
allows(resource: RemoteAccessResource): boolean
|
||||
|
||||
/**
|
||||
* The baseline a page opens with before its snapshot stream connects. A
|
||||
* business package rendering a non-owner page assigns this value to
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
|
||||
* replacing the owner baseline the webServer index carries.
|
||||
* The baseline a page opens with before its snapshot stream connects; every
|
||||
* index the webServer renders carries it under
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
|
||||
* @param principal - the page's caller identity.
|
||||
* @returns the owner baseline for the owner; otherwise the principal's current grants.
|
||||
*/
|
||||
|
||||
@@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision
|
||||
allows(resource: RemoteAccessResource): boolean
|
||||
|
||||
/**
|
||||
* The baseline a page opens with before its snapshot stream connects. A
|
||||
* business package rendering a non-owner page assigns this value to
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
|
||||
* replacing the owner baseline the webServer index carries.
|
||||
* The baseline a page opens with before its snapshot stream connects; every
|
||||
* index the webServer renders carries it under
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
|
||||
* @param principal - the page's caller identity.
|
||||
* @returns the owner baseline for the owner; otherwise the principal's current grants.
|
||||
*/
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
|
||||
/http-server:
|
||||
en: c9b2f67d3248c496
|
||||
zh: 5fa36d2837ff4f63
|
||||
en: 56b268a69964f651
|
||||
zh: c8ac8f16bf58a98a
|
||||
/http-server/routes:
|
||||
en: e949c3738a35b58f
|
||||
zh: 138e3aaeb319ddc4
|
||||
@@ -12,5 +12,5 @@
|
||||
en: 4b162bd9f280eb06
|
||||
zh: 3bea98dfee88c582
|
||||
/http-server/the-service:
|
||||
en: cc36972b8458279f
|
||||
zh: c129044935d44769
|
||||
en: bbf84a6cd0460dc2
|
||||
zh: cc63ce45b0675c6d
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](web-server.zh.md)
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -81,7 +81,7 @@ No composition can bind every interface at once: the unspecified address is reje
|
||||
|
||||
## The service
|
||||
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
|
||||
|
||||
@@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void
|
||||
applyIndexTaps(html: string): string
|
||||
|
||||
/**
|
||||
* Gather the structured injection table: one `webserver/index-inject` emit,
|
||||
* every subscriber pushes its current rows. Fresh per call, so subscribers
|
||||
* read live state (module graph, theme preference) at emit time.
|
||||
* Gather the structured injection table for one Peer: one
|
||||
* `webserver/index-inject` emit, every subscriber pushes its current rows.
|
||||
* Fresh per call, so subscribers read live state (module graph, theme
|
||||
* preference, the Peer's access) at emit time.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns rows in subscriber activation order.
|
||||
*/
|
||||
collectIndexInjections(): IndexInjection[]
|
||||
collectIndexInjections(peer: PeerScope): IndexInjection[]
|
||||
|
||||
/**
|
||||
* Render one index.html body: the structured injection table first, then
|
||||
* the raw `tapIndex` transforms over the result.
|
||||
* Render one index.html body for one Peer: the structured injection table
|
||||
* first, then the raw `tapIndex` transforms over the result.
|
||||
* @param html - the raw index.html body.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
renderIndex(html: string): string
|
||||
renderIndex(html: string, peer: PeerScope): string
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
@@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec
|
||||
|
||||
#### `webserver/index-inject` — emit
|
||||
|
||||
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Collect the structured index injection table. Emitted on every index
|
||||
* render and every worker boot-payload request; listeners push their
|
||||
* current rows, so a row's data is read fresh at emit time.
|
||||
* Collect the structured index injection table for the page `peer` speaks
|
||||
* for. Emitted on every index render and every worker boot-payload
|
||||
* request; listeners push their current rows, so a row's data is read
|
||||
* fresh at emit time.
|
||||
* @param table - Mutable row table; listeners append in activation order.
|
||||
* @param peer - The Peer the rendered page speaks for.
|
||||
* @mode emit
|
||||
*/
|
||||
'webserver/index-inject'(table: IndexInjection[]): void
|
||||
'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](web-server.md) | 中文
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
|
||||
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -81,7 +81,7 @@ interface TlsConfig {
|
||||
|
||||
## 服务
|
||||
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
|
||||
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
|
||||
|
||||
@@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void
|
||||
applyIndexTaps(html: string): string
|
||||
|
||||
/**
|
||||
* Gather the structured injection table: one `webserver/index-inject` emit,
|
||||
* every subscriber pushes its current rows. Fresh per call, so subscribers
|
||||
* read live state (module graph, theme preference) at emit time.
|
||||
* Gather the structured injection table for one Peer: one
|
||||
* `webserver/index-inject` emit, every subscriber pushes its current rows.
|
||||
* Fresh per call, so subscribers read live state (module graph, theme
|
||||
* preference, the Peer's access) at emit time.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns rows in subscriber activation order.
|
||||
*/
|
||||
collectIndexInjections(): IndexInjection[]
|
||||
collectIndexInjections(peer: PeerScope): IndexInjection[]
|
||||
|
||||
/**
|
||||
* Render one index.html body: the structured injection table first, then
|
||||
* the raw `tapIndex` transforms over the result.
|
||||
* Render one index.html body for one Peer: the structured injection table
|
||||
* first, then the raw `tapIndex` transforms over the result.
|
||||
* @param html - the raw index.html body.
|
||||
* @param peer - the Peer the page speaks for.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
renderIndex(html: string): string
|
||||
renderIndex(html: string, peer: PeerScope): string
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
@@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec
|
||||
|
||||
#### `webserver/index-inject` — emit
|
||||
|
||||
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Collect the structured index injection table. Emitted on every index
|
||||
* render and every worker boot-payload request; listeners push their
|
||||
* current rows, so a row's data is read fresh at emit time.
|
||||
* Collect the structured index injection table for the page `peer` speaks
|
||||
* for. Emitted on every index render and every worker boot-payload
|
||||
* request; listeners push their current rows, so a row's data is read
|
||||
* fresh at emit time.
|
||||
* @param table - Mutable row table; listeners append in activation order.
|
||||
* @param peer - The Peer the rendered page speaks for.
|
||||
* @mode emit
|
||||
*/
|
||||
'webserver/index-inject'(table: IndexInjection[]): void
|
||||
'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void
|
||||
```
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md
|
||||
/:
|
||||
en: 4182112137303167
|
||||
zh: 8451ce873cfca164
|
||||
/webserver-index-rendering-takes-a-peer:
|
||||
en: 7986c2aaf1511f53
|
||||
zh: a6f78fc166e1ea19
|
||||
/webserver-index-rendering-takes-a-peer/change:
|
||||
en: 07a11388305a0124
|
||||
zh: 662003b29c238d30
|
||||
/webserver-index-rendering-takes-a-peer/migration:
|
||||
en: 7ef144b29ffb2f39
|
||||
zh: defb62ecc4ba6c2e
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "WebServer index rendering takes the Peer the page speaks for, and the `webserver/index-inject` event passes that Peer to listeners."
|
||||
---
|
||||
# WebServer index rendering takes a Peer
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`renderIndex(html)` and `collectIndexInjections()` on `ctx.webServer` from `@deepseek-ai/dsh-host-webserver` took no Peer, and every index carried the owner's access snapshot. They are now `renderIndex(html, peer)` and `collectIndexInjections(peer)`, and the `webserver/index-inject` event passes the same Peer as its second argument, so the remote-access snapshot in the index belongs to that Peer. Code that calls either method breaks at compile time; listeners that read only the table keep working.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Pass the Peer the page speaks for to every `ctx.webServer.renderIndex(html)` and `ctx.webServer.collectIndexInjections()` call. A page served to the owner's browser passes `ctx.connection.browserPeer`.
|
||||
2. In a test that emits `webserver/index-inject` itself, pass a Peer after the table: `ctx.emit('webserver/index-inject', table, peer)`.
|
||||
3. Run your TypeScript build and confirm no call to either method reports a missing argument.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "WebServer 渲染 index 时需要传入页面所代表的 Peer,`webserver/index-inject` 事件会把该 Peer 传给监听方。"
|
||||
---
|
||||
# WebServer 渲染 index 需要传入 Peer
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-host-webserver` 中 `ctx.webServer` 的 `renderIndex(html)` 与 `collectIndexInjections()` 不接收 Peer,每个 index 携带的都是 owner 的访问快照。现在它们改为 `renderIndex(html, peer)` 与 `collectIndexInjections(peer)`,`webserver/index-inject` 事件以第二个参数传入同一个 Peer,index 中的 remote-access 快照因此属于该 Peer。调用这两个方法的代码会在编译时报错;只读取表格的监听方不受影响。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 每处 `ctx.webServer.renderIndex(html)` 与 `ctx.webServer.collectIndexInjections()` 调用都传入页面所代表的 Peer。提供给 owner 浏览器的页面传入 `ctx.connection.browserPeer`。
|
||||
2. 自行 emit `webserver/index-inject` 的测试在表格之后传入一个 Peer:`ctx.emit('webserver/index-inject', table, peer)`。
|
||||
3. 运行 TypeScript 构建,确认这两个方法的调用都不再报缺少参数。
|
||||
@@ -15,11 +15,11 @@
|
||||
en: a18ecc232e3c3933
|
||||
zh: b8e43580403f6d77
|
||||
/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess:
|
||||
en: 9175835ea5c5bff1
|
||||
zh: 0e33b266fdac3d13
|
||||
en: 8a4be795c7aa2ef0
|
||||
zh: 02550b975e2e8d61
|
||||
/deepseek-ai-dsh-api-remote-access/client-face:
|
||||
en: 1db01235b6af84fa
|
||||
zh: c0ced511cc1841a0
|
||||
en: d4cf8f646fdf2d39
|
||||
zh: 247f643e991aa806
|
||||
/deepseek-ai-dsh-api-remote-access/model-experience:
|
||||
en: 8b07250cecacd3aa
|
||||
zh: d28ab93980bdcdad
|
||||
|
||||
@@ -43,7 +43,7 @@ When `bindings()`, a condition, or `explain()` throws, the decision is refused w
|
||||
|
||||
A forwarded event is decided as the action `event:<name>` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`.
|
||||
|
||||
The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's.
|
||||
The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding `snapshotFor(peer.principal)` to every index the webServer renders through `webserver/index-inject`, for the Peer that render speaks for: the owner's browser page opens with `{ type: 'baseline', owner: true }`, and a page rendered for a non-owner Peer, such as a liveshare viewer page rendered on the owner Host, opens with that viewer's grants. The Client entry reads the global when it mounts.
|
||||
|
||||
| `details.reason` of `remote-access/denied` | Cause |
|
||||
|---|---|
|
||||
@@ -60,9 +60,10 @@ Snapshot grants list, per resource the principal's bindings name, the sorted end
|
||||
The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides:
|
||||
|
||||
- the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind;
|
||||
- `ctx.remoteAccess.whenAccess(action, callback)`, which keeps the callback's effects installed while the `global` resource allows `action`: the callback runs when the answer becomes true, its effects dispose in reverse order when it becomes false, and it runs again on the next true. The callback returns one disposer or an iterable of disposers, as with `ctx.slots.inject`; it runs synchronously when the answer is already true, as it always is for the owner; the controller belongs to the caller's fiber. A setup failure while allowed throws at once; a later one retires the controller and is rethrown in a microtask. `whenResourceAccess(kind, id, action, callback)` is the same for one resource of a declared kind;
|
||||
- the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource.
|
||||
|
||||
Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs.
|
||||
Domain UI packages publish their resource Hooks over `source()` and their `whenAccess` over `whenResourceAccess()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)`, the Session-scope `useAccess(action)`, and `ctx.uiSession.whenAccess(sessionId, action, callback)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` on Workspace resources. Only the package that owns a resource kind calls `source()` and `whenResourceAccess()`; other plugins read access through the access Hooks and the `whenAccess` methods. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with fixed answers, allow-all by default, in Client specs.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
@@ -43,7 +43,7 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo
|
||||
|
||||
转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:<name>`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。
|
||||
|
||||
本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。
|
||||
本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为该次渲染所代表 Peer 的 `snapshotFor(peer.principal)`:owner 的浏览器页面以 `{ type: 'baseline', owner: true }` 打开,为非 owner Peer 渲染的页面(例如在 owner Host 上渲染的 liveshare 读者页)以该读者的 grant 打开。Client 入口在挂载时读取该全局。
|
||||
|
||||
| `remote-access/denied` 的 `details.reason` | 原因 |
|
||||
|---|---|
|
||||
@@ -60,9 +60,10 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo
|
||||
Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供:
|
||||
|
||||
- Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source;
|
||||
- `ctx.remoteAccess.whenAccess(action, callback)`,在 `global` 资源允许 `action` 期间保持回调的效果:答案变为 true 时执行回调,变为 false 时逆序撤销其效果,下一次变为 true 时再次执行。回调返回一个 disposer 或一组 disposer,与 `ctx.slots.inject` 相同;答案已为 true 时(owner 恒为 true)回调同步执行;控制器属于调用方 fiber。允许期间的安装失败立即抛出;之后的安装失败使控制器停止,并在微任务中重新抛出。`whenResourceAccess(kind, id, action, callback)` 对已声明种类的某个资源做同样的事;
|
||||
- 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。
|
||||
|
||||
领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。
|
||||
领域 UI 包在 `source()` 之上发布各自资源的 Hook,在 `whenResourceAccess()` 之上发布各自的 `whenAccess`:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)`、会话作用域的 `useAccess(action)` 与 `ctx.uiSession.whenAccess(sessionId, action, callback)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)` 与 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`。只有拥有某个资源种类的包才调用 `source()` 与 `whenResourceAccess()`;其他插件经访问 Hook 与各 `whenAccess` 读取访问。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以固定答案(默认全允许)的访问替换本行。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
* Client face: starts from the access snapshot the index injected, follows
|
||||
* the calling Peer's snapshot stream, provides the `remoteAccess` Client
|
||||
* service whose `source(kind, id, action)` answers for any declared-kind
|
||||
* resource, and publishes `useRemoteAccess(action)` on the global resource.
|
||||
* Domain UI adapters publish their own resource Hooks over `source()`. Every
|
||||
* resource and whose `whenAccess(action, callback)` keeps effects installed
|
||||
* while the global resource allows an action, and publishes
|
||||
* `useRemoteAccess(action)` on the global resource. Domain UI adapters
|
||||
* publish their own resource Hooks over `source()` and their own
|
||||
* `whenAccess` over `whenResourceAccess()`. Every
|
||||
* answer is a boolean: true for the owner, false without a snapshot or after
|
||||
* a non-owner stream fails; the Host enforces every call regardless.
|
||||
* @module @deepseek-ai/dsh-api-remote-access/client
|
||||
@@ -18,7 +21,7 @@ import { parseInjectedBaseline } from './state.ts'
|
||||
export type {
|
||||
RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind,
|
||||
} from '../types.ts'
|
||||
export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts'
|
||||
export type { AccessEffect, ClientRemoteAccess, RemoteEndpoint } from './mount.ts'
|
||||
|
||||
/** Required Client services: the Remote mount face and the Slot registry. */
|
||||
export const inject = ['remote', 'slots']
|
||||
|
||||
@@ -19,6 +19,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteAccessFrame } from '../types.ts'
|
||||
import { ClientAccessState, type Baseline } from './state.ts'
|
||||
import { whenAllowed, type AccessEffect } from './when-allowed.ts'
|
||||
|
||||
export type { AccessEffect } from './when-allowed.ts'
|
||||
|
||||
/** Every endpoint the Client Remote namespaces declare. */
|
||||
export type RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
|
||||
@@ -29,12 +32,38 @@ export interface ClientRemoteAccess {
|
||||
* Whether the current Peer may call `action` on one resource.
|
||||
* True for the owner; false without a baseline and after a non-owner
|
||||
* stream fails; otherwise whether the resource's grant lists the action.
|
||||
* Only the package that owns a resource kind calls it, to build that kind's
|
||||
* access Hook; other plugins read access through the access Hooks or
|
||||
* `whenAccess`.
|
||||
* @param kind - resource kind declared in `TypertResourceMap`.
|
||||
* @param id - resource identity.
|
||||
* @param action - endpoint action the caller would invoke.
|
||||
* @returns identity-stable boolean source for this `(kind, id, action)`.
|
||||
*/
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call
|
||||
* `action` on the global resource: run it when the answer becomes true,
|
||||
* dispose its effects in reverse order when it becomes false, and run it
|
||||
* again on the next true. The callback runs synchronously when the answer
|
||||
* is already true, as it always is for the owner. Effect forms and fiber
|
||||
* ownership match `ctx.slots.inject`.
|
||||
* @param action - endpoint action the guarded effects depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
/**
|
||||
* {@link whenAccess} for one resource of a declared kind. Only the package
|
||||
* that owns a resource kind calls it, to build that kind's `whenAccess`, as
|
||||
* it builds the kind's access Hook over {@link source}.
|
||||
* @param kind - resource kind declared in `TypertResourceMap`.
|
||||
* @param id - resource identity.
|
||||
* @param action - endpoint action the guarded effects depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer for the observation and any installed effects.
|
||||
*/
|
||||
whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -66,6 +95,16 @@ class ClientRemoteAccessService extends Service implements ClientRemoteAccess {
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean> {
|
||||
return this.state.resource(kind, id, action)
|
||||
}
|
||||
|
||||
// Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context.
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
return whenAllowed(this.ctx, this.state.global(action), callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`)
|
||||
}
|
||||
|
||||
whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
return whenAllowed(this.ctx, this.state.resource(kind, id, action), callback,
|
||||
`remoteAccess.whenResourceAccess(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(action)})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The effect controller behind `whenAccess`: install a callback's effects
|
||||
* while one access answer is true and dispose them while it is false.
|
||||
* @module @deepseek-ai/dsh-api-remote-access/src/client/when-allowed
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** One synchronous effect installed while access holds: a disposer, or disposers installed as one transaction. */
|
||||
export type AccessEffect = (() => void) | Iterable<() => void, void, void>
|
||||
|
||||
/**
|
||||
* Install `callback`'s effects while `answer` is true. The callback runs
|
||||
* synchronously when the answer is already true; a later true runs it again
|
||||
* after a false disposed the previous installation in reverse order. The
|
||||
* controller belongs to `ctx`'s fiber.
|
||||
* @param ctx - the caller's Context; it owns the controller and every installation.
|
||||
* @param answer - the access answer to follow.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @param label - effect label for diagnostics.
|
||||
* @returns idempotent disposer for the controller and any installed effects.
|
||||
* @throws callback setup failures synchronously when the answer is already true.
|
||||
*/
|
||||
export function whenAllowed(
|
||||
ctx: Context,
|
||||
answer: HostObservable<boolean>,
|
||||
callback: () => AccessEffect,
|
||||
label: string,
|
||||
): () => void {
|
||||
const disposeController = ctx.effect(() => {
|
||||
let installed: (() => void) | undefined
|
||||
let stopped = false
|
||||
|
||||
const uninstall = (): void => {
|
||||
const dispose = installed
|
||||
installed = undefined
|
||||
dispose?.()
|
||||
}
|
||||
|
||||
const reconcile = (): void => {
|
||||
if (stopped) return
|
||||
const allowed = answer.getSnapshot()
|
||||
if (allowed === (installed !== undefined)) return
|
||||
if (!allowed) {
|
||||
uninstall()
|
||||
return
|
||||
}
|
||||
// Each installation is a nested Cordis effect, so an iterable callback
|
||||
// installs transactionally and disposes in reverse order.
|
||||
const disposeEffect = ctx.effect(callback, `${label}: allowed`)
|
||||
installed = () => { void disposeEffect() }
|
||||
}
|
||||
|
||||
const unsubscribe = answer.subscribe(() => { changed() })
|
||||
|
||||
const stop = (): void => {
|
||||
if (stopped) return
|
||||
// A failed installation retires the controller: a later grant never retries it.
|
||||
stopped = true
|
||||
unsubscribe()
|
||||
uninstall()
|
||||
}
|
||||
|
||||
const changed = (): void => {
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
stop()
|
||||
if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') return
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
queueMicrotask(() => { throw failure })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
stop()
|
||||
throw error
|
||||
}
|
||||
return stop
|
||||
}, label)
|
||||
return () => { void disposeController() }
|
||||
}
|
||||
@@ -134,10 +134,10 @@ export class RemoteAccessService extends TypertRemoteService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remoteAccess')
|
||||
ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations')
|
||||
// Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages.
|
||||
// Every index opens with the access snapshot of the Peer it speaks for.
|
||||
ctx.inject(['webServer'], (webCtx) => {
|
||||
webCtx.on('webserver/index-inject', (table) => {
|
||||
table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE })
|
||||
webCtx.on('webserver/index-inject', (table, peer) => {
|
||||
table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: this.snapshotFor(peer.principal) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -244,10 +244,9 @@ export class RemoteAccessService extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline a page opens with before its snapshot stream connects. A
|
||||
* business package rendering a non-owner page assigns this value to
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
|
||||
* replacing the owner baseline the webServer index carries.
|
||||
* The baseline a page opens with before its snapshot stream connects; every
|
||||
* index the webServer renders carries it under
|
||||
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for.
|
||||
* @param principal - the page's caller identity.
|
||||
* @returns the owner baseline for the owner; otherwise the principal's current grants.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh-
|
||||
import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts'
|
||||
import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts'
|
||||
import { mountAccess } from '../src/client/mount.ts'
|
||||
import { whenAllowed } from '../src/client/when-allowed.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertResourceMap {
|
||||
@@ -12,6 +13,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'test-shelf': TypertResource<string>
|
||||
'test-desk': TypertResource<string>
|
||||
}
|
||||
interface TypertRemoteMap {
|
||||
/** Test-only endpoint the `whenAccess` specs guard effects on. */
|
||||
'test/guarded': () => Promise<never>
|
||||
}
|
||||
}
|
||||
|
||||
const GRANTED: Extract<RemoteAccessFrame, { type: 'baseline' }> = {
|
||||
@@ -289,3 +294,223 @@ describe('remote-access Client mount', () => {
|
||||
await access.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
/** A baseline whose grants allow `test/guarded` on the listed resources. */
|
||||
function followOn(...resources: RemoteAccessGrant['resource'][]): Extract<RemoteAccessFrame, { type: 'baseline' }> {
|
||||
return { type: 'baseline', owner: false, grants: resources.map(resource => ({ resource, actions: ['test/guarded'] })) }
|
||||
}
|
||||
|
||||
describe('remote-access whenAccess', () => {
|
||||
it('installs the effects while the global resource allows the action, disposes them in reverse order on loss, and reinstalls on the next grant', async () => {
|
||||
const access = await mounted()
|
||||
const log: string[] = []
|
||||
const guarded = access.ctx.plugin({
|
||||
inject: ['remoteAccess'],
|
||||
apply: (ctx: Context) => {
|
||||
ctx.remoteAccess.whenAccess('test/guarded', function* () {
|
||||
log.push('install')
|
||||
yield () => { log.push('dispose first') }
|
||||
yield () => { log.push('dispose second') }
|
||||
})
|
||||
},
|
||||
})
|
||||
await guarded.await()
|
||||
expect(log).toEqual([])
|
||||
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(log).toEqual(['install']) })
|
||||
// A frame that leaves the answer unchanged installs nothing again.
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork'] } })
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } })
|
||||
await vi.waitFor(() => { expect(log).toEqual(['install', 'dispose second', 'dispose first']) })
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } })
|
||||
await vi.waitFor(() => { expect(log).toHaveLength(4) })
|
||||
expect(log[3]).toBe('install')
|
||||
|
||||
await guarded.dispose()
|
||||
expect(log.slice(4)).toEqual(['dispose second', 'dispose first'])
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('runs the callback at once for the owner and disposes it with the returned disposer', async () => {
|
||||
const access = await mounted({ type: 'baseline', owner: true })
|
||||
const dispose = vi.fn()
|
||||
const callback = vi.fn(() => dispose)
|
||||
const stop = access.ctx.remoteAccess.whenAccess('test/guarded', callback)
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
stop()
|
||||
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('follows one resource of a declared kind through whenResourceAccess', async () => {
|
||||
const access = await mounted(followOn({ kind: 'test-shelf', id: 's1' }))
|
||||
const dispose = vi.fn()
|
||||
const shelf = vi.fn(() => dispose)
|
||||
const desk = vi.fn(() => () => {})
|
||||
access.ctx.remoteAccess.whenResourceAccess('test-shelf', 's1', 'test/guarded', shelf)
|
||||
access.ctx.remoteAccess.whenResourceAccess('test-desk', 's1', 'test/guarded', desk)
|
||||
expect(shelf).toHaveBeenCalledOnce()
|
||||
expect(desk).not.toHaveBeenCalled()
|
||||
access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } })
|
||||
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('disposes a non-owner\'s effects once its stream fails terminally', async () => {
|
||||
const access = await mounted(followOn({ kind: 'global' }))
|
||||
vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined)
|
||||
const dispose = vi.fn()
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', () => dispose)
|
||||
access.stream().fail(new Error('carrier closed for good'))
|
||||
await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() })
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('throws a setup failure at once while allowed, and reports a later one without retrying it', async () => {
|
||||
const owner = await mounted({ type: 'baseline', owner: true })
|
||||
expect(() => owner.ctx.remoteAccess.whenAccess('test/guarded', () => { throw new Error('setup failed') }))
|
||||
.toThrow('setup failed')
|
||||
await owner.dispose()
|
||||
|
||||
const access = await mounted()
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (error: unknown): void => { failures.push(error) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const failing = vi.fn((): () => void => { throw new Error('late failure') })
|
||||
const throwsValue = vi.fn(function* (): Generator<() => void> {
|
||||
yield () => {}
|
||||
throw new Error('late value')
|
||||
})
|
||||
const later = vi.fn(() => () => {})
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', failing)
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', throwsValue)
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', later)
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(2) })
|
||||
expect(failures.map(String)).toEqual(['Error: late failure', 'Error: late value'])
|
||||
expect(later).toHaveBeenCalledOnce()
|
||||
access.stream().push({ type: 'remove', resource: { kind: 'global' } })
|
||||
access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } })
|
||||
await vi.waitFor(() => { expect(later).toHaveBeenCalledTimes(2) })
|
||||
expect(failing).toHaveBeenCalledOnce()
|
||||
expect(throwsValue).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('skips a controller another installation stopped during the same notification', async () => {
|
||||
const access = await mounted()
|
||||
let stopLater = (): void => {}
|
||||
const first = vi.fn(() => {
|
||||
stopLater()
|
||||
return () => {}
|
||||
})
|
||||
const later = vi.fn(() => () => {})
|
||||
access.ctx.remoteAccess.whenAccess('test/guarded', first)
|
||||
stopLater = access.ctx.remoteAccess.whenAccess('test/guarded', later)
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(first).toHaveBeenCalledOnce() })
|
||||
expect(later).not.toHaveBeenCalled()
|
||||
await access.dispose()
|
||||
})
|
||||
|
||||
it('installs nothing for a grant that arrives while its caller is unloading', async () => {
|
||||
const access = await mounted()
|
||||
const callback = vi.fn(() => () => {})
|
||||
let release = (): void => {}
|
||||
const blocked = new Promise<void>((resolve) => { release = resolve })
|
||||
const pauseUnload = vi.fn(async () => { await blocked })
|
||||
const caller = access.ctx.plugin({
|
||||
inject: ['remoteAccess'],
|
||||
apply: (ctx: Context) => {
|
||||
ctx.remoteAccess.whenAccess('test/guarded', callback)
|
||||
ctx.effect(() => pauseUnload, 'pause caller unload')
|
||||
},
|
||||
})
|
||||
await caller.await()
|
||||
const disposing = caller.dispose()
|
||||
await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
|
||||
access.stream().push(followOn({ kind: 'global' }))
|
||||
await vi.waitFor(() => { expect(value(access.global('test/guarded'))).toBe(true) })
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
release()
|
||||
await disposing
|
||||
await access.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
/** A hand-driven access answer that notifies synchronously. */
|
||||
function drivenAnswer(): { readonly answer: HostObservable<boolean>; set(allowed: boolean): void } {
|
||||
let allowed = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
answer: {
|
||||
getSnapshot: () => allowed,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
set(next) {
|
||||
allowed = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('whenAllowed', () => {
|
||||
it('installs nothing when the answer turns true while its caller is unloading', async () => {
|
||||
const root = new Context()
|
||||
const driven = drivenAnswer()
|
||||
const callback = vi.fn(() => () => {})
|
||||
const caller = root.plugin({
|
||||
apply: (ctx: Context) => {
|
||||
whenAllowed(ctx, driven.answer, callback, 'test')
|
||||
// Disposed before the controller: it turns the answer true mid-unload.
|
||||
ctx.effect(() => () => { driven.set(true) }, 'grant during unload')
|
||||
},
|
||||
})
|
||||
await caller.await()
|
||||
await caller.dispose()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
|
||||
it('ignores its disposer after a later failure retired it, and reports a non-Error failure as an Error', async () => {
|
||||
const root = new Context()
|
||||
const driven = drivenAnswer()
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (error: unknown): void => { failures.push(error) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const stop = whenAllowed(root, driven.answer, () => { throw new Error('late failure') }, 'test')
|
||||
driven.set(true)
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(1) })
|
||||
expect(() => { stop() }).not.toThrow()
|
||||
// A faulty answer that throws a non-Error value is reported as an Error naming it.
|
||||
let reads = 0
|
||||
const listeners: (() => void)[] = []
|
||||
whenAllowed(root, {
|
||||
getSnapshot: () => {
|
||||
reads++
|
||||
if (reads > 1) throw 'unreadable answer'
|
||||
return false
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.push(listener)
|
||||
return () => {}
|
||||
},
|
||||
}, () => () => {}, 'test')
|
||||
for (const listener of listeners) listener()
|
||||
await vi.waitFor(() => { expect(failures).toHaveLength(2) })
|
||||
expect(String(failures[1])).toBe('Error: unreadable answer')
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -400,18 +400,27 @@ describe('first snapshots', () => {
|
||||
expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__')
|
||||
})
|
||||
|
||||
it('injects the owner baseline into every index the webServer renders', async () => {
|
||||
const { ctx } = await setup()
|
||||
const render = (): IndexInjection[] => {
|
||||
it('injects the access snapshot of the Peer each index speaks for', async () => {
|
||||
const { ctx, access } = await setup()
|
||||
const policy = new GuestPolicy()
|
||||
policy.hold(GUEST, { role: READER, resource: GLOBAL })
|
||||
access.registerPolicy('test-guest', policy)
|
||||
const render = (principal: Principal): IndexInjection[] => {
|
||||
const table: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
ctx.emit('webserver/index-inject', table, new Peer(ctx, { principal }))
|
||||
return table
|
||||
}
|
||||
expect(render()).toEqual([])
|
||||
expect(render(OWNER)).toEqual([])
|
||||
ctx.provide('webServer', {} as Context['webServer'])
|
||||
await vi.waitFor(() => {
|
||||
expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }])
|
||||
expect(render(OWNER)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }])
|
||||
})
|
||||
expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: {
|
||||
type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }],
|
||||
} }])
|
||||
// A grant change reaches the next render.
|
||||
policy.hold(GUEST)
|
||||
expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: false, grants: [] } }])
|
||||
})
|
||||
|
||||
it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"src/client/index.ts",
|
||||
"src/client/mount.ts",
|
||||
"src/client/state.ts",
|
||||
"src/client/when-allowed.ts",
|
||||
"src/snapshot.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
|
||||
@@ -10,7 +10,7 @@ import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files'
|
||||
import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf'
|
||||
import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -20,6 +20,9 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
/** The Peer an index render speaks for; this listener reads no field of it. */
|
||||
const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters<WebServer['renderIndex']>[1]
|
||||
|
||||
const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise<Converter>>() }))
|
||||
vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
|
||||
|
||||
@@ -92,7 +95,7 @@ it('loads the shipped Office rows with separately patched settings and authorize
|
||||
const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry]))
|
||||
for (const row of configured) await entries.get(row.id)!.fiber!.await()
|
||||
const injections: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', injections)
|
||||
ctx.emit('webserver/index-inject', injections, PAGE_PEER)
|
||||
expect(injections).toEqual([
|
||||
{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig },
|
||||
])
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
|
||||
import { provideBrowserCredentials } from './browser-credentials.ts'
|
||||
|
||||
/** The Peer an index render speaks for; this listener reads no field of it. */
|
||||
const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters<WebServer['renderIndex']>[1]
|
||||
|
||||
/** Structural webServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
routes: WebRoute[],
|
||||
@@ -272,7 +275,7 @@ describe('connection node half', () => {
|
||||
const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } })
|
||||
try {
|
||||
const rows: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', rows)
|
||||
ctx.emit('webserver/index-inject', rows, PAGE_PEER)
|
||||
expect(rows).toEqual([{
|
||||
kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: {
|
||||
backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000,
|
||||
@@ -281,7 +284,7 @@ describe('connection node half', () => {
|
||||
}])
|
||||
await dispose()
|
||||
const after: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', after)
|
||||
ctx.emit('webserver/index-inject', after, PAGE_PEER)
|
||||
expect(after).toEqual([])
|
||||
} finally {
|
||||
await dispose()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { expect, it } from 'vitest'
|
||||
import { Config, apply } from '../src/index.ts'
|
||||
|
||||
/** The Peer an index render speaks for; this listener reads no field of it. */
|
||||
const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters<WebServer['renderIndex']>[1]
|
||||
|
||||
it('validates positive sequence timing and publishes it through the product page injection', async () => {
|
||||
expect(Config({}).stopSequenceMs).toBe(500)
|
||||
expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646)
|
||||
@@ -11,7 +14,7 @@ it('validates positive sequence timing and publishes it through the product page
|
||||
try {
|
||||
apply(ctx, Config({ stopSequenceMs: 800 }))
|
||||
const table: IndexInjection[] = []
|
||||
await ctx.parallel('webserver/index-inject', table)
|
||||
await ctx.parallel('webserver/index-inject', table, PAGE_PEER)
|
||||
expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }])
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 1e2172c756246031
|
||||
zh: f33dc7b40b14cceb
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-native/use-this-package/when-to-choose-it:
|
||||
en: bcba761168d2162c
|
||||
zh: d484e2305e074c0b
|
||||
en: f30bb7c6aae98fec
|
||||
zh: 1d28613c86531119
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-native/understand-the-implementation:
|
||||
en: f7a287740b4b08f9
|
||||
zh: 3347375c4e486b22
|
||||
|
||||
@@ -33,6 +33,8 @@ In the local Electron application, this flow uses the narrow preload directory-p
|
||||
|
||||
Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change.
|
||||
|
||||
Access: both directory flows are registered only while the current Peer may call `directoryPicker/pick`. The owner always has access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -33,6 +33,8 @@ kind: "package-reference"
|
||||
|
||||
当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。
|
||||
|
||||
访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Native directory flow using the local desktop bridge or the Host's OS chooser. */
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess).
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
|
||||
@@ -8,13 +10,14 @@ import type { NativeFlowInjected } from './flow.ts'
|
||||
import { NativeDirectoryFlow } from './flow.ts'
|
||||
|
||||
|
||||
/** Required services (cordis fiber inject): the slot registry and workspace UI service. */
|
||||
export const inject = ['slots', 'uiWorkspace']
|
||||
/** Required services (cordis fiber inject): the slot registry, workspace UI service, and access. */
|
||||
export const inject = ['slots', 'uiWorkspace', 'remoteAccess']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the renderless native flow into both
|
||||
* directory-flow holes through `slots.inject()` because the ui-workspace
|
||||
* entries may activate later or replace their declarations.
|
||||
* Client plugin body: while the current Peer may open the Host's directory
|
||||
* chooser, register the renderless native flow into both directory-flow holes
|
||||
* through `slots.inject()` because the ui-workspace entries may activate later
|
||||
* or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -26,13 +29,14 @@ export function apply(ctx: ClientContext): void {
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
}))
|
||||
ctx.remoteAccess.whenAccess('directoryPicker/pick', () =>
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
})))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { NativeDirectoryFlow } from '../src/client/flow.ts'
|
||||
@@ -29,9 +30,10 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); deskto
|
||||
|
||||
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
||||
|
||||
async function bench() {
|
||||
async function bench(allows: (action: string) => boolean = () => true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
new StaticRemoteAccess(ctx, allows)
|
||||
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
|
||||
ctx.provide('uiWorkspace', { pickDirectory } as never)
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
@@ -52,7 +54,19 @@ function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowO
|
||||
|
||||
describe('directory-picker-native client half', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'uiWorkspace'])
|
||||
expect(inject).toEqual(['slots', 'uiWorkspace', 'remoteAccess'])
|
||||
})
|
||||
|
||||
it('fills no directory flow while the current Peer may not open the Host chooser', async () => {
|
||||
const allows = vi.fn(() => false)
|
||||
const b = await bench(allows)
|
||||
b.declare()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(allows).toHaveBeenCalledWith('directoryPicker/pick')
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toEqual([])
|
||||
await fiber.dispose()
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: 76368b6b1da79c80
|
||||
zh: 52384aee991ac542
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/use-this-package:
|
||||
en: 7cc90968634e3f7b
|
||||
zh: 54f58e17b14f7404
|
||||
en: e642513bcfd42d68
|
||||
zh: 38b9109ec017900d
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation:
|
||||
en: 3dcb1f29d03d5afe
|
||||
zh: 5ee39637636cd062
|
||||
en: 2f1a873bebc098c8
|
||||
zh: c2ec33e50ff2ccc0
|
||||
/deepseek-ai-dsh-client-ui-entry-customization/model-experience:
|
||||
en: b891f311c09d536f
|
||||
zh: 68c0f41fbfed0d73
|
||||
|
||||
@@ -34,6 +34,8 @@ Open **More** beneath the left navigation entries, **More conversation views** a
|
||||
|
||||
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
|
||||
|
||||
The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -44,7 +46,7 @@ On Desktop, **Browse other plugins** and **Create your plugin** report `more_men
|
||||
|
||||
[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu` and supply the entries, their pin state, `onOpen`, `onPinnedChange`, and `onDismiss` as owner props. The rows call `onDismiss` before opening an entry or running a footer action; pin changes leave the menu open.
|
||||
|
||||
Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `pluginNavigation.openList()`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
|
||||
Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `ctx.get('pluginNavigation')?.openList()`; ui-plugin-manager provides that service only while the Plugins page has access, so this package reads it optionally and never lists it in `inject`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ kind: "package-reference"
|
||||
|
||||
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
|
||||
|
||||
「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -44,7 +46,7 @@ kind: "package-reference"
|
||||
|
||||
[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。
|
||||
|
||||
每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `pluginNavigation.openList()`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
|
||||
每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `ctx.get('pluginNavigation')?.openList()`;ui-plugin-manager 只在插件页有权限时提供该服务,因此本包按可选方式读取,不把它写进 `inject`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */
|
||||
/** Rows of a navigation region's More menu: customizable entries, plugin discovery while reachable, and creation. */
|
||||
import {
|
||||
IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import { EntryPickerRow } from './EntryPickerRow.tsx'
|
||||
|
||||
@@ -13,6 +13,10 @@ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar'
|
||||
export interface EntryMenuInjected {
|
||||
/** Region of this registration; selects pin copy and the Creator prompt. */
|
||||
readonly region: EntryRegion
|
||||
readonly hooks: {
|
||||
/** Whether the Plugins page navigation service is present; the discovery row shows only while it is. */
|
||||
readonly pluginNavigation: HostObservable<boolean>
|
||||
}
|
||||
/** Open the Plugins main list. */
|
||||
readonly browsePlugins: () => void
|
||||
/** Start a Creator draft with this region's prompt without sending it. */
|
||||
@@ -29,8 +33,9 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'>
|
||||
* @returns Menu rows for the region's More menu.
|
||||
*/
|
||||
export function EntryMenuContent({
|
||||
entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t,
|
||||
entries, onOpen, onPinnedChange, onDismiss, region, usePluginNavigation, browsePlugins, createPlugin, t,
|
||||
}: EntryMenuContentProps) {
|
||||
const browsable = usePluginNavigation(present => present)
|
||||
return <>
|
||||
{entries.map((entry) => {
|
||||
const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`)
|
||||
@@ -39,11 +44,12 @@ export function EntryMenuContent({
|
||||
onOpen={() => { onDismiss(); onOpen(entry.id) }}
|
||||
onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} />
|
||||
})}
|
||||
<MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
|
||||
{browsable && <MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
|
||||
onSelect={() => { onDismiss(); browsePlugins() }}>
|
||||
{t('install')}
|
||||
</MenuItemButton>
|
||||
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} onSelect={() => { onDismiss(); createPlugin() }}>
|
||||
</MenuItemButton>}
|
||||
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} separatorBefore={!browsable && entries.length > 0}
|
||||
onSelect={() => { onDismiss(); createPlugin() }}>
|
||||
{t('create')}
|
||||
</MenuItemButton>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -18,21 +19,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Services used by the menu rows' footer actions. */
|
||||
export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace']
|
||||
/** Services used by the menu rows' footer actions; the optional `pluginNavigation` shows the discovery row while present. */
|
||||
export const inject = ['slots', 'locale', 'uiWorkspace']
|
||||
|
||||
/**
|
||||
* Register the same menu rows into each region's More menu.
|
||||
* @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services.
|
||||
* @param ctx - Browser context with slot, locale, and Workspace services, and the optional plugin navigation.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh, ja }))
|
||||
const t = ctx.locale.bind('entryCustomization')
|
||||
// The Plugins page provides its navigation only while the current Peer may use it.
|
||||
const pluginNavigation = createSnapshotStore(false)
|
||||
ctx.inject(['pluginNavigation'], (scope: Context) => {
|
||||
scope.effect(() => {
|
||||
pluginNavigation.set(true)
|
||||
return () => { pluginNavigation.set(false) }
|
||||
}, 'entry-customization: plugin navigation presence')
|
||||
})
|
||||
const injected = (region: EntryRegion): EntryMenuInjected => ({
|
||||
region,
|
||||
hooks: { pluginNavigation },
|
||||
browsePlugins: () => {
|
||||
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' })
|
||||
ctx.pluginNavigation.openList()
|
||||
ctx.get('pluginNavigation')?.openList()
|
||||
},
|
||||
createPlugin: () => {
|
||||
ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' })
|
||||
|
||||
@@ -34,6 +34,7 @@ function content(overrides: Partial<ContentProps> = {}) {
|
||||
const props = {
|
||||
entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }],
|
||||
region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused,
|
||||
usePluginNavigation: <T,>(select: (present: boolean) => T): T => select(true),
|
||||
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused,
|
||||
useResource: unused, useWorkspaces: unused,
|
||||
...callbacks,
|
||||
@@ -112,7 +113,10 @@ function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
|
||||
</>
|
||||
}
|
||||
|
||||
async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) {
|
||||
async function bench(
|
||||
language: 'en' | 'zh' = 'en',
|
||||
{ analytics = true, navigation = true }: { analytics?: boolean; navigation?: boolean } = {},
|
||||
) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtimes.add(runtime)
|
||||
const openList = vi.fn(), startSession = vi.fn(), track = vi.fn<TrackProductEvent>()
|
||||
@@ -123,7 +127,7 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy
|
||||
locale.setLocale(language)
|
||||
ctx.provide('locale', locale)
|
||||
ctx.slots.installLocale(locale)
|
||||
ctx.provide('pluginNavigation', { openList } as never)
|
||||
if (navigation) ctx.provide('pluginNavigation', { openList } as never)
|
||||
ctx.provide('uiWorkspace', { startSession } as never)
|
||||
if (analytics) ctx.provide('productAnalytics', { track } as never)
|
||||
},
|
||||
@@ -139,6 +143,29 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy
|
||||
}
|
||||
|
||||
describe('More menu registrations', () => {
|
||||
it('shows plugin discovery only while the Plugins page navigation is present', async () => {
|
||||
const b = await bench('en', { navigation: false })
|
||||
const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
|
||||
expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull()
|
||||
expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy()
|
||||
// The Creator row takes the separator the absent discovery row would carry.
|
||||
expect(sidebar().getAllByRole('separator')).toHaveLength(1)
|
||||
const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void }
|
||||
expect(() => { injected.browsePlugins() }).not.toThrow()
|
||||
|
||||
const navigation = await b.runtime.mount({
|
||||
inject: [],
|
||||
apply(ctx: Context) { ctx.provide('pluginNavigation', { openList: b.openList } as never) },
|
||||
})
|
||||
await act(async () => { await Promise.resolve() })
|
||||
fireEvent.click(sidebar().getByRole('menuitem', { name: 'Browse other plugins' }))
|
||||
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
|
||||
|
||||
await navigation.dispose()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull()
|
||||
})
|
||||
|
||||
it('registers one row set per region and removes all of them when the plugin unloads', async () => {
|
||||
const b = await bench()
|
||||
for (const seat of SEATS) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
/** A page whose Peer may not list plugin bundles still boots, and the More menus keep their other rows. */
|
||||
import { expect } from 'vitest'
|
||||
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
|
||||
const withoutPluginManager = createClientTest({ roster: webApp }, { access: action => action !== 'pluginManager/listBundles' })
|
||||
|
||||
withoutPluginManager('boots the shipped Web client without the Plugins page navigation', async ({ start }) => {
|
||||
const c = await start()
|
||||
expect(c.ctx.get('pluginNavigation')).toBeUndefined()
|
||||
expect(c.ctx.slots.entries('sidebar.panellist.actions').map(entry => entry.options.id)).toContain('entry-customization')
|
||||
}, 60_000)
|
||||
@@ -13,6 +13,7 @@
|
||||
{ "path": "../../client/ui-workspace" },
|
||||
{ "path": "../../client/ui-primitives" },
|
||||
{ "path": "../../client/ui-renderer" },
|
||||
{ "path": "../../client/ui-slots" }
|
||||
{ "path": "../../client/ui-slots" },
|
||||
{ "path": "../store" }
|
||||
]
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
en: e0127a604ff4aebb
|
||||
zh: 38f1b3db30f81b22
|
||||
/deepseek-ai-dsh-client-ui-message-feedback/use-this-package/failures:
|
||||
en: f060de7d9f44b058
|
||||
zh: 60770de36be6e90f
|
||||
en: e4807e47bca06517
|
||||
zh: 649a3f9f62771865
|
||||
/deepseek-ai-dsh-client-ui-message-feedback/understand-the-implementation:
|
||||
en: 61260495346ceb3e
|
||||
zh: 32e05d3eb82fcaf2
|
||||
|
||||
@@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislik
|
||||
|
||||
A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls.
|
||||
|
||||
Access: the rating buttons render only while the current Peer may call `messageFeedback/put` on the Session; the `/feedback` decoration has no Client check, and the Host refuses a submission without access.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -31,6 +31,8 @@ kind: "package-reference"
|
||||
|
||||
评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。
|
||||
|
||||
访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user