mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(remote-access): 插件注册跟随访问状态(whenAccess),index 按 Peer 注入访问快照
- remote-access 新增 whenAccess / whenResourceAccess,ui-session、ui-workspace 各自提供 whenAccess - schedule、plugin-manager、settings-general、settings-account、directory-picker-native 的入口包进 global whenAccess;消息反馈按钮按会话 useAccess 渲染 - webserver/index-inject 带 Peer,renderIndex / collectIndexInjections 需要 Peer,remote-access 注入 snapshotFor(peer.principal) - client-test-runtime 新增 StaticRemoteAccess、staticAccessPlugin 与 TestClientOptions.access - implemented Agent Note 2026-10-21-when-access 与升级指南 webserver-index-peer Refs #2940
This commit is contained in:
1 parent
3e547bbd54
commit
09899f3a0f
87 files changed
+1497
-521
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: c12d401db33f7d77
|
||||
zh: 3bc2c8ad1a441bd7
|
||||
/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: 0487c2e2f5e71bde
|
||||
zh: 1750e83e7f9fa1cb
|
||||
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
|
||||
en: 670e07d9da3ea599
|
||||
zh: 625e8e8bff57ff8c
|
||||
/agent-note-whenaccess-registration-that-follows-access/consequences:
|
||||
en: 5c7c7c49446fd140
|
||||
zh: f8fa106040fac4bc
|
||||
/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 |
|
||||
|
||||
#6486 therefore writes `if (!ctx.remoteAccess.owner) return` at the start of `apply` in 8 plugins: the check is fixed on identity, and registrations 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 task tab type and the turn definition stay registered outside | `schedule/catalog` |
|
||||
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`; the controller and `pluginChanges` stay outside | `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, and settings launcher move into `remoteAccess.whenAccess` | `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`**: 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 | The 6 plugins no longer return at the start of `apply`; without a grant, a non-owner sees none of the corresponding entries, which appear after a grant and disappear after a revocation; the owner's behavior is unchanged |
|
||||
| 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`、订阅变化,再手动注册与撤销 |
|
||||
|
||||
#6486 因此在 8 个插件的 `apply` 开头写 `if (!ctx.remoteAccess.owner) return`:按身份写死,授权变化后注册不跟随。
|
||||
|
||||
## 决策
|
||||
|
||||
### 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`;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog` |
|
||||
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`;controller 与 `pluginChanges` 留在外面 | `pluginManager/listBundles` |
|
||||
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
|
||||
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器包进 `remoteAccess.whenAccess` | `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**:按身份写死,授权变化后不跟随。
|
||||
|
||||
**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
|
||||
|
||||
**插件包清单声明访问性**:在包清单上定义访问控制概念。
|
||||
|
||||
**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。
|
||||
|
||||
## 后果
|
||||
|
||||
| 后果 | 说明 |
|
||||
|---|---|
|
||||
| 注册跟随授权变化 | 6 个插件不再在 `apply` 开头 return;非 owner 无授权时相应入口不出现,授予后出现,撤销后消失;owner 行为不变 |
|
||||
| 回调必须交还 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. */
|
||||
|
||||
@@ -1706,7 +1706,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. */
|
||||
|
||||
@@ -1708,7 +1708,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. */
|
||||
|
||||
@@ -82,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`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`) | - |
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`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`) | - |
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -17,12 +17,17 @@ import type { MessageFeedbackActionProps } from './slots.ts'
|
||||
import css from './MessageFeedbackActions.module.css'
|
||||
|
||||
/**
|
||||
* One message's feedback controls.
|
||||
* @param props - the owner's message identity, the injected verbs, and the
|
||||
* shared feedback hook.
|
||||
* @returns the rating buttons with any failure notice beside them.
|
||||
* One message's feedback controls, rendered only while the current Peer may
|
||||
* record message feedback in this Session.
|
||||
* @param props - the owner's message identity, the injected verbs, the
|
||||
* shared feedback hook, and the Session access Hook.
|
||||
* @returns the rating buttons with any failure notice beside them, or nothing without access.
|
||||
*/
|
||||
export function MessageFeedbackActions({
|
||||
export function MessageFeedbackActions(props: MessageFeedbackActionProps) {
|
||||
return props.useAccess('messageFeedback/put') ? <FeedbackRatingButtons {...props} /> : null
|
||||
}
|
||||
|
||||
function FeedbackRatingButtons({
|
||||
messageId, ensure, current, retract, openDialog, useFeedback, t,
|
||||
}: MessageFeedbackActionProps) {
|
||||
const item = useFeedback(view => view.items.get(messageId))
|
||||
|
||||
@@ -45,6 +45,8 @@ function mount(options: {
|
||||
ensureResult?: MessageFeedbackActionResult
|
||||
retractResult?: MessageFeedbackActionResult
|
||||
status?: MessageFeedbackView['status']
|
||||
/** The Session access answer for recording message feedback; allowed when omitted. */
|
||||
allowed?: boolean
|
||||
} = {}) {
|
||||
const view: MessageFeedbackView = {
|
||||
status: options.status ?? 'ready',
|
||||
@@ -58,12 +60,22 @@ function mount(options: {
|
||||
const current = vi.fn((_id: MessageId) => options.committed ?? options.current)
|
||||
const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
|
||||
useSyncExternalStore(() => () => {}, () => select(view))) as never
|
||||
const useAccess = vi.fn((_action: string) => options.allowed ?? true)
|
||||
const props = { messageId: MSG, ensure, current, retract, openDialog, useFeedback, t } as unknown as
|
||||
Parameters<typeof MessageFeedbackActions>[0]
|
||||
return { ...render(<MessageFeedbackActions {...props} />), ensure, retract, openDialog }
|
||||
return { ...render(<MessageFeedbackActions {...props} useAccess={useAccess} />), ensure, retract, openDialog, useAccess }
|
||||
}
|
||||
|
||||
describe('MessageFeedbackActions', () => {
|
||||
it('renders nothing and reads nothing while the current Peer may not record message feedback in this Session', () => {
|
||||
const ui = mount({ allowed: false })
|
||||
|
||||
expect(ui.useAccess).toHaveBeenCalledWith('messageFeedback/put')
|
||||
expect(ui.queryByLabelText(zh['action.like'])).toBeNull()
|
||||
expect(ui.queryByLabelText(zh['action.dislike'])).toBeNull()
|
||||
expect(ui.ensure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders both rating buttons unpressed with no recorded feedback', () => {
|
||||
const ui = mount()
|
||||
|
||||
@@ -222,7 +234,7 @@ describe('MessageFeedbackActions', () => {
|
||||
useFeedback,
|
||||
t,
|
||||
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
||||
const ui = render(<MessageFeedbackActions {...props} />)
|
||||
const ui = render(<MessageFeedbackActions {...props} useAccess={() => true} />)
|
||||
const errors: unknown[] = []
|
||||
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
||||
window.addEventListener('error', onError)
|
||||
@@ -252,7 +264,7 @@ describe('MessageFeedbackActions', () => {
|
||||
useFeedback,
|
||||
t,
|
||||
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
||||
const ui = render(<MessageFeedbackActions {...props} />)
|
||||
const ui = render(<MessageFeedbackActions {...props} useAccess={() => true} />)
|
||||
const errors: unknown[] = []
|
||||
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
||||
window.addEventListener('error', onError)
|
||||
@@ -286,7 +298,7 @@ describe('MessageFeedbackActions', () => {
|
||||
useFeedback,
|
||||
t,
|
||||
} as unknown as Parameters<typeof MessageFeedbackActions>[0]
|
||||
const ui = render(<MessageFeedbackActions {...props} />)
|
||||
const ui = render(<MessageFeedbackActions {...props} useAccess={() => true} />)
|
||||
const errors: unknown[] = []
|
||||
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
|
||||
window.addEventListener('error', onError)
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
// Type-only: the ctx.remoteAccess Context merge.
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
// Type-only: the ctx.remote Context merge and the forwarded-event key face.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: the forwarded events' own declaration (`$on`'s key face resolves
|
||||
@@ -79,12 +81,13 @@ export const NS = 'pluginManager'
|
||||
/** The id shared by the sidebar entry and the main panel it opens. */
|
||||
export const PANEL_ID = 'plugins' as MainPanelId
|
||||
|
||||
/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']
|
||||
/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']
|
||||
|
||||
/**
|
||||
* Contribute the Plugins entry to the sidebar with the management page it
|
||||
* opens, and keep it current on the Host's change events.
|
||||
* opens, and keep it current on the Host's change events, while the current
|
||||
* Peer may list the installed bundles.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -93,82 +96,77 @@ export function apply(ctx: ClientContext): void {
|
||||
const controller = new PluginManagerController(ctx)
|
||||
ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller')
|
||||
ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) })
|
||||
// The Host says when what is installed, enabled, or composed changed — from
|
||||
// this page, the CLI, or another browser — and streams install output.
|
||||
ctx.effect(() => {
|
||||
// A page never rendered holds no snapshot to refresh.
|
||||
const refresh = (): void => {
|
||||
// A profile change can replace a bundle's files, and a restarted Host is read afresh.
|
||||
controller.details.reset()
|
||||
if (controller.getSnapshot().status !== 'idle') void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.remote.$on('plugin-manager/changed', refresh),
|
||||
ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }),
|
||||
ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }),
|
||||
ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }),
|
||||
ctx.on('connection/reset', refresh),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-plugin-manager: host invalidations')
|
||||
|
||||
// The page is a global panel: it belongs to the profile, not to a Session,
|
||||
// and the sidebar's entry selects it. What is installed and switched on is
|
||||
// the page's own; a plugin's configuration arrives through the slots the
|
||||
// page declares here, so the page never names a configurable plugin.
|
||||
const configLedger = configLedgerSource(ctx)
|
||||
const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text))
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
|
||||
inject: (): PluginRefreshToastFace => ({
|
||||
hooks: { pluginManager: face.hooks.pluginManager },
|
||||
dismissNotice: face.dismissNotice,
|
||||
}),
|
||||
}, PluginRefreshToast))
|
||||
ctx.slots.inject('main', function* () {
|
||||
const handle = createNavigationStore(), instance = handle.create()
|
||||
const store: typeof handle = { ...handle, create: () => instance }
|
||||
yield ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () {
|
||||
// The Host says when what is installed, enabled, or composed changed — from
|
||||
// this page, the CLI, or another browser — and streams install output.
|
||||
// A page never rendered holds no snapshot to refresh.
|
||||
const refresh = (): void => {
|
||||
// A profile change can replace a bundle's files, and a restarted Host is read afresh.
|
||||
controller.details.reset()
|
||||
if (controller.getSnapshot().status !== 'idle') void controller.load()
|
||||
}
|
||||
yield ctx.remote.$on('plugin-manager/changed', refresh)
|
||||
yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) })
|
||||
yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) })
|
||||
yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) })
|
||||
yield ctx.on('connection/reset', refresh)
|
||||
yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
|
||||
inject: (): PluginRefreshToastFace => ({
|
||||
hooks: { pluginManager: face.hooks.pluginManager },
|
||||
dismissNotice: face.dismissNotice,
|
||||
}),
|
||||
}, PluginRefreshToast))
|
||||
yield ctx.slots.inject('main', function* () {
|
||||
const handle = createNavigationStore(), instance = handle.create()
|
||||
const store: typeof handle = { ...handle, create: () => instance }
|
||||
yield ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
locale: NS,
|
||||
store,
|
||||
inject: () => face,
|
||||
children: {
|
||||
'plugins.add.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.tab': { kind: 'list', scope: 'root' },
|
||||
'plugins.item': { kind: 'list', scope: 'root' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.row.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.detail.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.badge': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.section': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, PluginManagerPage)
|
||||
yield ctx.layout.panelInfo.subscribe(() => {
|
||||
if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' })
|
||||
})
|
||||
const disposeNavigation = ctx.reflect.provide('pluginNavigation', {
|
||||
openList: () => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'list' })
|
||||
},
|
||||
openBundle: (packageName: string) => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'package', name: packageName })
|
||||
},
|
||||
})
|
||||
yield () => { void disposeNavigation() }
|
||||
})
|
||||
yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 0,
|
||||
label: () => t('panel'),
|
||||
locale: NS,
|
||||
store,
|
||||
inject: () => face,
|
||||
children: {
|
||||
'plugins.add.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.tab': { kind: 'list', scope: 'root' },
|
||||
'plugins.item': { kind: 'list', scope: 'root' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.row.config': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.detail.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.badge': { kind: 'list', scope: 'root' },
|
||||
'plugins.detail.section': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, PluginManagerPage)
|
||||
yield ctx.layout.panelInfo.subscribe(() => {
|
||||
if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' })
|
||||
})
|
||||
const disposeNavigation = ctx.reflect.provide('pluginNavigation', {
|
||||
openList: () => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'list' })
|
||||
},
|
||||
openBundle: (packageName: string) => {
|
||||
ctx.layout.selectPanel(PANEL_ID)
|
||||
instance.actions.setTab('installed')
|
||||
instance.actions.setView({ kind: 'package', name: packageName })
|
||||
},
|
||||
})
|
||||
yield () => { void disposeNavigation() }
|
||||
}, PluginsPanelIcon))
|
||||
})
|
||||
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 0,
|
||||
label: () => t('panel'),
|
||||
locale: NS,
|
||||
}, PluginsPanelIcon))
|
||||
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import * as settings from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
|
||||
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
|
||||
@@ -21,10 +21,11 @@ usePinnedBrowserLanguages('zh-CN')
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
afterEach(cleanup)
|
||||
|
||||
async function bench() {
|
||||
async function bench(allows: (action: string) => boolean = () => true) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
new StaticRemoteAccess(ctx, allows)
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
class LocaleHolder extends Service {
|
||||
@@ -153,7 +154,20 @@ describe('ui-plugin-manager browser plugin', () => {
|
||||
})
|
||||
|
||||
it('declares only the services the page and its Remote methods use', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'])
|
||||
})
|
||||
|
||||
it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => {
|
||||
const allows = vi.fn(() => false)
|
||||
const b = await bench(allows)
|
||||
declare(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(allows).toHaveBeenCalledWith('pluginManager/listBundles')
|
||||
for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([])
|
||||
expect(b.ctx.get('pluginNavigation')).toBeUndefined()
|
||||
expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:*",
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -73,16 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, and original-Session navigation. */
|
||||
/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, original-Session navigation, and access. */
|
||||
export const inject = [
|
||||
'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess',
|
||||
]
|
||||
|
||||
/**
|
||||
* Register the Host task page, the Session-header reminder catalog, the
|
||||
* right-Sidebar page that shows one task's detail, and the transcript card of
|
||||
* one created task.
|
||||
* one created task while the current Peer may read the task catalog.
|
||||
* @param ctx - browser services used by these contributions.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -90,157 +91,159 @@ export function apply(ctx: ClientContext): void {
|
||||
const managerDictionaries = { zh: managerZh, en: managerEn, ja: managerJa }
|
||||
ctx.effect(() => ctx.locale.register(MANAGER_NS, managerDictionaries), 'ui-schedule: manager dictionaries')
|
||||
const t = ctx.locale.bind(MANAGER_NS)
|
||||
const manager: CatalogInjected<ScheduleCatalogEntry> = createCatalogSource<ScheduleCatalogEntry>({
|
||||
list: () => ctx.remote.schedule.catalog(),
|
||||
remove: async (id) => {
|
||||
const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id)
|
||||
if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } }
|
||||
return ctx.remote.schedule.delete({ sessionId: record.sessionId, id })
|
||||
},
|
||||
subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener),
|
||||
subscribeReset: listener => ctx.on('connection/reset', listener),
|
||||
})
|
||||
// One outcome store behind one `shell.overlay` entry announces every deletion
|
||||
// this plugin's surfaces settle, so the notice outlives the panel, popover, or
|
||||
// tab the deletion started in.
|
||||
const deleteToast = createDeleteToastSource()
|
||||
const reportedDelete = (onDelete: (id: ScheduleId) => Promise<CatalogDeleteOutcome>) =>
|
||||
async (id: ScheduleId): Promise<CatalogDeleteOutcome> => {
|
||||
const outcome = await onDelete(id)
|
||||
deleteToast.report(outcome)
|
||||
return outcome
|
||||
}
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS,
|
||||
inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }),
|
||||
}, ScheduleDeleteToast))
|
||||
/**
|
||||
* Forward one compare-and-update request, which may replace the task name,
|
||||
* the instruction, and/or the timing, and refresh the authoritative catalog
|
||||
* after an accepted or stale outcome.
|
||||
* @param request - complete expected record with the optional content and timing change.
|
||||
* @returns the original Remote mutation result.
|
||||
*/
|
||||
const updateTask = async (request: ScheduleUpdateRequest) => {
|
||||
const result = await ctx.remote.schedule.update(request)
|
||||
if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict'
|
||||
|| result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) {
|
||||
// The write is in, so this readback must not join a read that was sent
|
||||
// before it: naming every request seen so far makes the source issue a new
|
||||
// one, the way the post-deletion readback supersedes.
|
||||
await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request)
|
||||
const openSession = (id: SessionId): void => {
|
||||
if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') {
|
||||
ctx.uiWorkspace.openSession(id)
|
||||
}
|
||||
}
|
||||
const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = {
|
||||
hooks: manager.hooks,
|
||||
onDelete: reportedDelete(manager.onDelete),
|
||||
onRetry: manager.onRetry,
|
||||
onUpdateTiming: updateTask,
|
||||
loadHistory,
|
||||
onOpenSession: openSession,
|
||||
}
|
||||
// The Sidebar persists a tab's layout record and not its navigation
|
||||
// parameters, so this page type keeps the task it last showed per Session and
|
||||
// tab id. That Session's committed tab ids bound every write, so a closed tab
|
||||
// leaves no entry behind.
|
||||
const taskBindings = new TaskTabBindings(
|
||||
sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id),
|
||||
)
|
||||
ctx.effect(() => ctx.sidebarRightTabs.register(scheduleTaskDefinition(t)), 'ui-schedule: task tab type')
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }),
|
||||
}, ScheduleTaskTab))
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }),
|
||||
}, ScheduleTaskTabTitle))
|
||||
ctx.slots.inject('main', () => ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): TaskManagerInjected => ({
|
||||
...detail,
|
||||
// The page has no creation form: a new reminder starts in a Session.
|
||||
onNewTask: () => { ctx.uiWorkspace.startSession() },
|
||||
}),
|
||||
}, TaskManagerPage))
|
||||
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 10,
|
||||
locale: MANAGER_NS,
|
||||
label: () => t('panel'),
|
||||
}, TaskManagerIcon))
|
||||
// The created task is a Turn-level element, not a Tool-group row: the Turn
|
||||
// Definition publishes the settled result and this tail list entry renders
|
||||
// the card beneath the closing prose.
|
||||
// Definition publishes the settled result and the tail list entry below
|
||||
// renders the card beneath the closing prose.
|
||||
ctx.uiConversation.events.register(scheduleTurnDefinition)
|
||||
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
id: 'schedule-created',
|
||||
order: 20,
|
||||
locale: MANAGER_NS,
|
||||
inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({
|
||||
hooks: { catalog: manager.hooks.catalog },
|
||||
ctx.remoteAccess.whenAccess('schedule/catalog', function* () {
|
||||
const manager: CatalogInjected<ScheduleCatalogEntry> = createCatalogSource<ScheduleCatalogEntry>({
|
||||
list: () => ctx.remote.schedule.catalog(),
|
||||
remove: async (id) => {
|
||||
const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id)
|
||||
if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } }
|
||||
return ctx.remote.schedule.delete({ sessionId: record.sessionId, id })
|
||||
},
|
||||
subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener),
|
||||
subscribeReset: listener => ctx.on('connection/reset', listener),
|
||||
})
|
||||
// One outcome store behind one `shell.overlay` entry announces every deletion
|
||||
// this plugin's surfaces settle, so the notice outlives the panel, popover, or
|
||||
// tab the deletion started in.
|
||||
const deleteToast = createDeleteToastSource()
|
||||
const reportedDelete = (onDelete: (id: ScheduleId) => Promise<CatalogDeleteOutcome>) =>
|
||||
async (id: ScheduleId): Promise<CatalogDeleteOutcome> => {
|
||||
const outcome = await onDelete(id)
|
||||
deleteToast.report(outcome)
|
||||
return outcome
|
||||
}
|
||||
yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS,
|
||||
inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }),
|
||||
}, ScheduleDeleteToast))
|
||||
/**
|
||||
* Forward one compare-and-update request, which may replace the task name,
|
||||
* the instruction, and/or the timing, and refresh the authoritative catalog
|
||||
* after an accepted or stale outcome.
|
||||
* @param request - complete expected record with the optional content and timing change.
|
||||
* @returns the original Remote mutation result.
|
||||
*/
|
||||
const updateTask = async (request: ScheduleUpdateRequest) => {
|
||||
const result = await ctx.remote.schedule.update(request)
|
||||
if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict'
|
||||
|| result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) {
|
||||
// The write is in, so this readback must not join a read that was sent
|
||||
// before it: naming every request seen so far makes the source issue a new
|
||||
// one, the way the post-deletion readback supersedes.
|
||||
await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request)
|
||||
const openSession = (id: SessionId): void => {
|
||||
if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') {
|
||||
ctx.uiWorkspace.openSession(id)
|
||||
}
|
||||
}
|
||||
const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = {
|
||||
hooks: manager.hooks,
|
||||
onDelete: reportedDelete(manager.onDelete),
|
||||
onRetry: manager.onRetry,
|
||||
openTaskDetail: (id) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}),
|
||||
}, ScheduleTurnCard))
|
||||
// The Session header keeps one per-Session source: it lists the open
|
||||
// Session's active tasks and starts only while that header is mounted. The
|
||||
// Sidebar row seats read the shared `manager` catalog instead, so every
|
||||
// visible row projects one Host query rather than issuing its own.
|
||||
const createSource: SessionScheduleSourceFactory = sessionId =>
|
||||
createSessionScheduleSource(ctx, sessionId)
|
||||
ctx.slots.inject(
|
||||
'conversation.session.header.utilities',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'schedule-catalog',
|
||||
// Sits left of the overflow menu (order 0) and right of the header's
|
||||
// leading utilities (order -10), matching the shipped mock.
|
||||
order: -5,
|
||||
onUpdateTiming: updateTask,
|
||||
loadHistory,
|
||||
onOpenSession: openSession,
|
||||
}
|
||||
// The Sidebar persists a tab's layout record and not its navigation
|
||||
// parameters, so this page type keeps the task it last showed per Session and
|
||||
// tab id. That Session's committed tab ids bound every write, so a closed tab
|
||||
// leaves no entry behind.
|
||||
const taskBindings = new TaskTabBindings(
|
||||
sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id),
|
||||
)
|
||||
yield ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }),
|
||||
}, ScheduleTaskTab))
|
||||
yield ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: SCHEDULE_TASK_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }),
|
||||
}, ScheduleTaskTabTitle))
|
||||
yield ctx.slots.inject('main', () => ctx.slots.register({
|
||||
name: 'main',
|
||||
key: PANEL_ID,
|
||||
locale: MANAGER_NS,
|
||||
inject: (): TaskManagerInjected => ({
|
||||
...detail,
|
||||
// The page has no creation form: a new reminder starts in a Session.
|
||||
onNewTask: () => { ctx.uiWorkspace.startSession() },
|
||||
}),
|
||||
}, TaskManagerPage))
|
||||
yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
||||
name: 'sidebar.panellist',
|
||||
id: PANEL_ID,
|
||||
order: 10,
|
||||
locale: MANAGER_NS,
|
||||
label: () => t('panel'),
|
||||
}, TaskManagerIcon))
|
||||
yield ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
id: 'schedule-created',
|
||||
order: 20,
|
||||
locale: MANAGER_NS,
|
||||
inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({
|
||||
hooks: { catalog: manager.hooks.catalog },
|
||||
onRetry: manager.onRetry,
|
||||
openTaskDetail: (id) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}),
|
||||
}, ScheduleTurnCard))
|
||||
// The Session header keeps one per-Session source: it lists the open
|
||||
// Session's active tasks and starts only while that header is mounted. The
|
||||
// Sidebar row seats read the shared `manager` catalog instead, so every
|
||||
// visible row projects one Host query rather than issuing its own.
|
||||
const createSource: SessionScheduleSourceFactory = sessionId =>
|
||||
createSessionScheduleSource(ctx, sessionId)
|
||||
yield ctx.slots.inject(
|
||||
'conversation.session.header.utilities',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'schedule-catalog',
|
||||
// Sits left of the overflow menu (order 0) and right of the header's
|
||||
// leading utilities (order -10), matching the shipped mock.
|
||||
order: -5,
|
||||
locale: NS,
|
||||
inject: (sessionId) => {
|
||||
const source = createSource(sessionId)
|
||||
return {
|
||||
...source,
|
||||
// The header's deletions settle through the same app-wide toast as the
|
||||
// Tasks page and the task tab.
|
||||
onDelete: reportedDelete(source.onDelete),
|
||||
openTaskDetail: (id: ScheduleId) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ScheduleCatalogAction),
|
||||
)
|
||||
yield ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.leading',
|
||||
id: 'schedule-mark',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId) => {
|
||||
const source = createSource(sessionId)
|
||||
return {
|
||||
...source,
|
||||
// The header's deletions settle through the same app-wide toast as the
|
||||
// Tasks page and the task tab.
|
||||
onDelete: reportedDelete(source.onDelete),
|
||||
openTaskDetail: (id: ScheduleId) => {
|
||||
ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } })
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ScheduleCatalogAction),
|
||||
)
|
||||
ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.leading',
|
||||
id: 'schedule-mark',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleMark))
|
||||
ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.hover',
|
||||
id: 'schedule-tasks',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleHover))
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleMark))
|
||||
yield ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
|
||||
name: 'sidebar.session.row.hover',
|
||||
id: 'schedule-tasks',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: () => ({ hooks: { catalog: manager.hooks.catalog } }),
|
||||
}, SessionScheduleHover))
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { StaticRemoteAccess, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
@@ -86,9 +86,11 @@ class RemoteStub extends Service {
|
||||
async function baseContext(
|
||||
remote: RemoteStubInput = { $on: () => () => {} },
|
||||
sidebar: SidebarStubs = sidebarStubs(),
|
||||
allows: (action: string) => boolean = () => true,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
new StaticRemoteAccess(ctx, allows)
|
||||
new UiConversation(ctx, { binding: () => undefined } as never)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never)
|
||||
@@ -140,10 +142,29 @@ describe('ui-schedule browser half', () => {
|
||||
it('declares only the services used by registration', () => {
|
||||
expect(inject).toEqual([
|
||||
'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight',
|
||||
'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess',
|
||||
])
|
||||
})
|
||||
|
||||
it('contributes its surfaces only while the current Peer may read the task catalog', async () => {
|
||||
for (const allowed of [false, true]) {
|
||||
const sidebar = sidebarStubs()
|
||||
const allows = vi.fn(() => allowed)
|
||||
const ctx = await baseContext(undefined, sidebar, allows)
|
||||
const header = declareHeader(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(allows).toHaveBeenCalledWith('schedule/catalog')
|
||||
// The Session-header reminder catalog is this plugin's entry point into the page.
|
||||
expect(headerEntryIds(ctx)).toEqual(allowed ? ['schedule-catalog'] : [])
|
||||
// The task tab type is a definition, registered either way.
|
||||
expect(sidebar.register).toHaveBeenCalledOnce()
|
||||
await fiber.dispose()
|
||||
header()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => {
|
||||
const ctx = await baseContext()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 12e1ffc25a2d6695
|
||||
zh: 321156dea1356e55
|
||||
/deepseek-ai-dsh-client-ui-session/access-hooks:
|
||||
en: 619ba442c3e40fb0
|
||||
zh: af0206a774ac9bc1
|
||||
en: 7bf4bca5607248fa
|
||||
zh: 665d5c4e9cd29f68
|
||||
/deepseek-ai-dsh-client-ui-session/model-experience:
|
||||
en: cb2d4451e8bf8520
|
||||
zh: 6d3adbb930d90c59
|
||||
|
||||
@@ -24,7 +24,7 @@ Running status comes from Host list baselines or status events. Subagent catalog
|
||||
<a id="access-hooks"></a>
|
||||
## Access Hooks
|
||||
|
||||
The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
|
||||
The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. `ctx.uiSession.whenAccess(sessionId, action, callback)` is the JS counterpart of `useSessionAccess`: it keeps the callback's effects installed while the current Peer may call `action` on that Session, with the semantics of `ctx.remoteAccess.whenAccess`. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
@@ -24,7 +24,7 @@ kind: "package-reference"
|
||||
<a id="access-hooks"></a>
|
||||
## 访问 Hook
|
||||
|
||||
本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
|
||||
本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。`ctx.uiSession.whenAccess(sessionId, action, callback)` 是 `useSessionAccess` 在 JS 侧的对应:当前 Peer 能在该 Session 上调用 `action` 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
|
||||
import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
|
||||
import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -360,6 +360,24 @@ export class UiSession extends Service {
|
||||
return this.sourceFor(owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call
|
||||
* `action` on one Session resource, with the semantics of
|
||||
* `ctx.remoteAccess.whenAccess`: `useSessionAccess` answers the same
|
||||
* question during render.
|
||||
* @param sessionId - the Session resource the guarded effects depend on.
|
||||
* @param action - endpoint action the guarded effects depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer owned by the caller's Cordis fiber.
|
||||
*/
|
||||
whenAccess(sessionId: SessionId, action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
// `ctx.get` traces the service to the caller's Context, which then owns the controller.
|
||||
const access = this.ctx.get('remoteAccess')
|
||||
/* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiSession is reachable. */
|
||||
if (access === undefined) throw new Error('ui-session: whenAccess requires the remoteAccess service')
|
||||
return access.whenResourceAccess('session', sessionId, action, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one Session-scoped standard-source contribution.
|
||||
* @param descriptor - static member roster and per-binding resolver.
|
||||
|
||||
@@ -728,7 +728,10 @@ describe('ui-session apply', () => {
|
||||
}
|
||||
return source
|
||||
}),
|
||||
whenAccess: vi.fn(() => () => {}),
|
||||
whenResourceAccess: vi.fn((): () => void => stopGuard),
|
||||
}
|
||||
const stopGuard = vi.fn()
|
||||
ctx.provide('sessions', bench.sessions)
|
||||
ctx.provide('slots', slots as never)
|
||||
ctx.provide('remoteAccess', remoteAccess)
|
||||
@@ -758,6 +761,11 @@ describe('ui-session apply', () => {
|
||||
const binding = bench.binding(sessionId('s2'))
|
||||
const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot()
|
||||
expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork'))
|
||||
|
||||
// `whenAccess` follows the same Session resource through the service's resource form.
|
||||
const guarded = vi.fn(() => () => {})
|
||||
expect(ctx.uiSession.whenAccess(sessionId('s1'), 'session/fork', guarded)).toBe(stopGuard)
|
||||
expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded)
|
||||
})
|
||||
|
||||
it('keeps the Host loader half inert', () => {
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { OnboardingChange } from './onboarding-contract.ts'
|
||||
@@ -41,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
/** Services required by account settings. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms']
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms', 'remoteAccess']
|
||||
/** Register account UI in Desktop and plain Web clients. @param ctx - client plugin context. */
|
||||
export function apply(ctx: Context): void {
|
||||
const desktop = 'dshDesktop' in globalThis
|
||||
@@ -115,37 +116,40 @@ export function apply(ctx: Context): void {
|
||||
void request.finally(() => { if (refreshing === request) refreshing = undefined })
|
||||
return request
|
||||
}
|
||||
ctx.effect(() => () => { revision++ }, 'account: details request lifetime')
|
||||
const stream = ctx.remote.$stream<AccountView>({
|
||||
name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'),
|
||||
// The account stream, and the reads it starts, follow access to this host's account state.
|
||||
ctx.remoteAccess.whenAccess('account/watch', function* () {
|
||||
yield () => { revision++ }
|
||||
const stream = ctx.remote.$stream<AccountView>({
|
||||
name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'),
|
||||
})
|
||||
let disposed = false
|
||||
yield () => { disposed = true; notices.end(); return stream.dispose() }
|
||||
void (async () => {
|
||||
for await (const frame of stream) {
|
||||
revision++
|
||||
refreshing = undefined
|
||||
avatar.reset()
|
||||
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
|
||||
&& snapshot.view?.attempt?.phase !== 'succeeded'
|
||||
const { notices: previousNotices, ...previous } = snapshot
|
||||
publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false,
|
||||
...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) })
|
||||
browser.update(frame.value)
|
||||
frame.accept()
|
||||
if (frame.value.status === 'credential-stored') notices.begin()
|
||||
else notices.end()
|
||||
if (initialize) void (async () => {
|
||||
try {
|
||||
const initialized = await ctx.remote.session.initializeDefaultModel()
|
||||
if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' })
|
||||
} catch (_error) {
|
||||
console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' })
|
||||
}
|
||||
})()
|
||||
void refresh()
|
||||
}
|
||||
})().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) })
|
||||
})
|
||||
let disposed = false
|
||||
ctx.effect(() => () => { disposed = true; return stream.dispose() }, 'account: state stream')
|
||||
void (async () => {
|
||||
for await (const frame of stream) {
|
||||
revision++
|
||||
refreshing = undefined
|
||||
avatar.reset()
|
||||
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
|
||||
&& snapshot.view?.attempt?.phase !== 'succeeded'
|
||||
const { notices: previousNotices, ...previous } = snapshot
|
||||
publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false,
|
||||
...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) })
|
||||
browser.update(frame.value)
|
||||
frame.accept()
|
||||
if (frame.value.status === 'credential-stored') notices.begin()
|
||||
else notices.end()
|
||||
if (initialize) void (async () => {
|
||||
try {
|
||||
const initialized = await ctx.remote.session.initializeDefaultModel()
|
||||
if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' })
|
||||
} catch (_error) {
|
||||
console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' })
|
||||
}
|
||||
})()
|
||||
void refresh()
|
||||
}
|
||||
})().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) })
|
||||
const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform
|
||||
// One request channel for the single native Platform view. It always exists
|
||||
// so every entry can observe whether a page is showing; only a native bridge
|
||||
@@ -290,14 +294,14 @@ export function apply(ctx: Context): void {
|
||||
ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({
|
||||
name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations,
|
||||
}, AccountOnboarding))
|
||||
ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({
|
||||
name: 'shell.quota-notice', locale: 'settings.account',
|
||||
select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null,
|
||||
inject: (): AccountQuotaNoticeInjected => ({
|
||||
hooks: { account: operations.hooks.account, platformPage: platformPages },
|
||||
...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) },
|
||||
}),
|
||||
}, AccountQuotaNotice))
|
||||
}, AccountQuotaNotice)))
|
||||
if (nativePlatform !== undefined) {
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'account.platform-page', locale: 'settings.account',
|
||||
@@ -307,9 +311,9 @@ export function apply(ctx: Context): void {
|
||||
}),
|
||||
}, AccountPlatformHost))
|
||||
}
|
||||
ctx.slots.inject('settings.launcher', () => ctx.slots.register({
|
||||
ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({
|
||||
name: 'settings.launcher', locale: 'settings.account', inject: () => operations,
|
||||
}, AccountMenu))
|
||||
}, AccountMenu)))
|
||||
ctx.slots.inject('settings.section', () => {
|
||||
let unregister: (() => void) | undefined
|
||||
const update = () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { AccountQuotaNotice } from '../src/client/AccountQuotaNotice.tsx'
|
||||
import type { AccountQuotaNoticeInjected } from '../src/client/AccountQuotaNotice.tsx'
|
||||
|
||||
const it = createClientTest({ roster: webApp })
|
||||
const withoutAccountAccess = createClientTest({ roster: webApp }, { access: action => action !== 'account/watch' })
|
||||
const SELF = '@deepseek-ai/dsh-client-ui-settings-account'
|
||||
const view: AccountView = {
|
||||
status: 'signed-out', attempt: null,
|
||||
@@ -77,6 +78,15 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
withoutAccountAccess('opens no account stream and registers no launcher or quota notice while the current Peer may not watch the account', async ({ start }) => {
|
||||
const c = await start()
|
||||
expect(c.mock.streams.end('account/watch')).toBe(0)
|
||||
expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0)
|
||||
expect(quotaNoticeEntry(c)).toBeUndefined()
|
||||
// The models sign-in entry lives inside the settings shell, which follows its own access.
|
||||
expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1)
|
||||
}, 60_000)
|
||||
|
||||
it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
/** Public questionnaire options are emitted in the page bootstrap. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { apply, Config } from '../src/index.ts'
|
||||
import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.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('injects configured contact options and removes the listener on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const config: Config = Config({ contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 })
|
||||
await ctx.plugin({ Config, apply }, { contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 })
|
||||
const table: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
ctx.emit('webserver/index-inject', table, PAGE_PEER)
|
||||
expect(table).toEqual([{ kind: 'global', name: CONTACT_CONFIG_GLOBAL, value: {
|
||||
contactFormUrl: config.contactFormUrl, contactSource: config.contactSource,
|
||||
bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100,
|
||||
} }])
|
||||
await ctx.fiber.dispose()
|
||||
table.length = 0
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
ctx.emit('webserver/index-inject', table, PAGE_PEER)
|
||||
expect(table).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remoteAccess: {
|
||||
whenAccess: (_action: string, install: () => (() => void) | Iterable<() => void> | undefined) => {
|
||||
const effect = install()
|
||||
if (typeof effect === 'function') disposers.push(effect)
|
||||
else if (effect !== undefined) for (const dispose of effect) disposers.push(dispose)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
$on: () => () => {},
|
||||
session: { initializeDefaultModel },
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
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 ctx.remote merge and its fixed Host facts.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -70,7 +72,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -185,46 +187,48 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.slots.inject('sidebar.settings', () => {
|
||||
const shellHandle = createSettingsShellStore()
|
||||
const shellInstance = shellHandle.create()
|
||||
const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance }
|
||||
const disposeCommand = ctx.shortcuts.register({
|
||||
id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'],
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'Comma', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'Comma', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'Comma', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] },
|
||||
'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: ['settings'],
|
||||
resolve: ({ modal }) => {
|
||||
if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' }
|
||||
return { status: 'handled', run: () => {
|
||||
if (modal === 'settings') closeTopModal(document)
|
||||
else shellInstance.actions.open()
|
||||
} }
|
||||
},
|
||||
})
|
||||
// The settings shell reads this host's configuration, so it follows access to its description.
|
||||
ctx.remoteAccess.whenAccess('settings/describe', () =>
|
||||
ctx.slots.inject('sidebar.settings', () => {
|
||||
const shellHandle = createSettingsShellStore()
|
||||
const shellInstance = shellHandle.create()
|
||||
const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance }
|
||||
const disposeCommand = ctx.shortcuts.register({
|
||||
id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'],
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'Comma', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'Comma', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'Comma', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] },
|
||||
'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: ['settings'],
|
||||
resolve: ({ modal }) => {
|
||||
if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' }
|
||||
return { status: 'handled', run: () => {
|
||||
if (modal === 'settings') closeTopModal(document)
|
||||
else shellInstance.actions.open()
|
||||
} }
|
||||
},
|
||||
})
|
||||
|
||||
const disposeSlot = ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
locale: NS,
|
||||
store: shellStore,
|
||||
children: {
|
||||
'settings.launcher': { kind: 'single', scope: 'root' },
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: shellInjected,
|
||||
}, SettingsRoot)
|
||||
return () => { disposeCommand(); disposeSlot() }
|
||||
})
|
||||
const disposeSlot = ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
locale: NS,
|
||||
store: shellStore,
|
||||
children: {
|
||||
'settings.launcher': { kind: 'single', scope: 'root' },
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
inject: shellInjected,
|
||||
}, SettingsRoot)
|
||||
return () => { disposeCommand(); disposeSlot() }
|
||||
}))
|
||||
|
||||
ctx.slots.inject('settings.trigger', () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
|
||||
@@ -96,7 +96,7 @@ function setPageUrl(url: string): void {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess'])
|
||||
})
|
||||
|
||||
it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { DesktopUpdatePresentation } from '../src/types.ts'
|
||||
const SELF = '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
|
||||
const it = createClientTest({ roster: webApp })
|
||||
const withoutSettingsAccess = createClientTest({ roster: webApp }, { access: action => action !== 'settings/describe' })
|
||||
/** The whole roster's first boot pays the cold module transform of every plugin package. */
|
||||
const COLD_BOOT_TIMEOUT_MS = 60_000
|
||||
|
||||
@@ -77,7 +78,7 @@ describe('ui-settings-general shell', () => {
|
||||
}, COLD_BOOT_TIMEOUT_MS)
|
||||
|
||||
it('declares its services', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess'])
|
||||
})
|
||||
|
||||
it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => {
|
||||
@@ -86,6 +87,11 @@ describe('ui-settings-general shell', () => {
|
||||
for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name])
|
||||
}, COLD_BOOT_TIMEOUT_MS)
|
||||
|
||||
withoutSettingsAccess('leaves sidebar.settings unoccupied while the current Peer may not read the settings description', async ({ start }) => {
|
||||
const c = await start()
|
||||
expect(c.ctx.slots.entries('sidebar.settings')).toEqual([])
|
||||
}, COLD_BOOT_TIMEOUT_MS)
|
||||
|
||||
it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => {
|
||||
const c = await start()
|
||||
const { sections } = injectedOf(c).hooks
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as hostPlugin from '../src/index.ts'
|
||||
import { ONBOARDING_CONFIG_GLOBAL } from '../src/onboarding-config.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]
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
|
||||
@@ -74,7 +77,7 @@ describe('ui-settings-models apply', () => {
|
||||
const host = ctx.plugin(hostPlugin, { credentialOnboarding: false })
|
||||
await host.await()
|
||||
const rows: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', rows)
|
||||
ctx.emit('webserver/index-inject', rows, PAGE_PEER)
|
||||
expect(rows).toEqual([{ kind: 'global', name: ONBOARDING_CONFIG_GLOBAL, value: { credentialOnboarding: false } }])
|
||||
for (const row of rows) if (row.kind === 'global') vi.stubGlobal(row.name, row.value)
|
||||
const plugin = ctx.plugin({ inject: [...inject], apply })
|
||||
@@ -87,7 +90,7 @@ describe('ui-settings-models apply', () => {
|
||||
expect(slots.entries('settings.onboarding')).toEqual([])
|
||||
await host.dispose()
|
||||
const after: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', after)
|
||||
ctx.emit('webserver/index-inject', after, PAGE_PEER)
|
||||
expect(after).toEqual([])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** Host configuration supplies bounded Office reuse settings to browser pages. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import * as host from '../src/index.ts'
|
||||
|
||||
/** 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('exposes Client cache and spreadsheet limits on the Host entry', () => {
|
||||
expect(host.Config).toBe(Config)
|
||||
expect(Config({})).toEqual({ office: {
|
||||
@@ -22,10 +25,10 @@ it('embeds YAML cache settings in browser pages and withdraws them on disposal',
|
||||
const fiber = ctx.plugin(host, config)
|
||||
await fiber.await()
|
||||
const rows: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', rows)
|
||||
ctx.emit('webserver/index-inject', rows, PAGE_PEER)
|
||||
expect(rows).toEqual([{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config }])
|
||||
await fiber.dispose()
|
||||
const after: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', after)
|
||||
ctx.emit('webserver/index-inject', after, PAGE_PEER)
|
||||
expect(after).toEqual([])
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as HostPlugin from '../src/index.ts'
|
||||
import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts'
|
||||
import { plainConfig } from '../../../settings/settings/src/schema.ts'
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
DEFAULT_PREFERENCE, Config, apply,
|
||||
} from '@deepseek-ai/dsh-client-ui-theme'
|
||||
|
||||
/** 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]
|
||||
|
||||
|
||||
/** Collect the injection table the way an index render or boot payload does. */
|
||||
function collect(ctx: Context): IndexInjection[] {
|
||||
const table: IndexInjection[] = []
|
||||
ctx.emit('webserver/index-inject', table)
|
||||
ctx.emit('webserver/index-inject', table, PAGE_PEER)
|
||||
return table
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 9fb88260380bf093
|
||||
zh: 3bace2b96ecd4a82
|
||||
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
|
||||
en: 0ae90db57a09c62c
|
||||
zh: 453f350f1dea2ac4
|
||||
en: 57f47add55b9f5c2
|
||||
zh: af7fe7f6b32cd30a
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
|
||||
en: 96a37e74555a36b7
|
||||
zh: d03941523509a5cf
|
||||
|
||||
@@ -83,7 +83,7 @@ Once both Workspace and Session startup baselines are ready, an empty installati
|
||||
|
||||
The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title.
|
||||
|
||||
The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
|
||||
The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource, and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`, which keeps the callback's effects installed while that answer is true, with the semantics of `ctx.remoteAccess.whenAccess`; snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face).
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
@@ -83,7 +83,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
|
||||
|
||||
首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。
|
||||
|
||||
本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
|
||||
本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`,以及 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`,在该答案为 true 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`;快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Workspace archive and directory UI capability. */
|
||||
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
ISessions,
|
||||
@@ -130,6 +131,17 @@ export interface UiWorkspace {
|
||||
* @returns created absolute path.
|
||||
*/
|
||||
createDirectory(path: string, name: string): Promise<string>
|
||||
/**
|
||||
* Keep `callback`'s effects installed while the current Peer may call
|
||||
* `action` on one Workspace resource, with the semantics of
|
||||
* `ctx.remoteAccess.whenAccess`: `useWorkspaceAccess` answers the same
|
||||
* question during render.
|
||||
* @param workspaceId - the Workspace resource the guarded effects depend on.
|
||||
* @param action - endpoint action the guarded effects depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer owned by the caller's Cordis fiber.
|
||||
*/
|
||||
whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -406,6 +418,14 @@ class UiWorkspaceService extends Service implements UiWorkspace {
|
||||
return result.value
|
||||
}
|
||||
|
||||
whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
// `ctx.get` traces the service to the caller's Context, which then owns the controller.
|
||||
const access = this.ctx.get('remoteAccess')
|
||||
/* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiWorkspace is reachable. */
|
||||
if (access === undefined) throw new Error('ui-workspace: whenAccess requires the remoteAccess service')
|
||||
return access.whenResourceAccess('workspace', workspaceId, action, callback)
|
||||
}
|
||||
|
||||
private watchNavigation(): () => void {
|
||||
let initial: 'waiting' | 'connecting' | 'done' = 'waiting'
|
||||
const reconcile = (): void => {
|
||||
|
||||
@@ -69,7 +69,9 @@ async function bench() {
|
||||
ctx.provide('uiConversation', {})
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable<boolean> => ALLOWED)
|
||||
ctx.provide('remoteAccess', { source: accessSource })
|
||||
const stopGuard = vi.fn()
|
||||
const whenResourceAccess = vi.fn((): () => void => stopGuard)
|
||||
ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess })
|
||||
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
|
||||
workspaceId: 'ws-new' as never,
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
@@ -162,7 +164,7 @@ async function bench() {
|
||||
locale.setLocale('zh')
|
||||
ctx.provide('locale', locale)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource,
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, whenResourceAccess, stopGuard,
|
||||
retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession,
|
||||
workspacesSubscribe, initializeDefault, history, scope, sessionOf,
|
||||
setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot },
|
||||
@@ -233,6 +235,10 @@ describe('ui-workspace apply', () => {
|
||||
expect(keyed?.('w1', 'workspace/follow')).toBe(ALLOWED)
|
||||
expect(b.accessSource).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow')
|
||||
expect(keyed?.('w1')).toBeUndefined()
|
||||
// `whenAccess` follows the same Workspace resource through the service's resource form.
|
||||
const guarded = vi.fn(() => () => {})
|
||||
expect(b.ctx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', guarded)).toBe(b.stopGuard)
|
||||
expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded)
|
||||
})
|
||||
|
||||
it('reports a default Workspace creation failure through the shared notice overlay', async () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import { apply, Config, inject, name } from '../src/index.ts'
|
||||
import { startInspector, type InspectorHandle, type InspectorOptions } from '../src/host/bridge/controller.ts'
|
||||
import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.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]
|
||||
|
||||
vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } }))
|
||||
|
||||
vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => {
|
||||
@@ -76,7 +79,7 @@ describe('experimental Inspector Host bootstrap route', () => {
|
||||
)
|
||||
const injected = () => {
|
||||
const rows: IndexInjection[] = []
|
||||
context!.emit('webserver/index-inject', rows)
|
||||
context!.emit('webserver/index-inject', rows, PAGE_PEER)
|
||||
return rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__')
|
||||
}
|
||||
return { fiber, bootstrap, injected, register, registerUpgrade, messages, log }
|
||||
|
||||
@@ -12,6 +12,9 @@ import { apply, Config, inject, name, startInspector } from '../src/index.ts'
|
||||
import { isPlainObject } from '../src/shared/json.ts'
|
||||
import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.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]
|
||||
|
||||
vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } }))
|
||||
|
||||
interface CdpResponse {
|
||||
@@ -51,7 +54,7 @@ describe('experimental Inspector Host plugin', () => {
|
||||
await fiber.await()
|
||||
|
||||
const rows: IndexInjection[] = []
|
||||
context.emit('webserver/index-inject', rows)
|
||||
context.emit('webserver/index-inject', rows, PAGE_PEER)
|
||||
const bootstrap = rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__')
|
||||
expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' })
|
||||
expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u))
|
||||
@@ -116,7 +119,7 @@ describe('experimental Inspector Host plugin', () => {
|
||||
expect(releaseUpgrade).toHaveBeenCalledOnce()
|
||||
expect(rows).toHaveLength(1)
|
||||
const afterDispose: IndexInjection[] = []
|
||||
context.emit('webserver/index-inject', afterDispose)
|
||||
context.emit('webserver/index-inject', afterDispose, PAGE_PEER)
|
||||
expect(afterDispose).toEqual([])
|
||||
expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer)).status).toBe(404)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
|
||||
import type { ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
||||
import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { HostConnectionHandle, PeerScope } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { AlsCausality } from './polyfill/async-context/als-runtime.ts'
|
||||
import { join } from './module-system/posix-path.ts'
|
||||
import { installProcessGlobal } from './node/globals/process.ts'
|
||||
@@ -273,7 +273,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
|
||||
const peer = connection.browserPeer
|
||||
tunnel.serve({
|
||||
directFetch: (request: Request) => handler.fetch(request, peer),
|
||||
bootPayload: () => readBootPayload(ctx),
|
||||
bootPayload: () => readBootPayload(ctx, peer),
|
||||
packageAssets: () => packPackageAssets(mounted, root),
|
||||
openStream: (endpoint, payload, uplink, signal) =>
|
||||
typertGateway.wireStream.open(endpoint, payload, uplink, peer, signal),
|
||||
@@ -454,14 +454,15 @@ function prepareProfile(
|
||||
* from the in-process webserver service, never from the API surface, because
|
||||
* the page has no Cordis tree yet.
|
||||
* @param ctx - Booted host context.
|
||||
* @param peer - the browser Peer the page speaks for.
|
||||
* @returns Boot payload for `GET /__boot__`.
|
||||
*/
|
||||
function readBootPayload(ctx: HostContext): { injections: unknown } {
|
||||
const webServer = ctx.get('webServer') as { collectIndexInjections(): unknown } | undefined
|
||||
function readBootPayload(ctx: HostContext, peer: PeerScope): { injections: unknown } {
|
||||
const webServer = ctx.get('webServer') as { collectIndexInjections(peer: PeerScope): unknown } | undefined
|
||||
if (webServer === undefined) {
|
||||
throw new Error('webworker host: no webServer service, so the page cannot receive its boot injections')
|
||||
}
|
||||
return { injections: webServer.collectIndexInjections() }
|
||||
return { injections: webServer.collectIndexInjections(peer) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2091,7 +2091,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'snapshotFor(principal: Principal): RemoteAccessBaseline',
|
||||
description: 'The baseline a page opens with before its snapshot stream connects. A business package rendering a non-owner page assigns this value to REMOTE_ACCESS_SNAPSHOT_KEY after every index injection row, replacing the owner baseline the webServer index carries.',
|
||||
description: 'The baseline a page opens with before its snapshot stream connects; every index the webServer renders carries it under REMOTE_ACCESS_SNAPSHOT_KEY for the Peer the page speaks for.',
|
||||
parameters: [{ name: 'principal', description: 'the page\'s caller identity.' }],
|
||||
returns: 'the owner baseline for the owner; otherwise the principal\'s current grants.',
|
||||
},
|
||||
@@ -3858,15 +3858,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
returns: 'the transformed body.',
|
||||
},
|
||||
{
|
||||
signature: 'collectIndexInjections(): IndexInjection[]',
|
||||
description: '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.',
|
||||
parameters: [],
|
||||
signature: 'collectIndexInjections(peer: PeerScope): IndexInjection[]',
|
||||
description: '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.',
|
||||
parameters: [{ name: 'peer', description: 'the Peer the page speaks for.' }],
|
||||
returns: 'rows in subscriber activation order.',
|
||||
},
|
||||
{
|
||||
signature: 'renderIndex(html: string): string',
|
||||
description: 'Render one index.html body: the structured injection table first, then the raw `tapIndex` transforms over the result.',
|
||||
parameters: [{ name: 'html', description: 'the raw index.html body.' }],
|
||||
signature: 'renderIndex(html: string, peer: PeerScope): string',
|
||||
description: 'Render one index.html body for one Peer: the structured injection table first, then the raw `tapIndex` transforms over the result.',
|
||||
parameters: [{ name: 'html', description: 'the raw index.html body.' }, { name: 'peer', description: 'the Peer the page speaks for.' }],
|
||||
returns: 'the transformed body.',
|
||||
},
|
||||
],
|
||||
@@ -4732,10 +4732,10 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'webserver/index-inject',
|
||||
mode: 'emit',
|
||||
signature: '\'webserver/index-inject\'(table: IndexInjection[]): void',
|
||||
summary: 'Collect the structured index injection table.',
|
||||
description: '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.',
|
||||
parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }],
|
||||
signature: '\'webserver/index-inject\'(table: IndexInjection[], peer: PeerScope): void',
|
||||
summary: 'Collect the structured index injection table for the page `peer` speaks for.',
|
||||
description: '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.',
|
||||
parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }, { name: 'peer', description: 'The Peer the rendered page speaks for.' }],
|
||||
},
|
||||
{
|
||||
name: 'workflow/agent-end',
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: c58118ab4aba9bac
|
||||
zh: 334fcd54edcae152
|
||||
/deepseek-ai-dsh-host-frontend-static/use-this-package/what-the-server-enforces:
|
||||
en: 3bd43604d1195427
|
||||
zh: 4912adee677ac589
|
||||
en: 2bfa62a708bf6357
|
||||
zh: af117941f288207f
|
||||
/deepseek-ai-dsh-host-frontend-static/use-this-package/observable-failures:
|
||||
en: 7b6cca6ed823f515
|
||||
zh: 30b5a5c594e8f1b8
|
||||
|
||||
@@ -39,7 +39,7 @@ Compose this plugin in a browser-facing host that serves the built Web shell: it
|
||||
|
||||
### What the server enforces
|
||||
|
||||
Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex`, so the boot manifest reaches the page on `/` and on the configured index path.
|
||||
Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex` for `connection.browserPeer`, the Peer Connection's browser authentication admits, so the boot manifest and that Peer's access snapshot reach the page on `/` and on the configured index path.
|
||||
|
||||
The served HTML carries one document base, `<base href="./">`, ahead of every injected resource row, so it freezes the entry directory the page was loaded from: the shell's own app-directory-relative references and the Host's plugin-resource rows both resolve under the mount that served the page. The same index therefore serves the origin root and whatever mount a prefix-stripping proxy owns; this plugin renders it only for the dist root and the configured index path.
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ kind: "package-reference"
|
||||
|
||||
### 服务器实施的约束
|
||||
|
||||
请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染,因此启动 manifest(元数据清单)会通过 `/` 与配置的 index 路径送达页面。
|
||||
请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 为 `connection.browserPeer`(Connection 浏览器认证所准入的 Peer)渲染,因此启动 manifest(元数据清单)与该 Peer 的访问快照会通过 `/` 与配置的 index 路径送达页面。
|
||||
|
||||
所服务的 HTML 携带唯一的文档 base `<base href="./">`,位于每一条注入资源行之前,因此它冻结页面加载时所处的入口目录:shell 自身的应用目录相对引用与宿主的插件资源行都在服务该页面的挂载下解析。同一份 index 因而既服务源站根目录,也服务剥离前缀的代理所拥有的任一挂载;本插件只为 dist 根目录与配置的 index 路径渲染它。
|
||||
|
||||
|
||||
@@ -117,7 +117,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const distRoot = dirname(distIndex)
|
||||
// Insert after all index transforms so the base precedes every resource reference.
|
||||
const renderIndex = async (): Promise<string> => {
|
||||
const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'))
|
||||
// Connection's browser authentication admits the owner's shared browser Peer.
|
||||
const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'), ctx.connection.browserPeer)
|
||||
return body.replace(/<head(?:\s[^>]*)?>/i, open => `${open}<base href="./">`)
|
||||
}
|
||||
ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 9c6c5fcadd836722
|
||||
zh: f5b79ea2c4be6dde
|
||||
/deepseek-ai-dsh-host-webserver/summary:
|
||||
en: 0d1ffed238b25446
|
||||
zh: 9f66adce1c593c53
|
||||
en: e4daa778a9126818
|
||||
zh: f239eb0c8c7a624b
|
||||
/deepseek-ai-dsh-host-webserver/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 296ffba200f0dc93
|
||||
zh: 5d78d6b5f48634c9
|
||||
/deepseek-ai-dsh-host-webserver/use-this-package/the-fallback-seat:
|
||||
en: e21232b7aecbe7a8
|
||||
zh: 2c4a94cab2aa53ff
|
||||
en: d7ecbb2b3ff9aa9b
|
||||
zh: 08ebfd9798d96a62
|
||||
/deepseek-ai-dsh-host-webserver/use-this-package/behavior-under-failure:
|
||||
en: dd2529dd54707e39
|
||||
zh: 2615f469d66ac681
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 42b15ac6321706aa
|
||||
zh: 4ed0861911abaf5d
|
||||
/deepseek-ai-dsh-host-webserver/understand-the-implementation/design-concept:
|
||||
en: 728da97b91b797b6
|
||||
zh: 22541daf0223c663
|
||||
en: 88adbd5967b978d3
|
||||
zh: b4fabfd6f5df1b71
|
||||
/deepseek-ai-dsh-host-webserver/understand-the-implementation/matching-and-lifecycle:
|
||||
en: 8589aba0cbaa1c76
|
||||
zh: babf3b27b0cdb35e
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server — `node:https` once `tls` names a certificate and key — where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It knows no harness concepts and serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge.
|
||||
Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server — `node:https` once `tls` names a certificate and key — where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -64,7 +64,7 @@ Set `compression: 'gzip'` to wrap eligible socket-backed responses without chang
|
||||
|
||||
`registerFallback(handler)` claims the one handler for every request no named route matches. A second registration throws; while no fallback is registered the server answers 404. In the shipped Web composition the [SPA dist server](../frontend-static/README.md) owns the seat and calls `renderIndex` on every index response it renders.
|
||||
|
||||
Index startup inputs are two layers. `collectIndexInjections()` gathers a fresh injection table — one `webserver/index-inject` emit per call, each subscriber pushing its current rows — and `renderIndex(html)` renders those rows into the index.html body before applying the raw `tapIndex(transform)` transforms in registration order. A `script-preload` row renders an advisory classic-script preload link. Static deployments carry the same rows in their boot payload. `applyIndexTaps(html)` applies only the raw transforms; it is the escape hatch for markup no row expresses.
|
||||
Index startup inputs are two layers, computed for the Peer the page speaks for. `collectIndexInjections(peer)` gathers a fresh injection table — one `webserver/index-inject` emit per call, passing `peer` to each subscriber, which pushes its current rows — and `renderIndex(html, peer)` renders those rows into the index.html body before applying the raw `tapIndex(transform)` transforms in registration order. A `script-preload` row renders an advisory classic-script preload link. Static deployments carry the same rows in their boot payload. `applyIndexTaps(html)` applies only the raw transforms; it is the escape hatch for markup no row expresses.
|
||||
|
||||
### Behavior under failure
|
||||
|
||||
@@ -80,7 +80,7 @@ A listen failure (for example EADDRINUSE) rejects plugin initialization with the
|
||||
|
||||
### Design concept
|
||||
|
||||
The package is a plain route registry with no harness vocabulary: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host`, `port`, and `protocol` getters and `getOrigin()` expose composition-time facts other plugins adapt to (for example the directory-picker chooser), and an explicit `tls: null` normalizes to an unset listener, so `protocol` has a single source.
|
||||
The package is a plain route registry whose only harness vocabulary is the Peer an index render speaks for: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host`, `port`, and `protocol` getters and `getOrigin()` expose composition-time facts other plugins adapt to (for example the directory-picker chooser), and an explicit `tls: null` normalizes to an unset listener, so `protocol` has a single source.
|
||||
|
||||
### Matching and lifecycle
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器——当 `tls` 指定证书与私钥时改用 `node:https`——其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不了解任何 harness 概念,也不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。
|
||||
浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器——当 `tls` 指定证书与私钥时改用 `node:https`——其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -64,7 +64,7 @@ kind: "package-reference"
|
||||
|
||||
`registerFallback(handler)` 认领所有未被具名 route 命中的请求的唯一一个 handler。第二次注册会抛错;没有注册回退时服务器回答 404。在随附的 Web 组合中,[SPA dist 服务器](../frontend-static/README.zh.md)拥有该席位,并对其渲染的每个 index 响应调用 `renderIndex`。
|
||||
|
||||
index 启动输入分两层。`collectIndexInjections()` 收集一张全新的注入表——每次调用发一次 `webserver/index-inject` 事件,每个订阅方推入其当前行——`renderIndex(html)` 先把这些行渲染进 index.html 正文,再按注册顺序应用原始 `tapIndex(transform)` 转换。`script-preload` 行会渲染为 classic script 的提示性 preload 链接。静态部署会在启动 payload 中携带同一批行。`applyIndexTaps(html)` 只应用原始转换;它是任何行都无法表达的标记的逃生口。
|
||||
index 启动输入分两层,按页面所代表的 Peer 计算。`collectIndexInjections(peer)` 收集一张全新的注入表——每次调用发一次 `webserver/index-inject` 事件,把 `peer` 传给每个订阅方,订阅方推入其当前行——`renderIndex(html, peer)` 先把这些行渲染进 index.html 正文,再按注册顺序应用原始 `tapIndex(transform)` 转换。`script-preload` 行会渲染为 classic script 的提示性 preload 链接。静态部署会在启动 payload 中携带同一批行。`applyIndexTaps(html)` 只应用原始转换;它是任何行都无法表达的标记的逃生口。
|
||||
|
||||
### 失败时的行为
|
||||
|
||||
@@ -80,7 +80,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的
|
||||
|
||||
### 设计理念
|
||||
|
||||
本包是一个不带任何 harness 词汇的普通路由注册表:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host`、`port`、`protocol` getter 与 `getOrigin()` 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器),而显式的 `tls: null` 会归一为未设置监听,使 `protocol` 只有一个来源。
|
||||
本包是一个普通路由注册表,唯一的 harness 词汇是 index 渲染所代表的 Peer:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host`、`port`、`protocol` getter 与 `getOrigin()` 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器),而显式的 `tls: null` 会归一为未设置监听,使 `protocol` 只有一个来源。
|
||||
|
||||
### 匹配与生命周期
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/negotiator": "^0.6.5",
|
||||
"selfsigned": "^4.0.1"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — node:http route registration — node:https
|
||||
* when `tls` names a certificate and key — with optional gzip, index injection,
|
||||
* and one fallback seat. It knows no harness concepts and serves no files; the
|
||||
* composing application owns dist serving. Electron uses file:// plus IPC
|
||||
* when `tls` names a certificate and key — with optional gzip, index injection
|
||||
* for the Peer a page speaks for, and one fallback seat. It serves no files;
|
||||
* the composing application owns dist serving. Electron uses file:// plus IPC
|
||||
* instead, and this package never prints the URL.
|
||||
* Route handlers retain direct response ownership.
|
||||
*/
|
||||
@@ -16,6 +16,7 @@ import { resolve } from 'node:path'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import compressionMiddleware from 'compression'
|
||||
import ipaddr from 'ipaddr.js'
|
||||
import Negotiator from 'negotiator'
|
||||
@@ -30,13 +31,15 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,25 +567,28 @@ export class WebServer extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
const table: IndexInjection[] = []
|
||||
this.ctx.emit('webserver/index-inject', table)
|
||||
this.ctx.emit('webserver/index-inject', table, peer)
|
||||
return table
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections()))
|
||||
renderIndex(html: string, peer: PeerScope): string {
|
||||
return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections(peer)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context, FiberState } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import HttpServer, { bindUrlHost, isLoopbackHost, normalizeBindAddress, renderIndexInjections, type TlsConfig } from '../src/index.ts'
|
||||
import { materializeTlsFixture, type TlsFixture } from './tls-fixture.ts'
|
||||
|
||||
@@ -516,11 +517,14 @@ describe('real Loader composition', () => {
|
||||
await expect(request(port, '/probe')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => {
|
||||
it('collects injection rows fresh per render for the rendered Peer and layers taps over the rendered rows', { timeout: 60_000 }, async () => {
|
||||
const loaded = await loadComposition()
|
||||
const server = loaded.webServer
|
||||
let flag = 'dark'
|
||||
loaded.on('webserver/index-inject', (table) => {
|
||||
const peer = { principal: { kind: 'owner' } } as PeerScope
|
||||
const renderedFor: PeerScope[] = []
|
||||
loaded.on('webserver/index-inject', (table, renderingFor) => {
|
||||
renderedFor.push(renderingFor)
|
||||
table.push(
|
||||
{ kind: 'script', placement: 'head', text: 'window.__Q__=1' },
|
||||
{ kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=<y>' },
|
||||
@@ -532,7 +536,7 @@ describe('real Loader composition', () => {
|
||||
)
|
||||
})
|
||||
|
||||
const html = server.renderIndex('<html><head></head><body>shell</body></html>')
|
||||
const html = server.renderIndex('<html><head></head><body>shell</body></html>', peer)
|
||||
// Head rows land right after the opening head tag in table order; the body
|
||||
// row lands right after the opening body tag.
|
||||
const order = [
|
||||
@@ -552,12 +556,14 @@ describe('real Loader composition', () => {
|
||||
|
||||
// Fresh collection per render: the listener reads live state at emit time.
|
||||
flag = 'light'
|
||||
expect(server.renderIndex('<head></head><body></body>')).toContain('window.__P__="light"')
|
||||
expect(server.renderIndex('<head></head><body></body>', peer)).toContain('window.__P__="light"')
|
||||
|
||||
// Raw taps still run, over the already-rendered rows.
|
||||
const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2'))
|
||||
expect(server.renderIndex('<head></head><body></body>')).toContain('window.__Q__=2')
|
||||
expect(server.renderIndex('<head></head><body></body>', peer)).toContain('window.__Q__=2')
|
||||
untap()
|
||||
// Every collection is dispatched with the Peer the render speaks for.
|
||||
expect(renderedFor).toEqual([peer, peer, peer])
|
||||
|
||||
// Tag-less fragments: head rows prepend, body rows append, and the
|
||||
// boot-readiness tail lands after the last body row.
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 12afbb217bc06678
|
||||
zh: c07f001ed8bb3300
|
||||
/deepseek-ai-dsh-client-test-runtime/use-this-package/setting-up-a-feature-spec:
|
||||
en: 620906bcbaba405a
|
||||
zh: b7c34920ba5873d1
|
||||
en: 411248e12f21d834
|
||||
zh: cfb151ce0acbe4e7
|
||||
/deepseek-ai-dsh-client-test-runtime/use-this-package/local-dom-snapshots:
|
||||
en: 1abbbed6cc4cf57b
|
||||
zh: ab3975178b589653
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 1442fc215c8128e8
|
||||
zh: 8439861b08e46ea2
|
||||
/deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior:
|
||||
en: 2525f42ba8d139f7
|
||||
zh: 772661c7358042c8
|
||||
en: 707b824ae6391cc0
|
||||
zh: bb7621826e6b07db
|
||||
/deepseek-ai-dsh-client-test-runtime/use-this-package/when-to-use-it:
|
||||
en: eb2f9bc90acb7a62
|
||||
zh: cb59a2134bc83106
|
||||
@@ -45,8 +45,8 @@
|
||||
en: 3264640018c1a0e8
|
||||
zh: d777e0b8ad8b602f
|
||||
/deepseek-ai-dsh-client-test-runtime/understand-the-implementation/source-map:
|
||||
en: e3ef8397426d484b
|
||||
zh: 23f735f4bda1534a
|
||||
en: f9d1e576b34bd2d6
|
||||
zh: 73bd7c2d57852a9f
|
||||
/deepseek-ai-dsh-client-test-runtime/understand-the-implementation/lifecycle:
|
||||
en: b42779f364504719
|
||||
zh: a10bad7ea1161388
|
||||
|
||||
@@ -44,7 +44,7 @@ await runtime.dispose()
|
||||
|
||||
`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides a `fileUpload` stub that rejects every call until a suite replaces `runtime.fileUpload.upload`. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions.
|
||||
|
||||
The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service answers true for every resource, so every access Hook allows every action. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook.
|
||||
The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service, a `StaticRemoteAccess`, answers true for every resource, so every access Hook allows every action and every `whenAccess` callback runs at once. `TestClientOptions.access` fixes the answer per action instead, through `staticAccessPlugin(allows)`. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook or provides `new StaticRemoteAccess(ctx, allows)`.
|
||||
|
||||
The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners.
|
||||
|
||||
@@ -93,7 +93,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every
|
||||
|
||||
### Roster and startup behavior
|
||||
|
||||
`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. Both bundle and browser-entry lookup prefer an anchor manifest with the requested package name over dependency directories, including self-referencing bundles. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.<ns>` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin` in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.<ns>` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote.<ns>.<method>(...args)` becomes a call on the endpoint `<ns>/<method>` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test.
|
||||
`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. Both bundle and browser-entry lookup prefer an anchor manifest with the requested package name over dependency directories, including self-referencing bundles. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.<ns>` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin`, or `staticAccessPlugin(options.access)`, in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.<ns>` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote.<ns>.<method>(...args)` becomes a call on the endpoint `<ns>/<method>` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test.
|
||||
|
||||
### When to use it
|
||||
|
||||
@@ -127,7 +127,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p
|
||||
| [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs |
|
||||
| [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state |
|
||||
| [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `<svg>` fingerprint) |
|
||||
| [`src/access.ts`](src/access.ts) | `allowAllAccess` props and `allowAllAccessPlugin`, the allow-all stand-in for the remote-access Client row |
|
||||
| [`src/access.ts`](src/access.ts) | `allowAllAccess` props, `StaticRemoteAccess`, `staticAccessPlugin`, and `allowAllAccessPlugin`, the fixed-answer stand-ins for the remote-access Client row |
|
||||
| [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export |
|
||||
| [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers |
|
||||
| [`src/config-form.ts`](src/config-form.ts) | `stubConfigForm` with test-driven publications and write spies |
|
||||
|
||||
@@ -44,7 +44,7 @@ await runtime.dispose()
|
||||
|
||||
`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时提供的 `fileUpload` 替身会拒绝每次调用,直到测试套件替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。
|
||||
|
||||
运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务对每个资源都回答 true,因此每个访问 Hook 都允许每个 action。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook。
|
||||
运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务是一个 `StaticRemoteAccess`,对每个资源都回答 true,因此每个访问 Hook 都允许每个 action,每个 `whenAccess` 回调都立即执行。`TestClientOptions.access` 改为按 action 固定答案,经由 `staticAccessPlugin(allows)`。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook,或提供 `new StaticRemoteAccess(ctx, allows)`。
|
||||
|
||||
可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。
|
||||
|
||||
@@ -93,7 +93,7 @@ test('registers into the sidebar', async ({ remote, start }) => {
|
||||
|
||||
### Roster 与启动行为
|
||||
|
||||
`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。查找 bundle 和浏览器入口时,优先使用包名匹配的 anchor 清单,再搜索依赖目录;这也适用于注册自身浏览器入口的 bundle。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.<ns>` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.<ns>` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote.<ns>.<method>(...args)` 变成对端点 `<ns>/<method>` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。
|
||||
`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。查找 bundle 和浏览器入口时,优先使用包名匹配的 anchor 清单,再搜索依赖目录;这也适用于注册自身浏览器入口的 bundle。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.<ns>` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin` 或 `staticAccessPlugin(options.access)`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.<ns>` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote.<ns>.<method>(...args)` 变成对端点 `<ns>/<method>` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。
|
||||
|
||||
### 何时使用
|
||||
|
||||
@@ -127,7 +127,7 @@ test('registers into the sidebar', async ({ remote, start }) => {
|
||||
| [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 |
|
||||
| [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 |
|
||||
| [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`<svg>` 指纹) |
|
||||
| [`src/access.ts`](src/access.ts) | `allowAllAccess` props 与 `allowAllAccessPlugin`:remote-access Client 行的全允许替身 |
|
||||
| [`src/access.ts`](src/access.ts) | `allowAllAccess` props、`StaticRemoteAccess`、`staticAccessPlugin` 与 `allowAllAccessPlugin`:remote-access Client 行的固定答案替身 |
|
||||
| [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 |
|
||||
| [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 |
|
||||
| [`src/config-form.ts`](src/config-form.ts) | 带测试驱动发布与写入 spy 的 `stubConfigForm` |
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* Allow-all access for client specs. Production pages receive the
|
||||
* Static access for client specs. Production pages receive the
|
||||
* `remoteAccess` Client service and the global-resource `useRemoteAccess` Hook
|
||||
* from the remote-access Client plugin, and the Session and Workspace access
|
||||
* Hooks (Session-scope `useAccess`, `useSessionAccess`, `useWorkspaceAccess`)
|
||||
* from ui-session and ui-workspace over that service. The test runtimes
|
||||
* install an allow-all service and `useRemoteAccess` Hook instead, and
|
||||
* hand-built component props spread {@link allowAllAccess}; a spec of a
|
||||
* refused path overrides one Hook.
|
||||
* refused path overrides one Hook or provides a {@link StaticRemoteAccess}
|
||||
* that refuses the action.
|
||||
* @module @deepseek-ai/dsh-client-test-runtime/src/access
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ClientRemoteAccess } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type { AccessEffect, ClientRemoteAccess, RemoteEndpoint, TypertResourceKind } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type { GlobalStandardProps, HostObservable, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
@@ -31,23 +32,64 @@ export const allowAllAccess: AccessStandardProps = {
|
||||
}
|
||||
|
||||
const ALLOWED: HostObservable<boolean> = { getSnapshot: () => true, subscribe: () => () => {} }
|
||||
const allowed = (): HostObservable<boolean> => ALLOWED
|
||||
|
||||
/** The `remoteAccess` Client service answering true for every resource. */
|
||||
const allowAllRemoteAccess: ClientRemoteAccess = { source: allowed }
|
||||
const REFUSED: HostObservable<boolean> = { getSnapshot: () => false, subscribe: () => () => {} }
|
||||
|
||||
/**
|
||||
* Plugin standing in for the remote-access Client row: it provides an
|
||||
* allow-all `remoteAccess` service, so ui-session and ui-workspace publish
|
||||
* their resource Hooks over it, and the allow-all `useRemoteAccess` Hook on
|
||||
* the global resource.
|
||||
* A `remoteAccess` Client service whose answers never change: `allows`
|
||||
* decides each action on every resource once. `whenAccess` installs an
|
||||
* allowed action's effects in the caller's fiber at once and never installs a
|
||||
* refused action's effects.
|
||||
*/
|
||||
export const allowAllAccessPlugin = {
|
||||
name: 'client-test-runtime:allow-all-access',
|
||||
inject: ['slots'],
|
||||
/** @param ctx - plugin Context with the Slot registry injected. */
|
||||
apply(ctx: Context): void {
|
||||
ctx.provide('remoteAccess', allowAllRemoteAccess)
|
||||
ctx.slots.provideRoot({ keyedHooks: { remoteAccess: allowed } })
|
||||
},
|
||||
export class StaticRemoteAccess extends Service implements ClientRemoteAccess {
|
||||
/**
|
||||
* @param ctx - Context the service is provided on.
|
||||
* @param allows - the fixed answer for each action; allows everything when omitted.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly allows: (action: string) => boolean = () => true) {
|
||||
super(ctx, 'remoteAccess')
|
||||
}
|
||||
|
||||
source(_kind: TypertResourceKind, _id: string, action: string): HostObservable<boolean> {
|
||||
return this.allows(action) ? ALLOWED : REFUSED
|
||||
}
|
||||
|
||||
// Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context.
|
||||
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
if (!this.allows(action)) return () => {}
|
||||
const dispose = this.ctx.effect(callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`)
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
whenResourceAccess(_kind: TypertResourceKind, _id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void {
|
||||
return this.whenAccess(action, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin shape {@link staticAccessPlugin} returns. */
|
||||
export interface StaticAccessPlugin {
|
||||
readonly name: string
|
||||
readonly inject: readonly string[]
|
||||
apply(ctx: Context): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin standing in for the remote-access Client row with fixed answers: it
|
||||
* provides a {@link StaticRemoteAccess}, so ui-session and ui-workspace
|
||||
* publish their resource Hooks over it, and the matching `useRemoteAccess`
|
||||
* Hook on the global resource.
|
||||
* @param allows - the fixed answer for each action on every resource.
|
||||
* @returns the plugin.
|
||||
*/
|
||||
export function staticAccessPlugin(allows: (action: string) => boolean): StaticAccessPlugin {
|
||||
return {
|
||||
name: 'client-test-runtime:static-access',
|
||||
inject: ['slots'],
|
||||
apply(ctx: Context): void {
|
||||
new StaticRemoteAccess(ctx, allows)
|
||||
ctx.slots.provideRoot({ keyedHooks: { remoteAccess: (action: string) => (allows(action) ? ALLOWED : REFUSED) } })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link staticAccessPlugin} allowing every action on every resource. */
|
||||
export const allowAllAccessPlugin: StaticAccessPlugin = staticAccessPlugin(() => true)
|
||||
@@ -18,7 +18,7 @@ import { act } from '@testing-library/react'
|
||||
import { createInProcessModules, loadPluginModules } from './modules.ts'
|
||||
import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts'
|
||||
import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts'
|
||||
import { allowAllAccessPlugin } from '../access.ts'
|
||||
import { allowAllAccessPlugin, staticAccessPlugin } from '../access.ts'
|
||||
|
||||
/** The remote-access row: it mounts its own generated Remote contribution and publishes the access Hooks. */
|
||||
const REMOTE_ACCESS_PACKAGE = '@deepseek-ai/dsh-api-remote-access'
|
||||
@@ -41,6 +41,8 @@ export interface TestClientOptions {
|
||||
readonly awaitConnected?: boolean
|
||||
/** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */
|
||||
readonly connectTimeoutMs?: number
|
||||
/** The fixed access answer for each action on every resource. Default: every action is allowed. */
|
||||
readonly access?: (action: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,8 +247,10 @@ export class TestClient {
|
||||
release = sharedJsdomShims.acquire()
|
||||
const system = createInProcessModules(graphFromRoster(roster.rows), modules)
|
||||
ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as Plugin)
|
||||
// The dropped remote-access row's Hooks: every action is allowed on every resource.
|
||||
if (generated.includes(REMOTE_ACCESS_PACKAGE)) ctx.plugin(allowAllAccessPlugin)
|
||||
// The dropped remote-access row's Hooks: every action is allowed on every resource unless the spec fixes the answers.
|
||||
if (generated.includes(REMOTE_ACCESS_PACKAGE)) {
|
||||
ctx.plugin(options.access === undefined ? allowAllAccessPlugin : staticAccessPlugin(options.access))
|
||||
}
|
||||
await bootClient({ ctx, modules: system, manifest: system.manifest })
|
||||
if (mountPoint.element !== undefined) {
|
||||
if (ctx.get('uiRenderer') === undefined) {
|
||||
|
||||
@@ -58,7 +58,7 @@ export type {
|
||||
FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer,
|
||||
} from './fixtures.ts'
|
||||
export { makeTranslate } from './translate.ts'
|
||||
export { allowAllAccess, allowAllAccessPlugin } from './access.ts'
|
||||
export { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess } from './access.ts'
|
||||
export type { AccessStandardProps } from './access.ts'
|
||||
export { usePinnedBrowserLanguages } from './locale-env.ts'
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Fixed-answer access stand-ins: the `remoteAccess` service and the plugin that installs it with the global Hook. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import type { RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess, staticAccessPlugin } from '../src/access.ts'
|
||||
|
||||
/** An endpoint the access stand-ins answer; the stand-ins read only its string. */
|
||||
const GUARDED = 'session/list' as RemoteEndpoint
|
||||
|
||||
describe('StaticRemoteAccess', () => {
|
||||
it('answers each action once for every resource and installs only an allowed action\'s effects in the caller', async () => {
|
||||
const root = new Context()
|
||||
const allows = vi.fn((action: string) => action === GUARDED)
|
||||
new StaticRemoteAccess(root, allows)
|
||||
expect(root.remoteAccess.source('session', 's1', GUARDED).getSnapshot()).toBe(true)
|
||||
expect(root.remoteAccess.source('session', 's1', 'session/fork').getSnapshot()).toBe(false)
|
||||
// Fixed answers never notify.
|
||||
for (const action of [GUARDED, 'session/fork']) {
|
||||
const listener = vi.fn()
|
||||
root.remoteAccess.source('session', 's1', action).subscribe(listener)()
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
const allowedDispose = vi.fn()
|
||||
const refused = vi.fn(() => () => {})
|
||||
let stopAllowed = (): void => {}
|
||||
let stopRefused = (): void => {}
|
||||
const caller = root.plugin({
|
||||
inject: ['remoteAccess'],
|
||||
apply: (ctx: Context) => {
|
||||
stopAllowed = ctx.remoteAccess.whenResourceAccess('session', 's1', GUARDED, () => allowedDispose)
|
||||
stopRefused = ctx.remoteAccess.whenAccess('session/fork', refused)
|
||||
},
|
||||
})
|
||||
await caller.await()
|
||||
expect(refused).not.toHaveBeenCalled()
|
||||
expect(() => { stopRefused() }).not.toThrow()
|
||||
stopAllowed()
|
||||
await vi.waitFor(() => { expect(allowedDispose).toHaveBeenCalledOnce() })
|
||||
await caller.dispose()
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('allow-all access', () => {
|
||||
it('allows every action through the props Hooks and a predicate-less service', async () => {
|
||||
expect([
|
||||
allowAllAccess.useRemoteAccess(GUARDED),
|
||||
allowAllAccess.useAccess(GUARDED),
|
||||
allowAllAccess.useSessionAccess('s1' as Parameters<typeof allowAllAccess.useSessionAccess>[0], GUARDED),
|
||||
allowAllAccess.useWorkspaceAccess('w1' as Parameters<typeof allowAllAccess.useWorkspaceAccess>[0], GUARDED),
|
||||
]).toEqual([true, true, true, true])
|
||||
const root = new Context()
|
||||
new StaticRemoteAccess(root)
|
||||
expect(root.remoteAccess.source('workspace', 'w1', 'workspace/follow').getSnapshot()).toBe(true)
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('staticAccessPlugin', () => {
|
||||
it('provides the fixed-answer service and the matching global-resource Hook', async () => {
|
||||
const root = new Context()
|
||||
let contribution: RootStandardSourceContribution | undefined
|
||||
root.provide('slots', { provideRoot: (value: RootStandardSourceContribution) => { contribution = value; return () => {} } } as never)
|
||||
await root.plugin(staticAccessPlugin(action => action === GUARDED)).await()
|
||||
const hook = contribution?.keyedHooks?.['remoteAccess']
|
||||
expect(hook?.(GUARDED)?.getSnapshot()).toBe(true)
|
||||
expect(hook?.('session/fork')?.getSnapshot()).toBe(false)
|
||||
expect(root.get('remoteAccess')?.source('session', 's1', 'session/fork').getSnapshot()).toBe(false)
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
|
||||
it('allows every action in its allow-all form', async () => {
|
||||
const root = new Context()
|
||||
let contribution: RootStandardSourceContribution | undefined
|
||||
root.provide('slots', { provideRoot: (value: RootStandardSourceContribution) => { contribution = value; return () => {} } } as never)
|
||||
await root.plugin(allowAllAccessPlugin).await()
|
||||
expect(contribution?.keyedHooks?.['remoteAccess']?.('session/fork')?.getSnapshot()).toBe(true)
|
||||
await root.fiber.dispose()
|
||||
})
|
||||
})
|
||||
Generated
+18
@@ -4200,6 +4200,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-remote-access':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remote-access
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/client-runtime
|
||||
@@ -4819,6 +4822,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-remote-access':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remote-access
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remotes
|
||||
@@ -5092,6 +5098,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-remote-access':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remote-access
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remotes
|
||||
@@ -5265,6 +5274,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-remote-access':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remote-access
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remotes
|
||||
@@ -5374,6 +5386,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-remote-access':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remote-access
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remotes
|
||||
@@ -11081,6 +11096,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../../typert/protocol
|
||||
'@types/compression':
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
|
||||
Reference in new issue
Block a user