Merge remote-tracking branch 'origin/master' into worktree/web-search-dynamic-tool-e26c2b

# Conflicts:
#	packages/web/tool-web/tests/tool-web.spec.ts
#	snapshots/session/pi-ai-conversation-updates/session.v4.jsonl
This commit is contained in:
Yichen Jiang committed 2026-10-22 10:32:41 +08:00
commit d373778fd8
1998 files changed
+41173 -10245

No files matched your search

@@ -24,8 +24,8 @@
en: 26e4b71366bebd1b
zh: 10598e12b28c586e
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification:
en: fbd101d4576afa92
zh: 35f5d3c2f6cad6a8
en: 983da6d0c09f5c10
zh: ca7c86117a1a4d5f
/agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered:
en: c4d86feb135d2ac4
zh: 67c56c0bc2213949
@@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making
## Verification
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership.
No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect.
@@ -46,7 +46,7 @@ Status: implemented
## 验证
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
@@ -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
@@ -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 |
@@ -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,19 @@
# Bilingual-pair consistency record for 2026-10-19-file-drag-plugin-ownership.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-19-file-drag-plugin-ownership.md
/agent-note-file-drag-ownership-outside-the-application-layout:
en: 951575b08912a949
zh: f33e8e6bcb9240bd
/agent-note-file-drag-ownership-outside-the-application-layout/problem:
en: 557edc1db9bee8d8
zh: 20f47c78c8b0078b
/agent-note-file-drag-ownership-outside-the-application-layout/decision:
en: 1dfea60901d6e6ae
zh: e4fc534dbe01b0d5
/agent-note-file-drag-ownership-outside-the-application-layout/alternatives-considered:
en: 571eabab132ce4c1
zh: b5f8207015aeb3be
/agent-note-file-drag-ownership-outside-the-application-layout/consequences:
en: 56ebb7c25911fd61
zh: 88120d2af74785ef
@@ -0,0 +1,25 @@
# Agent Note: File drag ownership outside the application layout
Status: implemented
English | [中文](2026-10-19-file-drag-plugin-ownership.zh.md)
## Problem
Image dragging spans conversation images, sidebar previews, and composer drop targets. Original files must survive source unmount during a drag, and failures must remain visible after the originating panel closes. An application-root image provider puts feature state in layout and makes independent image entries depend on an undeclared React ancestor.
## Decision
The [file-drag plugin](../../../../packages/client/ui-file-drag/README.md) owns generic file preparation, browser transfer tokens, retained original files, desktop bridge selection, and transient failure feedback. Consumers receive preparation and error callbacks through their slot or Factory injection. The plugin registers its feedback through `shell.overlay`; layout declares the generic slot and has no image dependency. The shared image primitive owns gestures and cancellation. Images are the first consumer, while MIME naming and transport accept other files.
## Alternatives considered
**Root React provider.** It preserves transfer lifetime but adds image-specific assembly to the layout package and hides dependencies from slot registration. Moving the same wrapper into the renderer would move the feature coupling into another generic owner.
**State on each image.** It avoids shared assembly but cannot deliver the retained file after the source unmounts or keep a late failure visible after the panel closes.
**Direct browser File only.** Manual macOS Chrome 154.0.8037.93 drags of PNG and PDF fixtures produced a populated `dataTransfer.files` in trusted `dragstart`, but empty files in trusted same-page and cross-window drops. Finder received filename-only text clippings. These measured failures require the application-local token path; they do not establish behavior in untested browsers or cross-tab delivery. Desktop retains Electron native file dragging.
## Consequences
One plugin lifetime governs transfer state, a native source-node end listener, and drop completion. The source listener remains attached after React unmount. Independent render roots can share a transfer without sharing a React ancestor. Custom compositions must load the service and declare the feedback slot; custom image Factory registrations supply the required callbacks. Application-local browser tokens are accepted only over the composer; native Desktop files retain the full-page drop overlay. Plugin disposal removes feedback and clears retained files. Loader-backed browser scenarios cover source removal and delivery, while focused tests cover local resource authorization and cleanup failures.
@@ -0,0 +1,25 @@
# Agent Note: 应用布局之外的文件拖放归属
Status: implemented
[English](2026-10-19-file-drag-plugin-ownership.md) | 中文
## Problem
图片拖放跨越会话图片、侧栏预览和输入框接收区。拖动期间原始文件必须在源组件卸载后继续保留,发起操作的面板关闭后失败提示也必须可见。应用根部的图片 Provider 把功能状态放进布局,并使独立图片入口依赖未声明的 React 祖先组件。
## Decision
[文件拖放插件](../../../../packages/client/ui-file-drag/README.zh.md) 持有通用文件准备、浏览器传递令牌、原始文件、桌面桥接选择和临时失败提示。消费方通过 slot 或 Factory 注入接收准备及错误回调。插件通过 `shell.overlay` 注册提示,布局只声明通用插槽,不依赖图片功能。共享图片原语负责手势与取消。图片是首个调用方,MIME 命名与传输也接受其他文件。
## Alternatives considered
**根部 React Provider。** 它能保留传递生命周期,但会把图片专属组合放进布局包,并对 slot 注册隐藏依赖。把同一个包裹组件移到渲染器,只会把功能耦合转移到另一个通用模块。
**每张图片独立持有状态。** 这能避免共享组合,却不能在源组件卸载后交付保留文件,也不能在面板关闭后继续显示延迟到达的失败提示。
**仅直接传递浏览器 File。** 在 macOS Chrome 154.0.8037.93 中手动拖放 PNG 与 PDF 时,可信 `dragstart` 中 `dataTransfer.files` 有文件,同页与跨窗口的可信 drop 却收到空文件列表。Finder 收到仅含文件名的文本摘录。这些实测失败要求保留应用内令牌路径,但不能据此判定未测试浏览器或跨标签页交付的行为。Desktop 保留 Electron 原生文件拖放。
## Consequences
一个插件生命周期管理传递状态、源节点上的原生结束监听器与投放完成。React 卸载后,监听器仍绑定源节点。独立渲染根可以共享传递,而不共享 React 祖先组件。自定义组合必须加载服务并声明反馈插槽,自定义图片 Factory 注册提供所需回调。应用内浏览器令牌仅在输入框范围接受;Desktop 原生文件保留全页投放遮罩。插件卸载会移除提示并清除保留文件。通过 Loader 启动的浏览器场景覆盖源图移除与交付,定向测试覆盖本地资源授权和清理失败。
@@ -0,0 +1,28 @@
# Bilingual-pair consistency record for 2026-10-21-when-access.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-when-access.md
/agent-note-whenaccess-registration-that-follows-access:
en: 2ef7cfef514c138a
zh: 064e3aecbfafcd9c
/agent-note-whenaccess-registration-that-follows-access/problem:
en: 4f88d3ee7868eaa5
zh: 72a4ce2dde13bef7
/agent-note-whenaccess-registration-that-follows-access/decision:
en: a15c7c13f167e5ac
zh: 63bcc2ef60c2f344
/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess:
en: c168de20989cf9a4
zh: bf3b0361beea083d
/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration:
en: 73577ed282c656f3
zh: 2f60a6824218e693
/agent-note-whenaccess-registration-that-follows-access/alternatives-considered:
en: ddc432b693608163
zh: 620294d8812b0a31
/agent-note-whenaccess-registration-that-follows-access/consequences:
en: de44fa92c49bc335
zh: 34436fe971ab013f
/agent-note-whenaccess-registration-that-follows-access/related:
en: 89073ffbfcff0641
zh: 736a180d13a08981
@@ -0,0 +1,88 @@
# Agent Note: whenAccess registration that follows access
Status: implemented
English | [中文](2026-10-21-when-access.zh.md)
## Problem
Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering:
| Where | Existing capability | Gap |
|---|---|---|
| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically |
| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable<boolean>` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand |
Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change.
## Decision
### whenAccess
The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook:
| Interface | Provider | Matching Hook |
|---|---|---|
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
```ts ignore-check
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
/** The global-resource member; the Session and Workspace versions take the resource id first. */
export interface WhenAccess {
/**
* Keep `callback`'s effects installed while the current Peer may call `action`
* on the resource: run it when the answer becomes true, dispose its effects in
* reverse order when it becomes false, and run it again on the next true.
* @returns disposer for the observation and any installed effects.
*/
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
}
```
`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`.
| Rule | Content |
|---|---|
| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads |
| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration |
| Stream failure | A non-owner's answer turns false and the installed effects dispose |
| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask |
| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
### Plugin migration
| Plugin | Change | Action |
|---|---|---|
| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` |
| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` |
| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` |
| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`. On revocation the stream's disposer synchronously invalidates the account reads this grant started, closes the pending authorization tab, clears the account view, details, picture, and sign-in dialog state, ends this generation's bonus notices, which a frame of that stream begins, and then closes the stream; a frame delivered before the revocation but not yet handled is dropped. The notice controller stays outside and begins again under the next grant; the account section appears once the new stream's first frame arrives | `account/watch` |
| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` |
| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` |
| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — |
## Alternatives considered
**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change.
**Trim the plugin list per connection**: a plugin cannot reload at once when access changes.
**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest.
**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds.
## Consequences
| Consequence | Note |
|---|---|
| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration |
| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads |
| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI |
| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path |
## Related
- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face.
@@ -0,0 +1,88 @@
# Agent Note: 按访问状态注册的 whenAccess
Status: implemented
[English](2026-10-21-when-access.md) | 中文
## 问题
Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染:
| 位置 | 现有能力 | 缺口 |
|---|---|---|
| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 |
| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable<boolean>` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 |
没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。
## 决策
### whenAccess
JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供:
| 接口 | 提供者 | 对应 Hook |
|---|---|---|
| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` |
| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` |
| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` |
```ts ignore-check
import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client'
/** The global-resource member; the Session and Workspace versions take the resource id first. */
export interface WhenAccess {
/**
* Keep `callback`'s effects installed while the current Peer may call `action`
* on the resource: run it when the answer becomes true, dispose its effects in
* reverse order when it becomes false, and run it again on the next true.
* @returns disposer for the observation and any installed effects.
*/
whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void
}
```
`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。
| 规则 | 内容 |
|---|---|
| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 |
| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 |
| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 |
| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 |
| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) |
### 插件迁移
| 插件 | 改法 | action |
|---|---|---|
| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` |
| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` |
| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` |
| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`。撤权时账号流的 disposer 同步作废这一轮发出的账号读取,关闭待授权的浏览器标签页,清空账号视图、详情、头像与登录对话框状态,结束本轮奖励提示(提示由该流的帧开始),再关闭流;撤权前已交付但尚未处理的帧被丢弃。提示控制器留在外面,下次授权时重新开始;账号分区等新流的第一帧到达后才出现 | `account/watch` |
| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` |
| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` |
| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — |
## 考虑过的替代方案
**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。
**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。
**插件包清单声明访问性**:在包清单上定义访问控制概念。
**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。
## 后果
| 后果 | 说明 |
|---|---|
| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 |
| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 |
| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 |
| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 |
## 相关
- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-17-multiline-display-dollar-math.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/bug-fix/2026-09-17-multiline-display-dollar-math.md
/agent-note-multi-line-display-dollar-math:
en: 8f57c77aa43fbd6c
zh: ef5192184e4923d5
/agent-note-multi-line-display-dollar-math/problem:
en: e4b73644a982844c
zh: 48f86358242d6ccc
/agent-note-multi-line-display-dollar-math/decision:
en: d6b136b1f62285b7
zh: aba323bfdb800162
/agent-note-multi-line-display-dollar-math/alternatives-considered:
en: e0833b5674e648e7
zh: b0472020b07099b9
/agent-note-multi-line-display-dollar-math/consequences:
en: 70f16597bd640e93
zh: 0e0c9005f331e514
/agent-note-multi-line-display-dollar-math/testing:
en: f17a1453df91ce88
zh: 4f15be0139e8b694
@@ -0,0 +1,66 @@
# Agent Note: Multi-line display-dollar math
Status: implemented
English | [中文](2026-09-17-multiline-display-dollar-math.zh.md)
## Problem
Authors and models write `$$` display math in three shapes. The settled grammar rendered two of them: `$$math$$` on one line, and an opening `$$` alone on its line closed by `$$` alone on its line. The third shape — math on the opening line, a closing `$$` at the end of the last math line — did not:
```markdown
$$f(x)=x
\text{continued}$$
```
`micromark-extension-math` treats text after an opening `$$` as fence meta and accepts a closing fence only on a line whose content is exactly `$$` (after optional whitespace). The [markdown compatibility layer](../../archived/feature/2026-07-23-web-assistant-markdown.md) claimed only the same-line shape, and the settled renderer passes `node.value` to KaTeX while ignoring `node.meta`. The block therefore never closed: it consumed the document text that followed, `value` retained the literal `$$`, KaTeX raised `Can't use function '$' in math mode`, and [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)'s error arm painted the TeX source plus the swallowed prose in red.
In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a single error span, and six note files lost every line after such a block (up to 500 lines) to one. A second defect shared the mechanism: when a later lone `$$` did close the flow, the opening line had already been assigned to `meta`, so the renderer silently dropped the first line of the formula.
## Decision
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node.
A `$$` sequence that fails to close ends the candidate: immediately on a later line, and at that line's ending on the opening line (`$$x$$ trailing` fails there). Within the opening line the scan continues, so a later `$$` there still closes (`$$\verb*a$$a$$`). Scanning past either point would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` whose text before the fence is empty or whitespace (`95%$$`, `95 %$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. That state and its gate on the closing check belong to the dollar flow. The characters right after `\verb` or `\verb*` are that command's delimiter rather than a fence, so the delimiter and, when the region is empty, the `$` closing it are content; that tail is cleared at a line ending and after an escaped backslash (`\\verb` is a line break followed by letters), and a comment holds no commands. Otherwise verbatim regions are not parsed, which keeps `\verb`-bearing formulas on the same value path as every other input.
Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there.
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so the dollar flow above takes precedence over upstream's `mathFlow` while `math()` still owns inline `$…$`.
## Alternatives considered
**Keep the compatibility layer at same-line `$$…$$`.** The multi-line shape is what GitHub, KaTeX's auto-render, VS Code, and Typora accept, and the form models emit for multi-case formulas; leaving it unparsed means a red error span in place of the formula plus the rest of the document.
**Repair the parsed tree afterwards.** The flow consumes the swallowed text into `mathFlowValue`, where it is indistinguishable from math source; `meta` and `value` cannot be reassembled into the intended block, and the following blocks were never parsed at all.
**Rewrite `$$` fences in the source text before parsing.** The compatibility layer is a grammar extension by design; a text pass would need to locate code spans, fences, and escapes itself, and would change author text rather than parse it.
**Continue the dollar flow after a failed close.** That is what a plain `multiline` flag produces: `$$x$$ trailing\n\n$$y$$` then scans to the second block's `$$` and renders one math node holding both lines' text, where upstream inline math renders `x`, the prose, and `y` separately. Existing behavior for `$$x$$ trailing` already pinned the fallback, so the flow declines the candidate instead. Declining on any `$` rather than a full `$$` sequence also broke formulas that use a solitary `$` inside `\text{}`.
**End the attempt as soon as one line resists parsing.** Declining every failed same-line close swallows nothing but turns `$$a$b` lines into a quadratic scan and drops the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character.
**Claim the paragraph interruption in the check.** Returning success there, as upstream's flow does for the fences it accepts, prevents the lazy bookkeeping that broke the list case, but it also ends the paragraph for candidates that later fail (`**before\n$$a\$b\nafter**` split in two and exposed its `**`). Declining instead lets upstream's own check decide.
**Apply the interruption guard to the backslash construct too.** The guard exists to protect the construct tried after this one; `\[`/`\(` have no second construct, and an unconditional guard dropped their multi-line blocks (`Prose line` followed by `\[`).
## Consequences
The three shapes now produce the same math node, so formulas stop truncating at the opening line and stop swallowing the prose after them. `$$` fence meta is no longer honored: `$$asciimath$$` parses as a formula whose value is `asciimath`, which upstream's flow treats as fence meta and drops. That spelling has no user in this repository, and the renderer never read `meta` anyway.
Six inputs still reach upstream's flow or stay in the surrounding paragraph, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after declines the block: immediately on a later line, and at that line's ending on the opening line. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF.
The fourth is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block.
The fifth follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do. A fence behind a whitespace-only comment closes the block there, which leaves a following lone `$$` as its own empty block where the flow it replaced kept one node.
The sixth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new.
One further limitation is not a fallback: the flow reads the six characters before a fence and does not parse verbatim regions, so a fence inside an unterminated region (`\verb|$$`) closes the block there, and a region whose text ends in `\verb` (`\verb**\verb*$$`) has its fence read as that text's delimiter. The six characters before either fence are locally identical, so no rule that reads only that tail separates them.
Repeated unclosed candidates keep linear parser work: an open candidate declines at the next line that starts with a full `$$` it cannot close, rather than scanning each candidate to EOF, which would make 9.6 KB of `$$a$b` lines take ~4 s instead of ~16 ms. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design.
## Testing
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Further cases pin one condition each: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), a closing fence inside a TeX comment, a `%` the fence follows immediately or after whitespace, a second `%` inside an open comment, a `%` inside `\verb|…|`, the delimiter right after `\verb` and `\verb*` together with its empty region, an escaped backslash before `verb`, a verb tail that crosses no line ending, a comment ending in `verb`, a later-line trailing fence whose following prose upstream takes, a failed fence inside a comment, and bounded-work cases for repeated candidates with that backslash, without it, and ending in a verb tail.
The DOM compatibility corpus adds `math-multiline-display` with its `.settled.txt` and `.streaming.txt` fixtures, which also pin that a column-zero `$$` after a list item stays outside the list item. `apps/web/tests/math-rendering.e2e.ts` renders the multi-line shape in the browser lane.
@@ -0,0 +1,66 @@
# Agent Note: Multi-line display-dollar math
Status: implemented
[English](2026-09-17-multiline-display-dollar-math.md) | 中文
## Problem
作者和模型写 `$$` 行间公式时有三种写法。定稿语法只渲染其中两种:一行内开闭的 `$$math$$`,以及 `$$` 独占一行、由独占一行的 `$$` 闭合。第三种——开围栏同一行就带公式内容,闭合 `$$` 落在最后一行公式行行尾——不渲染:
```markdown
$$f(x)=x
\text{continued}$$
```
`micromark-extension-math` 把开围栏 `$$` 之后的同一行文本当作围栏 meta,并且只接受内容恰为 `$$`(后接可选空白)的行作为闭合围栏。[markdown 兼容层](../../archived/feature/2026-07-23-web-assistant-markdown.md)只认同一行开闭这一种形状,而定稿渲染器把 `node.value` 交给 KaTeX,不读 `node.meta`。于是该块始终不闭合:它吞掉后续正文,`value` 里残留字面 `$$`,KaTeX 抛出 `Can't use function '$' in math mode`,[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx) 的错误分支把 TeX 原文连同被吞掉的正文一起显示为红色。
在暴露该问题的会话中,一条聊天回复 55 行里有 34 行(第 22–55 行)变成同一个错误块,六个笔记文件从出错点起的所有行(最多约 500 行)同样变成一个错误块。同一机制还带出第二个缺陷:当更靠后的独占行 `$$` 恰好闭合了这个流时,开围栏那一行已经归给 `meta`,渲染器于是静默丢掉公式的第一行。
## Decision
[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。
闭合失败的 `$$` 序列会结束该候选:出现在后续行时立即结束;出现在开围栏行时在该行行尾结束(`$$x$$ trailing` 即在此放弃)。在开围栏行内扫描继续,因此同一行更靠后的 `$$` 仍能闭合(`$$\verb*a$$a$$`)。越过这两点继续扫描都会吞掉更靠后一个块的闭合围栏。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而围栏之前只有空白或什么都没有的 `%`(`95%$$`、`95 %$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。该状态及其对闭合判定的门控属于 dollar flow。`\verb` 或 `\verb*` 之后的那个字符是该命令的分隔符而不是围栏,因此该分隔符、以及区域为空时紧随其后的那个 `$` 都属于内容;这段尾部状态在换行处、以及在被转义的反斜杠之后清空(`\\verb` 是换行加字母,不是该命令),注释里也不识别命令。除此之外不解析原样文本区域,使含 `\verb` 的公式与其它输入走同一条 value 路径。
它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。
[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 `mathFlow`,而行内 `$…$` 仍由 `math()` 负责。
## Alternatives considered
**兼容层维持只处理同一行 `$$…$$`。** 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。
**解析后修补语法树。** 被吞掉的正文已经进入 `mathFlowValue`,与数学源码无法区分;`meta` 和 `value` 无法还原成原意的块,而且后面的块根本没有被解析。
**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。
**闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 `$`(而不是完整的 `$$` 序列)就放弃,还会破坏在 `\text{}` 内使用单个 `$` 的公式。
**只要有一行解析不下去就结束尝试。** 对每次同一行闭合失败都放弃虽然不再吞文,却会让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。
**在检查阶段认领段落打断。** 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(`**before\n$$a\$b\nafter**` 被拆成两段并暴露 `**`)。改为不认领,把决定交给上游自己的检查。
**把打断守卫也用在反斜杠构造上。** 这个守卫是为了保护随后被尝试的构造;`\[`/`\(` 没有第二个构造,无条件套用会丢掉它们的跨行块(`Prose line` 后接 `\[`)。
## Consequences
三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath$$` 会解析成 value 为 `asciimath` 的公式,而上游 flow 会把该段当作 fence meta 丢弃。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。
有六种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。「后面不是行尾」的 `$$` 序列会让块放弃:出现在后续行时立即放弃,出现在开围栏行时在该行行尾放弃。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。
第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。
第五种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 `$$` 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。
第六种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。
另有一项不属于回退的限制:flow 只读取围栏之前的六个字符、不解析原样文本区域,因此未闭合区域内部的围栏(`\verb|$$`)会在那里闭合块,而内容以文本 `\verb` 结尾的区域(`\verb**\verb*$$`)其围栏会被读成该文本的分隔符。两种情况下围栏前的六个字符在局部完全相同,仅凭这段尾部无法区分。
重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 `$$a$b` 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。
## Testing
`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有一组用例各自对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏、紧邻围栏或只隔空白的 `%`、已开启注释内的第二个 `%`、`\verb|…|` 内的 `%`、`\verb` 与 `\verb*` 之后的分隔符及其空区域、`verb` 之前的转义反斜杠、不跨行的 verb 尾部、以 `verb` 结尾的注释、后续行尾随文本围栏(其后正文由上游接管)、注释内失败的围栏,以及带该反斜杠、不带该反斜杠、以 verb 尾部结尾三种重复候选的有界解析开销。
DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。
@@ -15,8 +15,8 @@
en: da694641e1d009de
zh: 9d7492d39039af66
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-registry-owns-the-mode:
en: 091b7bc49b027353
zh: 42dc77a810202694
en: 4cd76a34f4712e1a
zh: 303ef72a18644d7c
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-run-code-tool-and-the-dispatch-bridge:
en: 6a0e2698b082978e
zh: fbb03503520b30e9
@@ -40,7 +40,7 @@ The [Node execution decision](../architecture/2026-09-11-sandboxed-node-ptc-runt
**SDK prompt section.** In `'ptc'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.ptcRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output.
**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition.
**Assembly ownership.** `tools:sdk` enters the trusted `system-prompt/assemble` presentation waterfall as a normal section, while `run_code` stays a provider-owned declaration that the waterfall cannot remove or replace. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace the section. Whoever changes it owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition.
**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution.
@@ -40,7 +40,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
**SDK 提示词段。** 在 `'ptc'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) 加入了 Python 与按 `ctx.ptcRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**组装所有权。** `tools:sdk` 作为正常段进入受信任的 `system-prompt/assemble` 展示 waterfall,而 `run_code` 仍是提供方持有的声明,waterfall 不能移除或替换它。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换该段。修改它的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
@@ -12,8 +12,8 @@
en: 532bb7268d9b93ce
zh: ad47162233222ab0
/agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering:
en: 4e85280cc5eb8697
zh: 8e6b09b1527784fa
en: 47010d626ab15117
zh: a5b77385d7a3e781
/agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract:
en: 16c4ee08b65ead23
zh: df80d6e49982615b
@@ -21,11 +21,11 @@
en: e112957ed8a48039
zh: 37d63f9ea4968fb2
/agent-note-parallel-tool-call-execution-by-per-call-safety/verification:
en: eb5a6aa84980f0ab
zh: eed2680a160e8ed2
en: 224b6838130c113f
zh: f3d7eb68ab5932fb
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
en: 17becc038bb7df7c
zh: 673d9a9bdfa39edf
/agent-note-parallel-tool-call-execution-by-per-call-safety/consequences:
en: a5508057bc03f4f6
zh: 96b46c59402474c3
en: 20a3e29931e01aa0
zh: acbfc61dd710d960
@@ -26,7 +26,7 @@ A tagged mode, rather than a public boolean scheduler API, keeps resource-aware
## Scheduling and ordering
The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier.
The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. Calls resolve through the step's admitted tool set, so replacing or removing a call's registration makes that unstarted call fail as `UNKNOWN_TOOL`; the scheduler classifies it as exclusive, and the current pool drains before it settles as the next barrier.
For example:
@@ -66,7 +66,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati
## Verification
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration.
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, rejection of unstarted calls after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration.
Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. PTC mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior.
@@ -102,6 +102,6 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese
Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step.
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. A call runs only the registration its step declared ([conditional tools](../../../../packages/core/tools/README.md)), so a hot reload, MCP reconnect, or skill reload that re-registers a tool mid-step fails that step's unstarted calls of it; the next step declares and runs the new registration. Already-started calls retain the scheduling decision under which they entered the pool.
A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable.
@@ -26,7 +26,7 @@ Status: implemented
## 调度与顺序
循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。
循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。调用通过本步骤的准入工具集合解析,因此替换或移除某个调用的注册后,该尚未开始的调用会以 `UNKNOWN_TOOL` 失败;调度器把它分类为独占,当前池会先完全排空,然后它才作为下一个屏障结束。
例如:
@@ -66,7 +66,7 @@ Status: implemented
## 验证
单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后的运行时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。
单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后拒绝尚未开始的调用、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。
快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。PTC mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。
@@ -102,6 +102,6 @@ Status: implemented
并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。调用只执行其步骤所声明的注册([条件工具](../../../../packages/core/tools/README.zh.md)),因此在步骤中途重新注册工具的热重载、MCP 重连或 skill 重载会让该步骤里这个工具尚未开始的调用失败;下一个步骤声明并执行新的注册。已启动的调用保留它们进入池时所依据的调度决策。
终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。
@@ -9,8 +9,8 @@
en: e7c3b5d7702714fe
zh: 268f162b553b37d1
/agent-note-generic-file-upload/decision:
en: 31c7ad96c6ab82c8
zh: 806922fc8fcd1242
en: 1769950d73d30dca
zh: bf296b920ea9b997
/agent-note-generic-file-upload/alternatives-considered:
en: 23f386b05e4e79ef
zh: 0756c4fa30e8d0eb
@@ -21,5 +21,5 @@
en: 3a50ac370d51cda9
zh: d490f58922f8795b
/agent-note-generic-file-upload/related:
en: 7579c49eefdb8488
zh: 68ad384fd9d2f8f9
en: 14e8659b89dc08a8
zh: dec5baa11f168e91
@@ -14,7 +14,7 @@ The command plane had already established a whole-envelope rule for images: a co
Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>`; each read-only model-facing path at `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream<Uint8Array>`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand.
Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session.
Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session.
## Alternatives considered
@@ -54,3 +54,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat
The [Trajectory reference](../../../../packages/client/ui-trajectory/README.md) documents ledger counts and inspector attachment layouts.
The [input echo admission reference](../../../../packages/api/session-controller/README.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments.
The [composer image upload on add](2026-10-21-composer-image-upload-on-add.md) note extends this background-upload queue to the images of an ordinary Session's draft.
@@ -14,7 +14,7 @@ Status: implemented
文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream<Uint8Array>`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。
默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。
默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每项上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。
## 考虑过的替代方案
@@ -54,3 +54,5 @@ Status: implemented
[Trajectory 参考](../../../../packages/client/ui-trajectory/README.zh.md)说明列表计数与详情面板附件布局。
[输入回显入档参考](../../../../packages/api/session-controller/README.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。
[输入框在加入时上传图片](2026-10-21-composer-image-upload-on-add.zh.md) 记录把这条后台上传队列扩展到普通 Session 草稿里的图片。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-08-26-web-rich-text-quotes.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/feature/2026-08-26-web-rich-text-quotes.md
/agent-note-durable-rich-text-quotes-in-the-web-composer:
en: 49522a0a81db6981
zh: 92e81872377e96e9
/agent-note-durable-rich-text-quotes-in-the-web-composer/problem:
en: 7bd295c086ad9905
zh: 704dc665acfaa5ec
/agent-note-durable-rich-text-quotes-in-the-web-composer/decision:
en: d27134e46ab55ea0
zh: 3f1da063b661a1b3
/agent-note-durable-rich-text-quotes-in-the-web-composer/alternatives-considered:
en: 1999da9f900c9be3
zh: cf6aad990a180851
/agent-note-durable-rich-text-quotes-in-the-web-composer/verification:
en: b320c262d9c08ee4
zh: 6047f864213a2b4c
/agent-note-durable-rich-text-quotes-in-the-web-composer/consequences:
en: bb6f4338aad0ec41
zh: 93a4396b619492b1
@@ -0,0 +1,37 @@
# Agent Note: Durable rich-text quotes in the Web composer
Status: implemented
English | [中文](2026-08-26-web-rich-text-quotes.zh.md)
## Problem
Selected transcript excerpts need atomic editing and reload-safe drafts without depending on the availability of their source messages.
## Decision
`ui-conversation` owns an inline `QuoteNode` beside reference nodes in the existing editor runtime. Its clipboard projection is canonical Markdown, while its detect projection is one atomic character. The existing semantic `DraftSnapshot` persists quote text and clipboard-coordinate spans beside references; raw Lexical JSON and runtime node identities remain outside browser storage. Session switching, Workspace transfer, and failed-send restoration use this same snapshot. Quote controls receive runtime-only locale labels.
`ui-chat` accepts one nonblank browser selection within displayed user content, including pending steering and local submission echoes or finalized Assistant text and reasoning. Streaming and interrupted Assistant output, tools, injected context, and Markdown controls are excluded. Selection extraction follows visible line and block breaks and inserts through the current Session input actions.
Text interaction belongs to the shared primitive and the Chat-owned list Slot. Quote registers only gesture admission and insertion. Local absolute placement keeps actions in the content coordinate system; outside presses and Escape close the same area-owned interaction for every consumer. Dismissal and selection clearing are separate operations so a failed insertion preserves the excerpt. Context-menu consumers cancel their originating browser event synchronously; rendering a Slot cannot retroactively cancel it.
The [text-derived blockquote decision](../simplification/2026-10-13-text-derived-message-quotes.md) owns sent-message recognition and presentation. The [UserText evaluator](../architecture/2026-10-10-user-text-json-nodes.md) renders derived quote nodes through the existing matcher registry.
Quotes display a compact single-line preview; clipped excerpts expose the complete text in a shared inline HoverCard. Their text has no source-navigation action. The editable draft alone exposes whole-node removal. Only selected text survives message persistence: no source Session, message, or selection coordinates are retained.
## Alternatives considered
**Persist Lexical JSON or introduce another draft format.** Editor serialization would become a product storage obligation, while a second draft format would duplicate restoration and transfer ownership. The existing semantic snapshot already carries reference spans and extends to quotes.
**Retain source navigation.** Availability checks would require source coordinates, subscriptions, history pagination, focus routing, and failure states. A future navigation feature must define those durable coordinates and target-owned actions before adding them.
**Store UserText nodes or callbacks.** Text nodes are derived presentation, and callbacks are runtime state. Both can change independently of the logged input and semantic draft.
## Verification
Composer tests cover atomic removal and undo, semantic storage round trips, Markdown serialization, and failed-send restoration. UserText tests cover text recognition, literal excerpt rendering, literal references, and credential spelling. The keyless Web scenario exercises selection, clipped previews, draft switching and reload, persistence, and ordinary Markdown submission through the shipped composition.
## Consequences
The draft and Session retain enough data for atomic editing and replay without retaining source-history dependencies. UserText remains a display projection; its JSON nodes never replace authoritative content. Browser-local storage retains unsent selected text under the existing draft policy.
@@ -0,0 +1,37 @@
# Agent Note:Web composer 中的持久富文本 quote
Status: implemented
[English](2026-08-26-web-rich-text-quotes.md) | 中文
## 问题
所选 transcript 摘录需要原子编辑与可跨刷新恢复的草稿,不依赖来源消息的可用性。
## 决策
`ui-conversation` 在现有编辑器 runtime 中于 reference 节点旁拥有行内 `QuoteNode`。其剪贴板投影是规范 Markdown,检测投影则是一个原子字符。现有语义 `DraftSnapshot` 在 reference 旁持久化 quote 文本与剪贴板坐标范围;原始 Lexical JSON 与 runtime 节点身份不进入浏览器存储。Session 切换、Workspace 转移和发送失败恢复使用同一个 snapshot。Quote 控件接收仅用于 runtime 的 locale 标签。
`ui-chat` 接受已展示的用户内容(包括待接纳 steering 和本地发送回显)、或最终完成的 Assistant 文本与 reasoning 中的一个非空浏览器选区。流式及中断的 Assistant 输出、tool、注入上下文和 Markdown 控件被排除。选区提取遵循可见的行与 block 换行,通过当前 Session 的 input actions 插入。
文本交互属于共享基础能力与 Chat 拥有的 list Slot,quote 只注册手势接管与插入操作。本地绝对定位让操作处于内容坐标系;外部按下与 Escape 对所有使用方关闭同一个区域拥有的交互。关闭展示与清除选区是独立操作,因此插入失败时保留摘录。右键菜单使用方同步取消来源浏览器事件;渲染 Slot 无法事后取消事件。
[文本派生 blockquote 决策](../simplification/2026-10-13-text-derived-message-quotes.zh.md)拥有发送后的消息识别与展示。[UserText 计算器](../architecture/2026-10-10-user-text-json-nodes.zh.md)通过现有 matcher registry 渲染派生的 quote 节点。
Quote 显示紧凑的单行预览;截断的摘录通过共享行内 HoverCard 展示完整文本。文本不提供来源导航操作,只有可编辑草稿提供整节点删除。消息持久化只保留所选文本:不保留来源 Session、消息或选区坐标。
## 考虑过的替代方案
**持久化 Lexical JSON 或引入另一套草稿格式。** 编辑器序列化会成为产品存储义务,第二套草稿格式则会重复拥有恢复与转移逻辑。现有语义 snapshot 已携带 reference 范围,也可扩展到 quote。
**保留来源导航。** 可用性检查需要来源坐标、订阅、历史分页、焦点路由和失败状态。未来若添加导航功能,必须先定义其持久坐标与目标拥有的操作。
**存储 UserText 节点或 callback。** 文本节点是派生的展示,callback 是 runtime 状态;二者都可以独立于记录的输入和语义草稿变化。
## 验证
Composer 测试覆盖原子删除与撤销、语义存储往返、Markdown 序列化和发送失败恢复。UserText 测试覆盖文本识别、字面摘录展示、字面 reference 与凭据原文。无密钥 Web 场景通过已发布组合验证选区、截断预览、草稿切换与刷新、持久化和普通 Markdown 提交。
## 后果
草稿与 Session 保留足以支持原子编辑和 replay 的数据,不保留对来源历史的依赖。UserText 仍是展示投影;其 JSON 节点不会替代权威 content。浏览器本地存储按现有草稿策略保留未发送的所选文本。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-21-composer-image-upload-on-add.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/feature/2026-10-21-composer-image-upload-on-add.md
/agent-note-composer-image-upload-on-add:
en: 996b39b53086b9ac
zh: c81fade50ac895ec
/agent-note-composer-image-upload-on-add/problem:
en: b184ade2bd353f50
zh: 00b97c91df54b310
/agent-note-composer-image-upload-on-add/decision:
en: eada67e3c3d50f47
zh: 5fb4f3a5e774e47e
/agent-note-composer-image-upload-on-add/alternatives-considered:
en: afa82b9bce9015f6
zh: d088b92c2cb6c622
/agent-note-composer-image-upload-on-add/consequences:
en: 08d1bcb970beb40e
zh: f42db4052618c289
/agent-note-composer-image-upload-on-add/testing:
en: f2d2c59272ffcf70
zh: 46c933fd2aeb87ea
/agent-note-composer-image-upload-on-add/related:
en: 6eac98f60c3b99f9
zh: c1e318e74f44fbb4
@@ -0,0 +1,36 @@
# Agent Note: Composer image upload on add
Status: implemented
English | [中文](2026-10-21-composer-image-upload-on-add.zh.md)
## Problem
The Web composer kept image drafts in the browser and base64-encoded each image into `session/prompt` at send time, so the Host received, validated, normalized, and stored image bytes inside prompt admission. Generic files already uploaded when picked ([generic file upload](2026-08-26-generic-file-upload.md)). An image message therefore carried up to the per-message image byte limit in one RPC body, and its send time grew with image size; a composer that waits for the Host to accept a message before clearing it would hold the draft for that whole transfer.
## Decision
An ordinary Session's composer uploads each image draft as soon as it is added, through the same FIFO background-upload queue, Worker carriers, progress, retry, and cancellation that generic files use. The raw upload route takes a `mediaType` query parameter for an image upload; the Host reads at most the smaller of the deployment's per-image and per-message image byte limits, then validates, normalizes, and stores the image with `ctx.attachments.saveImages` exactly as prompt admission would, and stages the normalized image under a receipt for that Session. The prompt cites the receipt in an ordinary file part, so `session/prompt` keeps its wire types; prompt admission resolves the receipt to the stored image, applies the model's image-input check to the prompt, and counts stored images toward `maxImagesPerMessage` and `maxMessageImageBytes` together with encoded ones, each stored image by the bytes of its upload. Send waits until every image draft has a receipt, and a failed image upload turns its thumbnail into a retry control. Commands accept only file receipts, so command submissions keep encoding images inline. A continuable subagent's composer keeps inline images because the Host stages uploads only for ordinary Sessions; SDK and other wire callers may still send encoded images.
## Alternatives considered
- **A dedicated image-receipt prompt part.** Rejected: the receipt already names what was staged, so a second part kind would widen the `session/prompt` wire union, its generated catalogs, and every consumer that switches on parts without changing what the Host admits.
- **A separate image upload route or Remote method.** Rejected: one route with an explicit `mediaType` parameter reuses Session addressing, carrier selection, progress, and the failure envelope; the exact-bytes Remote fallback has no image caller.
- **Upload images but normalize them at send.** Rejected: normalization is the expensive part of image admission, so deferring it would keep the send-time cost this change removes.
## Consequences
- Sending an image message transfers only references, and the normalization cost moves to the moment the image is added.
- Per-image refusals such as an undecodable or oversized image surface when the image is added: the thumbnail names the refusal instead of offering a retry, and Enter repeats it. The model's image-input check and the per-message image count and byte limits still apply at send.
- Commands accept only file receipts, so a command submission that carries an image waits for the image upload like a message and then encodes the image inline: the image is stored a second time, and its unused receipt lasts until its Session ends.
- Removing an image while it uploads cancels the upload, and a cancelled upload receives no receipt; an image whose storage had already begun, or whose upload had finished, stays under `DSH_HOME`, which never deletes attachments. On a remote Host the bytes reach the server as soon as the user adds the image.
- Staged image receipts share the generic files' in-memory lifetime: a Host restart between adding and sending surfaces `FILE_NOT_STAGED`, and the user adds the image again.
- Image drafts, like file drafts, are not restored after a page reload or after their Session's client scope is released.
## Testing
Host coverage pins image staging through the raw route, the per-image byte bound that stops reading, unsupported types, subagent refusal, command refusal of image receipts, receipt resolution into the admitted image, the image-input check for an uploaded image, the per-message byte limit across receipts, and withheld receipts for uploads cancelled after their last chunk or during storage. Attachment admission coverage pins stored-image pass-through and the combined image count and byte limits. Client coverage pins image upload start, retry, cancellation, the send gate, receipt citation without re-encoding, inline images for drafts without an upload, the rail's progress, retry, and refusal states, and the Enter explanation of a held send.
## Related
The [generic file upload](2026-08-26-generic-file-upload.md) note owns the background-upload queue, carriers, staging lifetime, and file storage that image uploads reuse.
@@ -0,0 +1,36 @@
# Agent Note: 输入框在加入时上传图片
Status: implemented
[English](2026-10-21-composer-image-upload-on-add.md) | 中文
## 问题
Web 输入框把图片草稿留在浏览器里,发送时才把每张图片 base64 编码进 `session/prompt`,Host 在 prompt 准入时才接收、校验、规整并存储图片字节。通用文件早已在选中时上传([通用文件上传](2026-08-26-generic-file-upload.zh.md))。因此一条图片消息要在一个 RPC 请求体里携带最多达到单条消息图片字节上限的数据,发送耗时随图片大小增长;如果输入框要等 Host 接纳消息后才清空,草稿就要在整个传输期间一直保留。
## 决定
普通 Session 的输入框在图片草稿加入时就上传它,复用通用文件的先进先出后台上传队列、Worker 载体、进度、重试与取消。原始上传路由用 `mediaType` 查询参数表示图片上传;Host 最多读取部署配置的单张图片与单条消息图片字节上限中较小的一个,然后按 prompt 准入的同样方式用 `ctx.attachments.saveImages` 校验、规整并存储图片,再以该 Session 的凭证暂存规整后的图片。prompt 在普通文件部分引用该凭证,因此 `session/prompt` 的线上类型不变;prompt 准入把凭证解析为已存储的图片,对这条 prompt 执行模型的图片输入检查,并把已存储图片与编码图片一起计入 `maxImagesPerMessage` 和 `maxMessageImageBytes`,已存储图片按其上传的字节数计入。发送会等到每个图片草稿都取得凭证;图片上传失败时,缩略图变成重试控件。命令只接受文件凭证,所以命令提交仍以内联方式编码图片。continuable 子代理的输入框继续内联图片,因为 Host 只为普通 Session 暂存上传;SDK 和其他线上调用方仍可发送编码图片。
## 考虑过的替代方案
- **专用的图片凭证 prompt 部分。** 已否决:凭证本身已经指明暂存的内容,新增一种部分类型会扩大 `session/prompt` 线上联合类型、它的生成目录以及每个按部分类型分支的消费方,却不改变 Host 准入的内容。
- **单独的图片上传路由或 Remote 方法。** 已否决:一条带显式 `mediaType` 参数的路由复用了 Session 寻址、载体选择、进度和失败信封;精确字节的 Remote 兜底没有图片调用方。
- **加入时上传、发送时再规整。** 已否决:规整是图片准入中开销最大的部分,推迟它会保留这次改动要消除的发送耗时。
## 后果
- 发送图片消息只传引用,规整开销移到图片加入的时刻。
- 无法解码、超过单张上限等逐张拒绝在图片加入时出现:缩略图说明拒绝原因而不提供重试,按 Enter 也会提示同样的原因。模型的图片输入检查以及单条消息的图片数量与总字节上限仍在发送时执行。
- 命令只接受文件凭证,所以带图片的命令提交也要像消息一样等图片上传完成,再以内联方式编码图片:同一张图会再存储一次,未被引用的凭证一直留到 Session 结束。
- 图片上传中移除会取消上传,取消的上传不会取得凭证;已开始存储或已上传完成的图片仍留在 `DSH_HOME` 下,附件存储从不删除。Host 在远端时,用户一加入图片,字节就到达服务器。
- 暂存的图片凭证与通用文件同样只存在内存里:在加入与发送之间重启 Host 会得到 `FILE_NOT_STAGED`,用户需要重新加入图片。
- 与文件草稿一样,图片草稿在页面刷新后或其 Session 的客户端作用域释放后不会恢复。
## 测试
Host 侧覆盖经原始路由暂存图片、单张字节上限一超出即停止读取、不支持的类型、子代理拒绝、命令拒绝图片凭证、凭证解析为准入后的图片、已上传图片的图片输入检查、多个凭证合计的单条消息字节上限,以及在最后一个分块之后或存储期间取消的上传不签发凭证。附件准入覆盖已存储图片原样通过,以及合并后的图片数量与总字节上限。客户端覆盖图片上传的开始、重试、取消、发送闸门、发送时引用凭证而不重新编码、没有上传的草稿以内联方式发送图片,附件栏的进度、重试与拒绝状态,以及发送被挡住时按 Enter 的说明。
## 相关记录
[通用文件上传](2026-08-26-generic-file-upload.zh.md) 记录负责图片上传所复用的后台上传队列、载体、暂存生命周期与文件存储。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-13-text-derived-message-quotes.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/simplification/2026-10-13-text-derived-message-quotes.md
/agent-note-recognize-message-quotes-from-markdown-text:
en: a2abd7f693937d51
zh: f9e4bb817536dbbe
/agent-note-recognize-message-quotes-from-markdown-text/problem:
en: d71ebbacbd9bd8a3
zh: 45c1f7fd1d797f61
/agent-note-recognize-message-quotes-from-markdown-text/decision:
en: 0abb09d5ba396f69
zh: dd067b0ca5fd376f
/agent-note-recognize-message-quotes-from-markdown-text/alternatives-considered:
en: 05c6009796927583
zh: 9f08229767c07b31
/agent-note-recognize-message-quotes-from-markdown-text/verification:
en: 790598fd0f3f0c67
zh: 0399e0e322ffca40
/agent-note-recognize-message-quotes-from-markdown-text/consequences:
en: 21f96c04f6edcc1d
zh: 8ea3f42a8e981592
@@ -0,0 +1,29 @@
# Agent Note: Recognize message quotes from Markdown text
Status: implemented
English | [中文](2026-10-13-text-derived-message-quotes.zh.md)
## Problem
Quote-only content indexes duplicate information in Markdown text and require coordinated prompt, source, echo, and queue metadata. The same visible blockquote can acquire different presentation and direct-input behavior depending on how it was created.
## Decision
Messages contain ordinary Markdown text. The shared blockquote matcher recognizes consecutive explicitly marked lines for UserText presentation. Hand-written and composer-created blockquotes have identical sent-message behavior. Quote previews display decoded text without running UserText rules over their contents. Session-reference and skill processing applies normally to quoted text; recognized Session labels retain the same presentation as the surrounding quote text.
The [rich-text quote feature](../feature/2026-08-26-web-rich-text-quotes.md) retains atomic draft nodes and semantic restoration spans. This decision supersedes only its durable quote-index distinction. UserText nodes and recognition ranges remain derived values.
## Alternatives considered
**Keep quote-only indexes.** Indexes preserve a distinction between typed and inserted quotes, but that distinction is unnecessary for sent messages and requires metadata coordination across every producer and renderer.
**Remove atomic draft spans.** Plain text cannot restore whole-node editing, undo, Session switching, or failed-send restoration. Draft spans remain browser-owned editing state.
## Verification
Matcher tests cover original offsets, explicit line markers, blank excerpts, and literal nested quotes. Composer tests assert ordinary text submission. The shipped Web scenario checks text-only persistence and quote display after reload.
## Consequences
Messages need no quote fields or content indexes. Typed blockquotes also display as quotes without changing direct-input processing. Recognition deliberately covers explicit quote lines rather than Markdown lazy continuation or fenced-code parsing. Source navigation still requires a separate durable-coordinate design.
@@ -0,0 +1,29 @@
# Agent Note: 从 Markdown 文本识别消息 quote
Status: implemented
[English](2026-10-13-text-derived-message-quotes.md) | 中文
## 问题
纯 quote content 索引重复了 Markdown 文本中的信息,并要求 prompt、source、回显与队列协同携带元数据。同样可见的 blockquote 会因创建方式不同而获得不同的展示与直接输入行为。
## 决策
消息包含普通 Markdown 文本。共享 blockquote 匹配器识别连续的显式标记行,用于 UserText 展示。手写与输入框创建的 blockquote 在发送后具有相同行为。Quote 预览直接展示还原后的文本,不对正文再次运行 UserText 规则。Session-reference 与 skill 处理照常作用于引用文本;已识别的 Session 标签与周围引用文字保持相同样式。
[富文本 quote 功能](../feature/2026-08-26-web-rich-text-quotes.zh.md)保留原子草稿节点与语义恢复范围。本决策仅取代其持久 quote 索引区分。UserText 节点与识别范围仍是派生值。
## 考虑过的替代方案
**保留纯 quote 索引。** 索引保留手写与插入 quote 的区别,但发送后的消息无需这一区分,且要求每个生产方与渲染器协同处理元数据。
**移除原子草稿范围。** 纯文本无法恢复整节点编辑、撤销、Session 切换或发送失败恢复。草稿范围仍是浏览器拥有的编辑状态。
## 验证
匹配器测试覆盖原始偏移、显式行标记、空白摘录与字面的嵌套 quote。Composer 测试断言普通文本提交。已发布 Web 场景检查仅文本的持久化与刷新后的 quote 展示。
## 后果
消息无需 quote 字段或 content 索引。手写 blockquote 也显示为 quote,不改变直接输入处理。识别有意限定为显式引用行,不涵盖 Markdown 懒惰续行或代码围栏解析。来源导航仍需独立设计持久坐标。
+1
View File
@@ -114,6 +114,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
| [`mime`](https://github.com/broofa/mime) | MIT |
| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
| [`minisearch`](https://github.com/lucaong/minisearch) | MIT |
| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
+6
View File
@@ -188,6 +188,10 @@ flowchart LR
cfg --> plugin_dsh_base_mcp_resources
plugin_dsh_base_tools["tools<br/>@deepseek-ai/dsh-tools"]
cfg --> plugin_dsh_base_tools
plugin_dsh_base_tool_executor_native["tool-executor-native<br/>@deepseek-ai/dsh-tool-executor-native"]
cfg --> plugin_dsh_base_tool_executor_native
plugin_dsh_base_tool_executor_ptc["tool-executor-ptc<br/>@deepseek-ai/dsh-tool-executor-ptc"]
cfg --> plugin_dsh_base_tool_executor_ptc
plugin_dsh_base_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
cfg --> plugin_dsh_base_system_prompt
plugin_dsh_base_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
@@ -292,6 +296,8 @@ flowchart LR
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
| `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` |
| `tools` | `@deepseek-ai/dsh-tools` |
| `tool-executor-native` | `@deepseek-ai/dsh-tool-executor-native` |
| `tool-executor-ptc` | `@deepseek-ai/dsh-tool-executor-ptc` |
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
+5
View File
@@ -0,0 +1,5 @@
- id: tool-executor-native
disabled: true
- id: tool-executor-ptc
disabled: false
+1
View File
@@ -155,6 +155,7 @@
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tool-testkit": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@types/js-yaml": "^4.0.9",
+2 -2
View File
@@ -21,8 +21,8 @@
en: 8d2cfff9bab7327f
zh: 21515ab6e6798504
/dsh-cli-behavior-reference/web-profile:
en: 850434e8ca162221
zh: 4893613f97a21eb8
en: 149c5571acd51baf
zh: 300e3c2425273a92
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: f40edcf903a51f3f
zh: 8a9a915451047c8b
+1 -1
View File
@@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root
New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place.
Web, headless, and SDK inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place.
## Shared deployment behavior
+1 -1
View File
@@ -132,7 +132,7 @@ dsh web --help
基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
## 共享部署行为
+1
View File
@@ -1268,6 +1268,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
['session-title', '@deepseek-ai/dsh-session-title'],
['system-prompt', '@deepseek-ai/dsh-system-prompt'],
['tools', '@deepseek-ai/dsh-tools'],
['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'],
['mcp-resources', '@deepseek-ai/dsh-mcp-resources'],
['agent', '@deepseek-ai/dsh-agent'],
['llm-retry', '@deepseek-ai/dsh-llm-retry'],
+2 -1
View File
@@ -1,3 +1,4 @@
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import { fileURLToPath } from 'node:url'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
@@ -52,7 +53,7 @@ try {
&& message.source.kind === 'skill-catalog')?.content
: undefined
const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge')
const result = await ctx.tools.execute({
const result = await executeToolFixture(ctx, {
callId: ToolCallId('dsh-badge-snapshot'),
name: 'skill',
arguments: { name: 'dsh-badge' },
@@ -1,4 +1,5 @@
/** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -232,7 +233,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
it('loads the actual badge asset only while its bundle is selected', async () => {
const owner = await agent()
const load = (callId: string) => ctx.tools.execute({
const load = (callId: string) => executeToolFixture(ctx, {
callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' },
agent: owner.agent, signal: new AbortController().signal,
})
@@ -17,8 +17,8 @@
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}}
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"exit_plan_mode"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}}
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
@@ -555,7 +555,7 @@ describe('headless stream-json snapshots', () => {
'--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.',
],
tsconfigPath,
env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TELEMETRY_DISABLED: '1' },
inspect: async (cwd) => {
const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions'))
expect(logs).toHaveLength(1)
@@ -605,7 +605,7 @@ describe('headless stream-json snapshots', () => {
const first = await runLoaderSmoke({
label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath,
binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath,
env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TELEMETRY_DISABLED: '1' },
})
const sessionId = parseJsonl(first.stdout)[0]?.sessionId
if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity')
@@ -615,7 +615,7 @@ describe('headless stream-json snapshots', () => {
const second = await runLoaderSmoke({
label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath,
binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath,
env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' },
env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TELEMETRY_DISABLED: '1' },
inspect: async (directory) => {
const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions'))
expect(logs).toHaveLength(1)
@@ -40,7 +40,7 @@ interface ResolutionEvidence {
sourceToolsCjs: string | null
sourceLeafCjs: string
sourceLeafExplicitCjs: string
scheduler: boolean
executor: boolean
modules: string[]
}
@@ -139,7 +139,7 @@ export function testProfileResolution(mode: ExampleMode): void {
const staleTools = join(root, 'stale-tools')
const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools')
await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, {
'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')",
'index.mjs': "export default class Tools {}\nthrow new Error('STALE_DSH_TOOLS')",
'index.cjs': "throw new Error('STALE_DSH_TOOLS')",
})
const sourceDir = join(sourcePackageDir, 'src')
@@ -183,7 +183,7 @@ export function testProfileResolution(mode: ExampleMode): void {
'probe.mjs': [
"import { createRequire } from 'node:module'",
"import { getEnvironmentData } from 'node:worker_threads'",
"import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'",
"import Tools from '@deepseek-ai/dsh-tools'",
`import { leaf } from '${leafName}'`,
`import { leaf as bridgeLeaf } from '${bridgeName}'`,
`import { external } from '${externalName}'`,
@@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void {
' sourceToolsInstance: ctx.tools instanceof SourceTools,',
` profileToolsCjs: ${toolsCjsExpression},`,
' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,',
" scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',",
" executor: typeof ctx.tools.execute === 'function',",
' modules: [...ctx.loader.internal.loadCache.keys()]',
' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),',
' }',
@@ -292,7 +292,7 @@ export function testProfileResolution(mode: ExampleMode): void {
expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename)
expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs')))
expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs)
expect(evidence.scheduler).toBe(true)
expect(evidence.executor).toBe(true)
expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : [])
for (const name of ['tools', 'agent-loop']) {
const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js'
@@ -1,3 +1,4 @@
import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -96,7 +97,7 @@ function runCode(
signal: AbortSignal = testToolSignal,
agent?: Agent,
): Promise<ToolExecutionResult> {
return harness.tools.execute({
return executeModelToolFixture(harness, {
callId: ToolCallId(`keyless-code-${++keylessCall}`),
name: RUN_CODE_NAME,
arguments: { code, description: 'Run the e2e program' },
@@ -308,7 +309,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) };
`, testToolSignal, agent))
expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] })
expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query'] })
})
})
@@ -58,7 +58,6 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE
DSH_HOME: join(root, 'home'),
DSH_AGENTS_HOME: join(root, 'agents'),
DSH_TELEMETRY_DISABLED: '1',
DSH_TOOLS_MODE: 'native',
DSH_CLI_MOCK_FAILURE: '0',
},
input: '',
@@ -85,7 +85,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea
retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig,
models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }],
} },
{ id: 'tools', config: { mode: 'native' } },
{ id: 'session-log-deepseek', disabled: true },
{ id: 'plugin-package-inventory-deepseek', disabled: true },
{ insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] },
@@ -58,7 +58,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
{ label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false },
{ label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false },
{ label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true },
{ label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true },
{ label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true },
])('$label', async (scenario) => {
const { envValue, editorEnabled } = scenario
const ptc = 'ptc' in scenario && scenario.ptc
@@ -92,6 +92,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
'--profile',
'sdk',
...(editorEnabled ? ['--patch', editorPatch] : []),
...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []),
], {
cwd: repoRoot,
env: {
@@ -99,7 +100,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
DSH_HOME: join(root, '.dsh'),
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_DISABLED: '1',
DSH_TOOLS_MODE: ptc ? 'ptc' : 'native',
DSH_TOOLS_MODE: ptc ? 'native' : 'ptc',
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
@@ -58,7 +58,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
], {
cwd: root, env: {
PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native',
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '',
},
timeout: 110_000, killSignal: 'SIGKILL', reject: false,
@@ -49,7 +49,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
clearAmbientProxyEnv(env)
const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch],
@@ -3,6 +3,7 @@ import { createServer } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client'
import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts'
import { expect, it, onTestFinished } from 'vitest'
@@ -47,13 +48,14 @@ it.each([
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
clearAmbientProxyEnv(env)
const seen: SdkUserQuestionRequest[] = []
const entered = Promise.withResolvers<AbortSignal>()
const harness = state.harness = new DeepSeekHarness({
cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000,
patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [],
onUserQuestion: async (request, { signal }) => {
expect(signal.aborted).toBe(false)
seen.push(request)
@@ -1,4 +1,6 @@
/** Test-only IPC assertions over real Creator presets and profile management. */
import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit'
export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets']
export function apply(ctx, config) {
@@ -30,7 +32,7 @@ export function apply(ctx, config) {
try {
const first = await make(`${phase}-first`)
const before = names(first)
const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first,
const manage = args => executeModelToolFixture(ctx, { name: 'plugin_manager', arguments: args, agent: first,
callId: `${phase}-manage`, signal: new AbortController().signal })
ctx.permissionPresets.set(first.session, 'workspace-write')
first.session.append('turn/start', { turn: 1 })
@@ -45,7 +47,7 @@ export function apply(ctx, config) {
const second = await make(`${phase}-second`)
const after = names(first)
const other = names(second)
const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first,
const ping = await executeModelToolFixture(ctx, { name: 'mcp__demo__ping', arguments: {}, agent: first,
callId: `${phase}-ping`, signal: new AbortController().signal })
const removed = phase === 'restart'
? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined
+11 -10
View File
@@ -1,3 +1,4 @@
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts'
import { createRequire } from 'node:module'
@@ -629,7 +630,7 @@ describe('the shipped Web composition', () => {
expect(scoped).toContain('project-proof')
// The preset's own loader tool resolves the global-layer skill.
const loaded = await ctx.tools.execute({
const loaded = await executeToolFixture(ctx, {
callId: ToolCallId('preset-skills-load'),
name: 'skill',
arguments: { name: 'dsh-badge' },
@@ -787,7 +788,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
]))
}
const signal = new AbortController().signal
const listed = await copyCtx.tools.execute({
const listed = await executeToolFixture(copyCtx, {
callId: ToolCallId('copied-preset-inspect-list'),
name: 'cordis_inspect_list',
arguments: {},
@@ -802,7 +803,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
// The `Tool` provider is the one built over the host context: it
// answers with the shared registry's view of the REQUESTING agent,
// so the copy sees its own preset's tools, not the host's empty set.
const queried = await copyCtx.tools.execute({
const queried = await executeToolFixture(copyCtx, {
callId: ToolCallId('copied-preset-inspect-tools'),
name: 'cordis_inspect_query',
arguments: { platform: 'host', provider: 'Tool', method: 'listTools' },
@@ -819,12 +820,12 @@ describe('a user preset declared from the shipped cordis rows', () => {
expect(queriedText).toContain(`"name": "${toolName}"`)
}
// The `Config` provider reads the booted profile tree: the shipped `tools` row declares a Config,
// The `Config` provider reads the booted profile tree: the Agent loop declares a Config,
// and the bootstrap include row is a carrier. The name filter keeps each page small.
type ConfigRow = { id: string; patchId: string; name: string; status: string }
type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } }
const listConfigs = async (input: Record<string, string | number>, callId: string): Promise<ConfigPage['data']> => {
const page = await copyCtx.tools.execute({
const page = await executeToolFixture(copyCtx, {
callId: ToolCallId(callId),
name: 'cordis_inspect_query',
arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input },
@@ -834,16 +835,16 @@ describe('a user preset declared from the shipped cordis rows', () => {
expect(page.isError, resultText(page)).toBe(false)
return (JSON.parse(resultText(page)) as ConfigPage).data
}
const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tools' }, 'copied-preset-inspect-configs')
const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-loop' }, 'copied-preset-inspect-configs')
const toolsRow = toolsRows.entries[0]
expect(toolsRow).toMatchObject({ patchId: 'tools', status: 'schema' })
expect(toolsRow).toMatchObject({ patchId: 'agent-loop', status: 'schema' })
expect(toolsRows).toMatchObject({ total: 1, nextOffset: null })
const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes')
expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' })
const firstPage = await listConfigs({ limit: 5 }, 'copied-preset-inspect-page')
expect(firstPage.entries).toHaveLength(5)
expect(firstPage.nextOffset).toBe(5)
const projected = await copyCtx.tools.execute({
const projected = await executeToolFixture(copyCtx, {
callId: ToolCallId('copied-preset-inspect-config'),
name: 'cordis_inspect_query',
arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input: { entry: toolsRow!.id } },
@@ -855,7 +856,7 @@ describe('a user preset declared from the shipped cordis rows', () => {
const { data } = JSON.parse(resultText(projected)) as { data: Projected }
expect(data.status).toBe('schema')
expect(data.schema.$defs).toHaveProperty('loaderExpression')
expect(JSON.stringify(data.schema)).toContain('"mode"')
expect(JSON.stringify(data.schema)).toContain('"maxParallelToolCalls"')
} finally {
await copied.dispose()
await shipped.dispose()
@@ -954,7 +955,7 @@ describe('a delegated child', () => {
] as const
for (const [name, args] of calls) {
expect(toolNames(ctx, child.agent)).toContain(name)
const result = await ctx.tools.execute({
const result = await executeToolFixture(ctx, {
callId: ToolCallId(`child-${name}`), signal: new AbortController().signal,
name, arguments: args, agent: child.agent,
})
+9 -1
View File
@@ -30,6 +30,14 @@
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/cordis-plugin-include": "workspace:~",
"@deepseek-ai/dsh-skill": "workspace:*"
"@deepseek-ai/dsh-skill": "workspace:*",
"@deepseek-ai/dsh-fs": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-tool-executor-native": "workspace:*"
}
}
+64
View File
@@ -0,0 +1,64 @@
/** Resolve desktop-only file references through the active Host filesystem and Session. */
import { createHash } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
/** Private Host-to-main answer; never exposed to renderer JavaScript. */
export interface LocalDragResource {
readonly path: string
readonly sha256: string
readonly size: number
}
/**
* Authorize an unmodified local file through the composed filesystem's actual read operations.
* @param ctx - booted Desktop Host.
* @param address - Session-scoped resource reference.
* @param cancellation - caller cancellation, combined with the read deadline.
* @returns verified Host-local path and content identity, or null for nonlocal resources.
*/
export async function localDragResource(ctx: Context, address: string, cancellation?: AbortSignal): Promise<LocalDragResource | null> {
const resource = parseFileAddress(address)
if (resource?.scope !== 'session') throw new Error('File drag requires a Session resource')
const sessions = ctx.get('sessions')
const persistence = ctx.get('sessionPersistence')
const fs = ctx.get('fs')
const policy = ctx.get('sandboxPolicy')
if (fs === undefined || sessions === undefined || policy === undefined) throw new Error('File access unavailable')
const sessionId = resource.sessionId as SessionId
const header = sessions.get(sessionId)?.header ?? (await persistence?.stat(sessionId))?.header
if (header === undefined) throw new Error('File drag Session unavailable')
const signal = cancellation === undefined ? AbortSignal.timeout(10_000) : AbortSignal.any([cancellation, AbortSignal.timeout(10_000)])
signal.throwIfAborted()
const cwd = header.cwd ?? policy.workspaceRoot
const entry = await fs.lstat(resource.path, { cwd }, signal)
if (entry?.type !== 'file') return null
const target = await fs.resolve(resource.path, { cwd, signal })
const path = fs.processPath(target)
const mapped = fs.processPathFromHostPath(path)
if (mapped === undefined) return null
const roundTrip = await fs.resolve(mapped, { signal })
if (roundTrip.targetKey !== target.targetKey) return null
const info = await fs.stat(target, signal)
if (info?.type !== 'file' || info.size === undefined) return null
const hash = createHash('sha256')
let size = 0
while (size < info.size) {
signal.throwIfAborted()
const data = await fs.readByteRange(target, { offset: size, length: Math.min(64 * 1024, info.size - size) }, signal)
if (data.length === 0) return null
hash.update(data)
size += data.length
}
// Even empty files require a read authorization check.
if (size === 0) await fs.readByteRange(target, { offset: 0, length: 1 }, signal)
const after = await fs.stat(target, signal)
signal.throwIfAborted()
if (after?.version !== info.version || after.size !== size) return null
return { path, size, sha256: hash.digest('hex') }
}
+31 -1
View File
@@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-deepseek-account'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import * as desktopOffice from './office.ts'
import { localDragResource } from './file-drag.ts'
import { installDesktopUpdateTaskControl } from './update-tasks.ts'
import { installDesktopQuitInspection } from './quit-inspection.ts'
@@ -41,6 +42,8 @@ async function main(): Promise<void> {
}),
})
let stopping: Promise<void> | undefined
const dragReads = new Map<number, AbortController>()
const dragTasks = new Set<Promise<void>>()
const control: {
updateTasks?: ReturnType<typeof installDesktopUpdateTaskControl>
quitInspection?: ReturnType<typeof installDesktopQuitInspection>
@@ -50,6 +53,8 @@ async function main(): Promise<void> {
process.send(message, (error) => { if (error === null) resolve(); else reject(error) })
})
const stop = (): Promise<void> => stopping ??= (async () => {
for (const abort of dragReads.values()) abort.abort()
await Promise.allSettled(dragTasks)
// Startup failure is reported by main; shutdown only owns a tree that booted.
const running = await application.catch(() => undefined)
await running?.shutdown.shutdown(0)
@@ -62,6 +67,31 @@ async function main(): Promise<void> {
process.on('message', (message: unknown) => {
if (typeof message !== 'object' || message === null || !('type' in message)) return
if (message.type === 'shutdown') { void stop(); return }
if (message.type === 'file-drag-cancel') {
if ('requestId' in message && typeof message.requestId === 'number') dragReads.get(message.requestId)?.abort()
return
}
if (message.type === 'file-drag-resource') {
if (!('requestId' in message) || typeof message.requestId !== 'number' || !Number.isSafeInteger(message.requestId)
|| !('address' in message) || typeof message.address !== 'string') return
const { requestId, address } = message
const abort = new AbortController()
dragReads.set(requestId, abort)
const task = (async () => {
try {
if (stopping !== undefined) throw new Error('Host stopping')
const { ctx } = await application
const resource = await localDragResource(ctx, address, abort.signal)
await send({ type: 'file-drag-resource', requestId, resource })
} catch (error) {
if (!abort.signal.aborted) console.error(error)
await send({ type: 'file-drag-resource', requestId, resource: null, error: 'File drag resource unavailable' })
} finally { dragReads.delete(requestId) }
})().catch((error: unknown) => { console.error(error) })
dragTasks.add(task)
void task.finally(() => { dragTasks.delete(task) })
return
}
if (message.type === 'quit-inspection') {
if (!('requestId' in message) || !Number.isSafeInteger(message.requestId)) return
const requestId = message.requestId
@@ -104,7 +134,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. */
+82
View File
@@ -0,0 +1,82 @@
import { createHash } from 'node:crypto'
import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import SessionStore from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { expect, it, onTestFinished, vi } from 'vitest'
import { localDragResource } from '../src/file-drag.ts'
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-host-drag-'))
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
await ctx.plugin(LocalFileSystem, { cwd: root })
await ctx.plugin(SessionStore)
ctx.provide('sandboxPolicy', { workspaceRoot: root } as never)
const id = SessionId('file-drag-test')
ctx.sessions.create(id, { meta: { cwd: root, origin: 'subagent' } })
const path = join(root, '报告.pdf')
const bytes = Buffer.from('%PDF-1.4\n原始内容\n%%EOF')
await writeFile(path, bytes)
return { ctx, root, id, path, bytes, address: sessionFileAddress(id, '报告.pdf') }
}
it('authorizes Session-relative file reads and returns their original digest privately to main', async () => {
const f = await fixture()
expect(await localDragResource(f.ctx, f.address)).toEqual({
path: await realpath(f.path), size: f.bytes.length, sha256: createHash('sha256').update(f.bytes).digest('hex'),
})
})
it('rejects unscoped references and missing Sessions', async () => {
const f = await fixture()
await expect(localDragResource(f.ctx, f.path)).rejects.toThrow('Session resource')
await expect(localDragResource(f.ctx, sessionFileAddress('missing', f.path))).rejects.toThrow('Session unavailable')
await expect(localDragResource(f.ctx, sessionFileAddress(f.id, f.root))).resolves.toBeNull()
})
it('does not authorize borrowing when the filesystem has no Host mapping', async () => {
const f = await fixture()
vi.spyOn(f.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
expect(await localDragResource(f.ctx, f.address)).toBeNull()
})
it('honors filesystem read denial without returning a path', async () => {
const f = await fixture()
vi.spyOn(f.ctx.fs, 'readByteRange').mockRejectedValue(new Error('read denied'))
await expect(localDragResource(f.ctx, f.address)).rejects.toThrow('read denied')
})
it.skipIf(process.platform === 'win32')('declines symlink borrowing so the authorized byte reader can copy it', async () => {
const f = await fixture()
await symlink(f.path, join(f.root, 'linked.pdf'))
expect(await localDragResource(f.ctx, sessionFileAddress(f.id, 'linked.pdf'))).toBeNull()
})
it('stops a chunked authorization read when the caller cancels', async () => {
const f = await fixture()
const pending = Promise.withResolvers<Uint8Array>()
const reading = vi.spyOn(f.ctx.fs, 'readByteRange').mockReturnValue(pending.promise)
const abort = new AbortController()
const preparing = localDragResource(f.ctx, f.address, abort.signal)
const rejected = expect(preparing).rejects.toThrow()
await vi.waitFor(() => { expect(reading).toHaveBeenCalledOnce() })
const signal = reading.mock.calls[0]![2]
abort.abort()
expect(signal?.aborted).toBe(true)
pending.resolve(f.bytes)
await rejected
})
it('authorizes empty files and detects content changes while reading', async () => {
const f = await fixture()
await writeFile(f.path, '')
expect(await localDragResource(f.ctx, f.address)).toMatchObject({ size: 0, sha256: createHash('sha256').digest('hex') })
await writeFile(f.path, f.bytes)
vi.spyOn(f.ctx.fs, 'readByteRange').mockResolvedValueOnce(new Uint8Array())
await expect(localDragResource(f.ctx, f.address)).resolves.toBeNull()
})
+2 -1
View File
@@ -1,3 +1,4 @@
import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native'
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
@@ -29,7 +30,7 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => {
expect('default' in desktopOffice).toBe(false)
expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice)
const modules = new Map<string, unknown>([
['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime],
['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor],
['skills', SkillRegistry], ['office', desktopOffice],
])
ctx.loader.internal = {
+18
View File
@@ -30,6 +30,24 @@
},
{
"path": "../../packages/credentials/deepseek-account"
},
{
"path": "../../packages/fs/fs"
},
{
"path": "../../packages/core/session"
},
{
"path": "../../packages/session/session-persistence"
},
{
"path": "../../packages/sandbox/sandbox-policy"
},
{
"path": "../../packages/util/workspace-path"
},
{
"path": "../../packages/util/brand"
}
]
}
+12 -12
View File
@@ -21,8 +21,8 @@
en: 5f2d25a1d8472647
zh: 2acb5c5d7d1917e2
/deepseek-harness-desktop/closing-the-window-and-quitting:
en: 24387a07ae4eb8e7
zh: 0119352685cf273c
en: bb35a022c37955c9
zh: ab2ef22d398d83a0
/deepseek-harness-desktop/key-technical-decisions:
en: 3724c53da4f7647b
zh: 76ae549d43c99bb4
@@ -33,8 +33,8 @@
en: 372297005b57a08b
zh: 074a635ac17e4362
/deepseek-harness-desktop/installation-ownership:
en: 22153a298a6662e1
zh: 3600803c8c9d0274
en: 7cbb1f9e5006bd34
zh: 01e9e15a75a90450
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: a1a47b09f4a72ba4
zh: 7b1cf27f0b72cf31
@@ -45,8 +45,8 @@
en: faf7676ba18bbf2f
zh: f1d7b75ad348c548
/deepseek-harness-desktop/develop/startup-onboarding:
en: a51346b8d880ddba
zh: 7913214c1c66063c
en: d87d51b901b72ecd
zh: 525da1c4d56af659
/deepseek-harness-desktop/develop/welcome-window-appearance:
en: 617a89aeb28131b9
zh: 4d84bd5d86501c86
@@ -57,14 +57,14 @@
en: 9b90a2651a6d2b52
zh: 86d4d9c4358aad80
/deepseek-harness-desktop/updates:
en: 87db37529b51da62
zh: 4fc0d752e437e7d6
en: 67bf0a18faa7aa76
zh: 782e14ce62bb939f
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2e57457c44c85b5f
zh: 5b8510a3dc4c6c9d
/deepseek-harness-desktop/updates/mandatory-update-policy:
en: 54596bef0706d305
zh: 48737302dcf3c939
en: 182a9c6705213e13
zh: 681762f83490f4c6
/deepseek-harness-desktop/updates/local-updater-qualification:
en: 68ec2f8bd5763ea6
zh: 23b932dfe8503602
@@ -72,8 +72,8 @@
en: 8264b135871e8978
zh: 0939aafb1b2961fc
/deepseek-harness-desktop/known-limitations:
en: c63d4dc7a9104954
zh: a4c676e5b46a6ac0
en: b1eeadfefb338056
zh: 28683fbdf1e98685
/deepseek-harness-desktop/dev-note:
en: 8e26b460ba31f21b
zh: fbcd0307b3867125
+13 -7
View File
@@ -70,7 +70,7 @@ Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and th
Windows keeps a tray icon for the whole run. Its tooltip is the product name, a single click shows and focuses the window, and the context menu offers Open DeepSeek Harness and Quit DeepSeek Harness in the shell locale. Before the first hide, the shared update-dialog overlay explains that running tasks continue and the window can be reopened from the system tray. Confirm hides the window and writes `background-close-confirmed` under Electron userData; Escape, dismissal, or dialog failure keeps the window visible and does not record acknowledgement. Repeated close requests focus the existing shell dialog. In-place updates retain the marker and uninstall removes it. The old `background-notice-shown` marker does not suppress this confirmation. Closing the window sends no system notification. The tray bitmaps are `resources/tray-windows.ico`, rendered from `resources/icon-windows.svg` at 16, 20, 24, 32, 40, 48, and 64 pixels by `pnpm run render:tray-icon`, and packaged as `resources/tray.ico`. macOS ships no menu bar icon.
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quit caused by closing the welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation.
@@ -142,6 +142,8 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages
The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
The file-drag bridge accepts arbitrary file bytes or Session-scoped resource references from the owned product main frame. The Host authorizes resource reads through its composed filesystem, confirms a local mapping, and returns a private content digest; main checks content and the shared export filename before borrowing the source. Native startup checks the prepared file’s device, inode, size, modification time, and change time without rereading its bytes. Borrowed files are never deleted. Renamed, transformed, remote, or Blob content uses a private temporary copy. MIME data completes names without restricting file types; compatible suffixes such as `.jpeg` survive. Temporary preparations, partial writes, and retained exports share 64 MiB; unused files release on pointer leave, and OS-delivered copies expire after 60 seconds. Exhaustion reports the generic retry message until capacity is reclaimed. Caller cancellation and shutdown abort Host reads and main-process verification, interrupt icon waits, and release unused files. Shutdown waits for writes/releases and retries failed removals without waiting for the icon service. Update preparation attempts cleanup before stopping the Host; cleanup failures are logged and do not block installation or shutdown. Failed installation restores the bridge. Asynchronous system file icons avoid decoding original images in main.
Only the main application window enables `<webview>`. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering.
The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks.
@@ -150,7 +152,7 @@ The product UI retains Web actions, including "Open In..." through the shared au
Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version.
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Electron chooses typed English, Chinese, or Japanese shell copy from its application locale and falls back to English. The macOS application bundle declares English, Simplified Chinese, and Japanese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. Windows NSIS installer resources live in one JSON file per language under `installer/locales/`; packaging generates NSIS strings and discovers the installer language list from these files. English, Simplified Chinese, and Japanese resources must have matching keys and format placeholders; `pnpm run check:i18n` validates them. The native installer test builds and exercises each configured language. Platform account pages and onboarding artwork use English for languages other than Chinese. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance.
@@ -218,7 +220,7 @@ Repeated launches and `dsh://open` keep the workspace hidden until the startup c
Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
The welcome window reads the shared `locale.preference` before it appears. An explicit English, Chinese, or Japanese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
The browser-login waiting page offers a copy-link action for the current pending authorization, a loading indicator, and cancellation. Clipboard failures leave the copy action available for retry. Copy feedback resets after two seconds; the copied state disables the link until it resets. Welcome text uses Montserrat Light with system fallbacks; large action buttons keep the system font, while text buttons use Montserrat Light. English introductory copy is 24px throughout. Chinese introductory copy is 24px with a 26px product name. Login action buttons are 240px wide with 14px labels. Authorization status headings use 20px Montserrat Regular text. The API-key page uses a 20px heading and a 14px back action, with 84px between the secondary button’s bottom edge and the window bottom.
@@ -271,9 +273,11 @@ On Windows, the downloaded-update confirmation explains that the application clo
On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window.
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. The Windows caption lies outside the view; its native and product Web controls remain operable. Caption clicks can toggle the sidebar, create or reuse a blank Session, and switch the underlying page. Application menu actions such as About or CLI command management can replace and cancel the current ordinary prompt; this does not approve installation or clear mandatory policy. Product keyboard shortcuts and editing-key forwarding remain blocked while the view is attached.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page.
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
@@ -330,10 +334,12 @@ When permitted, login is offered during user-initiated checks and the packaged a
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay uses an isolated shell-origin WebContentsView inside its native owner. It fades in and out in place, retains its backdrop across update-state changes, and blocks product keyboard input. Loading or updating the view never shows or activates a separate native window; returning to an already focused owner focuses the view. Closing its native owner follows the [normal window-close behavior](#closing-the-window-and-quitting). View loading failures retain policy and input blocking without changing the owner title. A native recovery dialog appears only while the owner is visible and focused, showing the policy explanation and available target version. Open download page uses the policy URL after the existing origin validation and is omitted when no approved URL is available; Quit uses normal application exit confirmation. No Cancel button is offered; non-action dismissals retain blocking and reopen recovery. Hiding the owner, policy clearance, successful loading, and disposal dismiss the recovery dialog; obsolete responses have no effect. This recovery covers the isolated view, not the Windows embedded frame. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact.
Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked.
Selecting Check for Updates while mandatory policy is already blocking focuses its existing interface. If a manual check finishes with mandatory policy active, it also restores, shows, and focuses the owner even after the user has switched applications, minimized, or hidden it. This explicit foreground request is retained independently of the view carrier; the ordinary-result path does not make that request. Focusing the interface does not authorize downloading, installation, or task stopping. The asynchronous mandatory-result foreground path has not received a dedicated background/fullscreen manual qualification.
Background mandatory-installation confirmation requests Windows taskbar attention or an informational macOS Dock bounce plus one silent notification per readiness episode. It does not restore or focus the app. Notification clicks only return to current confirmation. Foreground return, installation, policy clearance, and shutdown clear owned reminders. System permissions and focus modes can suppress notifications; installed Windows and macOS notification qualification remains required.
### Local updater qualification
@@ -375,7 +381,7 @@ When an expired account returns to Welcome, the main process retains a one-time
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later. Update policy and ordinary update prompts refuse Platform admission, including a pending open whose storage cleanup completes after blocking starts. Reloading or replacing the owning application document, renderer termination, and window closure also destroy the native view without relying on React cleanup.
Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin, token, and resolved language.
+13 -7
View File
@@ -70,7 +70,7 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会
Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单击显示并聚焦窗口,右键菜单提供壳语言下的"打开 DeepSeek Harness"和"退出 DeepSeek Harness"。首次隐藏前复用更新弹窗,显示“正在运行的任务不会中断,可在系统托盘中重新打开窗口”和“确认”按钮。确认后隐藏窗口,并在 Electron userData 下写入 `background-close-confirmed`;Esc、关闭弹窗或加载失败均保持主窗口可见,不记录确认。重复关闭请求会聚焦已有壳弹窗。覆盖更新保留标记,卸载删除标记。旧的 `background-notice-shown` 标记不会跳过此确认。关闭窗口不发送系统通知。托盘位图是 `resources/tray-windows.ico`,由 `pnpm run render:tray-icon` 从 `resources/icon-windows.svg` 按 16、20、24、32、40、48、64 像素分别渲染,打包为 `resources/tray.ico`。macOS 不提供菜单栏图标。
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。
@@ -142,6 +142,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
文件拖放桥接接受所属主应用主框架传入的任意文件字节或 Session 资源地址。Host 通过组合的文件系统授权读取、确认本地映射并返回私有内容摘要;主进程检查内容与统一解析的导出名后借用源文件。原生起拖核对准备时的设备、inode、大小、修改时间和变更时间,不再读取整份字节。借用文件不会被删除。改名、转换、远程或 Blob 内容使用私有临时副本。MIME 数据补全名称,不限制文件类型;`.jpeg` 等兼容后缀保持不变。临时准备、部分写入及保留的导出共享 64 MiB;指针移开释放未使用文件,已交给系统的副本在 60 秒后过期。空间耗尽时显示通用重试提示,容量回收后可重试。调用方取消及退出会中止 Host 读取和主进程验证、打断图标等待并释放未使用文件。退出等待写入和释放,重试失败的删除,但不等待图标服务。更新准备在停止 Host 前尝试清理;清理失败只记录日志,不阻断安装或退出。安装失败后恢复桥接。异步系统文件图标避免主进程解码原图。
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
`dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。
@@ -150,7 +152,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Electron 根据应用语言选择类型化的英文、中文或日文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语、简体中文和日语,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。 Windows NSIS 安装器在 `installer/locales/` 下按语言保存独立 JSON 资源;打包从这些文件生成 NSIS 文案和安装器语言清单。英文、简体中文和日语资源的键与格式占位符必须一致,`pnpm run check:i18n` 负责检查。原生安装器测试逐个构建并验证配置中的语言。账户平台页面和引导插图对中文以外的语言使用英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器同步全屏状态,让浮层调整顶栏避让距离。
@@ -218,7 +220,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C
Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文、中文或日文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
等待浏览器登录时,欢迎页提供当前待授权请求的链接复制入口、加载指示和取消操作;剪贴板写入失败后可以重试复制,复制结果提示在两秒后恢复;已复制状态下链接禁用,恢复后可再次点击。Welcome 文字使用 Montserrat Light 并回退到系统字体,底部大按钮保留系统字体,文字按钮使用 Montserrat Light。英文欢迎正文及产品名均为 24px,中文欢迎正文为 24px、产品名为 26px。登录操作按钮宽 240px,文字为 14px。授权状态标题使用 20px Montserrat Regular 字重。API Key 页的标题为 20px,返回操作为 14px,次级按钮底边距窗口底部 84px。
@@ -271,9 +273,11 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 顶栏位于视图覆盖范围外,其中的原生及产品 Web 控件仍可操作。点击顶栏可切换侧栏、创建或复用空 Session,并改变底层页面。“关于”或 CLI 命令管理等应用菜单操作可替换并取消当前普通提示,但不代表批准安装,也不会清除强更策略。视图挂载期间仍阻断产品键盘快捷键和编辑按键转发。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
@@ -330,10 +334,12 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层使用原生窗口内、隔离的壳静态源 WebContentsView,原位渐入渐出,在更新状态切换时保留蒙层并拦截产品键盘输入。加载或更新视图不会显示或激活独立原生窗口;回到已获焦点的父窗口时聚焦视图。关闭原生父窗口遵循[常规关窗行为](#closing-the-window-and-quitting)。视图加载失败保留策略与输入阻断,不改写父窗口标题。仅当所属窗口可见且有焦点时显示原生恢复提示,包含策略说明与可用目标版本。打开下载页面使用现有来源校验后的策略 URL,缺少获准地址时不显示该按钮;退出走正常应用退出确认。不提供取消按钮,非操作性关闭保留阻断并重新显示恢复提示。隐藏窗口、策略解除、加载成功或释放控制器会撤销提示,过期响应无效。此恢复覆盖独立视图,不包含 Windows 内嵌 frame。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
已处于强更阻断状态时,选择“检查更新”会聚焦现有界面。手动检查完成时若强更策略已生效,也会恢复、显示并聚焦所属窗口,即使用户已切换应用、最小化或隐藏窗口。此显式置前行为独立于视图承载方式保留;普通检查结果路径不发起该请求。聚焦界面不代表批准下载、安装或停止任务。异步强更结果置前路径尚未单独完成后台/全屏人工验证。
后台强更安装确认请求 Windows 任务栏提醒或 macOS 信息级 Dock 弹跳,并在每轮就绪时尝试一次无声通知,不还原窗口或抢焦点。点击通知只返回当前确认界面。回到前台、安装、策略解除和退出时清理提醒。系统权限和专注模式可能抑制通知;仍需完成 Windows 与 macOS 安装包通知验收。
### 本地 updater 验证
@@ -375,7 +381,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。强更策略或普通更新提示阻止打开 Platform 视图;清理存储期间开始阻断时,待打开视图也不会挂载。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin、token 和已解析的语言。
+5 -2
View File
@@ -22,7 +22,10 @@ export interface DesktopElectronBuilderConfig {
...{ readonly from: string, readonly to: 'tray.ico' }[],
]
readonly mac: {
readonly extendInfo: { readonly NSMicrophoneUsageDescription: string }
readonly extendInfo: {
readonly CFBundleLocalizations: readonly string[]
readonly NSMicrophoneUsageDescription: string
}
readonly entitlements: string
readonly entitlementsInherit: string
readonly identity: string | undefined
@@ -50,7 +53,7 @@ export interface DesktopElectronBuilderConfig {
readonly perMachine: false
readonly allowElevation: false
readonly allowToChangeInstallationDirectory: false
readonly installerLanguages: readonly ['en_US', 'zh_CN']
readonly installerLanguages: readonly string[]
}
readonly beforeBuild: () => Promise<boolean>
readonly beforePack: (context: BeforePackContext) => Promise<void>
+40
View File
@@ -0,0 +1,40 @@
{
"nsisLanguage": "ENGLISH",
"strings": {
"INSTALLER_INSTALL": "Install now",
"INSTALLER_FINISH": "Finish",
"INSTALLER_NATIVE_PROGRESS": "Preparing installation… %d%%",
"INSTALLER_PROGRESS_EXTRACT": "Extracting files… %d%%",
"INSTALLER_PROGRESS_COPY": "Installing files… %d%%",
"INSTALLER_PROGRESS_REGISTER": "Finishing installation… %d%%",
"INSTALLER_PROGRESS_CLEAN": "Cleaning temporary files… %d%%",
"INSTALLER_CHOOSE_PATH": "Choose location",
"INSTALLER_BROWSE": "Browse",
"INSTALLER_LAUNCH": "Launch now",
"INSTALLER_LAUNCH_FAILED": "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer.",
"INSTALLER_UI_ERROR": "The installer could not initialize its window. Close it and try again.",
"INSTALLER_THEME_ERROR": "Use /THEME=auto, /THEME=light or /THEME=dark.",
"INSTALLER_FAILED": "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again.",
"INSTALLER_RUNNING": "DeepSeek Harness is running. Quit it from the system tray and try again.",
"INSTALLER_DIRECTORY_FAILED": "The installation directory could not be replaced.",
"INSTALLER_DIRECTORY_HINT": "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists.",
"INSTALLER_DIRECTORY_RESTORE_HINT": "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again.",
"INSTALLER_PATH_INVALID": "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported.",
"INSTALLER_PATH_OWNERSHIP": "Choose an empty folder or the registered DeepSeek Harness installation folder.",
"INSTALLER_PATH_WRITABLE": "This location is not writable. Choose a folder available to the current user.",
"INSTALLER_DISK_SPACE": "There is not enough free disk space. Choose another location.",
"INSTALLER_PER_USER": "This installer supports the current user only. Uninstall the existing all-users installation first.",
"INSTALLER_EXTRACT_FAILED": "The application files could not be extracted.",
"INSTALLER_EXTRACT_HINT": "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below.",
"INSTALLER_EXTRACT_COPY": "Copy error details",
"INSTALLER_EXTRACT_EXPAND": "Show details",
"INSTALLER_EXTRACT_COLLAPSE": "Hide details",
"INSTALLER_EXTRACT_SAVED": "The full report was saved to %s",
"INSTALLER_EXTRACT_UNSAVED": "The full report could not be saved. Use Copy error details to keep it.",
"INSTALLER_EXTRACT_COPIED": "Error details were copied to the clipboard."
},
"uninstaller": {
"welcome": "Welcome",
"finished": "has been uninstalled"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"nsisLanguage": "JAPANESE",
"strings": {
"INSTALLER_INSTALL": "今すぐインストール",
"INSTALLER_FINISH": "完了",
"INSTALLER_NATIVE_PROGRESS": "インストールを準備しています… %d%%",
"INSTALLER_PROGRESS_EXTRACT": "ファイルを展開しています… %d%%",
"INSTALLER_PROGRESS_COPY": "ファイルをインストールしています… %d%%",
"INSTALLER_PROGRESS_REGISTER": "インストールを完了しています… %d%%",
"INSTALLER_PROGRESS_CLEAN": "一時ファイルを削除しています… %d%%",
"INSTALLER_CHOOSE_PATH": "インストール先を選択",
"INSTALLER_BROWSE": "参照",
"INSTALLER_LAUNCH": "今すぐ起動",
"INSTALLER_LAUNCH_FAILED": "DeepSeek Harness を起動できませんでした。再試行するか、「今すぐ起動」のチェックを外してインストーラーを閉じてください。",
"INSTALLER_UI_ERROR": "インストーラーのウィンドウを初期化できませんでした。閉じてから再試行してください。",
"INSTALLER_THEME_ERROR": "/THEME=auto、/THEME=light、/THEME=dark のいずれかを指定してください。",
"INSTALLER_FAILED": "インストールが完了しませんでした。インストーラーを閉じて再試行してください。問題が解決しない場合は、インストーラーを再度ダウンロードしてください。",
"INSTALLER_RUNNING": "DeepSeek Harness が実行中です。システムトレイから終了して再試行してください。",
"INSTALLER_DIRECTORY_FAILED": "インストール先のディレクトリを置き換えられませんでした。",
"INSTALLER_DIRECTORY_HINT": "インストール先へのアクセス権限を確認し、そのファイルを使用しているプログラムを閉じてから、インストーラーを再実行してください。問題が続く場合はエラーの詳細をコピーしてください。",
"INSTALLER_DIRECTORY_RESTORE_HINT": "以前のインストールを復元できませんでした。エラーの詳細に表示されたバックアップディレクトリを保持し、再インストール前にサポートへお問い合わせください。",
"INSTALLER_PATH_INVALID": "ローカルフォルダーの完全なパスを選択してください。ドライブのルート、システムフォルダー、リンク、特殊文字を含むパスは使用できません。",
"INSTALLER_PATH_OWNERSHIP": "空のフォルダー、または登録済みの DeepSeek Harness インストール先を選択してください。",
"INSTALLER_PATH_WRITABLE": "この場所には書き込めません。現在のユーザーが書き込めるフォルダーを選択してください。インストーラーは管理者権限を要求しません。",
"INSTALLER_DISK_SPACE": "ディスクの空き容量が不足しています。別の場所を選択してください。",
"INSTALLER_PER_USER": "このインストーラーは現在のユーザーのみを対象としています。先にすべてのユーザー向けの既存のインストールを削除してください。",
"INSTALLER_EXTRACT_FAILED": "アプリケーションのファイルを展開できませんでした。",
"INSTALLER_EXTRACT_HINT": "セキュリティソフトがファイルをブロックしたか、ディスクの空き容量が不足している可能性があります。インストーラーを閉じて再試行してください。問題が続く場合は、以下のエラーの詳細をお送りください。",
"INSTALLER_EXTRACT_COPY": "エラーの詳細をコピー",
"INSTALLER_EXTRACT_EXPAND": "詳細を表示",
"INSTALLER_EXTRACT_COLLAPSE": "詳細を非表示",
"INSTALLER_EXTRACT_SAVED": "完全なレポートを %s に保存しました",
"INSTALLER_EXTRACT_UNSAVED": "完全なレポートを保存できませんでした。「エラーの詳細をコピー」で保存してください。",
"INSTALLER_EXTRACT_COPIED": "エラーの詳細をクリップボードにコピーしました。"
},
"uninstaller": {
"welcome": "アンインストール ウィザードへようこそ",
"finished": "アンインストールされました"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"nsisLanguage": "SIMPCHINESE",
"strings": {
"INSTALLER_INSTALL": "立即安装",
"INSTALLER_FINISH": "完成",
"INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%",
"INSTALLER_PROGRESS_EXTRACT": "正在解压文件… %d%%",
"INSTALLER_PROGRESS_COPY": "正在安装文件… %d%%",
"INSTALLER_PROGRESS_REGISTER": "正在完成安装… %d%%",
"INSTALLER_PROGRESS_CLEAN": "正在清理临时文件… %d%%",
"INSTALLER_CHOOSE_PATH": "选择安装位置",
"INSTALLER_BROWSE": "选择",
"INSTALLER_LAUNCH": "立即启动",
"INSTALLER_LAUNCH_FAILED": "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。",
"INSTALLER_UI_ERROR": "无法初始化安装窗口,请关闭后重试。",
"INSTALLER_THEME_ERROR": "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。",
"INSTALLER_FAILED": "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。",
"INSTALLER_RUNNING": "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。",
"INSTALLER_DIRECTORY_FAILED": "无法替换安装目录。",
"INSTALLER_DIRECTORY_HINT": "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。",
"INSTALLER_DIRECTORY_RESTORE_HINT": "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。",
"INSTALLER_PATH_INVALID": "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。",
"INSTALLER_PATH_OWNERSHIP": "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。",
"INSTALLER_PATH_WRITABLE": "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。",
"INSTALLER_DISK_SPACE": "此磁盘的可用空间不足,请选择其他位置。",
"INSTALLER_PER_USER": "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。",
"INSTALLER_EXTRACT_FAILED": "无法解压应用程序文件。",
"INSTALLER_EXTRACT_HINT": "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。",
"INSTALLER_EXTRACT_COPY": "复制错误信息",
"INSTALLER_EXTRACT_EXPAND": "显示详情",
"INSTALLER_EXTRACT_COLLAPSE": "隐藏详情",
"INSTALLER_EXTRACT_SAVED": "完整错误报告已保存到 %s",
"INSTALLER_EXTRACT_UNSAVED": "无法保存完整错误报告,请使用“复制错误信息”保留。",
"INSTALLER_EXTRACT_COPIED": "错误信息已复制到剪贴板。"
},
"uninstaller": {
"welcome": "欢迎",
"finished": "已从你"
}
}
-63
View File
@@ -1,63 +0,0 @@
; NSIS owns installer localization independently of the Electron application locale.
LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now"
LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装"
LangString INSTALLER_FINISH ${LANG_ENGLISH} "Finish"
LangString INSTALLER_FINISH ${LANG_SIMPCHINESE} "完成"
LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation… %d%%"
LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "正在准备安装… %d%%"
LangString INSTALLER_PROGRESS_EXTRACT ${LANG_ENGLISH} "Extracting files… %d%%"
LangString INSTALLER_PROGRESS_EXTRACT ${LANG_SIMPCHINESE} "正在解压文件… %d%%"
LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Installing files… %d%%"
LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在安装文件… %d%%"
LangString INSTALLER_PROGRESS_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%"
LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%"
LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%"
LangString INSTALLER_PROGRESS_CLEAN ${LANG_SIMPCHINESE} "正在清理临时文件… %d%%"
LangString INSTALLER_CHOOSE_PATH ${LANG_ENGLISH} "Choose location"
LangString INSTALLER_CHOOSE_PATH ${LANG_SIMPCHINESE} "选择安装位置"
LangString INSTALLER_BROWSE ${LANG_ENGLISH} "Browse"
LangString INSTALLER_BROWSE ${LANG_SIMPCHINESE} "选择"
LangString INSTALLER_LAUNCH ${LANG_ENGLISH} "Launch now"
LangString INSTALLER_LAUNCH ${LANG_SIMPCHINESE} "立即启动"
LangString INSTALLER_LAUNCH_FAILED ${LANG_ENGLISH} "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer."
LangString INSTALLER_LAUNCH_FAILED ${LANG_SIMPCHINESE} "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。"
LangString INSTALLER_UI_ERROR ${LANG_ENGLISH} "The installer could not initialize its window. Close it and try again."
LangString INSTALLER_UI_ERROR ${LANG_SIMPCHINESE} "无法初始化安装窗口,请关闭后重试。"
LangString INSTALLER_THEME_ERROR ${LANG_ENGLISH} "Use /THEME=auto, /THEME=light or /THEME=dark."
LangString INSTALLER_THEME_ERROR ${LANG_SIMPCHINESE} "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。"
LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again."
LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。"
LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again."
LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。"
LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced."
LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。"
LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists."
LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。"
LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again."
LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。"
LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported."
LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。"
LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder."
LangString INSTALLER_PATH_OWNERSHIP ${LANG_SIMPCHINESE} "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。"
LangString INSTALLER_PATH_WRITABLE ${LANG_ENGLISH} "This location is not writable. Choose a folder available to the current user."
LangString INSTALLER_PATH_WRITABLE ${LANG_SIMPCHINESE} "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。"
LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk space. Choose another location."
LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。"
LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first."
LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。"
LangString INSTALLER_EXTRACT_FAILED ${LANG_ENGLISH} "The application files could not be extracted."
LangString INSTALLER_EXTRACT_FAILED ${LANG_SIMPCHINESE} "无法解压应用程序文件。"
LangString INSTALLER_EXTRACT_HINT ${LANG_ENGLISH} "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below."
LangString INSTALLER_EXTRACT_HINT ${LANG_SIMPCHINESE} "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。"
LangString INSTALLER_EXTRACT_COPY ${LANG_ENGLISH} "Copy error details"
LangString INSTALLER_EXTRACT_COPY ${LANG_SIMPCHINESE} "复制错误信息"
LangString INSTALLER_EXTRACT_EXPAND ${LANG_ENGLISH} "Show details"
LangString INSTALLER_EXTRACT_EXPAND ${LANG_SIMPCHINESE} "显示详情"
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_ENGLISH} "Hide details"
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_SIMPCHINESE} "隐藏详情"
LangString INSTALLER_EXTRACT_SAVED ${LANG_ENGLISH} "The full report was saved to %s"
LangString INSTALLER_EXTRACT_SAVED ${LANG_SIMPCHINESE} "完整错误报告已保存到 %s"
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not be saved. Use Copy error details to keep it."
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。"
LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard."
LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。"
+2 -1
View File
@@ -82,6 +82,7 @@
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
}
}
+15 -12
View File
@@ -1,16 +1,19 @@
#status, #version { margin-top: 12px; color: #555962; }
#status, #version { margin-top: 12px; color: var(--dsw-alias-label-secondary); }
#status:empty, #version:empty { display: none; }
#error { margin-top: 12px; color: #d93025; }
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid #e6e7eb; border-radius: 8px; background: #f5f5f5; font: 12px/20px monospace; color: #555962; overflow-wrap: anywhere; resize: vertical; }
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: #555962; background: transparent; }
#error { margin-top: 12px; color: var(--dsw-alias-state-error-primary); }
#address { display: block; width: 100%; margin-top: 4px; padding: 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/20px monospace; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; resize: vertical; }
#actions button:not(.primary) { min-height: 32px; padding: 5px 8px; color: var(--dsw-alias-label-secondary); background: transparent; }
#update, #refresh { border-radius: 100px; }
#fallback { border-top: 1px solid #e6e7eb; margin-top: 12px; padding-top: 12px; }
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: #555962; }
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
#copy-message { color: #d93025 !important; }
#fallback { border-top: 1px solid var(--dsw-alias-border-l2); margin-top: 12px; padding-top: 12px; }
#fallback p, #manual-copy { font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: var(--dsw-alias-link); font-size: 12px; }
#copy-message { color: var(--dsw-alias-state-error-primary) !important; }
#manual-copy { display: block; margin-top: 8px; }
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; }
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: var(--dsw-alias-border-l2); accent-color: var(--dsw-alias-button-primary-fill); }
#progress::-webkit-progress-bar { background: #e6e7eb; }
#progress::-webkit-progress-value { background: #0f1115; }
#progress::-moz-progress-bar { background: #0f1115; }
#progress::-webkit-progress-bar { background: var(--dsw-alias-border-l2); }
#progress::-webkit-progress-value { background: var(--dsw-alias-button-primary-fill); }
#progress::-moz-progress-bar { background: var(--dsw-alias-button-primary-fill); }
@media (hover: hover) {
#actions button:not(.primary):hover:not(:disabled), #copy:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
}
@@ -5,10 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'">
<title></title>
<link rel="stylesheet" href="design-platform.css">
<link rel="stylesheet" href="update-dialog.css">
<link rel="stylesheet" href="mandatory-update.css">
</head>
<body class="update-dialog">
<script src="shell-theme.js"></script>
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
<header><h1 id="title"></h1></header>
<p id="detail"></p>
+6
View File
@@ -0,0 +1,6 @@
/** Shell documents follow Electron's app-resolved palette before displaying their content. */
const palette = window.matchMedia('(prefers-color-scheme: dark)')
const applyPalette = () => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) }
applyPalette()
palette.addEventListener('change', applyPalette)
window.addEventListener('pagehide', () => { palette.removeEventListener('change', applyPalette) }, { once: true })
+20 -14
View File
@@ -1,31 +1,37 @@
:root {
color-scheme: light;
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
color: #0f1115;
background: transparent;
}
* { box-sizing: border-box; }
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; }
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 24%); }
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: #fff; box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 0 4px rgb(0 0 0 / 2%), 0 12px 32px rgb(0 0 0 / 8%); }
body { margin: 0; height: 100vh; display: grid; place-items: center; isolation: isolate; background: transparent; color: var(--dsw-alias-label-primary); color-scheme: light; }
body[data-ds-dark-theme] { color-scheme: dark; }
body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: var(--dsw-alias-bg-mask-1); }
main { width: min(380px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 22px 24px 24px; border-radius: 24px; background: var(--dsw-alias-bg-layer-1); box-shadow: none; }
body[data-ds-dark-theme] main { box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
main:focus { outline: none; }
h1 { margin: 0; min-height: 28px; font-size: 16px; font-weight: 500; line-height: 24px; overflow-wrap: anywhere; }
p { margin: 0; font-size: 14px; line-height: 22px; white-space: pre-wrap; overflow-wrap: anywhere; }
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: #fff; }
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; }
.primary { background: #0f1115; color: #fff; }
.secondary { background: #f5f5f5; color: inherit; }
#actions { display: flex; flex-direction: column; gap: 8px; margin-top: 20px; position: sticky; bottom: 0; background: var(--dsw-alias-bg-layer-1); }
button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: 12px; font-family: inherit; font-size: 14px; font-weight: 500; line-height: 22px; color: inherit; cursor: pointer; }
.primary { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-inverted); }
.secondary { background: var(--dsw-alias-button-primary-dimmed); color: inherit; }
.close { display: grid; place-items: center; flex: none; width: 28px; min-height: 28px; padding: 7px; border-radius: 50%; background: transparent; }
.close img { width: 14px; height: 14px; }
button:hover { filter: brightness(0.95); }
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
.close::before { content: ""; width: 14px; height: 14px; background: currentColor; mask: url("update-close.svg") center / contain no-repeat; }
button:focus-visible, summary:focus-visible, pre:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 3px; }
details { margin-top: 12px; font-size: 13px; line-height: 20px; }
summary { cursor: pointer; color: #5b6472; }
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: #f5f5f5; font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
summary { cursor: pointer; color: var(--dsw-alias-label-secondary); }
pre { margin: 8px 0 0; padding: 10px; max-height: min(180px, 30vh); overflow: auto; border-radius: 8px; background: var(--dsw-alias-markdown-code-block); font: 12px/18px monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
button:disabled { opacity: 0.5; cursor: default; }
[hidden] { display: none !important; }
body.update-dialog { opacity: 0; transition: opacity 150ms ease; }
body.update-dialog.visible { opacity: 1; }
@starting-style { body.update-dialog.visible { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { body.update-dialog { transition: none; } }
@media (hover: hover) {
.primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
.secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); }
}
+3 -1
View File
@@ -5,11 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'">
<title></title>
<link rel="stylesheet" href="design-platform.css">
<link rel="stylesheet" href="update-dialog.css">
</head>
<body class="update-dialog">
<script src="shell-theme.js"></script>
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
<header><h1 id="title"></h1><button id="close" class="close" type="button"><img src="update-close.svg" alt=""></button></header>
<header><h1 id="title"></h1><button id="close" class="close" type="button"></button></header>
<p id="detail"></p>
<details id="technical-details" hidden>
<summary id="technical-details-label"></summary>
+1
View File
@@ -11,6 +11,7 @@
<script src="../lib/welcome/welcome.js" defer></script>
</head>
<body>
<script src="shell-theme.js"></script>
<div id="root"></div>
</body>
</html>
@@ -1,7 +1,8 @@
import { readInstallerLocales, renderInstallerStrings } from './installer-locales.mjs'
import { officePackageDirectories } from '../../../scripts/libreoffice-packages.mjs'
import { X509Certificate } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { readFile, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFile } from 'node:child_process'
@@ -129,6 +130,7 @@ export function createElectronBuilderConfig(
'-OutputDirectory', join(buildPaths.root, 'installer-ui')], {
env: scrubWindowsSigningEnvironment(env), windowsHide: true,
})
await writeFile(join(buildPaths.root, 'installer-ui', 'strings.nsh'), renderInstallerStrings(readInstallerLocales()), 'utf8')
if (windowsSigner !== undefined) {
await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
}
@@ -162,12 +164,14 @@ export function createElectronBuilderConfig(
icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)),
category: 'public.app-category.developer-tools',
// macOS matches the application locale against this bundle, not Electron Framework resources.
extendInfo: { CFBundleLocalizations: ['en', 'zh_CN'] },
extendInfo: {
CFBundleLocalizations: ['en', 'zh_CN', 'ja'],
NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.',
},
// Ad-hoc signing keeps the modified Electron executable runnable without a Developer ID.
identity: unsigned ? '-' : macOSSigning?.signingIdentity,
forceCodeSigning: !unsigned,
hardenedRuntime: true,
extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' },
entitlements: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)),
entitlementsInherit: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)),
// Prepared runtime files retain their signatures; PAK resources are sealed by their enclosing bundle.
@@ -260,7 +264,7 @@ export function createElectronBuilderConfig(
perMachine: false,
allowElevation: false,
allowToChangeInstallationDirectory: false,
installerLanguages: ['en_US', 'zh_CN'],
installerLanguages: readInstallerLocales().map(locale => locale.id),
differentialPackage: true,
},
detectUpdateChannel: false,
@@ -29,7 +29,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi
directories: { ...config.directories, output: join(run.root, version, 'installer') },
extraMetadata: { ...config.extraMetadata, name: `dsh-update-test-${run.id}`, version, main: 'qualification-bootstrap.mjs' },
files: [
{ from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] },
{ from: application, to: '.', filter: ['lib/*.js', 'lib/*.cjs', 'lib/welcome/design-platform.css', 'renderer/**/*', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'] },
'package.json',
...config.files.filter((file): file is string => typeof file === 'string' && file.startsWith('!')),
{ from: dsh, to: 'dsh', filter: ['**/*'] },
@@ -0,0 +1,25 @@
/** One installer language's compiler identifier, UI strings and NSIS Modern UI test expectations. */
export interface InstallerLocale {
/** electron-builder language and resource filename without its suffix. */
id: string
/** NSIS LANG_ constant suffix. */
nsisLanguage: string
/** Custom installer messages, with Windows printf placeholders preserved. */
strings: Record<string, string>
/** Substrings of the upstream Modern UI uninstaller's welcome and finished pages. */
uninstaller: Record<string, string>
}
/**
* Read complete per-language JSON resources and reject inconsistent keys or placeholders.
* @param directory - Resource directory, defaulting to Desktop installer/locales.
* @returns Validated resources, ordered by filename.
*/
export function readInstallerLocales(directory?: string): InstallerLocale[]
/**
* Generate NSIS LangString declarations from validated resources.
* @param locales - All configured languages or one isolated native-test language.
* @returns UTF-8 NSIS source with literal text escaped.
*/
export function renderInstallerStrings(locales: InstallerLocale[]): string
@@ -0,0 +1,75 @@
/** Per-language JSON resources shared by NSIS packaging, native UI tests and the i18n check. */
import { readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const defaultDirectory = fileURLToPath(new URL('../installer/locales/', import.meta.url))
/**
* Read and validate installer translations before generating compiler input.
* @param {string} directory - Directory containing one JSON file per installer language.
* @returns {{id: string, nsisLanguage: string, strings: Record<string, string>, uninstaller: Record<string, string>}[]} Complete language resources.
*/
export function readInstallerLocales(directory = defaultDirectory) {
const locales = readdirSync(directory).filter(name => name.endsWith('.json')).sort().map(name => {
if (!/^[a-z]{2}_[A-Z]{2}\.json$/u.test(name)) throw new Error(`${name}: invalid installer language filename`)
/** @type {unknown} */
const resource = JSON.parse(readFileSync(`${directory}/${name}`, 'utf8'))
if (typeof resource !== 'object' || resource === null || !('nsisLanguage' in resource)
|| typeof resource.nsisLanguage !== 'string' || !/^[A-Z]+$/u.test(resource.nsisLanguage)) {
throw new Error(`${name}: invalid NSIS language identifier`)
}
/** @param {unknown} value - Parsed JSON dictionary. @returns {Record<string, string>} Nonempty text values. */
const dictionary = (value) => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${name}: expected text dictionary`)
/** @type {Record<string, string>} */
const result = {}
for (const [key, text] of Object.entries(value)) {
if (typeof text !== 'string' || text.trim() === '') throw new Error(`${name}: ${key} must be nonempty text`)
result[key] = text
}
return result
}
const strings = dictionary('strings' in resource ? resource.strings : undefined)
if (Object.keys(strings).some(key => !/^INSTALLER_[A-Z_]+$/u.test(key))) throw new Error(`${name}: invalid installer string key`)
return { id: name.slice(0, -5), nsisLanguage: resource.nsisLanguage, strings,
uninstaller: dictionary('uninstaller' in resource ? resource.uninstaller : undefined) }
})
for (const id of ['en_US', 'zh_CN', 'ja_JP']) {
if (!locales.some(locale => locale.id === id)) throw new Error(`installer locales: missing ${id}.json`)
}
const english = locales.find(locale => locale.id === 'en_US')
if (!english || Object.keys(english.strings).length === 0) throw new Error('installer locales: missing English strings')
if (Object.keys(english.uninstaller).sort().join(',') !== 'finished,welcome') throw new Error('installer locales: expected uninstaller welcome and finished text')
const languages = new Set()
for (const locale of locales) {
if (languages.has(locale.nsisLanguage)) throw new Error(`${locale.id}: duplicate NSIS language ${locale.nsisLanguage}`)
languages.add(locale.nsisLanguage)
for (const field of ['strings', 'uninstaller']) {
const expected = english[field]
const actual = locale[field]
if (Object.keys(expected).sort().join(',') !== Object.keys(actual).sort().join(',')) throw new Error(`${locale.id}: ${field} keys differ from English`)
for (const [key, text] of Object.entries(expected)) {
const placeholders = (/** @type {string} */ value) => (value.match(/%.?/gu) ?? []).join(',')
if (placeholders(text) !== placeholders(actual[key])) throw new Error(`${locale.id}: ${key} changes format placeholders`)
}
}
}
return locales
}
/**
* Generate guarded NSIS LangString declarations from validated JSON resources.
* @param {ReturnType<typeof readInstallerLocales>} locales - Resources for all languages or one isolated native-test build.
* @returns {string} UTF-8 NSIS source, with literal dollar signs, quotes and line breaks escaped.
*/
export function renderInstallerStrings(locales) {
const escape = (/** @type {string} */ text) => text.replace(/\$|"|\r|\n/gu, character => ({ '$': '$$', '"': '$\\"', '\r': '$\\r', '\n': '$\\n' })[character])
return '; Generated from installer/locales/*.json.\n' + locales.map(locale =>
`!ifdef LANG_${locale.nsisLanguage}\n` + Object.entries(locale.strings).map(([key, text]) =>
`LangString ${key} \${LANG_${locale.nsisLanguage}} "${escape(text)}"\n`).join('') + '!endif\n').join('')
}
if (import.meta.main) {
const locales = readInstallerLocales()
console.log(`verify-installer-locales: ${locales.length} languages have matching keys and format placeholders.`)
}
+1 -1
View File
@@ -9,7 +9,7 @@ ManifestDPIAware true
!endif
!macro customHeader
!define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_SOURCE_DIR}\strings.nsh"
!define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_BUILD_DIR}\strings.nsh"
!include "${INSTALLER_STRINGS_FILE}"
!ifdef BUILD_UNINSTALLER
BrandingText " "
@@ -32,7 +32,7 @@ export async function prepareInstalledUpdateApplication(manifest: string, source
const modules = (await readdir(join(source, 'lib'), { withFileTypes: true }))
.filter(entry => /\.(?:js|cjs)$/u.test(entry.name))
if (modules.some(entry => !entry.isFile())) throw new Error('installed update: application modules must be regular files')
const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer')].sort()
const paths = [...modules.map(entry => `lib/${entry.name}`), ...await applicationFiles(source, 'renderer'), 'lib/welcome/design-platform.css'].sort()
for (const required of ['lib/main.js', 'lib/preload-app.cjs', 'lib/preload-mandatory.cjs', 'lib/preload-update-dialog.cjs']) {
if (!paths.includes(required)) throw new Error('installed update: rebuild Desktop before freezing application files')
}
@@ -76,7 +76,8 @@ export async function verifyInstalledUpdateApplication(root: string): Promise<st
if (typeof entry !== 'object' || entry === null || !('path' in entry) || !('sha256' in entry)
|| typeof entry.path !== 'string' || typeof entry.sha256 !== 'string'
|| entry.path.includes('\\') || entry.path.includes(':') || entry.path.split('/').some(part => ['', '.', '..'].includes(part))
|| !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path)
|| (entry.path !== 'lib/welcome/design-platform.css'
&& !/^(?:lib\/[^/]+\.(?:js|cjs)|renderer\/.+|qualification-bootstrap\.mjs|installed-update-identity\.mjs)$/u.test(entry.path))
|| !/^[a-f0-9]{64}$/u.test(entry.sha256) || paths.has(entry.path)) {
throw new Error('installed update: invalid application file inventory')
}
@@ -87,7 +88,7 @@ export async function verifyInstalledUpdateApplication(root: string): Promise<st
}
const actual = await applicationFiles(join(directory, 'files'))
if (actual.length !== paths.size || actual.some(path => !paths.has(path))
|| ['lib/main.js', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) {
|| ['lib/main.js', 'lib/welcome/design-platform.css', 'qualification-bootstrap.mjs', 'installed-update-identity.mjs'].some(path => !paths.has(path))) {
throw new Error('installed update: frozen application inventory has missing or additional files')
}
return join(directory, 'files')
@@ -1,4 +1,5 @@
/** Builds an isolated native payload through the production NSIS configuration and exercises its UI. */
import { readInstallerLocales, renderInstallerStrings } from './installer-locales.mjs'
import { execFile } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
@@ -107,8 +108,8 @@ SectionEnd
const compiler = await getMakeNsisPath()
await execute(compiler.path, ['/V2', payloadSource], { ...childOptions, env: { ...childOptions.env, ...compiler.env } })
if (sign) await sign({ path: join(payload, `${productName}.exe`), hash: 'sha256', isNest: false })
const sourceStrings = await readFile(join(appRoot, 'installer', 'strings.nsh'), 'utf8')
for (const language of ['en_US', 'zh_CN']) {
for (const locale of readInstallerLocales()) {
const language = locale.id
const config = createElectronBuilderConfig()
if (sign) {
config.win.forceCodeSigning = true
@@ -117,9 +118,7 @@ SectionEnd
const languageOutput = join(output, language)
await mkdir(languageOutput)
const strings = join(languageOutput, 'strings.nsh')
const languageId = language === 'en_US' ? 'ENGLISH' : 'SIMPCHINESE'
await writeFile(strings, sourceStrings.split('\n').filter((line) =>
!line.startsWith('LangString ') || line.includes(`\${LANG_${languageId}}`)).join('\n'))
await writeFile(strings, renderInstallerStrings([locale]), 'utf8')
const include = join(languageOutput, 'include.nsh')
await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!define INSTALLER_STRINGS_FILE "${strings}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`)
await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
@@ -132,7 +131,7 @@ SectionEnd
join(appRoot, 'tests', uninstallOnly ? 'windows-uninstall-smoke.ps1' : 'windows-installer-smoke.ps1'),
'-Installer', join(languageOutput, 'installer-test.exe'),
'-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput,
...uninstallOnly ? ['-Language', languageId, '-PackageName', packageName] : []], childOptions)
'-Language', language, ...uninstallOnly ? ['-PackageName', packageName] : []], childOptions)
process.stdout.write(`${language}\n${result.stdout}`)
}
succeeded = true
-6
View File
@@ -12,12 +12,6 @@ declare global {
}
}
const palette = window.matchMedia('(prefers-color-scheme: dark)')
const syncPalette = (): void => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) }
syncPalette()
palette.addEventListener('change', syncPalette)
window.addEventListener('pagehide', () => { palette.removeEventListener('change', syncPalette) }, { once: true })
const container = document.getElementById('root')
if (container === null) throw new Error('desktop welcome: missing React root')
const root = createRoot(container)
+121
View File
@@ -0,0 +1,121 @@
/** Application-owned temporary exports and authorized borrowed files for native dragging. */
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { resolveDragFileName, type DragFileName, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
// Aggregate temporary bytes, including incomplete writes and failed removals.
const MAX_TOTAL_DRAG_FILE_BYTES = 64 * 1024 * 1024
// Native receivers may open the path after startDrag returns.
const DELIVERED_FILE_RETENTION_MS = 60_000
interface DragFile {
readonly directory?: string
readonly path: string
readonly bytes: number
ready: boolean
used: boolean
timer?: ReturnType<typeof setTimeout>
}
/** Borrowed files are never deleted; temporary exports retain cleanup ownership until removal succeeds. */
export class DragFiles {
private readonly files = new Map<FileDragHandle, DragFile>()
private readonly pending = new Set<Promise<FileDragHandle>>()
private readonly releasing = new Map<FileDragHandle, Promise<void>>()
private stagedBytes = 0
private disposed = false
/** @param temporaryDirectory - OS directory for private temporary exports. */
constructor(private readonly temporaryDirectory: string) {}
/** @param bytes - complete file bytes. @param naming - desired export name. @returns an opaque preparation handle. */
prepare(bytes: Uint8Array, naming: DragFileName): Promise<FileDragHandle> {
if (this.disposed || bytes.byteLength > MAX_TOTAL_DRAG_FILE_BYTES - this.stagedBytes) {
return Promise.reject(new Error('File drag temporary storage unavailable'))
}
this.stagedBytes += bytes.byteLength
const operation = this.write(bytes, resolveDragFileName(naming))
this.pending.add(operation)
void operation.then(() => { this.pending.delete(operation) }, () => { this.pending.delete(operation) })
return operation
}
private async write(bytes: Uint8Array, name: string): Promise<FileDragHandle> {
let directory: string
try { directory = await mkdtemp(join(this.temporaryDirectory, 'dsh-file-drag-')) }
catch (error) { this.stagedBytes -= bytes.byteLength; throw error }
const handle = randomUUID() as FileDragHandle
const file: DragFile = { directory, path: join(directory, name), bytes: bytes.byteLength, used: false, ready: false }
this.files.set(handle, file)
try {
await writeFile(file.path, bytes, { flag: 'wx', mode: 0o600 })
if (this.disposed) throw new Error('File transfer closed')
file.ready = true
return handle
} catch (error) {
await this.remove(handle, file)
throw error
}
}
/**
* Register an authorized source without owning its content.
* @param path - verified local source.
* @returns borrowed handle.
*/
borrow(path: string): FileDragHandle {
if (this.disposed) throw new Error('File transfer closed')
const handle = randomUUID() as FileDragHandle
this.files.set(handle, { path, bytes: 0, used: false, ready: true })
return handle
}
/** @param handle - preparation identity. @param retain - whether the OS accepted this file. @returns path only while ready and owned. */
take(handle: unknown, retain = true): string | undefined {
if (typeof handle !== 'string') return undefined
const key = handle as FileDragHandle
const file = this.files.get(key)
if (file === undefined || !file.ready || this.disposed || this.releasing.has(key)) return undefined
if (retain && !file.used) {
file.used = true
file.timer = setTimeout(() => {
void this.remove(key, file).catch((error: unknown) => { console.error(error) })
}, DELIVERED_FILE_RETENTION_MS)
file.timer.unref()
}
return file.path
}
/** Release unused preparations immediately; used exports have a bounded read grace period. @param handle - preparation identity. */
async release(handle: unknown): Promise<void> {
if (typeof handle !== 'string') return
const key = handle as FileDragHandle
const file = this.files.get(key)
if (file === undefined || file.used) return
await this.remove(key, file)
}
private remove(handle: FileDragHandle, file: DragFile): Promise<void> {
const existing = this.releasing.get(handle)
if (existing !== undefined) return existing
clearTimeout(file.timer)
file.ready = false
const operation = (file.directory === undefined ? Promise.resolve() : rm(file.directory, { recursive: true, force: true })).then(() => {
this.files.delete(handle)
this.stagedBytes -= file.bytes
}).finally(() => { this.releasing.delete(handle) })
this.releasing.set(handle, operation)
return operation
}
/** Stop preparation, await every write/release, and retry all tracked removals before reporting failure. */
async dispose(): Promise<void> {
this.disposed = true
await Promise.allSettled(this.pending)
await Promise.allSettled(this.releasing.values())
const results = await Promise.allSettled([...this.files].map(([handle, file]) => this.remove(handle, file)))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
}
+167
View File
@@ -0,0 +1,167 @@
/** Native copy drags authorized only for the product main frame and the active Host. */
import { createHash } from 'node:crypto'
import { createReadStream, statSync, type Stats } from 'node:fs'
import { stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type NativeImage } from 'electron'
import { resolveDragFileName, type DesktopFileSource, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
import { DESKTOP_IPC } from './ipc.ts'
import { DragFiles } from './file-drag-files.ts'
interface LocalResource { readonly path: string; readonly size: number; readonly sha256: string }
function requestOf(value: unknown): DesktopFileSource {
if (typeof value !== 'object' || value === null || !('kind' in value)
|| !('mediaType' in value) || typeof value.mediaType !== 'string'
|| ('name' in value && value.name !== undefined && typeof value.name !== 'string')
|| ('outputExtension' in value && value.outputExtension !== undefined && typeof value.outputExtension !== 'string')) throw new Error('Invalid file drag request')
const naming = {
mediaType: value.mediaType, name: 'name' in value ? value.name as string | undefined : undefined,
outputExtension: 'outputExtension' in value ? value.outputExtension as string | undefined : undefined,
}
if (value.kind === 'bytes' && 'bytes' in value && value.bytes instanceof Uint8Array) return { kind: 'bytes', bytes: value.bytes, ...naming }
if (value.kind === 'resource' && 'address' in value && typeof value.address === 'string'
&& (!('sha256' in value) || (typeof value.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(value.sha256)))
&& (!('size' in value) || (typeof value.size === 'number' && Number.isSafeInteger(value.size) && value.size >= 0))) {
return { kind: 'resource', address: value.address, ...naming,
...('sha256' in value ? { sha256: value.sha256 as string } : {}),
...('size' in value ? { size: value.size as number } : {}) }
}
throw new Error('Invalid file drag request')
}
function sameVersion(before: Stats, after: Stats): boolean {
return after.isFile() && before.dev === after.dev && before.ino === after.ino
&& before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs && before.size === after.size
}
async function verifiedVersion(resource: LocalResource, signal: AbortSignal): Promise<Stats | undefined> {
signal.throwIfAborted()
const info = await stat(resource.path)
if (!info.isFile() || info.size !== resource.size) return undefined
const hash = createHash('sha256')
const stream = createReadStream(resource.path, { signal })
const chunks: AsyncIterable<unknown> = stream
for await (const chunk of chunks) {
if (!(chunk instanceof Uint8Array)) throw new Error('File stream returned non-binary data')
hash.update(chunk)
}
const after = await stat(resource.path)
return sameVersion(info, after) && hash.digest('hex') === resource.sha256 ? after : undefined
}
async function cancellable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted()
const cancelled = Promise.withResolvers<never>()
const abort = (): void => { cancelled.reject(signal.reason) }
signal.addEventListener('abort', abort, { once: true })
try { return await Promise.race([operation, cancelled.promise]) }
finally { signal.removeEventListener('abort', abort) }
}
/**
* Register file preparation and native dragging.
* @param owner - shared product sender authorization, returning its window.
* @param resolveResource - cancellable private Host authorization; renderer paths are never used directly.
* @returns cleanup aborting resource reads and awaiting tracked writes/removals without waiting for the system icon service.
*/
export function installDesktopFileDrag(
owner: (event: IpcMainInvokeEvent) => BrowserWindow,
resolveResource: (address: string, signal: AbortSignal) => Promise<LocalResource | null>,
): () => Promise<void> {
let disposed = false
const preparations = new Map<string, AbortController>()
const files = new DragFiles(app.getPath('temp'))
const pending = new Set<Promise<FileDragHandle | undefined>>()
const icons = new Map<string, NativeImage>()
const borrowed = new Map<string, { resource: LocalResource; version: Stats }>()
const authorize = (event: IpcMainInvokeEvent): BrowserWindow => {
if (disposed) throw new Error('File transfer closed')
return owner(event)
}
const prepare = async (event: IpcMainInvokeEvent, value: unknown, signal: AbortSignal): Promise<FileDragHandle | undefined> => {
authorize(event)
const source = requestOf(value)
let handle: FileDragHandle
if (source.kind === 'bytes') handle = await files.prepare(source.bytes, source)
else {
const resource = await cancellable(resolveResource(source.address, signal), signal)
authorize(event)
if (resource === null || basename(resource.path) !== resolveDragFileName(source)
|| (source.sha256 !== undefined && source.sha256 !== resource.sha256)
|| (source.size !== undefined && source.size !== resource.size)) return undefined
const version = await verifiedVersion(resource, signal)
if (version === undefined) return undefined
handle = files.borrow(resource.path)
borrowed.set(handle, { resource, version })
}
try {
signal.throwIfAborted()
const file = files.take(handle, false)
if (file === undefined) throw new Error('File transfer closed')
const icon = await cancellable(app.getFileIcon(file, { size: 'normal' }), signal)
authorize(event)
icons.set(handle, icon)
return handle
} catch (error) {
borrowed.delete(handle)
await files.release(handle)
throw error
}
}
ipcMain.handle(DESKTOP_IPC.fileDragPrepare, (event, source: unknown, requestId: unknown) => {
authorize(event)
if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > 128 || preparations.has(requestId)) throw new Error('Invalid file preparation identity')
const abort = new AbortController()
preparations.set(requestId, abort)
const operation = prepare(event, source, abort.signal).finally(() => { preparations.delete(requestId) })
pending.add(operation)
void operation.then(() => { pending.delete(operation) }, () => { pending.delete(operation) })
return operation
})
ipcMain.handle(DESKTOP_IPC.fileDragCancel, (event, requestId: unknown) => {
authorize(event)
if (typeof requestId === 'string') preparations.get(requestId)?.abort(new Error('File preparation cancelled'))
})
ipcMain.handle(DESKTOP_IPC.fileDragStart, (event, handle: unknown) => {
const window = authorize(event)
const icon = typeof handle === 'string' ? icons.get(handle) : undefined
if (icon === undefined || typeof handle !== 'string') return false
const file = files.take(handle, false)
if (file === undefined) return false
try {
const original = borrowed.get(handle)
if (original !== undefined && !sameVersion(original.version, statSync(original.resource.path))) throw new Error('File changed before dragging')
authorize(event)
window.webContents.startDrag({ file, icon })
files.take(handle)
icons.delete(handle)
borrowed.delete(handle)
return true
} catch (_error) {
icons.delete(handle)
borrowed.delete(handle)
void files.release(handle).catch((error: unknown) => { console.error(error) })
return false
}
})
ipcMain.handle(DESKTOP_IPC.fileDragRelease, async (event, handle: unknown) => {
authorize(event)
if (typeof handle === 'string') { icons.delete(handle); borrowed.delete(handle) }
await files.release(handle)
})
return async () => {
if (!disposed) {
disposed = true
for (const abort of preparations.values()) abort.abort(new Error('File transfer closed'))
ipcMain.removeHandler(DESKTOP_IPC.fileDragPrepare)
ipcMain.removeHandler(DESKTOP_IPC.fileDragCancel)
ipcMain.removeHandler(DESKTOP_IPC.fileDragStart)
ipcMain.removeHandler(DESKTOP_IPC.fileDragRelease)
}
await Promise.allSettled(pending)
icons.clear()
borrowed.clear()
await files.dispose()
}
}
+42 -6
View File
@@ -24,6 +24,11 @@ interface PlatformSessionEvent {
}
type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {
readonly type: 'file-drag-resource'
readonly requestId: number
readonly resource: { readonly path: string; readonly sha256: string; readonly size: number } | null
readonly error?: string
} | {
readonly type: 'update-tasks'
readonly requestId: number
readonly active: boolean
@@ -82,6 +87,13 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
case 'update-tasks':
return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'
&& (candidate.error === undefined || typeof candidate.error === 'string')
case 'file-drag-resource': {
const resource = candidate.resource
return Number.isSafeInteger(candidate.requestId) && (candidate.error === undefined || typeof candidate.error === 'string')
&& (resource === null || (typeof resource === 'object' && 'path' in resource && typeof resource.path === 'string'
&& 'sha256' in resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(resource.sha256)
&& 'size' in resource && typeof resource.size === 'number' && Number.isSafeInteger(resource.size) && resource.size >= 0))
}
case 'quit-inspection':
return Number.isSafeInteger(candidate.requestId) && typeof candidate.activeTasks === 'boolean'
&& typeof candidate.scheduledTasks === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string')
@@ -241,7 +253,7 @@ export class DesktopHostProcess {
* an unanswered drain fails at the control-request deadline without authorizing installation.
*/
async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out')
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out', 'desktop update')
if (response.type !== 'update-tasks') throw new Error('desktop update: Host answered with a different control response')
return response.active
}
@@ -252,29 +264,53 @@ export class DesktopHostProcess {
* {@link QUIT_INSPECTION_DEADLINE_MS}, and the shell then asks before quitting.
*/
async inspectQuit(): Promise<DesktopQuitInspection> {
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out')
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out', 'desktop quit')
if (response.type !== 'quit-inspection') throw new Error('desktop quit: Host answered with a different control response')
return { activeTasks: response.activeTasks, scheduledTasks: response.scheduledTasks }
}
/**
* @param address - Session-scoped file reference.
* @param signal - preparation cancellation.
* @returns privately authorized local resource, or null when nonlocal.
*/
async resolveDragResource(address: string, signal?: AbortSignal): Promise<
{ readonly path: string; readonly sha256: string; readonly size: number } | null
> {
const response = await this.control({ type: 'file-drag-resource', address }, 12_000, 'File drag authorization timed out', 'file drag', signal)
if (response.type !== 'file-drag-resource') throw new Error('Host returned a different control response')
return response.resource
}
private async control(
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' },
deadlineMs: number, deadlineMessage: string,
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' }
| { readonly type: 'file-drag-resource'; readonly address: string },
deadlineMs: number, deadlineMessage: string, operation: string, signal?: AbortSignal,
): Promise<DesktopHostControlResponse> {
const child = this.child
if (child === undefined || !child.connected || this.failureReported || this.stopping) {
throw new Error(`${request.type === 'update-tasks' ? 'desktop update' : 'desktop quit'}: Host is unavailable`)
throw new Error(`${operation}: Host is unavailable`)
}
signal?.throwIfAborted()
const requestId = this.nextControlId++
let timer: ReturnType<typeof setTimeout> | undefined
const abort = (): void => {
if (request.type === 'file-drag-resource' && child.connected) child.send({ type: 'file-drag-cancel', requestId }, (_error) => {
// The request still rejects locally if the Host has disconnected.
})
const reason: unknown = signal?.reason
this.controlRequests.get(requestId)?.reject(reason instanceof Error ? reason : new Error(deadlineMessage))
}
try {
return await new Promise<DesktopHostControlResponse>((resolve, reject) => {
this.controlRequests.set(requestId, { resolve, reject })
timer = setTimeout(() => { reject(new Error(deadlineMessage)) }, deadlineMs)
timer = setTimeout(abort, deadlineMs)
signal?.addEventListener('abort', abort, { once: true })
child.send({ ...request, requestId }, (error) => { if (error !== null) reject(error) })
})
} finally {
clearTimeout(timer)
signal?.removeEventListener('abort', abort)
this.controlRequests.delete(requestId)
}
}
+6
View File
@@ -3,6 +3,7 @@
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { IpcMainInvokeEvent } from 'electron'
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag'
/** IPC channel names kept private to the desktop application bundle. */
export const DESKTOP_IPC = {
@@ -21,6 +22,10 @@ export const DESKTOP_IPC = {
browserRelease: 'dsh-desktop:browser-release',
browserOpenRequested: 'dsh-desktop:browser-open-requested',
directoryPick: 'dsh-desktop:directory-pick',
fileDragPrepare: 'dsh-desktop:file-drag-prepare',
fileDragCancel: 'dsh-desktop:file-drag-cancel',
fileDragStart: 'dsh-desktop:file-drag-start',
fileDragRelease: 'dsh-desktop:file-drag-release',
deviceInfo: 'dsh-desktop:device-info',
localeBootstrap: 'dsh-desktop:locale-bootstrap',
localeChanged: 'dsh-desktop:locale-changed',
@@ -73,6 +78,7 @@ export interface DesktopUpdatePresentation {
export interface DshDesktopProductApi {
readonly protocolVersion: 1
readonly browser: DesktopBrowserBridge
readonly fileDrag: DesktopFileDrag
readonly keyboard: DesktopKeyboardApi
readonly shortcuts: DesktopShortcutsApi
/**
+183 -5
View File
@@ -1,4 +1,4 @@
/** Typed English and Chinese copy owned by the Electron shell. */
/** Typed English, Chinese, and Japanese copy owned by the Electron shell. */
export const en = {
cliCommandMenu: 'Manage dsh Command…',
@@ -150,6 +150,8 @@ export const en = {
mandatoryPage: 'Download from the official website',
mandatoryCopy: 'Copy download link',
mandatoryPageFailed: 'Could not open the official download page. Copy the link and open it in your browser.',
mandatoryViewDownload: 'Open download page',
mandatoryViewPageFailed: 'Could not open the download page. Please try again.',
mandatoryActionFailed: 'The action failed. Please try again. You can use the app again after the update is complete.',
mandatoryReady: 'Update ready',
mandatoryVersion: 'New version: {version}',
@@ -322,6 +324,8 @@ export const zh = {
mandatoryPage: '前往官网下载',
mandatoryCopy: '复制下载链接',
mandatoryPageFailed: '无法打开官网下载页面,请复制链接后在浏览器中打开。',
mandatoryViewDownload: '打开下载页面',
mandatoryViewPageFailed: '无法打开下载页面,请重试',
mandatoryActionFailed: '操作失败,请重试。完成更新后才能继续使用应用。',
mandatoryReady: '更新已准备就绪',
mandatoryVersion: '新版本:{version}',
@@ -341,15 +345,189 @@ export const zh = {
mandatoryNotification: '返回应用确认安装并重启。',
} as const satisfies DesktopMessages
/** Japanese copy, checked against the English keys. */
export const ja = {
cliCommandMenu: 'dsh コマンドを管理…',
cliCommandTitle: 'dsh コマンドを管理',
cliCommandLocation: 'デスクトップコマンド: {path}',
cliCommandSelected: '現在の dsh コマンド: {path}',
cliCommandTarget: '現在のランチャーターゲット: {path}',
cliCommandShadowed: '別の dsh が優先されています。デフォルトでデスクトップコマンドを使用するには、そのインストールを削除するか順序を変更してください。デスクトップコマンドはフルパスでも実行できます。',
cliCommandSelectionUnknown: 'シェルコマンドを確認できませんでした。エイリアスや別の dsh インストールが優先されている可能性があります。',
cliCommandInstalled: 'デスクトップコマンドがインストールされています。',
cliCommandNotInstalled: 'デスクトップコマンドをターミナルに追加します。',
cliCommandBroken: 'デスクトップコマンドの修復が必要です。',
cliCommandInstall: 'インストール',
cliCommandRepair: '修復',
cliCommandRemove: '削除',
cliCommandClose: '閉じる',
cliCommandSwitch: 'デスクトップコマンドで続行しますか?',
cliCommandPreserve: '既存のコマンドは保持されます。他のインストールやシェルの起動ファイルは変更されません。',
cliCommandContinue: '続行',
cliCommandInstallApp: 'dsh コマンドを管理する前に、デスクトップをアプリケーションフォルダにインストールしてください。',
cliCommandUpdating: 'アップデートをインストール中です。インストール完了後にコマンドを管理してください。',
cliCommandNewTerminal: '新しいターミナルを開いて dsh --version を実行してください。',
cliCommandRemoved: 'デスクトップコマンドの登録を削除しました。',
cliCommandPreviousRestored: '以前のランチャーを復元しました。',
cliCommandOtherKept: '他のコマンドインストールはそのまま残されています。',
cliCommandBackupKept: '以前のランチャーは次の場所に保持されています: {path}',
cliCommandChanged: 'このダイアログを開いている間にコマンドまたは PATH が変更されました。現在の状態を確認するには、dsh コマンドを管理を再度開いてください。',
cliCommandOwnershipError: 'コマンド登録またはバックアップが変更されました。無関係なコマンドは削除されていません。',
cliCommandFailed: 'コマンドを更新できませんでした。アプリケーションがインストールされ、保存先に書き込み権限があることを確認してから再試行してください。',
application: 'アプリケーション',
fileMenu: 'ファイル',
closePage: 'ページまたはウィンドウを閉じる',
aboutMenu: 'DeepSeek Harness について',
aboutProduct: 'DeepSeek Harness',
aboutVersion: 'バージョン V{version}',
hideApplication: 'DeepSeek Harness を隠す',
hideOtherApplications: 'ほかを隠す',
showAllApplications: 'すべて表示',
quitApplication: 'DeepSeek Harness を終了',
openApplication: 'DeepSeek Harness を開く',
quit: '終了',
cancel: 'キャンセル',
quitTitle: 'DeepSeek Harness を終了しますか?',
quitActiveTasks: '実行中のタスクは中断されます。',
quitScheduledTasks: 'アプリの終了中はスケジュールされたタスクが実行されません。',
quitActiveAndScheduledTasks: '実行中のタスクは中断され、アプリの終了中はスケジュールされたタスクが実行されません。',
backgroundNoticeBody: '実行中のタスクは継続されます。システムトレイからウィンドウを再度開けます。',
backgroundNoticeConfirm: '確認',
edit: '編集',
menuBar: 'アプリケーションメニュー',
delete: '削除',
undo: '元に戻す',
redo: 'やり直す',
cut: '切り取り',
copy: 'コピー',
paste: '貼り付け',
selectAll: 'すべて選択',
startupFailed: 'DeepSeek Harness を利用できません',
fatalSummary: 'アプリケーションを起動できなかったか、予期せず停止しました。',
startupAddressInUse: '別の DSH インスタンス (dsh web やデスクトップアプリなど) が実行中です。同時に起動することはできません。実行中の他の DSH インスタンスを終了してから再起動してください。',
diagnosticTruncated: '… エラー詳細は省略されています。',
reportWrittenTo: '診断レポート: {path}',
startupReinstallAdvice: 'アプリケーションファイルが見つからないか破損している場合は、アプリケーションを閉じて再インストールしてください。タスクは別途保存されています。',
exitApplication: '終了',
restartApplication: '再起動',
recoveryOperationFailed: '復旧操作に失敗しました',
disableThirdPartyPlugins: 'サードパーティのプラグインを無効化し、プロファイルパッチをバックアップして再起動',
welcomeTitle: 'DeepSeek Harness',
welcomeBrand: 'DeepSeek Harness',
welcomeTaglineBefore: 'ようこそ ',
welcomeTaglineBrand: 'DeepSeek Harness',
welcomeTaglineAfter: '',
welcomeDescription: '可能性を築く。知能を探求する。',
welcomeAuthStarting: 'ログインを開いています…',
welcomeAuthWaiting: 'ブラウザが自動で開きませんでしたか?',
welcomeAuthWaitingDescription: 'ログインリンクをコピーしてブラウザで開き、ログインしてください。',
welcomeAuthExchanging: 'ログインを完了しています…',
welcomeAuthExpired: 'ログインがタイムアウトしました',
welcomeAuthExpiredDescription: '続行するには再度ログインしてください',
welcomeAuthFailed: 'ログインを完了できませんでした。もう一度お試しください。',
welcomeAuthCopyLink: 'ログインリンクをコピー',
welcomeAuthCopied: 'コピーしました',
welcomeAuthCopyFailed: 'コピーできませんでした。もう一度お試しください。',
welcomeAuthCancel: 'キャンセル',
welcomeAuthRetry: '再度ログイン',
welcomeSignIn: 'ログイン',
welcomeApiKey: 'APIキーを追加',
welcomeKeyTitle: 'APIキーを追加して開始',
welcomeKeyDescription: '公式のDeepSeekモデルを設定してHarnessを使い始めましょう',
welcomeKeyPlaceholder: 'APIキーを入力',
welcomeKeySave: '保存して続行',
welcomeKeyLater: '後で設定',
welcomeKeyBack: 'ログインに戻る',
welcomeSessionExpired: 'アカウントからログアウトしました。再度ログインしてください。',
welcomeKeyBlank: 'APIキーを入力してください。',
welcomeKeyInvalid: '引用符、スペース、環境変数の代入を含めず、APIキーそのものを入力してください。',
welcomeKeyFailed: 'APIキーを保存できませんでした。もう一度お試しください。',
welcomeContinueFailed: 'ワークスペースを開けませんでした。もう一度お試しください。',
checkUpdatesMenu: 'アップデートを確認…',
reloadPageMenu: 'ページを再読み込み',
restartAppHostMenu: 'アプリとホストを再起動',
updateCheckFailedTitle: 'アップデートの確認に失敗しました',
updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。',
updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。',
updateInstallFailed: 'アップデートをインストールできませんでした。後でもう一度お試しください。',
updateCheckNetworkFailed: 'アップデートを確認できませんでした。接続を確認してもう一度お試しください。',
updateDownloadNetworkFailed: 'アップデートをダウンロードできませんでした。接続を確認してもう一度お試しください。',
updateInstallNetworkFailed: 'アップデートをインストールできませんでした。接続を確認してもう一度お試しください。',
unknownError: '不明なエラー',
updateCheckTitle: 'アップデートを確認',
updateCurrentDetail: '現在のバージョン: {version}',
updateCurrent: '最新です!',
updateChecking: 'アップデートを確認中…',
updateDownload: 'アップデートをダウンロード',
updateDownloadedTitle: 'バージョン {version} をインストールする準備ができました',
updateDownloadedDetail: 'アップデート中はアプリが終了し、完了後に自動的に再起動します。',
updateDownloadedDetailWindows: 'アップデート中はアプリが一時的に終了し、完了後に自動的に再起動します。\n\nアップデートには時間がかかる場合があります。インストール中はアプリを再度起動せずにお待ちください。',
updateClose: '閉じる',
updateAcknowledge: 'OK',
updateLater: '後でアップデート',
updateDownloading: 'ダウンロード中 {percent}%…',
updateVerifying: 'アップデートファイルを検証中…',
updateInstalling: '再起動の準備中…',
updateRetry: 'アップデートを再試行',
updateActiveTasks: 'タスクがまだ実行中です',
updateActiveTasksDetail: 'アップデートすると実行中のタスクが停止し、アプリが再起動します。続行しますか?',
updateStopTasks: 'タスクを停止してアップデート',
updateTasksChanged: '新しいタスクが開始されました。タスクを停止してアップデートするには再度確認してください。',
updateTasksUnavailable: 'タスクの状態を取得できません。ワークスペースの準備ができてから再度アップデートをお試しください。',
updateFullscreenTimeout: 'フルスクリーンを終了できませんでした。フルスクリーンを終了してからアップデートを再試行してください。',
updateStopFailed: 'タスクを安全に停止できませんでした。アップデートはインストールされていません。後でもう一度お試しください。',
updateTechnicalDetails: '技術的な詳細を表示',
updateTitle: 'DeepSeek Harness アップデート',
updateAvailable: '新しいバージョンがあります: {version}',
updateDetail: 'ダウンロードが完了したら、アップデートをインストールしてアプリを再起動できます。',
installAndRestart: 'インストールして再起動',
later: '後で',
updateFailedTitle: 'アップデートに失敗しました',
mandatoryTitle: '続行するにはアップデートしてください',
mandatoryDetail: 'このバージョンはサポートされなくなりました。続行するにはアップデートしてください。実行中のタスクは、インストールの確認と再起動を行うまで実行を継続します。',
mandatoryUnavailable: 'アップデートの要件を確認できませんでした。後でもう一度お試しください。',
policyLoginTitle: 'テスト環境にログイン',
policyLoginRequired: 'Feishuでログインして、このテストビルドのアップデート要件を確認します。ログインしてもアップデートのダウンロードやインストールは行われません。',
policyLogin: 'Feishuでログイン',
policyLoginFailed: 'Feishuのログインが完了しませんでした。もう一度お試しください。',
policyLoginLoading: 'ログインページを読み込み中…',
mandatoryNoRelease: '互換性のあるアップデートが見つかりませんでした。再度確認するか、サポートにお問い合わせください。',
mandatoryRefresh: '再確認',
mandatoryPage: '公式サイトからダウンロード',
mandatoryCopy: 'ダウンロードリンクをコピー',
mandatoryPageFailed: '公式ダウンロードページを開けませんでした。リンクをコピーしてブラウザで開いてください。',
mandatoryViewDownload: 'ダウンロードページを開く',
mandatoryViewPageFailed: 'ダウンロードページを開けませんでした。もう一度お試しください。',
mandatoryActionFailed: '操作に失敗しました。もう一度お試しください。アップデートが完了すると、アプリを再度使用できます。',
mandatoryReady: 'アップデートの準備完了',
mandatoryVersion: '新しいバージョン: {version}',
mandatoryReadyDetail: 'アップデート中にアプリが終了し、完了後に自動的に再起動します。',
mandatoryDeferred: '実行中のタスクはそのまま続行されます。アプリを再度使用する前にアップデートを完了してください。',
mandatoryContinue: 'アップデートを続行',
mandatoryInspecting: 'タスクを確認中…',
mandatoryStopping: 'タスクを停止中…',
mandatoryRestarting: 'まもなくアプリが再起動します。しばらくお待ちください。',
mandatoryDownloadFailed: 'アップデートファイルをダウンロードまたは準備できませんでした。再試行してください。',
mandatoryInstallFailed: 'アップデートはインストールされていません。タスクを再度確認して再試行してください。',
mandatoryOpenHelp: 'ページが開きませんでしたか?',
mandatoryReopen: '公式ダウンロードページを再度開く',
mandatoryCopied: 'リンクをコピーしました',
mandatoryCopyFailed: 'リンクをコピーできませんでした。以下から選択してコピーしてください。',
mandatoryAddress: 'ダウンロードリンク',
mandatoryNotification: 'アプリに戻り、インストールと再起動を確認してください。',
} satisfies Record<keyof typeof en, string>
/** Locale payload exposed to the Desktop-owned renderer. */
export interface DesktopLocale {
readonly id: 'en' | 'zh-CN'
readonly id: 'en' | 'zh-CN' | 'ja'
readonly messages: DesktopMessages
}
/** Resolve Electron's locale to one shipped Desktop dictionary. */
export function resolveDesktopLocale(locale: string): DesktopLocale {
return locale.toLowerCase().startsWith('zh')
const primary = locale.toLowerCase().split('-')[0]
if (primary === 'ja') return { id: 'ja', messages: ja }
return primary === 'zh'
? { id: 'zh-CN', messages: zh }
: { id: 'en', messages: en }
}
@@ -362,10 +540,10 @@ export function resolveDesktopLocale(locale: string): DesktopLocale {
*/
export function resolveDesktopStartupLocale(preference: string | null, languages: readonly string[]): DesktopLocale {
const selected = preference?.toLowerCase()
if (selected === 'zh' || selected === 'en') return resolveDesktopLocale(selected)
if (selected === 'zh' || selected === 'en' || selected === 'ja') return resolveDesktopLocale(selected)
for (const language of languages) {
const primary = language.toLowerCase().split('-')[0]
if (primary === 'zh' || primary === 'en') return resolveDesktopLocale(primary)
if (primary === 'zh' || primary === 'en' || primary === 'ja') return resolveDesktopLocale(primary)
}
return resolveDesktopLocale('en')
}
+34 -4
View File
@@ -26,6 +26,7 @@ import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
import { installDesktopFileDrag } from './file-drag.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
import { installMicrophonePermissions } from './microphone-permissions.ts'
import { DesktopBackendController } from './backend-controller.ts'
@@ -345,6 +346,25 @@ async function main(): Promise<void> {
let workspaceRecovery: Promise<void> | undefined
let mainWindow: BrowserWindow | undefined
let welcomeWindow: BrowserWindow | undefined
const installFileDrag = (): (() => Promise<void>) => installDesktopFileDrag(
event => assertProductSender(event),
(address, signal) => {
const host = backend.host
if (host === undefined) return Promise.reject(new Error('File drag Host unavailable'))
return host.resolveDragResource(address, signal)
},
)
const fileDragCleanups = new Set([installFileDrag()])
let fileDragStopped = false
const disposeFileDrag = async (): Promise<void> => {
fileDragStopped = true
const results = await Promise.allSettled([...fileDragCleanups].map(async (release) => {
await release()
fileDragCleanups.delete(release)
}))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
let enteredWorkspace = false
// NSIS passes --updated when it launches the application after installation.
let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated')
@@ -419,12 +439,13 @@ async function main(): Promise<void> {
let returnedAttempt: string | undefined
let pendingWelcomeNotice: WelcomeNotice | undefined
let previousAccountStatus: string | undefined
const assertProductSender = (event: IpcMainInvokeEvent): void => {
const assertProductSender = (event: IpcMainInvokeEvent): BrowserWindow => {
assertDesktopSender(event, ['app'])
if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
|| event.senderFrame === null || event.senderFrame !== mainWindow.webContents.mainFrame) {
throw new Error('dsh desktop: rejected IPC from an unowned renderer')
}
return mainWindow
}
let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
const navigateMain = (url: string): Promise<void> => {
@@ -442,7 +463,8 @@ async function main(): Promise<void> {
return next.promise
}
const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs'),
() => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin')
() => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US', process.platform === 'win32' ? 'win32' : 'darwin',
owner => isMandatory() || updateOverlays.input(owner).blocked)
const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
@@ -510,6 +532,7 @@ async function main(): Promise<void> {
},
updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action),
inspectQuit: () => host.inspectQuit(),
resolveDragResource: (address: string, signal?: AbortSignal) => host.resolveDragResource(address, signal),
}
}, (state) => {
if (state.phase === 'error') reportFatal(state.failure, 'host')
@@ -537,6 +560,10 @@ async function main(): Promise<void> {
window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
}
if (state.phase === 'error' && state.failedOperation !== 'check') {
if (state.failedOperation === 'install' && fileDragStopped && !quitting) {
fileDragCleanups.add(installFileDrag())
fileDragStopped = false
}
const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
shellInstallerOwnsQuit = false
updateStoppedHost = false
@@ -621,6 +648,7 @@ async function main(): Promise<void> {
mandatoryUI?.preparingRestart(stillActive)
// The embedded Platform document holds credentials issued by the Host that is about to stop.
await platformView.closeAndWait()
await disposeFileDrag().catch((error: unknown) => { console.error(error) })
requireCleanStop = true
updateStopFailure = undefined
await backend.stop()
@@ -668,7 +696,8 @@ async function main(): Promise<void> {
protocol.handle(SCHEME, (request) => {
const url = new URL(request.url)
// Shell-owned documents live in the application bundle and never pass through the Host.
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer'))
if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(),
url.pathname === '/design-platform.css' ? 'lib/welcome' : 'renderer'))
if (url.hostname === 'app') {
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
|| ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) {
@@ -1261,7 +1290,8 @@ async function main(): Promise<void> {
mandatoryUI?.dispose()
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(),
// A Platform cleanup failure is logged without cutting the remaining Host shutdown short.
platformView.dispose().catch((error: unknown) => { console.error(error) })])
platformView.dispose().catch((error: unknown) => { console.error(error) }),
disposeFileDrag().catch((error: unknown) => { console.error(error) })])
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
}
app.on('before-quit', (event) => {
+122 -23
View File
@@ -1,11 +1,11 @@
/** Shell-owned modal policy UI; only explicit actions authorize downloads or browser navigation. */
import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import { app, type BrowserWindow, clipboard, dialog, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import type { DesktopLocale } from './locale.ts'
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
import type { DesktopUpdateOverlays } from './update-overlay.ts'
import type { DesktopUpdateOverlay, DesktopUpdateOverlays } from './update-overlay.ts'
import { DesktopUpdateAttention } from './update-attention.ts'
/** A renderer action never carries a URL or authorizes a different version. */
@@ -47,13 +47,15 @@ export interface MandatoryUpdateWindowOptions {
// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route.
const page = 'dsh-app://shell/mandatory-update.html'
/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */
/** Shell-owned update presentation inside the native owner; policy and installation approval remain main-owned. */
export class DesktopMandatoryUpdateWindow {
private readonly embedded = process.platform === 'win32'
private embeddedParent: BrowserWindow | undefined
private embeddedBlocking = false
private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() }
private window: BrowserWindow | undefined
private window: DesktopUpdateOverlay | undefined
private surfaceParent: BrowserWindow | undefined
private recovery: { load: () => Promise<void>; dismiss: () => void; dispose: () => void } | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private disposed = false
private error: string | undefined
@@ -110,7 +112,7 @@ export class DesktopMandatoryUpdateWindow {
}
/** Active modal used as the owner of shell installation-confirmation dialogs. */
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window }
get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.surfaceParent }
/**
* @param version - Updater-owned target, already downloaded and verified.
@@ -145,6 +147,15 @@ export class DesktopMandatoryUpdateWindow {
/** Publish current status, create the block immediately, or close it only after policy clearance. */
sync(): void {
if (this.disposed) return
if (!this.embedded && this.window !== undefined && this.surfaceParent !== this.options.parent()) {
this.recovery?.dispose()
this.recovery = undefined
this.finishConfirmation(false)
const previous = this.window
this.window = undefined
this.surfaceParent = undefined
previous.destroy()
}
if (this.embedded) {
const parent = this.options.parent()
if (parent !== this.embeddedParent) {
@@ -158,6 +169,7 @@ export class DesktopMandatoryUpdateWindow {
if (parent === undefined || parent.isDestroyed()) return
}
if (!this.options.policy().blocking) {
this.recovery?.dismiss()
this.finishConfirmation(false)
this.attention.reset()
this.clearNavigation()
@@ -168,7 +180,7 @@ export class DesktopMandatoryUpdateWindow {
window.webContents.send(MANDATORY_IPC.state, this.view())
this.closing = setTimeout(() => {
this.closing = undefined
if (this.window === window) this.window = undefined
if (this.window === window) { this.window = undefined; this.surfaceParent = undefined }
window.destroy()
}, 150)
}
@@ -190,24 +202,23 @@ export class DesktopMandatoryUpdateWindow {
}
if (this.window === undefined) {
const parent = this.options.parent()
if (parent === undefined) return
const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
if (parent === undefined || parent.isDestroyed()) return
const window = this.options.overlays.create(parent, this.options.preload)
this.window = window
window.setMenu(null)
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
window.on('closed', () => { if (this.window === window) this.window = undefined })
this.surfaceParent = parent
window.webContents.once('destroyed', () => {
if (this.window === window) {
this.window = undefined; this.surfaceParent = undefined
this.finishConfirmation(false)
if (!this.disposed) queueMicrotask(() => { this.sync() })
}
})
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
window.webContents.on('render-process-gone', () => {
if (!this.disposed) void window.loadURL(page).catch(() => {
// A failed recovery keeps the parent blocked and leaves application exit available.
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
})
})
void window.loadURL(page).catch(() => {
// The parent stays modal-blocked if its dedicated recovery document cannot load.
if (!window.isDestroyed()) window.setTitle(this.options.locale.messages.mandatoryActionFailed)
})
window.webContents.on('will-redirect', (event) => { event.preventDefault() })
const recovery = this.attachRecovery(window, parent)
this.recovery = recovery
void recovery.load()
}
this.window.webContents.send(MANDATORY_IPC.state, this.view())
}
@@ -219,14 +230,15 @@ export class DesktopMandatoryUpdateWindow {
if (parent?.isDestroyed()) return
if (parent?.isMinimized()) parent.restore()
parent?.show()
if (this.embedded) parent?.focus()
this.window?.show()
parent?.focus()
this.window?.focus()
}
/** Detach IPC and release the modal during shutdown, including after the main window closes. */
dispose(): void {
this.disposed = true
this.recovery?.dispose()
this.recovery = undefined
clearTimeout(this.closing)
this.closing = undefined
this.finishConfirmation(false)
@@ -240,6 +252,93 @@ export class DesktopMandatoryUpdateWindow {
}
this.window?.destroy()
this.window = undefined
this.surfaceParent = undefined
}
private attachRecovery(surface: DesktopUpdateOverlay, parent: BrowserWindow): {
load: () => Promise<void>
dismiss: () => void
dispose: () => void
} {
let released = false
let failed = false
let attempt = 0
let prompt: AbortController | undefined
const valid = (): boolean => !released && !this.disposed && this.options.policy().blocking
&& this.options.parent() === parent && this.window === surface && !surface.isDestroyed() && !parent.isDestroyed()
const dismiss = (): void => { prompt?.abort() }
const show = (): void => {
if (!valid() || !failed || prompt !== undefined || !parent.isVisible() || !parent.isFocused()) return
const controller = new AbortController()
prompt = controller
const messages = this.options.locale.messages
const policy = this.options.policy()
const update = this.options.update()
const destination = desktopPolicyPage(policy.page, this.options.allowedPageOrigins)
const actions: readonly ('page' | 'quit')[] = destination === undefined ? ['quit'] : ['page', 'quit']
const labels = { page: messages.mandatoryViewDownload, quit: messages.quit }
const detail = [policy.detail ?? messages.mandatoryDetail,
...('version' in update ? [messages.mandatoryVersion.replace('{version}', update.version)] : []),
...(this.navigation?.page === 'failed' ? [messages.mandatoryViewPageFailed] : [])].join('\n\n')
void dialog.showMessageBox(parent, { type: 'error', title: messages.mandatoryTitle,
message: policy.title ?? messages.mandatoryTitle, detail,
buttons: actions.map(action => labels[action]), defaultId: 0, cancelId: -1,
noLink: true, signal: controller.signal }).then(({ response }) => {
if (prompt === controller) prompt = undefined
if (controller.signal.aborted) { show(); return }
if (!valid() || !failed) return
if (actions[response] === 'quit') app.quit()
else if (actions[response] === 'page') {
if (destination !== desktopPolicyPage(this.options.policy().page, this.options.allowedPageOrigins)) { show(); return }
void this.navigate('page').then(() => { if (this.navigation?.page === 'failed') show() })
.catch((error: unknown) => { console.error('desktop policy: recovery download page failed', error) })
} else show()
}).catch((error: unknown) => {
if (prompt === controller) prompt = undefined
console.error('desktop policy: native update recovery failed', error)
})
}
const load = async (): Promise<void> => {
if (!valid()) return
const revision = ++attempt
failed = false
dismiss()
try {
await surface.loadURL(page)
} catch (error) {
if (!valid() || revision !== attempt) return
console.error('desktop policy: mandatory update view failed to load', error)
failed = true
show()
return
}
if (!valid() || revision !== attempt) return
failed = false
dismiss()
this.sync()
}
const loaded = (): void => {
if (!valid()) return
failed = false
dismiss()
}
const crashed = (): void => { void load() }
const dispose = (): void => {
if (released) return
released = true
dismiss()
parent.off('focus', show)
parent.off('hide', dismiss)
surface.webContents.off('did-finish-load', loaded)
surface.webContents.off('render-process-gone', crashed)
surface.webContents.off('destroyed', dispose)
}
parent.on('focus', show)
parent.on('hide', dismiss)
surface.webContents.on('did-finish-load', loaded)
surface.webContents.on('render-process-gone', crashed)
surface.webContents.once('destroyed', dispose)
return { load, dismiss, dispose }
}
private view(): MandatoryUpdateView {
+5 -1
View File
@@ -51,9 +51,10 @@ export class DesktopPlatformView {
* @param preload - bundled sandboxed Platform preload path.
* @param getLocale - current resolved Desktop language.
* @param platform - operating system this shell runs on, reported to Platform.
* @param blocked - Whether shell policy or an update prompt prevents opening a product view.
*/
constructor(private readonly preload: string, private readonly getLocale: () => PlatformLocale,
private readonly platform: 'darwin' | 'win32') {}
private readonly platform: 'darwin' | 'win32', private readonly blocked: (owner: PlatformOwner) => boolean) {}
/** @param next - private Host credentials; identity enrichment preserves an already open temporary document. */
setSession(next: PlatformSession | null): void {
@@ -75,9 +76,11 @@ export class DesktopPlatformView {
* @param page - explicit supported Platform page.
* @param bounds - owned renderer rectangle.
* @returns when loading finishes, or without a document when superseded or the owner closes or navigates.
* @throws When a shell update prompt blocks admission, including after storage cleanup.
*/
async open(owner: PlatformOwner, page: 'usage' | 'top-up', bounds: PlatformBounds): Promise<void> {
if (this.disposed) throw new Error('Platform view disposed')
if (this.blocked(owner)) throw new Error('Platform view blocked by update prompt')
this.close()
const account = this.account
if (account === null) throw new Error('Platform account unavailable')
@@ -104,6 +107,7 @@ export class DesktopPlatformView {
// Sanitize persisted authentication even after an unclean previous process exit.
const failure = await this.cleanStorage(browserSession)
if (generation !== this.generation) return
if (this.blocked(owner)) { this.close(); throw new Error('Platform view blocked by update prompt') }
if (failure !== null) {
this.close()
throw failure.error
+12
View File
@@ -9,11 +9,23 @@ import { syncNativeTheme } from './preload-theme.ts'
import { syncWindowsAppearance } from './preload-windows.ts'
import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts'
import { createDesktopBrowserBridge } from './preload-browser.ts'
import type { FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
function createProductApi(): DshDesktopProductApi {
return {
protocolVersion: 1,
browser: createDesktopBrowserBridge(),
fileDrag: {
prepare: (source, requestId) => ipcRenderer.invoke(DESKTOP_IPC.fileDragPrepare, source, requestId) as
Promise<FileDragHandle | undefined>,
cancel: (requestId) => { void ipcRenderer.invoke(DESKTOP_IPC.fileDragCancel, requestId).catch((_error: unknown) => {
// Main-process shutdown cancels any remaining preparations.
}) },
start: handle => ipcRenderer.invoke(DESKTOP_IPC.fileDragStart, handle) as Promise<boolean>,
release: (handle) => { void ipcRenderer.invoke(DESKTOP_IPC.fileDragRelease, handle).catch((_error: unknown) => {
// Main-process shutdown also releases every prepared file.
}) },
},
deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise<string>,
keyboard: {
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
+30 -27
View File
@@ -1,7 +1,7 @@
/** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
import type { DesktopLocale } from './locale.ts'
import type { DesktopUpdateOverlays } from './update-overlay.ts'
import type { DesktopUpdateOverlays, DesktopUpdateOverlay } from './update-overlay.ts'
/** Channels available only to the isolated update-dialog document. */
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
@@ -40,13 +40,17 @@ const page = 'dsh-app://shell/update-dialog.html'
export class DesktopUpdateDialog {
private disposed = false
private revision = 0
private window: BrowserWindow | undefined
private view: DesktopUpdateOverlay | undefined
private parent: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private active: { window: BrowserWindow; view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
/** Focus the current explanation or confirmation without replacing it or granting permission. */
focus(): void { this.active?.window.focus() }
focus(): void {
if (this.active === undefined || this.parent?.isDestroyed() !== false) return
this.parent.focus()
this.view?.webContents.focus()
}
/** Whether a shell prompt is awaiting a response. */
get isOpen(): boolean { return this.active !== undefined }
@@ -54,7 +58,7 @@ export class DesktopUpdateDialog {
/**
* @param preload - Bundled isolated preload.
* @param locale - Shell-owned copy or a reader of the current UI language.
* @param overlays - Application-owned overlay creation and input tracking.
* @param overlays - Application-owned view creation and input tracking.
*/
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale),
private readonly overlays: Pick<DesktopUpdateOverlays, 'create'>) {
@@ -87,9 +91,9 @@ export class DesktopUpdateDialog {
this.active?.finish(this.active.view.cancelId, true)
clearTimeout(this.closing)
this.closing = undefined
const existing = this.window
const window = existing ?? this.overlays.create(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
this.window = window
const existing = this.view
const surface = existing ?? this.overlays.create(parent, this.preload)
this.view = surface
this.parent = parent
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose,
@@ -100,20 +104,23 @@ export class DesktopUpdateDialog {
if (this.active?.view !== view) return
this.active = undefined
options.signal?.removeEventListener('abort', abort)
if (!retain && !window.isDestroyed()) {
window.webContents.send(UPDATE_DIALOG_IPC.changed, null)
if (!retain && !surface.webContents.isDestroyed()) {
surface.webContents.send(UPDATE_DIALOG_IPC.changed, null)
this.closing = setTimeout(() => { this.close() }, 150)
}
resolve({ response, checkboxChecked: false })
}
this.active = { window, view, finish }
this.active = { view, finish }
options.signal?.addEventListener('abort', abort, { once: true })
if (existing !== undefined) { window.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
const failed = (): void => { if (this.window === window) { this.cancel(); this.close() } }
window.once('closed', failed)
window.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
window.webContents.once('render-process-gone', failed)
void window.loadURL(page).catch(failed)
if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } }
surface.webContents.once('destroyed', failed)
surface.webContents.once('render-process-gone', failed)
surface.webContents.once('preload-error', failed)
surface.webContents.on('will-navigate', (event, url) => { if (url !== page) event.preventDefault() })
surface.webContents.on('will-redirect', (event) => { event.preventDefault() })
surface.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
void surface.loadURL(page).catch(failed)
})
}
@@ -133,20 +140,16 @@ export class DesktopUpdateDialog {
private close(): void {
clearTimeout(this.closing)
this.closing = undefined
const window = this.window
this.window = undefined
const surface = this.view
this.view = undefined
this.parent = undefined
if (window !== undefined && !window.isDestroyed()) {
// Release Windows modality before destruction so another application cannot become active.
if (process.platform === 'win32' && window.isVisible()) window.hide()
window.destroy()
}
surface?.destroy()
}
private owned(event: IpcMainInvokeEvent): void {
const window = this.window
if (window === undefined || event.sender !== window.webContents
|| event.senderFrame !== window.webContents.mainFrame || event.senderFrame.url !== page) {
const surface = this.view
if (surface === undefined || event.sender !== surface.webContents
|| event.senderFrame !== surface.webContents.mainFrame || event.senderFrame.url !== page) {
throw new Error('desktop update: rejected unowned dialog renderer')
}
}
+71 -51
View File
@@ -1,63 +1,83 @@
/** Shell-owned modal windows cover the parent's content without replacing its native window controls. */
import { BrowserWindow } from 'electron'
/** Isolated update documents share their owner's native window and input lifetime. */
import { WebContentsView, type BrowserWindow, type WebContents, type View } from 'electron'
import type { EventEmitter } from 'node:events'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
const unblockedInput = { revision: 0, blocked: false } as const
/** Main-owned document with no independent native window or activation state. */
export interface DesktopUpdateOverlay {
readonly webContents: WebContents
/** Focus the document only while its owner already has focus. */
focus(): void
/** Release the view, input interception, and owner listeners. */
destroy(): void
/** @returns Whether the document has been destroyed. */
isDestroyed(): boolean
/** @param url - Shell-owned document URL. @returns Completion of document loading. */
loadURL(url: string): Promise<void>
}
/** Tracks application-owned update overlays and their parent input state. */
type UpdateOwner = Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed' | 'isFocused'>
& Pick<EventEmitter, 'on' | 'once' | 'off'> & {
readonly webContents: Pick<EventEmitter, 'prependListener' | 'off'> & Pick<WebContents, 'focus'>
readonly contentView: Pick<View, 'addChildView' | 'removeChildView'>
}
/** Tracks isolated update views and each owner's keyboard-blocking revision. */
export class DesktopUpdateOverlays {
private readonly inputStates = new WeakMap<BrowserWindow, { revision: number; active: number; readonly blocked: boolean }>()
private readonly inputs = new WeakMap<object, { revision: number; active: number; readonly blocked: boolean }>()
/**
* @param parent - Product window whose input may belong to an update dialog.
* @returns Current blocking state; its revision changes whenever an overlay opens or closes.
*/
input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } {
return this.inputStates.get(parent) ?? unblockedInput
/** @param parent - Product window. @returns Blocking state and attachment/release revision. */
input(parent: Pick<BrowserWindow, 'isDestroyed'>): { readonly revision: number; readonly blocked: boolean } {
const state = this.inputs.get(parent)
return { revision: state?.revision ?? 0, blocked: state?.blocked ?? false }
}
/**
* @param parent - Product window whose content is blocked while the overlay is open.
* @param preload - Isolated shell-only preload.
* @param title - Localized window title.
* @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets.
* @returns A transparent child that follows its parent's bounds and visibility after loading and releases its listeners on close.
* @param parent - Native owner; its caption remains operable.
* @param preload - Isolated shell preload.
* @returns Document attached inside the owner, without showing or activating a native window.
*/
create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
const window = new BrowserWindow({
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
skipTaskbar: true, hasShadow: false, title,
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
})
const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
this.inputStates.set(parent, inputState)
inputState.active++; inputState.revision++
window.once('closed', () => { inputState.active--; inputState.revision++ })
// macOS native modals animate the entire viewport as a sheet.
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
if (!nativeModal && process.platform === 'darwin') {
parent.on('focus', focus)
// Shell dialogs block input before product shortcut listeners can dispatch it.
parent.webContents.prependListener('before-input-event', blockInput)
window.once('closed', () => {
parent.off('focus', focus)
if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput)
})
create(parent: UpdateOwner, preload: string): DesktopUpdateOverlay {
const view = new WebContentsView({ webPreferences: {
preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true,
} })
const ownerContents = parent.webContents
const contents = view.webContents
const input = this.inputs.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
this.inputs.set(parent, input)
input.active++; input.revision++
const resize = (): void => {
const { width, height } = parent.getContentBounds()
const top = process.platform === 'win32' ? WINDOWS_TITLEBAR_HEIGHT : 0
view.setBounds({ x: 0, y: top, width, height: Math.max(0, height - top) })
}
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
parent.on('move', follow)
parent.on('resize', follow)
window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) })
let ready = false
const show = (): void => {
if (ready && !window.isDestroyed() && !parent.isDestroyed() && parent.isVisible()) window.show()
const focus = (): void => {
if (!parent.isDestroyed() && parent.isFocused() && !contents.isDestroyed()) contents.focus()
}
parent.on('show', show)
window.once('closed', () => { parent.off('show', show) })
window.once('ready-to-show', () => { ready = true; show() })
window.setMenu(null)
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
return window
const block = (event: Electron.Event): void => { event.preventDefault(); focus() }
const destroy = (): void => { if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false }) }
const release = (): void => {
parent.off('resize', resize)
parent.off('focus', focus)
parent.off('closed', destroy)
ownerContents.off('before-input-event', block)
input.active--; input.revision++
if (!parent.isDestroyed()) {
parent.contentView.removeChildView(view)
if (parent.isFocused()) ownerContents.focus()
}
}
view.setBackgroundColor('#00000000')
parent.contentView.addChildView(view)
resize()
parent.on('resize', resize)
parent.on('focus', focus)
parent.once('closed', destroy)
ownerContents.prependListener('before-input-event', block)
contents.once('destroyed', release)
contents.on('did-finish-load', focus)
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
return { webContents: contents, focus, destroy, isDestroyed: () => contents.isDestroyed(),
loadURL: url => contents.loadURL(url) }
}
}
+9 -6
View File
@@ -9,17 +9,20 @@
en: 94559f431b0b2151
zh: 1a22166a2bc59686
/desktop-local-update-verification/table-of-contents:
en: a5edee063916cd27
zh: 7a5286350ff09e58
en: 86afdf78fa613876
zh: 66fad143d99e999f
/desktop-local-update-verification/update-dialog-themes:
en: d086c3b38ce623f1
zh: 8327c31092f5f174
/desktop-local-update-verification/native-overlay-visibility:
en: cff2c259a195bb57
zh: fa03ff3fdc32a346
en: 6f4de4503748f3e2
zh: 648370375f16e463
/desktop-local-update-verification/manual-walkthrough:
en: c4b4ee8763d8464a
zh: ee2159bc08966592
/desktop-local-update-verification/evidence:
en: 331a6709d0f7d03f
zh: 98499192ef9d3a81
en: dcb2473264c825d4
zh: cebb2832efa15974
/desktop-local-update-verification/open-verification:
en: 20e9ea47acb1ff50
zh: acce24b56d57e12c
+17 -4
View File
@@ -10,11 +10,24 @@ Installed qualification can opt into `DSH_DESKTOP_UPDATE_JOURNAL_DIR`, an absolu
## Table of Contents
- [Update dialog themes](#verification-theme)
- [Native overlay visibility](#verification-overlay)
- [Evidence](#verification-evidence)
- [Manual walkthrough](#verification-interactive)
- [Open verification](#verification-open)
<a id="verification-theme"></a>
## Update dialog themes
After `pnpm --dir apps/desktop run build`, run the real Electron palette fixture on macOS:
```sh
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-theme.mjs
```
The fixture creates a private profile, loads the production shell HTML, sandboxed preloads, renderer scripts, and bundled design tokens, and substitutes only fixed IPC status responses. Its 12 cases cover ordinary and mandatory documents starting in light/dark mode, switching while open, and following system appearance. It checks transparent document backgrounds, surface/text/button/mask colors, the code/address surface, the dark-only 1 CSS px inset outline, and the ordinary close icon, then writes four screenshots and `result.json` to `.desktop-build/qualification/update-theme-*`. It does not start a Host, download updates, run an installer, or qualify Windows native rendering. The first-close notice shares the ordinary document's CSS and theme script.
<a id="verification-overlay"></a>
## Native overlay visibility
@@ -26,7 +39,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
```
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It compares native visibility, unfiltered parent content, and listener cleanup with the owner-local expected output for parent hide/show and document readiness while the parent is hidden. It uses no network, product login, or dsh Host. This qualifies native window restoration rather than the full first-login flow.
The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It checks visible and hidden owners, absence of native child windows, retention of a separate control window’s focus after owner visibility notifications, and view/input cleanup including owner destruction. It uses no network, product login, or dsh Host. It qualifies the in-window carrier, not the full first-login or installation flow.
<a id="verification-interactive"></a>
@@ -49,8 +62,8 @@ Packaging supervision fixtures control their Git metadata and use real file hash
| Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass |
| Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass |
| Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup |
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy |
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; focused main-entry tests verify close-to-hide without quitting or stopping the Host, with policy retained |
| Actual ordinary dialog | Isolated preload, 380px card, 24px corners, theme-resolved primary button, translucent backdrop without parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass |
| Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload |
| Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking |
| Host task protection | The actual controller with substituted composition detects running agents, queued turns/steps, and global and agent jobs; API reads do not warn. Admission locking returns 503 for new requests, drains existing requests, and rechecks tasks; unlock restores admission |
@@ -63,7 +76,7 @@ Chromium headless shell revision 1228 is installed in the ignored `.desktop-buil
The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written.
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results.
The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. The recorded Windows run passes ten scenarios, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. The macOS workspace path has not completed qualification; these Windows results do not cover it.
The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact.
+17 -4
View File
@@ -10,11 +10,24 @@
## 目录
- [更新弹窗主题](#verification-theme)
- [原生浮层可见性](#verification-overlay)
- [验证证据](#verification-evidence)
- [手动演练](#verification-interactive)
- [待验证事项](#verification-open)
<a id="verification-theme"></a>
## 更新弹窗主题
执行 `pnpm --dir apps/desktop run build` 后,在 macOS 上运行真实 Electron 配色夹具:
```sh
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-theme.mjs
```
夹具创建独立 profile,加载生产壳 HTML、sandboxed preload、渲染脚本及打包的 design token,仅替换固定 IPC 状态响应。12 个用例覆盖普通和强更文档的浅色/暗色初始状态、打开时切换及跟随系统。它检查文档背景透明、卡片/文字/按钮/遮罩配色、代码/地址表面、仅暗色显示的 1 CSS px 内描边及普通弹窗关闭图标,并将四张截图和 `result.json` 写入 `.desktop-build/qualification/update-theme-*`。它不启动 Host、不下载更新、不运行安装器,也不验证 Windows 原生渲染。首次关闭提示共用普通文档的 CSS 和主题脚本。
<a id="verification-overlay"></a>
## 原生浮层可见性
@@ -26,7 +39,7 @@ pnpm exec tsc -b apps/desktop/tsconfig.host.json
apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs
```
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它对照所属测试目录中的预期输出,检查父窗口隐藏/显示、父窗口隐藏期间文档就绪两种顺序中的原生可见性、父页面不受模糊影响及监听清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖原生窗口恢复,不覆盖完整的首次登录流程。
夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它检查可见和隐藏的父窗口、不创建原生子窗口、父窗口可见性通知后独立对照窗口仍保留焦点,以及销毁父窗口时的视图和输入清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖窗内承载方式,不覆盖完整的首次登录或安装流程。
<a id="verification-interactive"></a>
@@ -49,8 +62,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
| 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 |
| 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 |
| 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 |
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 |
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向主入口测试验证关闭时隐藏、不退出也不停止 Host,并保留策略 |
| 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、随主题变化的主按钮、不模糊父页面的半透明遮罩、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 |
| 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 |
| 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 |
| Host 任务保护 | 替换组合环境后的真实控制器识别运行中的 agent、排队的 turn/step、全局和 agent job;API 读取不触发警告。请求准入锁定向新请求返回 503,等待已有请求结束并复查任务;解锁恢复准入 |
@@ -63,7 +76,7 @@ Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/p
[构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。
[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。已有 Windows 运行记录中十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。macOS 工作区路径尚未完成验证;这些 Windows 结果不覆盖它。
[Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。
@@ -1,15 +1,5 @@
[
{
"initiallyVisible": true,
"loaded": { "parent": true, "overlay": true, "filter": "none" },
"restored": { "parent": true, "overlay": true, "filter": "none" },
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
},
{
"initiallyVisible": false,
"loaded": { "parent": false, "overlay": false, "filter": "none" },
"restored": { "parent": true, "overlay": true, "filter": "none" },
"released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 }
},
{ "initiallyVisible": true, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
{ "initiallyVisible": false, "childWindows": 0, "controlKeepsFocus": true, "viewsReleased": true, "input": { "revision": 2, "blocked": false } },
{ "parentDestroyed": true, "overlayDestroyed": true }
]
@@ -0,0 +1,67 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { expect, it, onTestFinished } from 'vitest'
import { DragFiles } from '../src/file-drag-files.ts'
async function files() {
const root = await mkdtemp(join(tmpdir(), 'dsh-drag-test-'))
const store = new DragFiles(root)
onTestFinished(async () => { await store.dispose(); await rm(root, { recursive: true, force: true }) })
return { root, store }
}
it('exports original bytes and keeps a used file readable until disposal', async () => {
const { root, store } = await files()
const bytes = new Uint8Array([137, 80, 78, 71])
const handle = await store.prepare(bytes, { name: '../../original.png', mediaType: 'image/png' })
const path = store.take(handle)!
expect(basename(path)).toBe('_.._original.png')
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
await store.release(handle)
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
await store.dispose()
expect(await readdir(root)).toEqual([])
expect(store.take(handle)).toBeUndefined()
})
it('accepts non-image and unknown types while rejecting writes after disposal', async () => {
const { store } = await files()
for (const [name, mediaType] of [['报告.pdf', 'application/pdf'], ['custom.asset', 'application/x-unknown']]) {
const bytes = new Uint8Array([37, 80, 68, 70])
const handle = await store.prepare(bytes, { name, mediaType: mediaType! })
const path = store.take(handle, false)!
expect(basename(path)).toBe(name)
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
}
expect(store.take('foreign')).toBeUndefined()
await store.dispose()
await expect(store.prepare(new Uint8Array([1]), { name: 'x.png', mediaType: 'image/png' })).rejects.toThrow('File drag temporary storage unavailable')
})
it('releases unused preparations and waits for a concurrent write before cleanup', async () => {
const { root, store } = await files()
const handle = await store.prepare(new Uint8Array([1]), { name: 'x.png', mediaType: 'image/png' })
await store.release(handle)
expect(await readdir(root)).toEqual([])
const pending = store.prepare(new Uint8Array([2]), { name: 'y.png', mediaType: 'image/png' })
const rejected = expect(pending).rejects.toThrow('File transfer closed')
await store.dispose()
await rejected
expect(await readdir(root)).toEqual([])
})
it.each(['CON', 'prn', 'AUX', 'NUL', 'COM1', 'LPT9', 'COM¹', 'CON.extra'])('sanitizes the Windows device filename %s', async (name) => {
const { store } = await files()
const handle = await store.prepare(new Uint8Array([1]), { name: `${name}.png`, mediaType: 'image/png' })
expect(basename(store.take(handle, false)!)).toBe(`_${name}.png`)
})
it('bounds all retained bytes, including pending writes, and frees unused capacity', async () => {
const { store } = await files()
const pending = store.prepare(new Uint8Array(64 * 1024 * 1024), { name: 'large.png', mediaType: 'image/png' })
await expect(store.prepare(new Uint8Array([1]), { name: 'extra.png', mediaType: 'image/png' })).rejects.toThrow('File drag temporary storage unavailable')
const handle = await pending
await store.release(handle)
await expect(store.prepare(new Uint8Array([1]), { name: 'extra.png', mediaType: 'image/png' })).resolves.toEqual(expect.any(String))
})
Loaded 100 of 1998 files, more files were not shown because too many files have changed in this diff. Show more