Merge master into ihsiang/unify-code-block-ui

This commit is contained in:
yixiangihsiang committed 2026-09-22 14:23:25 +08:00
commit ce2ba9b216
951 files changed
+19223 -3063

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
2026-07-23-client-plugin-loading-model.md: a8d016a70792ad4ac6a71083677ce5d82a30af0a
2026-07-23-client-plugin-loading-model.zh.md: 1b94909c9611f01e5a6d7fcb50ec3f91ee88fab1
2026-07-23-client-plugin-loading-model.md: b8c23108a7a1cf3a04ded373d8705bae626c70c2
2026-07-23-client-plugin-loading-model.zh.md: efaea843120e5b023bbc8f59663ea47468944c71
@@ -46,7 +46,7 @@ A `require.async("./client.<name>.js")` call requests the exact revisioned `/plu
The shared tsdown preset emits a map for every plugin entry and chunk and rewrites first-party source paths into the browser-resolvable repository form `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute combo map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` points to `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. A chunk script likewise points to its own map URL. Source-map files are read and combined only when those map URLs receive `GET`; `HEAD` materializes neither script nor map bodies. The Vite shell also emits source maps, letting shell code and externally loaded plugins map stacks and performance profiles back to TypeScript/TSX.
The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. The shared preset stamps the entry after every package output is written. After the watcher observes that completed-build stamp, `rebuilt(id)` hashes the entry bytes together with its timestamp and publishes the resulting revision; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial publication and `rebuilt(id)` use the same hash of entry mtime, ctime, and size; artifact contents never enter the hash. Ctime distinguishes rewrites that preserve mtime and size. A Host restart over unchanged artifacts therefore retains every row revision, so the reconnecting SSE snapshot does not trigger browser plugin replacement. The shared preset stamps the entry after every package output is written; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
### The loading flow, end to end
@@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
@@ -80,7 +80,7 @@ The Host SSE adapter forwards existing graph-change notifications and sends the
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance forces a new revision even with identical metadata. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime or size delta, or a dirty row, `rebuilt(id)` derives the revision from the entry bytes and completed-build timestamp. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids redundant reads when installing the watcher and avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance retries publication even when mtime and size match the baseline. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime, ctime, or size delta, or a dirty row, `rebuilt(id)` derives the revision from those three metadata values. It reads the new bundle snapshot only if the revision changes. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol.
On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation:
@@ -120,4 +120,4 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi
| Full ctx-ification now (React and libraries via services, no module table) | Static UI libraries still expose synchronous values, so removing the table would leave those imports without a shared identity |
| Eager instantiation with a frozen table | Runs bundle side effects at script arrival; lazy registration keeps execution at Cordis import while recursive `require` materializes registered requests |
| Fetch response text, then inject an inline `<script>` | Makes the module system buffer the complete source and maintain separate fetch/execute paths; dynamic source execution also breaks the browser-native association among the network resource, source map, and profile |
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (re-hash on every stat change) that once justified pushing |
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (publication on every stat change) that once justified pushing |
@@ -46,7 +46,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
共享 tsdown 预设为每个插件入口与 chunk 产出 map,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 combo map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。chunk 脚本同样指向自己的 map URL。只有在这些 map URL 收到 `GET` 后,source map 文件才会被读取和组合;`HEAD` 不会物化脚本或 map body。Vite 壳同样产出 sourcemap,使壳代码与经外部加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。共享预设会在每个包输出写完后标记入口。watcher 观察到该构建完成标记后,`rebuilt(id)` 会把入口字节与其时间戳一起哈希并发布所得 revision;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。首次发布与 `rebuilt(id)` 使用相同的入口 mtime、ctime 和大小哈希;产物内容不参与求哈希。ctime 用于区分保留 mtime 和大小的重写。Host 重启时若产物未变,每个 row 的 revision 都保持不变,因此重连后的 SSE 快照不会触发浏览器插件替换。共享预设会在每个包输出写完后标记入口;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
### 装载流程,端到端
@@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
@@ -80,7 +80,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制生成新 revision。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime 或 size 变化、或 row 处于标脏状态时,`rebuilt(id)` 从入口字节与构建完成时间戳派生 revision。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这既避免安装 watcher 时重复读取,也避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使 mtime 和大小与基线相同也重试发布。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime、ctime 或大小变化、或 row 处于标脏状态时,`rebuilt(id)` 从这三个元数据值派生 revision。只有 revision 变化时,它才读取新的 bundle 快照。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议。
浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
@@ -120,4 +120,4 @@ Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块
| 现在就彻底 ctx 化(React 与库全走服务,不设模块表) | 静态 UI 库仍暴露同步实体,因此删除模块表会让这些 import 失去共享身份 |
| 冻结表 + 到达即实例化 | 会在 script 到达时执行 bundle 副作用;惰性登记把执行推迟到 Cordis import,并由递归 `require` 物化已登记请求 |
| fetch 响应文本后注入内联 `<script>` | 模块系统必须缓冲整份源码并维护 fetch/execute 两条路径;动态源码执行也切断浏览器网络资源、sourcemap 与 profile 的原生关联 |
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即重哈希)已兜住当年为推送辩护的撕裂写竞态 |
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即发布)已兜住当年为推送辩护的撕裂写竞态 |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1
2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720
2026-08-31-released-session-format-migrations.md: 00672af920cf8369022b4274c1c50552f5407a7e
2026-08-31-released-session-format-migrations.zh.md: 9404d8f454b7c17be69ae064c405c4dcc0794f88
@@ -89,6 +89,8 @@ Each released codec creates a row decoder with explicit `strict` or `recoverable
The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled v2 events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`.
The V3-to-V4 edge applies the same bounded restart repair to logs written directly as V3. Keeping this repair in the incoming edge preserves [native V4 lifecycle validation](2026-09-17-native-v4-read-validation.md); accepting repeated starts there would admit producer corruption in new logs. Insertions require local-reference and inherited-cut remapping, while captured generations retain their original coordinates. The [format specification](../../../../packages/session/session-format-v3-to-v4/README.md#sequence-references) owns the exact evidence and fields.
The catalog exposes one `createRestore()` operation for production, Worker, fixture, and replay callers. Recovery policy and final validation policy are chosen once at restore creation. Historical production uses recoverable source parsing with transformed-current validation; this validates the released current result after migration, while input that is already current receives only codec validation. Worker and fixture verification use strict parsing with full installed current restoration. A migration-stage or transformed-current validation refusal remains `SessionFormatUnsupportedMigrationError`; physical decoding failures remain corruption. Test support keeps only fixture-specific token and envelope materialization.
### JSONL integration
@@ -89,6 +89,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 v2 event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。
V3-to-V4 边对直接写成 V3 的日志应用同样的有限 restart 修复。将修复保留在迁入边,可以维持[原生 V4 生命周期校验](2026-09-17-native-v4-read-validation.zh.md);若在那里接受重复 start,新日志也会允许生产者损坏。插入事件要求重映射本地引用与继承截点,而捕获代际保留原坐标。[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#sequence-references)负责定义具体证据和字段。
Catalog 为 production、Worker、fixture 与 replay 暴露同一个 `createRestore()`。Recovery policy 与最终 validation policy 在 restore 创建时一次确定。Historical production 使用 recoverable source parsing 与 transformed-current validation;这种策略会在迁移后校验已发布 current 结果,而已经是 current 的输入只接受 codec 校验。Worker 与 fixture verification 使用 strict parsing 与已安装 current 格式的完整 restoration。Migration stage 或 transformed-current validation 的拒绝会保持为 `SessionFormatUnsupportedMigrationError`;物理解码失败仍是 corruption。Test support 只保留 fixture 自身需要的 token 和 envelope materialization。
### JSONL 串联
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md
2026-09-08-desktop-welcome-window-material.md: 4ee5f6d2934378b06754c4a8f134a4ca07a80840
2026-09-08-desktop-welcome-window-material.zh.md: 1689af8481925c787c1d2412173fbd7d8c2f4b64
@@ -0,0 +1,37 @@
# Agent Note: Desktop welcome window material
Status: implemented
English | [中文](2026-09-08-desktop-welcome-window-material.zh.md)
## Problem
The desktop welcome design blurs the desktop behind its entire window. Chromium backdrop filters only sample content inside the renderer. Credential setup also needs an explicit startup owner so a native skip is not followed by a second Web dialog.
## Decision
The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) owns native material and window controls. Its independently bundled React renderer supplies the entry, account sign-in states and API-key form. It shares the `StateDot` loading component with the Web UI without booting the Web plugin graph. The Desktop build emits local JavaScript and CSS under `lib/welcome`, included in packaged applications; the document keeps its network-denying content security policy. A narrow preload supplies typed shell copy, a write-only key operation, and a skip operation; IPC rejects other windows and subframes. Each sandboxed preload is bundled independently because Electron’s restricted require cannot load sibling chunks. The Electron main process authenticates through the shared Web launch URL and resolves the official provider's reference through the existing settings and credential RPC methods. The [thin Web wrapper](2026-09-10-desktop-web-wrapper.md) owns the HTTP server; onboarding adds no Host endpoint or child IPC operation. Responses contain metadata or a safe outcome, never a key or private provider diagnostics.
Cold startup opens the entry when neither an account credential nor a model API key is stored. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. Startup reads account presence and API-key configuration independently. Account sign-in enters the workspace; sign-out reopens the welcome window only when no model API key remains configured.
The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins.
Desktop reads the shared `locale.preference` before opening the welcome window. The Client awaits an isolated preload read of that preference and the same OS language order before mounting, then reports resolved locale changes to the shell. Only an explicit Settings selection writes the preference; automatic detection stays provisional. The shell ships English and Simplified Chinese dictionaries, so a Client-only language pack falls back to a supported OS language in native windows.
The welcome flow shares the Desktop backend controller with startup recovery. The main recovery document loads offscreen while the Host starts; a failure reveals it, and successful startup chooses the welcome window or workspace after reading credentials and locale. Recovery actions retain the controller’s serialized retries and awaited child shutdown. The application preload exposes recovery controls only to shell documents and the locale bridge only to application documents.
## Alternatives considered
**A CSS-only backdrop filter.** It cannot blur other application windows or the desktop, so it cannot provide the required native effect.
**Persisting a completed-onboarding flag.** A skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials.
**Keeping both native and Web credential dialogs.** Their separate completion state would ask for a key twice after a native skip. The composition assigns credential onboarding to one owner.
**Drawing replacement traffic lights.** Native controls preserve platform window behavior and accessibility. Their dimensions and outer window corners follow the OS rather than reproducing Figma geometry exactly.
**Separate preview callbacks.** A visual-only skip cannot demonstrate entry into the workspace and can hide broken startup dependencies. The preview uses the same callbacks and Host as the product.
## Consequences
Native blur strength and font fallback vary by system. macOS uses menu vibrancy with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. Native materials own the blur strength. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and a built-Host acceptance test proves credential persistence across restarts. Account sign-out preserves independently configured API keys. Onboarding presentation produces no Session events.
@@ -0,0 +1,37 @@
# Agent Note: 桌面欢迎窗口材质
Status: implemented
[English](2026-09-08-desktop-welcome-window-material.md) | 中文
## 问题
桌面欢迎界面的设计要求整个窗口模糊其背后的桌面。Chromium 背景滤镜只能采样渲染器内部的内容。凭证配置还需要明确的启动归属,避免跳过原生引导后又出现 Web 弹窗。
## 决策
[Electron 欢迎窗口](../../../../apps/desktop/src/welcome-window.ts)负责原生材质和窗口控件。独立打包的 React 渲染器提供入口、账号登录状态和 API Key 表单。它与 Web UI 共用 `StateDot` 加载组件,不启动 Web 插件图。Desktop 构建将本地 JavaScript 和 CSS 输出到 `lib/welcome`,并纳入应用安装包;文档继续使用禁止网络访问的内容安全策略。窄接口 preload 提供强类型桌面文案、仅写密钥操作和跳过操作;IPC 拒绝其他窗口和子 frame。每个沙箱 preload 都独立打包,因为 Electron 受限的 require 无法加载同目录中的拆分模块。Electron 主进程通过共享 Web 启动 URL 认证,并通过现有设置和凭证 RPC 方法解析官方提供方的引用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)负责 HTTP 服务;引导不增加 Host 端点或子进程 IPC 操作。响应只包含元数据或安全结果,不包含密钥或提供方的私有诊断。
冷启动在既无已存账号凭证、也无已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。启动分别读取账号凭证是否存在和 API Key 配置状态。账号登录后进入工作区;退登后仅在没有已配置的模型 API Key 时重新打开欢迎窗口。
生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。
Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在挂载前等待隔离 preload 返回该偏好和同一系统语言顺序,之后将解析后的语言变更通知桌面壳。只有设置中的明确选择会写入偏好;自动检测保持临时状态。桌面壳自带英文和简体中文词典,因此仅在 Client 中提供的语言包会在原生窗口回退到受支持的系统语言。
欢迎流程与启动恢复共用 Desktop 后端控制器。Host 启动期间,主窗口在屏幕外加载恢复文档;失败时显示该文档,成功后读取凭证和语言,再选择欢迎窗口或工作区。恢复操作保留控制器的串行重试与等待子进程退出的行为。应用 preload 只向壳文档提供恢复控件,只向应用文档提供语言桥接。
## 考虑过的替代方案
**仅使用 CSS 背景滤镜。** 它无法模糊其他应用窗口或桌面,因此不能提供所需的原生效果。
**持久化已完成引导标记。** 跳过只对当前进程有效;保存该标记会在用户仍无凭证时抑制下次冷启动的选择。
**同时保留原生与 Web 凭证弹窗。** 两者独立的完成状态会在跳过原生引导后再次要求输入密钥。组合将凭证引导交给单一归属方。
**绘制替代红绿灯。** 原生控件保留平台窗口行为和无障碍能力。它们的尺寸与窗口外部圆角遵循操作系统,不严格复制 Figma 几何参数。
**独立的预览回调。** 纯视觉预览中的跳过无法证明能进入工作区,还会掩盖启动依赖缺失。预览使用与产品相同的回调和 Host。
## 后果
原生模糊强度和字体回退因系统而异。macOS 使用 menu vibrancy,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。模糊强度由原生材质控制。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收测试证明凭证可跨重启持久化。账号退登保留独立配置的 API Key。引导展示不会生成 Session 事件。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
2026-09-14-deepseek-account-login.md: 9d9853617a3b7e288084c93eac4e150f3c180999
2026-09-14-deepseek-account-login.zh.md: d9cdbed25b0ae79caa4c61b310383cc52d8b586a
@@ -0,0 +1,69 @@
# Agent Note: DeepSeek account browser login
Status: implemented
English | [中文](2026-09-14-deepseek-account-login.zh.md)
Sign-in captures the initiating UI language per attempt so browser authorization follows the Desktop or Settings locale without depending on the Platform default.
## Problem
Desktop login must preserve local cancellation across the browser, UI, and Host and report success only after credentials are saved.
## Decision
The account Service Definition separates UI and model consumers from the platform protocol implementation. The platform provider registers an AuthorizationFlow and persists an owner-scoped GrantRecord. Account presence is derived from storage. Login attempts stay in memory and expose safe snapshots through the account Remote controller.
Local cancellation is authoritative. The provider stops callbacks and ignores late exchange results; auth_cancel invalidates the remote application and unexchanged code using authorize_id and the original PKCE verifier. Its background request does not delay local cancellation, and failure never restores login. session.commit admits persistence synchronously before its first await and rejects an already-cancelled flow. Cancellation after admission waits for the commit result. The callback redirects to auth_exchange.biz_data.authorized_url only after authorization settlement confirms storage.
Authorization registers a temporary /oauth/callback route on the Host webServer. The authenticated initiating client supplies its browser-accessible loopback HTTP origin, including any local SSH forwarding port. Non-loopback reverse proxies are unsupported. Cleanup removes only the route and preserves shared connections. Exchange failure returns Web to its login UI or focuses Desktop through account state; retry is explicit. PKCE secrets never cross UI transports. Model and file request tokens resolve only for the configured inferenceOrigin, defaulting to `https://api.deepseek.com`; redirects are refused. An explicit alternate origin requires grants from the configured Platform issuer, so changing the endpoint alone never forwards account credentials. Development loopback grants cannot authenticate production requests. API-key references remain separately stored.
Platform navigation and authorization share one validated platformOrigin from Cordis configuration. Private profile patches or environment expressions supply deployment-specific origins without committing development addresses.
The Desktop profile supplies the native platform for Host API headers. Account and update-policy clients share one mapping to avoid different client identities for the same installation. This identity does not depend on a login attempt, since stored grants also issue requests after restart. Embedded pages use the same platform identity and retain their other separately configured headers.
## Alternatives considered
Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. These alternatives are rejected.
## Consequences
Sign out removes the local grant before starting Platform POST /auth-api/v0/users/logout with the captured token. Remote failure never restores login. The provider retries at most five times after the initial request, with configurable exponential backoff starting at one second by default. Retry jobs retain only their old token, cannot change later login state, and end at provider shutdown without durable storage. Local credential deletion must succeed before publishing signed-out state. Sign out belongs to the sidebar account menu; Account settings owns profile, balance and sign-in. Both read one plugin-owned Host stream through framework hooks.
API keys and account grants remain separately stored. Account tokens have no expiry or refresh flow; exhausted logout retries can leave the removed token valid remotely. Provider initialization discards grants from a different configured Platform issuer locally, without remote revocation, so switching environments starts signed out instead of aborting Desktop startup. API keys and device identity survive this cleanup. Profile and balance query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances and positive bonus_wallets in separate rows, so promotional credit is never presented as recharge funds.
The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs.
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
Support questionnaires receive only the Platform UID and available environment metadata through explicit prefill fields. Account tokens and masked contact details stay out of questionnaire URLs. The account provider projects the current-user UID for this purpose; the UI uses the latest account snapshot so sign-out cannot reuse a previous UID.
Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs.
DSH grants authenticate Platform, inference and Files requests with x-dsh-auth-token, without a Bearer prefix. API-key authentication remains protocol-specific.
Profile and recharge-wallet queries use independent getProfile and getBalance operations. The client publishes each result as it arrives, so a slow or failed balance request cannot delay the sidebar username. Account changes invalidate both pending results.
Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider.
The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application.
Exchange user data supplies the first profile read after credential commit, avoiding a second request before displaying the username. The Host retains only projected UI fields in the active attempt, matched to its token and consumed once; later reads use current. Missing or malformed user data does not discard successful authorization.
The embedded Platform document stays hidden during loading because native child views cover renderer overlays. Only the current document may become visible after loading; returning or signing out invalidates pending visibility changes. Native ownership also ends when the application document reloads or is replaced, its renderer terminates, or its window closes. React effect cleanup alone is insufficient because document teardown may never execute it. Same-document and subframe navigation preserve the view.
Separate accountRequestHeaders route account data and embedded Platform traffic independently of authorization and logout. Cookie overrides merge by name, retaining deployment authentication. Host passes the resolved headers over private process IPC; Electron injects them only at the configured origin and omits them from bootstrap.
The Host binds private Platform sessions to the account provider lifetime. Removal or watch termination clears Electron’s session; replacement subscribes to the new provider, and disposed reads cannot publish old credentials.
The client distinguishes plugin disposal from terminal account-stream failure. RemoteStream aborts its signal after either outcome, so the plugin owns a separate disposal flag to preserve failure feedback while suppressing reports after unload.
## Verification
Provider tests exercise real loopback callbacks, invalid state, delayed exchange cancellation, credential persistence, sign-out, and official-origin restrictions. Desktop tests cover the native action bridge and localized entry. Manual development integration uses the platform dev middleware Mock and the real Electron Host, including cancellation before browser approval. Production backend credentials and installer scheme registration require release-environment validation.
## Related
[Credential records and flows](2026-08-13-credential-records-and-authorization-flows.md) remains the generic credential authority. [Desktop wrapper](2026-09-10-desktop-web-wrapper.md) owns the transport composition.
@@ -0,0 +1,69 @@
# Agent Note: DeepSeek 账号浏览器登录
Status: implemented
[English](2026-09-14-deepseek-account-login.md) | 中文
登录申请保存发起界面的语言,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。
## 问题
桌面登录必须在浏览器、UI 和 Host 之间保留本地取消意图,并只在凭证保存后宣告成功。
## 决策
账号服务定义将 UI 和模型使用方与平台协议实现分开。平台提供者注册 AuthorizationFlow,并保存按所有者寻址的 GrantRecord。账号是否存在从存储派生。登录尝试保留在内存中,通过账号 Remote 控制器提供安全快照。
本地取消具有决定权。提供者停止回调并忽略迟到的兑换结果;auth_cancel 使用 authorize_id 和原始 PKCE verifier 作废远程申请及未兑换的 code。后台请求不延迟本地取消,失败也不会恢复登录。session.commit 在第一次 await 前同步准入持久化,并拒绝已取消的流程。准入后的取消等待提交结果。回调只有在授权结束确认存储后才跳转 auth_exchange.biz_data.authorized_url。
授权在 Host webServer 上注册临时 /oauth/callback 路由。已鉴权的发起客户端提供浏览器可访问的本机 HTTP 来源,包含 SSH 本地转发端口。不支持非本机域名反向代理。清理仅移除路由,保留共享连接。兑换失败返回 Web 登录界面,或通过账号状态聚焦 Desktop;重试由用户明确发起。PKCE 私密数据不经过 UI 传输。模型和文件请求的 token 仅为配置的 inferenceOrigin 解析,默认为 `https://api.deepseek.com`;请求拒绝重定向。显式配置其他来源时,要求授权由配置的 Platform 来源签发,因此仅修改请求地址不会转发账号凭证。本机开发授权记录不能认证生产请求。API Key 引用独立保存。
平台导航与授权共享 Cordis 配置中同一个经过校验的 platformOrigin。私有 profile patch 或环境表达式提供部署来源,开发地址不进入仓库。
Desktop profile 为 Host API 请求头提供原生平台信息。账号与更新策略客户端共用一份映射,避免同一安装实例发送不同的客户端标识。此标识不依赖登录尝试,因为重启后保存的授权仍会发起请求。内嵌页面使用同一平台标识,并保留独立配置的其他请求头。
## 替代方案
把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。这些方案均被拒绝。
## 影响
退出登录先删除本地授权,再使用捕获的 token 调用 Platform POST /auth-api/v0/users/logout。远程失败不会恢复登录态。首次请求失败后,提供者最多重试五次,使用可配置的指数退避,默认从一秒开始。重试任务只保留旧 token,不改变后续登录态,并在提供者关闭时结束,不持久化。本地凭证删除成功后才发布已退出状态。退出登录位于侧边栏账号菜单;账号设置负责资料、余额和登录。两者通过框架 hook 读取插件持有的同一条 Host 状态流。
API Key 与账号记录独立保存。账号 token 没有过期或刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。提供者初始化时在本地丢弃与配置的 Platform 来源不同的授权,不调用远端撤销,使环境切换以未登录态启动,而不导致 Desktop 启动失败。API Key 和设备标识保留。资料与余额查询失败保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 分行显示 normal_wallets 充值余额与正额 bonus_wallets 赠金余额,避免将赠金额度显示为充值资金。
账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
支持问卷仅通过显式预填字段接收 Platform UID 和已有的环境信息,URL 不包含账号 token 或脱敏联系方式。账号提供者为此投影 current-user 的 UID;UI 使用最新账号快照,避免退出登录后复用旧 UID。
私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。
DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请求,不加 Bearer 前缀。API Key 继续使用各协议的认证方式。
资料和充值钱包余额分别通过 getProfile 与 getBalance 查询。客户端在各自返回时立即更新,余额请求慢或失败不会延迟侧边栏用户名显示。账号变化使两种进行中的查询结果失效。
开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。
内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。
exchange 的 user 数据在凭证提交后供首次资料读取使用,展示用户名无需再等待一次请求。Host 仅在当前尝试中保留筛选后的 UI 字段,与 token 匹配并读取一次后移除;后续读取使用 current。缺失或格式无效的 user 不会使已成功的授权失效。
内嵌 Platform 文档在加载期间保持隐藏,因为原生子视图会覆盖渲染层浮层。加载完成后仅当前文档可显示;返回或退登会使待执行的显示操作失效。应用文档刷新或替换、其渲染进程终止或窗口关闭时,原生视图的所有权也随之结束。仅依赖 React effect 清理不足以保证释放,因为文档销毁时可能不执行它。同文档导航和子框架导航保留视图。
独立的 accountRequestHeaders 将账号资料及内嵌 Platform 流量与授权、退登分开路由。Cookie 覆盖按名称合并,保留部署认证。Host 通过私有进程 IPC 传递合并后的请求头;Electron 仅在配置来源注入,并从 bootstrap 排除。
Host 将私有 Platform 会话绑定到账号提供者的生命周期。提供者移除或订阅结束时清空 Electron 会话;替换后订阅新的提供者,已释放的读取不得发布旧凭据。
客户端区分插件卸载和账户状态流的终止错误。RemoteStream 在两种情况下都会中止其信号,因此插件独立记录卸载状态,保留错误反馈并抑制卸载后的报告。
## 验证
提供者测试覆盖真实本机回调、错误 state、延迟兑换取消、凭证持久化、退出和官方来源限制。桌面测试覆盖原生操作桥接与本地化入口。手动开发联调使用平台 dev middleware Mock 和真实 Electron Host,包括在浏览器批准前取消。生产后端凭证及安装器 scheme 注册仍需发布环境验证。
## 相关记录
[凭证记录与流程](2026-08-13-credential-records-and-authorization-flows.zh.md)仍是通用凭证依据。[桌面外壳](2026-09-10-desktop-web-wrapper.zh.md)负责传输组合。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md
2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602
2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0
2026-09-21-conversation-build-groups.md: 7c02de8dd0aaf842690806ea67ddaf5d4561fc4e
2026-09-21-conversation-build-groups.zh.md: 4f2b331596cacdd63d902c9db73931d65ac589ad
@@ -12,9 +12,11 @@ A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat proc
Process groups are orthogonal to Steps: one Assistant Node can contribute reasoning inside a group and a reply outside it; tools after that reply enter a later group. Step-number ranges cannot describe membership. Existing event Definitions interpret one event at a time, whereas grouping needs the Nodes those Definitions have already interpreted.
Broad benchmark budgets do not establish that grouping updates are local. Keyed notifications, component identity, browser layout, and retained memory are distinct properties; passing one check does not establish the others.
## Decision
The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation are not registered by this foundation; the [business migration proposal](../../proposed/feature/2026-09-20-chat-work-details-migration.md) retains their requirements. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details.
The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Chat owns its registered segmentation and presentation in the [business-rule reference](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md). The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details.
### Responsibilities
@@ -22,21 +24,19 @@ The grouping foundation provides a separate, Node-input `ConversationGroupDefini
|---|---|
| Business grouping belongs to a registered Definition | Membership, segmentation, replies, steering, retries, summaries, and cache invalidation stay together. |
| Builder and assembler do generic work | Supply inputs, dispatch Definitions, validate references, reuse identities, and publish; never construct a concrete grouping class. |
| PR #4565 defines product behavior | Preserve its behavior without copying its invasive implementation. |
| PR #4565 supplies the product reference | The Chat business-rule reference owns the current behavior and its agreed adjustments. |
| React has two root kinds | The ordered root contains NodeReference and GroupReference, not another layout object model. |
| All modes share grouping | Mode is absent from Definition input, keys, and parent selection. |
| No buildLayout | ConversationViewDefinition keeps its existing responsibilities. |
| Foundation precedes business | No production Chat grouping, summaries, notifications, or footer changes in the foundation PR. |
| Business remains target-owned | Chat owns process grouping, summaries, notifications, and footer behavior; generic assembly does not interpret them. |
### Related decisions
| Record | Relationship |
|---|---|
| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. |
| [Presentation policy and grouping](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. |
| [Work-details migration](../../proposed/feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. |
These records retain independent rationale and remain active. Cross-View navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced.
Grouping preserves the independent Node-assembly decisions. Cross-View navigation and `toolCallFocus` remain separate responsibilities; Group references carry no View handles or resource-navigation policy.
## Definition input, state, and registration
@@ -116,7 +116,9 @@ The root selects `views.grouped('chat')?.entries` through the existing useConver
React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; presentation changes do not rerun the Group Definition or select a different root branch.
The stable Group parent is a `div` with `display: contents`, without its own layout box. CSS inheritance remains available; business styles adapt child/sibling selectors and own measurable body containers. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part.
The stable Group parent is a measurable `div`; its body owns capped inner scrolling and its content box reports growth. CSS inheritance remains available, and business styles adapt child/sibling selectors. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part.
The presentation channel maps each stored mode to a stable policy object. Seats and renderers select the fields they consume instead of receiving a mode prop through every renderer. Mode changes do not register Definitions or replay Contexts. Whole-Turn eligibility uses that Turn's loaded lifecycle facts rather than Session-wide pagination completion; the business-rule reference defines partial-history folding. Local group disclosure remains component state, while explicit whole-Turn closing resets participating disclosures through the injected Hook without replacing keys.
## Alternatives considered
@@ -138,6 +140,12 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
**Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive.
**Attach a header to the first member or to a per-Step Context.** A first-member header couples group UI to an arbitrary business row. Per-Step identity cannot distinguish multiple groups in one Step; sharing aggregate Turn data also refreshes unrelated headers or leaves later Step reads stale.
**Emit multiple Nodes from one event Context or derive child Contexts.** Most event Contexts own one Node; arrays add indirection without solving cross-Node grouping. Parent-derived Contexts require forwarding, replay, and removal lifecycles. A separate framework decision is warranted only if derived entities need those lifecycles independently of grouping.
**Keep all Markdown mounted or reset renderers by remounting.** Retaining lightweight seats is not a reason to retain every full Markdown body. Full bodies remain disclosure-owned, and explicit resets avoid destroying unrelated renderer state. Importing all of #4565 at once would also mix independent visual changes with grouping costs and obscure which behavior caused a regression.
## Verification
- [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes.
@@ -145,6 +153,15 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
- [Node source tests](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts) cover projected grouping inputs, indexed readers, and empty change batches.
- [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation.
Business behavior is verified through keyed-update regressions and recorded Web replay.
| Evidence | Required observation |
|---|---|
| Keyed notifications and React updates | Content growth affects the owning Node and affected Group; unchanged historical rows and unrelated Turns receive no extra notifications. Mode changes preserve keys and member parents. |
| Recorded Web replay | Current accessible titles, group disclosure, hidden ordinary Context, trigger notices, and footer placement match the committed expected output. |
[Recorded Web scenarios](../../../../apps/web/tests/steering.e2e.ts) cover online steering, reconnect handoff, and grouped presentation through the shipped Web profile. These behavior checks do not establish a quantified latency or memory improvement.
## Consequences
- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback.
@@ -152,4 +169,5 @@ The stable Group parent is a `div` with `display: contents`, without its own lay
- Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure.
- Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made.
- Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes.
- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups.
- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction belong to Chat's business adaptation; generic reference tests alone do not establish those behaviors.
- Turn status/duration replaces the old tool/message-count title, so status, accessible names, and empty-process Turns require explicit presentation coverage. Hiding ordinary Context retains non-human waking notices and original Session/Trajectory inspection; footer placement depends on the real Turn end rather than group membership.
@@ -12,9 +12,11 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。
宽松的基准预算不能证明分组更新具有局部性。按键通知、组件身份、浏览器布局与常驻内存是不同的性质,通过其中一项检查不能证明其他性质。
## Decision
分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。基础机制不注册真实 Chat 分段及 PR #4565 展示,[业务迁移提案](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md)保留这些需求。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。
分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。Chat 在[业务规则参考](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md)中拥有其已注册的分段与展示规则。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。
### 职责
@@ -22,21 +24,19 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
|---|---|
| 分组业务属于已注册 Definition | 成员、分段、回复、steering、重试、摘要和缓存失效放在一起。 |
| Builder 与 assembler 做统一工作 | 提供输入、调度 Definition、校验引用、复用身份并发布,不创建具体分组类。 |
| 产品行为以 PR #4565 为准 | 保留行为,不复制其侵入性实现。 |
| PR #4565 提供产品参考 | Chat 业务规则参考拥有当前行为及已确认的调整。 |
| React 根循环只有两种 kind | 根顺序包含 NodeReference 和 GroupReference,不引入另一套布局对象模型。 |
| 所有模式共用分组 | 模式不进入 Definition 输入、key 或父级选择。 |
| 不采用 buildLayout | ConversationViewDefinition 保留现有职责。 |
| 先基础、后业务 | 基础 PR 不包含真实 Chat 分组、摘要、通知或页脚变化。 |
| 业务属于目标包 | Chat 拥有过程分组、摘要、通知和页脚行为,通用组装不解释这些业务。 |
### 与已有记录的关系
| 记录 | 关系 |
|---|---|
| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 |
| [展示策略与过程分组](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 |
| [工作过程展示迁移](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 |
这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。
分组保留独立的节点组装决策。跨 View 导航与 `toolCallFocus` 仍是独立职责,Group 引用不携带 View 句柄或资源导航策略。
## Definition 输入、状态与注册
@@ -116,7 +116,9 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。
稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
稳定 Group 父级使用可测量的 `div`,正文拥有组内限高滚动,内容盒子报告尺寸增长。CSS 继承仍然可用,业务样式适配子级/兄弟选择器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。
展示通道将每个存储模式映射为稳定策略对象。Seat 和 renderer 选择自己使用的字段,不通过所有 renderer 透传模式 prop。模式变化不注册 Definition,也不重放 Context。整轮折叠资格使用本轮已加载的生命周期事实,不依赖整个 Session 的分页完成状态;半截历史的折叠规则由业务参考定义。组开合保留在组件本地状态中,显式收起整轮通过注入 Hook 重置参与折叠的内部开合,不更换 key。
## Alternatives considered
@@ -138,6 +140,12 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
**Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。
**让首成员或每 Step 的 Context 承载组头。** 首成员组头把组 UI 耦合到任意业务行。每 Step 的身份无法区分同一步内的多个组,共享整轮聚合数据还会刷新无关组头,或让后续 Step 的读取过期。
**一个事件 Context 输出多个 Node,或派生子 Context。** 多数事件 Context 只拥有一个 Node,数组增加间接层却不能解决跨 Node 分组。父级派生 Context 需要转发、重放及移除生命周期,只有派生实体需要独立于分组的这些生命周期时,才值得另作框架决策。
**常驻全部 Markdown,或靠重挂载重置 renderer。** 保留轻量 Seat 不意味着需要常驻所有完整 Markdown 正文。完整正文仍由开合状态控制,显式重置避免销毁无关 renderer 状态。一次性引入 #4565 的全部改动还会混合独立视觉变化与分组成本,难以判断导致回退的具体行为。
## Verification
- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。
@@ -145,6 +153,15 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
- [Node 来源测试](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts)覆盖投影后的分组输入、索引读取器和空变化批次。
- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。
业务行为通过按键更新回归与已录制的 Web 回放验证。
| 证据 | 必须观察到的结果 |
|---|---|
| 按键通知与 React 更新 | 正文增长影响所属 Node 和相关 Group;未变化历史行及其他 Turn 没有额外通知。模式变化保留 key 与成员父级。 |
| 已录制的 Web 回放 | 当前可访问标题、组开合、普通 Context 隐藏、触发通知及页脚位置与提交的预期输出一致。 |
[已录制的 Web 场景](../../../../apps/web/tests/steering.e2e.ts)通过正式 Web profile 覆盖在线 steering、重连接续与分组展示。这些行为检查不构成量化延迟或内存改善的证明。
## Consequences
- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。
@@ -152,4 +169,5 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派
- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。
- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。
- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。
- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动属于 Chat 业务适配,仅凭通用引用测试不能确认这些行为。
- 整轮状态与用时替代旧工具/消息计数标题,因此状态、可访问名称和空过程轮次需要明确的展示覆盖。隐藏普通 Context 时保留非人工唤醒通知及原始 Session/Trajectory 查看能力;页脚定位依赖真实 Turn 结束,不依赖分组成员。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md
2026-09-16-browser-excel-preview.md: 208bcdcd69d8ac30fcb5a7776271942424a92a05
2026-09-16-browser-excel-preview.zh.md: c48d9156e44befaa265d5c1f4116d86efe099fda
2026-09-16-browser-excel-preview.md: 15db888330b2a1a6d6d78dad46689962466d531d
2026-09-16-browser-excel-preview.zh.md: 6b3a6a617a0cd18dcd3da1682a31f26bd7e70386
@@ -8,12 +8,16 @@ English | [中文](2026-09-16-browser-excel-preview.zh.md)
PDF conversion loses spreadsheet navigation and the relationship between a formula and its saved value. Spreadsheet preview also needlessly depends on the Host Office engine when the browser can read the workbook.
Unsupported drawing content can also prevent cell preview. ExcelJS 4.4.0 expects prefixed DrawingML element names and fails on valid default-namespace drawings emitted by Openpyxl. A chart that the viewer cannot display must not make otherwise readable worksheets unavailable.
## Decision
The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX, XLS, CSV, and TSV with FortuneSheet and package-local adapters for ExcelJS, SheetJS CE, and PapaParse. The adapter accepts bytes, format, and limits and returns worksheet data; it has no React state setters, timers, or component references. Dependencies own file parsing, while the adapters share display formatting and initial selection without introducing another workbook model. CSV/TSV retain literal strings and a plain-text option; implicit type inference could otherwise discard leading zeros or interpret source text as formulas. The shared document reader calls `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)` and receives native bytes through the binary Remote, retaining Session authorization, file versions, reload, and transient bytes without base64 decoding. Workspace Files owns bounded file reads; format parsing stays in the Client. The registration marks only XLSX/XLS as `binaryExtensions`, so the shared document container offers Plain text for CSV/TSV. Each renderer registers its body under its own id.
Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank. Workbooks containing formulas show a compact warning beside the formula icon instead of consuming worksheet height with a notice row. The spreadsheet is neither an authoring tool nor a full Excel rendering engine.
The package-local `XlsxPreviewArchive` inspects XLSX content inside the parser Worker and removes DrawingML parts and their relationship parts from a temporary ZIP. ExcelJS also ignores worksheet drawing references; ignoring those references alone still leaves its archive-level drawing parser exposed to the failure. The source bytes and retained worksheet XML remain unchanged. A separate notice lists detected charts, images, shapes, and conditional formatting that the preview does not display and recommends a system application. Formula warnings remain independent, and files without detected unsupported content have no notice. Other formats return no unsupported-content categories.
A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only.
The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@[email protected]) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under the shipped React 18.3.1 runtime. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry.
@@ -28,8 +32,14 @@ The ESM and CommonJS entries receive the same patches. Recheck worksheet switchi
**Write an OOXML parser or create a new public package.** ExcelJS already owns the container and file semantics. One preview consumer does not justify another public API, Cordis service, or generic workbook abstraction; a second independent consumer can motivate extraction.
**Patch ExcelJS's drawing namespace handling.** Correct parsing would still leave drawings undisplayed. Filtering the unsupported parts prevents them from blocking the supported cell preview without maintaining drawing behavior that the viewer cannot use.
**Replace ExcelJS or require users to rewrite their files.** A parser replacement puts retained fonts, borders, merges, and frozen panes at risk. Rewriting a source file makes a viewer defect the user's responsibility. A temporary preview copy preserves both the existing adapter and the user's file.
**Keep PDF as an Excel fallback or calculate every formula on import.** PDF retains neither spreadsheet interaction nor formula inspection, while recalculation can change saved results. The [Office engine decision](../architecture/2026-09-11-node-office-kit.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets.
## Consequences
The renderer trades complete Excel fidelity for browser-local navigation and reusable conversion code. The package README owns supported formats, resource defaults, and unsupported features. Real XLSX/XLS fixtures and delimited-text cases cover conversion, Worker tests cover cancellation and cleanup, and the shipped-profile browser scenario opens a workbook with Office conversion disabled and copies a saved XLOOKUP result under read-only settings. It also exercises XLS sheet switching and CSV/TSV literal copying, text-view switching, and reload. SheetJS CE is pinned to its official release tarball because the public npm registry is stale; its Apache-2.0 license follows the existing permissive-license and bundled-notice policy.
Drawing filtering adds ZIP inspection and, when parts are omitted, temporary uncompressed ZIP allocation. Existing Worker lifetime and source/matrix limits still apply; they do not bound decompressed memory. Drawing fixtures cover default and alternate prefixes, exact retained XML and input bytes, images, shapes, and comment VML. The browser scenario checks the notice, saved-value copying, and notice removal on file replacement. Adding drawing rendering requires revisiting both filtering and notice classification; a namespace fix alone does not restore that capability.
@@ -1,4 +1,4 @@
# Agent Note:显示已保存公式结果的浏览器 Excel 预览
# Agent Note: 显示已保存公式结果的浏览器 Excel 预览
Status: implemented
@@ -8,12 +8,16 @@ Status: implemented
PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。浏览器能够读取工作簿时,表格预览也没有必要依赖 Host Office 引擎。
不支持的绘图内容也可能阻断单元格预览。ExcelJS 4.4.0 要求 DrawingML 元素名带前缀,遇到 Openpyxl 生成的合法默认命名空间绘图时会失败。查看器无法展示的图表不应使原本可读的工作表无法打开。
## 决策
[文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS、SheetJS CE 和 PapaParse 适配层打开 XLSX、XLS、CSV 和 TSV。适配层接收字节、格式和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。依赖库负责文件解析,适配层复用显示格式化和初始选区,不引入另一种工作簿模型。CSV/TSV 保留字面字符串和纯文本选项;隐式类型推断可能丢失前导零或把源文本解释为公式。共享文档读取器调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`,并通过二进制 Remote 直接接收原生字节,保留 Session 授权、文件版本、重新载入和临时字节,无需 base64 解码。Workspace Files 负责有界文件读取,格式解析留在 Client。注册仅将 XLSX/XLS 标为 `binaryExtensions`,共享文档容器因此为 CSV/TSV 提供纯文本选项。每个渲染器以自己的 id 注册正文。
预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空。包含公式的工作簿会在公式图标旁显示紧凑提示,不再用整行提示占用工作表高度。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。
包内 `XlsxPreviewArchive` 在解析 Worker 中检查 XLSX 内容,从临时 ZIP 中移除 DrawingML 部件及其关系部件。ExcelJS 同时忽略工作表绘图引用;仅忽略这些引用仍会让其归档级绘图解析器触发该故障。源字节和保留的工作表 XML 均不改变。独立提示列出实际检测到但预览未展示的图表、图片、形状和条件格式,并建议使用系统应用。公式提示保持独立,未检测到不支持内容的文件不显示此提示。其他格式不返回不支持内容分类。
独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。
固定版本的 FortuneSheet React 依赖带有一个[补丁](../../../../patches/@[email protected]):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在仓库交付的 React 18.3.1 运行时下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障。
@@ -28,8 +32,14 @@ ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新
**自行编写 OOXML 解析器或创建新的公共包。** ExcelJS 已经负责容器和文件语义。单个预览使用方不足以支持另一套公共 API、Cordis 服务或通用工作簿抽象;出现第二个独立使用方时再考虑提取。
**修补 ExcelJS 的绘图命名空间处理。** 正确解析仍不能使查看器展示绘图。过滤不支持的部件可防止其阻断已支持的单元格预览,无需维护查看器无法使用的绘图行为。
**替换 ExcelJS 或要求用户重写文件。** 更换解析器会使现有字体、边框、合并及冻结窗格的保留面临风险。重写源文件则把查看器缺陷交给用户处理。临时预览副本同时保留现有适配器和用户文件。
**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。
## 影响
渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX/XLS fixture 和分隔文本用例覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。该场景还验证 XLS 工作表切换,以及 CSV/TSV 字面值复制、纯文本切换和重新载入。由于公共 npm 仓库版本过旧,SheetJS CE 固定使用官方发布的 tarball;其 Apache-2.0 许可证沿用现有宽松许可证及打包声明规则。
绘图过滤增加 ZIP 检查,并在移除部件时分配临时未压缩 ZIP。现有 Worker 生命周期和源文件/矩阵限制仍然适用,但不约束解压后的内存。绘图 fixture 覆盖默认及替代前缀、保留 XML 和输入字节的逐字节一致性、图片、形状及批注 VML。浏览器场景检查提示、已保存值的复制,以及文件替换后的提示移除。增加绘图渲染时,必须同时重新审视过滤和提示分类;仅修复命名空间不能恢复该能力。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md
2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47
@@ -1,288 +0,0 @@
# Agent Note: Chat presentation policy channel and process-group architecture
Status: proposed
English | [中文](2026-09-20-chat-presentation-policy-and-step-groups.zh.md)
## Problem
At the start of this proposal, Chat's display setting had two modes: `normal` did not fold content, while `compact` folded process rows behind a whole-Turn control after completion. `TranscriptViewPolicy.mode` was injected into ChatView as an observable. ChatView read it through `useTranscriptView`, converted it to the boolean `compactTranscript`, and passed that prop through `ChatNodeList` to every `ChatNodeSeat`. The global `hasMore` boolean followed the same path as `historyIncomplete`. Either change re-rendered every seat, even when only a few foldable rows changed their output.
The product requires three modes—Compact, Detailed, and Expanded—with consecutive process rows between replies collected into collapsible groups in Compact and Detailed. PR #4565 implemented the full feature, but was reverted eight minutes after merging because of performance regressions. The recorded local before/after comparison found long-session pagination +27%, DOM nodes +23%, heap +14%, and Trajectory switching +10%, all within CI budgets. None of the six existing repository benchmarks covered the new hot path: reasoning or tool chunks entering an active group during streaming, group reprojection, and seat re-rendering.
The review identified implementation choices, not the product requirements, as the problem:
- The mode became an input to list structure. `ChatNodeList` subscribed to the entire `layout` and generated wrapper components from ranges; Expanded removed those wrappers. Switching modes remounted the list, and regrouping rebuilt wrapper keys.
- The mode entered `ChatNodeOwnerProps`. Every seat's owner object changed with the mode, re-rendering all business renderers, including unrelated user messages, tool rows, and compaction markers.
- A seat split one Assistant node into derived reasoning and response objects solely to wrap the former in the DOM. Master's `AssistantMarkdown` already had `reasoningHidden` and `revealProcess`, which could hide reasoning independently.
- Consumers repeated infrastructure-row filtering in four places, although nodes already had a Definition-owned `visibility` field.
The deeper difficulty is information ownership: a group header must exist before its members as soon as the group opens, but its content requires a view of the entire Turn. Each event-driven Context receives only its own events. Only a turn-level Context can observe the whole Turn, and it can produce only one node.
This proposal supplies an architecture for three modes and grouping that satisfies the constraints below and confines later product changes to a small set of nodes.
The three settings, presentation policy channel, and per-Turn folding eligibility are implemented; step-group remains proposed. The [migration progress table](../feature/2026-09-20-chat-work-details-migration.md) owns current implementation, committed scope, and incomplete items. The grouping state machine and change inventory below do not describe delivered work.
## Goals and constraints
The [implemented Definition-owned Build Group foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan.
The following decisions were confirmed during design review and are inputs to this proposal.
| Number | Decision | Meaning |
|---|---|---|
| 1 | Definitions own group structure | A Definition matching multiple events in the Turn folds group opening, closing, member ranges, and summaries; renderers do not derive them by scanning nodes |
| 2 | Limit seat re-rendering | Mode changes re-render only seats whose presentation changes; streaming chunks re-render only their seat and group header; user messages, replies, and turn-tail do not re-render for either change |
| 3 | One node per Context | Do not introduce multiple nodes per Context; most Contexts have one element, and arrays would fragment memory further |
| 4 | Modes do not toggle Definitions | Registry changes rebuild and replay all Contexts, refreshing every seat; Definitions must be mode-independent |
| 5 | Decide folding per Turn | A Turn can fold when its own `turn/start` is loaded; the oldest partial Turn stays open and is not optimized here |
| 6 | ui-conversation may change | Engine changes are allowed only where needed for this design to work correctly |
| 7 | Initial implementation scope | Initially change only code and this note; add tests and other documentation during CI repair |
Accepting a full-page browser layout on mode changes does not mean accepting every seat re-rendering. Layout is a geometric result; a seat re-render executes a React component function. This proposal constrains the latter.
## Viable approaches
There are five approaches to group-header identity and content ownership.
**Approach A: the first member's seat renders the header.** Keep the list unchanged. The builder derives membership and tells the first member's seat to render a title above itself; other member seats use the existing `hidden` mechanism. This adds no new concept, but creates a third seat state—visible with only a title—and attaches the header to a business node's seat.
**Approach α: one Context per step supplies a header.** Add a Definition identified by `turn:step`. During construction, read the preceding `assistant-step` data to decide whether this is a new group start and emit a header node if so. Another turn-level Definition publishes the group's content as Turn data for the renderer.
**Approach β: one Context emits multiple nodes.** A turn-level Definition emits the whole-Turn control and N group headers.
**Approach γ: a parent Context derives child Contexts.** When a group opens, the turn-level fold creates a `step-group` child Context and forwards later events to it. Each child owns one node. The engine needs forwarding and cascaded replay primitives.
**Approach δ: Definitions own structure; the builder materializes rows.** A turn-level Definition folds the group list and publishes Turn data. The Chat builder materializes header nodes and assigns membership. Seats use combined selectors for visibility. `ViewBuilder.apply` receives the Location data whose references changed in the flush.
## Alternatives considered
**One Context per group cannot satisfy the matching rules.** The engine requires `match(event)` to derive an id from one event. A group number depends on whether a reply preceded the event; one event cannot determine it. This is incompatible with the matching contract, not merely difficult to implement.
**Approach α conflicts with decision 2.** Header identity is derivable, but every header reads the same Turn data, so one group's change refreshes all headers in that Turn. Reading later steps through `turn.steps` instead leaves stale references because a header node is not rebuilt when later steps arrive.
**Decision 3 rules out approach β.** See the table above.
**Approach γ is not justified yet.** It has the clearest ownership, but forwarding and cascaded replay would add hundreds of lines to the assembler core. It remains an alternative under the conditions at the end of this note.
**Approach A remains the fallback.** If builder-owned materialization is rejected in practice, use A. Both share the same structural source and differ only in what carries the header row.
Choose approach δ.
## Proposal
### Data flow
```
事件流
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
──▶ Turn 数据 step-groups:组列表,每组一个对象
──▶ Chat builder
├─ 物化组头节点:一组一个 key,data 即组对象
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
──▶ 组头渲染器:读自己节点的 data
展示策略
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
──▶ seat:组合 selector,选出的是自己的结论
```
The only engine change passes the Location data whose references changed during a flush to the builder through `apply`.
### Responsibilities
| Layer | Owns | Explicitly does not own |
|---|---|---|
| `step-groups` Definition | Opening and closing groups, `headStep`, `closeStep`, tool counts and names, running tools; publishes Turn data | View nodes or identification of member nodes |
| Chat builder | Header-node identity and lifecycle, membership, `turnStarted`, identity comparison and publication of per-key presentation | Reading events or deriving business structure |
| Presentation policy object | Mapping the enum to capability fields | Node or seat state |
| Seat | Combining policy fields, role, and store state into hidden state; supplying group open/close actions to the header renderer | Determining group boundaries |
| Header renderer | Title, chevron, click-to-toggle interaction | The source of group content |
| assistant-step renderer | Settled reasoning first-line preview; hiding reasoning when closing a group | Group boundaries |
### Header-node identity and lifecycle
A header node's key is `step-group:${turn}:${headStep}`. `headStep` is fixed when a group opens, and later events cannot change its first member. The header exists and is reused from that point onward; its key does not change during streaming. Closing the group only changes `closed` in its data from false to true.
The header anchor is the first member's anchor minus a synthetic offset, placing it immediately before that member. A streaming Assistant is anchored at its first visible chunk, then at its message event after persistence. Its anchor moves once; the header follows in the same structural update without adding another update.
A header is part of the whole-Turn process and hides with its members when the Turn folds. It is not in `TURN_PROCESS_INDEPENDENT_KINDS`.
Header data is the group object published by the fold. Closed group objects never change references, so their header sources never notify. Each event that changes a running group replaces only that group's object and re-renders only its header.
### Fold state machine
The `step-groups` Definition uses the Turn number as its id, starts on `turn/start`, and matches the same event set as `turn-process`. Its State is `{ turn, groups, open }`: `groups` is an array of group objects, and `open` is the active group's index or empty.
| Event | Transition |
|---|---|
| `tool/call` | If no group is open, open one at the event's step; increment the count, record distinct tool names, and add callId to the running set |
| Appended `tool/result` | Remove callId from the running set |
| `assistant/live-chunk` reasoning delta with visible characters | Open a group at the chunk's step if none is open |
| `assistant/live-chunk` text delta or text-block end with visible characters | Close an open group at the chunk's step |
| Appended `assistant/message` | Apply the reasoning rule for visible reasoning and the text rule for a visible reply, in reasoning-then-text order |
| `turn/end` | Mark an open group as closed by the Turn end |
| `step/start`, `step/end`, `llm/retry` | No state change; retry rows belong to no group |
Three invariants apply:
- Idempotence: opening an already-open group and closing an already-closed group do nothing. Live chunks and persisted messages describing the same content do not open or close twice; replay with only persisted messages derives the same group list.
- Stable references: `update` returns the original object when State is unchanged; the `groups` array changes reference only when the group list changes; closed group objects are never replaced.
- Forward-only folding: do not read neighboring Contexts, nodes, or later history.
Turn-data publication: `buildLocationData` returns a value only for the turn phase and reuses its previous value when the `groups` reference is unchanged, preventing republication. A history window without `turn/start` uses the same fallback as `turn-process`: fold State from the available matches.
Publication timing: batch live chunks at animation frames; publish other changes immediately.
### Membership rules
The builder assigns membership by step, not by seq ranges, because live-chunk seqs and persisted-event seqs use different scales.
| Node | Rule |
|---|---|
| `tool-call` at step s | Belongs to the last group with `headStep ≤ s`. If text closes the preceding group in the same step, later tool calls open a new group at that step, so the last group is the applicable one |
| `assistant-step` at step s where a group has `closeStep === s` | Closer role: the node remains visible; folding the group hides only its reasoning through the existing `reasoningHidden` mechanism |
| Other `assistant-step` at step s | Member role: belongs to the group with `headStep ≤ s` whose `closeStep` has not been passed |
| `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail`, `user`, `steering` | Belong to no group |
Recompute membership only when the group list changes, with cost proportional to that Turn's node count. Streaming chunks that leave the group list unchanged do not publish member presentation.
The header anchors before the earliest member. If a group contains only its closer's reasoning and no members, such as reasoning followed by a reply in one step, anchor the header before the closer so the hidden reasoning still has a control that reveals it.
### Delivering group changes to the builder
The fold publishes Turn data, not view nodes. When Location data changes, the engine's `flush` sets `timelineDirty` and calls `apply` on all active targets, but the builder cannot identify the changed Turn and key. Inferring them from an unrelated node update in the same Turn and flush is not a reliable guarantee.
Add optional `changedLocationData` to `ConversationViewBuilder.apply`: the Location values whose references changed in this flush, each carrying `kind`, `turn`, optional `step`, and `key`. The assembler already compares references in `applyDirtyLocationData`; it collects those results for the builder. Chat reprojects only Turns with `key === 'step-groups'`. When the optional input is absent, the builder falls back to Turns affected by the current upserts.
### Combining hidden state in a seat
Seats stop receiving `compactTranscript` and `historyIncomplete`. A seat reads policy through `usePresentation`, selecting its own result rather than the mode:
```ts
declare function usePresentation<T>(select: (policy: {
stepGrouping: 'collapsed' | 'none'
foldCompletedTurns: boolean
}) => T): T
declare const group: { readonly headStep: number } | undefined
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
```
For a seat outside every group, `grouping` is `'none'` in all modes, so it does not re-render. `foldCompletedTurns` likewise stays true in all modes. The selector hook uses `useSyncExternalStoreWithSelector` and re-renders only when the selected value changes.
Three sources combine into `hidden`:
| Source | Condition | Reveal action |
|---|---|---|
| Whole-Turn folding | Existing rule: `turnClosed && turnStarted && has reply`, without manual expansion | Open the Turn |
| Group folding | `grouping === 'collapsed'`, member role, group not manually expanded | Open the group |
| Group header in Expanded | Header role and `grouping === 'none'` | None |
The closer's reasoning is hidden independently: the seat passes `stepGroup.reasoningHidden` through owner props to the assistant-step renderer.
The chat store keeps group expansion under a key combining `turn` and `headStep`, alongside whole-Turn manual expansion.
### Re-render scope
The following guarantees also define the next round of render-count assertions. Each row names an exact set, not an upper bound.
| External change | May re-render |
|---|---|
| A running group receives a reasoning chunk | That assistant seat |
| A running group receives `tool/call` | That tool seat and the group's header |
| A reply appears and closes a group | That assistant seat and group header; membership recomputation occurs only when opening or closing groups |
| A new group opens | Insert the new header into `order` with one structural layout; no other seat refreshes |
| Switch Compact ↔ Detailed | Only assistant-step renderers with settled reasoning |
| Switch to or from Expanded | Only member and header seats |
| Change `performanceUsage` | Only turn-tail |
| Load an earlier page | New seats and historical seats whose presentation changes |
| Change locale | All seats, as a control case |
### Presentation policy object
Separate the persisted enum from runtime capabilities. `TranscriptViewMode` contains `compact`, `detailed`, and `expanded`; reading the legacy saved value `normal` maps it to `detailed` without writing it back. Runtime capabilities are the derived `ChatPresentationPolicy`:
| Field | compact | detailed | expanded |
|---|---|---|---|
| `foldCompletedTurns` | true | true | true |
| `stepGrouping` | collapsed | collapsed | none |
| `settledReasoningPreview` | false | true | true |
The mapping has one owner. Each mode maps to a constant object with a stable reference, so the derived observable needs no subscription bookkeeping: `getSnapshot` performs a lookup and `subscribe` forwards to the mode observable.
Renderers select needed fields instead of comparing enum values. Policy is injected by kind: the assistant-step registration receives it through `inject.hooks.presentation`, just as turn-tail receives `performanceUsage`. Adding a fourth mode changes the table, not the renderers.
### Per-Turn folding
Add `turnStarted` to per-key presentation, derived from whether the Turn's `turn/start` is in the loaded window. Folding eligibility uses it instead of global `!historyIncomplete`. Completing pagination changes no global seat prop; only new nodes and presentation in their Turns change.
### Complexity requirements
| Scenario | Fold | Builder | Publication |
|---|---|---|---|
| Content chunk without a group-boundary change | O(1) transition, original object returned | No reprojection | None |
| Event that changes a group boundary | O(1) | Reproject only the owning Turn, proportional to its node count | Keys whose facts changed in that Turn |
| Structural changes and pagination | Proportional to matched events | One full pass | Keys whose facts changed |
| Mode change | None | None | None; seat and renderer selectors determine re-renders |
## Change inventory
| Package | File | Change |
|---|---|---|
| ui-conversation | `contract/conversation.ts` | Add `ConversationLocationDataUpdate` and optional `changedLocationData` to `ConversationViewBuilder.apply` |
| ui-conversation | `conversation/assembler.ts` | Collect changed Location references in `applyDirtyLocationData` and pass them through `apply` |
| ui-conversation | `index.ts` | Export the new type |
| ui-chat | `chat-settings.ts` | Three-mode enum; schema accepts legacy `normal` |
| ui-chat | `transcript-view.ts` | Map `normal` to `detailed` on read |
| ui-chat | New `presentation-policy.ts` | Policy objects, mapping table, derived observable |
| ui-chat | `settings/TranscriptViewRow.tsx` | Three options |
| ui-chat | `locale.ts` | Mode labels and group-header copy |
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`, `PresentationInjected`, `ChatNodeOwnerProps.stepGroup`, `StepGroupOwnerProps` |
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`, `ChatNodeGroupPresentation`; combine `processSource` values per key |
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
| ui-chat | `contract/store.ts`, `stores.ts` | Manual group-expansion state and actions |
| ui-chat | `conversation-nodes/common.ts` | Header anchor offset |
| ui-chat | New `conversation-nodes/step-groups.ts` | Fold Definition and Turn-data declaration merging |
| ui-chat | New `conversation-nodes/step-group-nodes.ts` | Materialize headers and project membership |
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted` and combined per-key presentation |
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | Integrate materialization, consume `changedLocationData`, support key deletion in the store |
| ui-chat | `conversation-nodes/register.ts` | Register the fold Definition |
| ui-chat | `chat/ChatView.tsx` | Stop passing `compactTranscript` and `historyIncomplete`; pass `usePresentation` |
| ui-chat | `chat/ChatNodeSeat.tsx` | Combined selector, three hiding sources, `stepGroup` owner props |
| ui-chat | `chat/AssistantNodeView.tsx`, `chat/AssistantMarkdown.tsx`, `chat/ReasoningRow.tsx` | Inject policy, toggle settled reasoning previews, hide closer reasoning |
| ui-chat | New `chat/StepGroupNodeView.tsx` | Header renderer |
| ui-chat | `chat/register-node-renderers.ts` | Register header renderer and inject policy into assistant-step |
| ui-chat | `apply.ts`, `index.ts` | Create and inject policy observable; export types |
This inventory retains the complete grouping proposal. Independent features have updated READMEs, i18n pairing files, and the slot catalog. The migration record tracks tests and snapshots; those updates do not complete the outstanding grouping work.
## Acceptance criteria
Grouping validation remains unimplemented; the migration record tracks independent presentation-feature evidence.
**Render-count isolation.** Add counters to the existing `chat-view.client.spec` mounting approach: React Profiler `onRender` counts per seat, and test renderers registered in `conversation.chat.node` count by kind. Assert the exact sets in the re-render-scope table for each stimulus.
**Publication counts.** Count notifications from each key's source under the same stimuli and assert the exact set.
**Fold state-machine tests.** Feed event sequences and assert opening, closing, a new group after text then tools in one step, Turn-end closure, idempotence, unchanged closed-group references, and equivalent replay using only persisted messages.
**Membership tests.** Given the group list and Turn nodes, assert each key's role.
**Benchmark coverage.** Add incremental apply with reasoning and tools to `conversation-fold`, and a streaming fixture with tools and reasoning to `long-session-browser`. These provide wall-clock evidence, not a gate.
## Risks
- **Builder-owned node creation.** This is the first proposed node creation by a view builder. A builder may only materialize rows from Turn or Step data; it must not read events or derive business structure. Synthetic keys use `step-group:`, while Context keys begin with digits, preventing collisions. Fall back to approach A if this ownership is rejected in practice.
- **Header anchor moves once with its first member.** Persisting a streaming Assistant moves its anchor from the first visible chunk to the message event. The header follows in the Assistant's structural update.
- **Actual mode differences in this phase.** Compact and Detailed currently differ only in settled reasoning first-line previews. Running detail titles and category summaries are outside this phase; the policy table leaves room for them.
- **Pagination and partial Turns.** The oldest partial Turn neither folds nor groups. Membership uses steps and is independent of pagination.
- **Header copy.** This phase proposes only processing/completed labels and tool-call counts; category summaries remain product work.
- **Streaming chunk step.** `assistant/live-chunk` carries `step`, as used by `turn-process`; a chunk without a step does not open a group.
## Retained alternative: parent-derived child Contexts
If builder materialization exposes more ownership problems than expected, add an engine primitive: a Context's `update` may derive a child Context with an explicit kind and id, then forward later matches. Each child owns its node, publication timing, and Location. Parent replay aligns children by id; children no longer derived become hidden under the existing withdrawal rules.
This returns row identity and content to Definitions with one Context per group and no array fragmentation. It requires assembler forwarding and cascaded replay, and introduces a Context creation path outside `match`. Reconsider it if more than two business features exceed the builder's materialization limits, or if membership requires cases that steps cannot express.
@@ -1,288 +0,0 @@
# Agent Note: Chat 展示策略通道与过程分组的基础架构
Status: proposed
[English](2026-09-20-chat-presentation-policy-and-step-groups.md) | 中文
## 问题
Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。
产品要把这个设置改成三档:简洁、详细、完全展开;并在简洁和详细两档里把回复之间的连续过程行归成可折叠的组。PR #4565 做过一次完整实现,合并八分钟后因性能回退被整体撤回。合并前后在本机的对照实测:长会话分页耗时 +27%,DOM 节点 +23%,堆内存 +14%,Trajectory 切换 +10%,全部仍在 CI 预算内;仓库现有六个基准没有任何一个覆盖「流式期间 reasoning 或工具 chunk 进入活动分组、分组重投影、seat 重渲染」这条它新增的热路径。
审阅那次实现,问题不在业务需求,在选型:
- 模式被当成列表结构的输入。`ChatNodeList` 订阅整表 `layout`,按 range 生成 wrapper 组件;完全展开模式下 wrapper 消失。模式切换等于整棵列表重挂载,任何一次重新分组都重建 wrapper 的 key。
- 模式进了 `ChatNodeOwnerProps`。每个 seat 的 owner 对象随模式变,所有业务渲染器跟着重渲染,包括与模式无关的用户消息、工具行、压缩标记。
- 同一个 Assistant 节点在 seat 里被拆成 reasoning 与 response 两份派生对象,只为了让 wrapper 在 DOM 上包住前者不包住后者。master 的 `AssistantMarkdown` 早有 `reasoningHidden` 与 `revealProcess`,靠隐藏状态就能只藏 reasoning。
- 基础设施行的 Chat 可见性由消费方过滤函数在四处各判一遍,而节点本身有 `visibility` 字段由 Definition 决定。
再往下一层,是一个信息流上的两难:分组的行要出现在成员之前,从组打开那一刻就该存在;但它要显示的内容,只有看到整轮所有事件的人才知道。事件驱动的 Context 模型里每个 Context 只收自己的事件,能看到整轮的只有 turn 级 Context,而它只能产出一个节点。
本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。
三档设置、展示策略通道与按轮折叠资格已实现;step-group 仍是提案。当前实现、已提交范围和未完成项以[迁移进度表](../feature/2026-09-20-chat-work-details-migration.zh.md)为准,下面的分组状态机与改动清单不表示已经交付。
## 目标与约束
[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。
下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。
| 编号 | 裁决 | 含义 |
|---|---|---|
| 1 | 分组结构在 Definition 层定义 | 组的开合、成员范围、摘要由一个多重匹配本轮事件的 Definition 折叠出来,不由渲染层扫描节点得到 |
| 2 | 有限刷 seat | 切模式只重渲染 presentation 真正变化的 seat;流式 chunk 只重渲染所属 seat 和它所在组的组头;用户消息、回复、turn-tail 在这两种变化下零重渲染 |
| 3 | 一个 Context 一个节点 | 不引入「一个 Context 产出多个节点」,理由是绝大多数 Context 只会有一个元素,数组化让内存更零散 |
| 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 |
| 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 |
| 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 |
| 7 | 初始实现范围 | 初始阶段只写代码与本记录;测试与其他文档在 CI 修复阶段补齐 |
有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。
## 可行的路线
围绕「组头这一行的身份从哪来、内容从哪来」,可行的路线有五条。
**路线 A,头成员的 seat 画组头。** 列表不变,builder 算出分组后告诉第一个成员的 seat「你是组头」,该 seat 在自己上方画标题,其余成员 seat 用现有 `hidden` 机制藏起来。零新概念。缺点是 seat 里出现「自身可见但只显示标题」的第三种状态,且标题寄生在一个业务节点的 seat 里。
**路线 α,每步一个 Context 当组头。** 增加一个 id 为 `turn:step` 的 Definition,在构建时读上一步的 `assistant-step` 数据判断自己是否新组开头,是则产出组头节点。组的内容由另一个 turn 级 Definition 发布成 Turn 数据供渲染器读取。
**路线 β,一个 Context 产出多个节点。** turn 级 Definition 一个人产出整轮控件加 N 个组头。
**路线 γ,父 Context 派生子 Context。** turn 级 fold 在开组时生成一个 `step-group` 子 Context 并把后续事件转发给它,子 Context 有自己的单个节点。引擎需要新增转发原语与重放级联。
**路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。
## 备选方案与取舍
**每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。
**路线 α 与裁决 2 冲突。** 组头身份能算出,但内容要读整轮一份 Turn 数据,任何一组变化全轮组头都重渲染。改成去读 `turn.steps` 里后面几步的数据,又碰上组头节点不会因为后面的步到来而重建,读到的是陈旧引用。
**路线 β 被裁决 3 否决。** 见上表。
**路线 γ 现在不值。** 它在所有权上最纯,但要给 assembler 加转发与重放级联,是几百行的引擎核心改动。本记录把它保留为备选,条件写在末尾。
**路线 A 作为退路保留。** 如果 builder 物化节点在实践中被否决,退到 A;两者的结构来源相同,只差组头这一行的承载。
选择路线 δ。
## 方案
### 数据流
```
事件流
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
──▶ Turn 数据 step-groups:组列表,每组一个对象
──▶ Chat builder
├─ 物化组头节点:一组一个 key,data 即组对象
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
──▶ 组头渲染器:读自己节点的 data
展示策略
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
──▶ seat:组合 selector,选出的是自己的结论
```
引擎改动只有一处:assembler 在 flush 时把「哪些 Location 数据换了引用」随 `apply` 入参交给 builder。
### 各层职责
| 层 | 拥有 | 明确不拥有 |
|---|---|---|
| `step-groups` Definition | 组的开合、`headStep`、`closeStep`、工具计数与名称、运行中的工具;作为 Turn 数据发布 | 任何视图节点;成员是哪些节点 |
| Chat builder | 组头节点的身份与生命周期;成员归属;`turnStarted`;per-key presentation 的身份比较发布 | 读事件;自己算业务结构 |
| 展示策略对象 | 枚举到能力字段的映射 | 任何节点或 seat 状态 |
| seat | 用策略字段、角色、store 状态合成 hidden;把组的开合能力交给组头渲染器 | 判断组的边界 |
| 组头渲染器 | 标题、箭头、点击开合 | 组的内容来源 |
| assistant-step 渲染器 | 是否显示已完成推理的首行预览;作为组的关闭者时隐藏推理部分 | 组的边界 |
### 组头节点的身份与生命周期
组头节点的 key 是 `step-group:${turn}:${headStep}`。`headStep` 在组打开那一刻就确定,之后任何事件都改不了「谁是第一个」,所以组头节点从打开起就存在并被复用,运行中不会换 key。组关闭只是它 data 里的 `closed` 从 false 变 true。
组头节点的锚点是该组头成员节点锚点减去一个合成偏移,位置紧贴在第一个成员之前。头成员的 Assistant 节点在流式期间锚在第一个可见 chunk,落盘后锚在消息事件,锚点会移动一次;组头随之移动一次,这是一次结构性更新,与 Assistant 节点自己的结构性更新同时发生,不额外增加次数。
组头节点属于整轮过程的一部分:整轮折叠时它随成员一起隐藏。它不在 `TURN_PROCESS_INDEPENDENT_KINDS` 里。
组头节点的 data 就是 fold 发布的组对象。已关闭的组对象引用永不改变,因此已关闭组头的 source 永不通知;正在运行的组每来一个改变它的事件换一个对象,只有这一组的组头重渲染。
### fold 状态机
`step-groups` Definition 的 id 是 turn 号,在 `turn/start` 上启动,匹配与 `turn-process` 同一批事件。State 是 `{ turn, groups, open }`,`groups` 是组对象数组,`open` 是当前打开组的下标或空。
| 事件 | 转移 |
|---|---|
| `tool/call` | 没有打开的组则以该事件的 step 开新组;计数加一;记录 distinct 工具名;把 callId 加入运行集合 |
| `tool/result` 追加 | 从运行集合移除 callId |
| `assistant/live-chunk` 推理 delta 含可见字符 | 没有打开的组则以该 chunk 的 step 开新组 |
| `assistant/live-chunk` 文本 delta 或文本块结束含可见字符 | 有打开的组则以该 chunk 的 step 关闭它 |
| `assistant/message` 追加 | 消息含可见推理则按推理规则处理,含可见回复则按文本规则处理;顺序是先推理后文本 |
| `turn/end` | 有打开的组则标记为被轮次结束关闭 |
| `step/start`、`step/end`、`llm/retry` | 不改变状态;重试行不属于任何组 |
三条不变量:
- 幂等。开组时已有打开的组则无操作;关组时组已关闭则无操作。live chunk 与落盘消息重复描述同一内容不会开两次或关两次,历史重放只有落盘消息也能得到同样的组列表。
- 引用稳定。State 没变时 `update` 返回原对象;`groups` 数组只在组列表变化时换引用;已关闭的组对象永不替换。
- 只向前折叠。不读邻居 Context,不读节点,不向后看。
Turn 数据发布:`buildLocationData` 只在 turn 阶段返回值,`groups` 引用不变时返回上一次发布的值,引擎据此不重新发布。没有 `turn/start` 的历史窗口用与 `turn-process` 相同的兜底:从已有匹配折叠出 State。
发布节奏:live chunk 走动画帧合并,其余立即。
### 成员归属规则
归属由 builder 按 step 判定,不用 seq 区间,因为 live chunk 的 seq 与落盘事件的 seq 不在一个尺度上。
| 节点 | 规则 |
|---|---|
| `tool-call`,step 为 s | 属于 `headStep ≤ s` 的最后一组。文本在同一 step 关闭了上一组之后的工具调用,会由 fold 以该 step 开出新组,所以「最后一组」总是正确的那一组 |
| `assistant-step`,step 为 s,且某组 `closeStep === s` | 角色为关闭者。节点保持可见;组折叠时只隐藏它的推理部分,用现有 `reasoningHidden` 机制 |
| `assistant-step`,step 为 s,其余情况 | 属于 `headStep ≤ s` 且 `s` 未越过 `closeStep` 的组,角色为成员 |
| `model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail`、`user`、`steering` | 不属于任何组 |
归属只在组列表变化时重算,成本是该轮节点数。流式 chunk 不改变组列表时,成员的 presentation 不发布。
组头行锚在最早的成员之前。一个组如果只有关闭者的推理而没有任何成员,比如一步里先推理后回复,组头行就锚在关闭者之前,这样被隐藏的推理仍然有一行可以点开。
### 分组变化怎样到达 builder
fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Location 数据有变化时会把 `timelineDirty` 置真并对所有活跃目标调用 `apply`,但 builder 不知道是哪一轮的哪个 key 变了。依赖「同一轮总有别的节点也在这次 flush 里变了」去推断,是巧合不是契约。
所以给 `ConversationViewBuilder.apply` 的入参增加可选的 `changedLocationData`:本次 flush 里换了引用的 Location 数据清单,每项含 `kind`、`turn`、可选 `step`、`key`。assembler 在 `applyDirtyLocationData` 里本来就逐条比较了前后引用,只是把比较结果收集起来交给 builder。Chat builder 只取 `key === 'step-groups'` 的轮次做重投影。入参可选,不传时 builder 退化为按本次 upsert 涉及的轮次重投影。
### seat 的 hidden 合成
seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论:
```ts
declare function usePresentation<T>(select: (policy: {
stepGrouping: 'collapsed' | 'none'
foldCompletedTurns: boolean
}) => T): T
declare const group: { readonly headStep: number } | undefined
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
```
不在任何组里的 seat,`grouping` 在三档下都是 `'none'`,值没变就不重渲染。`foldCompletedTurns` 在三档下都是 true,同理。selector hook 的底层是 `useSyncExternalStoreWithSelector`,只有选出的值变化才重渲染。
三个隐藏来源合成一个 `hidden`:
| 来源 | 条件 | 揭示动作 |
|---|---|---|
| 整轮折叠 | 现有规则,`turnClosed && turnStarted && 有回复` 且未手动展开 | 打开整轮 |
| 组折叠 | `grouping === 'collapsed'`、角色为成员、组未手动展开 | 打开该组 |
| 组头在完全展开档 | 角色为组头且 `grouping === 'none'` | 无 |
关闭者节点的推理部分单独隐藏:seat 通过 owner props 里的 `stepGroup.reasoningHidden` 告诉 assistant-step 渲染器。
组的展开状态进 chat store,key 是 `turn` 加 `headStep`,与整轮的手动展开状态并列。
### 刷新范围
下表是这套结构给出的保证,也是下一轮渲染次数测试的断言表。每一行断言的是精确集合,不是上限。
| 外部变化 | 允许重渲染的 |
|---|---|
| 运行中的组来一个推理 chunk | 该 assistant seat |
| 运行中的组来一个 `tool/call` | 该 tool seat,加这一组的组头 |
| 回复出现,组关闭 | 该 assistant seat,加这一组的组头;成员归属重算只发生在开关组时 |
| 新组打开 | 新组头进 `order`,一次结构重排;其他 seat 不刷 |
| 模式在简洁与详细之间切换 | 仅有已完成推理的 assistant-step 渲染器 |
| 模式切到完全展开或切回 | 仅成员与组头 seat |
| `performanceUsage` 切换 | 仅 turn-tail |
| 加载更早一页 | 新增 seat,加 presentation 变了的历史 seat |
| 语言切换 | 全部,作为对照 |
### 展示策略对象
持久化的枚举与运行时词汇分开。枚举 `TranscriptViewMode` 是 `compact`、`detailed`、`expanded`,存档里的旧值 `normal` 读取时映射为 `detailed`,不回写。运行时词汇是派生的 `ChatPresentationPolicy`:
| 字段 | compact | detailed | expanded |
|---|---|---|---|
| `foldCompletedTurns` | true | true | true |
| `stepGrouping` | collapsed | collapsed | none |
| `settledReasoningPreview` | false | true | true |
映射只存在于一处。每档对应一个常量对象,同一档永远同一引用,所以派生观察量不需要自己的订阅簿记:`getSnapshot` 是查表,`subscribe` 直接转给模式观察量。
渲染器不比较枚举,只 select 自己需要的字段。策略观察量按 kind 注入:assistant-step 的注册通过 `inject.hooks.presentation` 拿到它,与 turn-tail 注入 `performanceUsage` 是同一写法。以后加第四档只改这张表,渲染器一个不动。
### 按轮折叠
per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否在加载窗口内。seat 的折叠资格由 `turnStarted` 替代原来的全局 `!historyIncomplete`。分页加载完成时不再有任何全局 prop 变化,只有新增节点与它们所在轮的 presentation 变化。
### 复杂度契约
| 场景 | fold | builder | 发布 |
|---|---|---|---|
| 内容型 chunk,不改变组边界 | O(1) 状态转移,返回原对象 | 无重投影 | 零 |
| 改变组边界的事件 | O(1) | 只重投影所属轮,成本为该轮节点数 | 该轮内事实变化的 key |
| 结构变化与分页 | 按匹配的事件数 | 全量一次 | 事实变化的 key |
| 模式切换 | 无 | 无 | 无;重渲染由 seat 与渲染器的 selector 决定 |
## 改动清单
| 包 | 文件 | 改动 |
|---|---|---|
| ui-conversation | `contract/conversation.ts` | 新增 `ConversationLocationDataUpdate`;`ConversationViewBuilder.apply` 入参增加可选 `changedLocationData` |
| ui-conversation | `conversation/assembler.ts` | `applyDirtyLocationData` 收集换了引用的 Location 数据并随 `apply` 传出 |
| ui-conversation | `index.ts` | 导出新类型 |
| ui-chat | `chat-settings.ts` | 三档枚举;schema 接受旧值 `normal` |
| ui-chat | `transcript-view.ts` | 读取时 `normal` 映射为 `detailed` |
| ui-chat | `presentation-policy.ts` 新增 | 策略对象、映射表、派生观察量 |
| ui-chat | `settings/TranscriptViewRow.tsx` | 三个选项 |
| ui-chat | `locale.ts` | 三档文案;组头文案 |
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`;`PresentationInjected`;`ChatNodeOwnerProps.stepGroup`;`StepGroupOwnerProps` |
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`;`ChatNodeGroupPresentation`;`processSource` 的值改为 per-key 组合对象 |
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
| ui-chat | `contract/store.ts`、`stores.ts` | 组的手动展开状态与动作 |
| ui-chat | `conversation-nodes/common.ts` | 组头锚点偏移 |
| ui-chat | `conversation-nodes/step-groups.ts` 新增 | fold Definition 与 Turn 数据声明合并 |
| ui-chat | `conversation-nodes/step-group-nodes.ts` 新增 | 组头节点物化与成员归属投影 |
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted`;组合 per-key presentation |
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | 接入物化投影;消费 `changedLocationData`;store 支持删除 key |
| ui-chat | `conversation-nodes/register.ts` | 注册 fold Definition |
| ui-chat | `chat/ChatView.tsx` | 删除 `compactTranscript` 与 `historyIncomplete` 的传递;传 `usePresentation` |
| ui-chat | `chat/ChatNodeSeat.tsx` | 组合 selector;三个隐藏来源;`stepGroup` owner props |
| ui-chat | `chat/AssistantNodeView.tsx`、`chat/AssistantMarkdown.tsx`、`chat/ReasoningRow.tsx` | 注入策略;已完成推理的预览开关;关闭者的推理隐藏 |
| ui-chat | `chat/StepGroupNodeView.tsx` 新增 | 组头渲染器 |
| ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 |
| ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 |
上述清单保留完整分组方案;独立功能已经更新 README、i18n 配对文件及 slot catalog。测试与快照进度见迁移记录,未完成的分组改动不随这些更新自动完成。
## 验收标准
分组相关验收仍待实现;独立展示功能的验证进度见迁移记录。
**渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。
**发布次数测试。** 同样的刺激下统计每个 key 的 source 通知次数,断言精确集合。
**fold 状态机单测。** 喂事件序列,断言组列表:开组、关组、同 step 先文本后工具开新组、轮次结束关组、幂等性、已关闭组对象引用不变、只有落盘消息的历史重放得到相同组列表。
**成员归属单测。** 给定组列表与该轮节点,断言每个 key 的角色。
**基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。
## 风险
- **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。
- **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。
- **三档在本轮的实际差异。** 简洁与详细目前只差已完成推理的首行预览;运行中的详情标题、类别摘要等产品特性不在本轮。策略表已为它们留了位置。
- **分页与半截轮。** 被分页切开的最老一轮不折叠、不分组;归属只按 step 计算,不受分页影响。
- **组头文案。** 本轮只有「正在处理」与「已完成处理」加工具调用次数,类别摘要留给产品层。
- **流式 chunk 的 step 字段。** `assistant/live-chunk` 事件携带 `step`,与 `turn-process` 的用法相同;若 chunk 缺少 step 则不开组。
## 备选保留:父 Context 派生子 Context
如果 builder 物化节点在使用中暴露出比预期更多的边界问题,替代方案是给引擎加一个原语:Context 在 `update` 中可以派生一个指定 kind 与 id 的子 Context,并把后续匹配转发给它;子 Context 有自己的单个节点、自己的发布节奏与 Location。父 Context 重放时子 Context 按 id 对齐,未再派生的子 Context 按现有撤回规则转为隐藏。
它的收益是行的身份与内容都回到 Definition 层,一组一个 Context,没有数组碎片。代价是 assembler 要新增转发与重放级联,且 `match` 之外多出一条 Context 创建路径。切换条件:builder 物化节点的边界在两个以上业务里被突破,或成员归属的按 step 规则出现无法用 step 表达的用例。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md
2026-09-20-chat-work-details-migration.md: 126b61d32d4a4dbf27cb5997149abc9820d7f881
2026-09-20-chat-work-details-migration.zh.md: 20727c299307881b67d0662092d269afa9af176c
@@ -1,176 +0,0 @@
# Agent Note: Chat work-details migration progress
Status: proposed
English | [中文](2026-09-20-chat-work-details-migration.zh.md)
## Problem
PR #4565 combines three display modes, process grouping, whole-Turn status, trigger notifications, tool-row visuals, and infrastructure-row hiding. Migrating it as one batch mixes independent features with list restructuring and makes individual performance changes difficult to assess. A feature-based progress table allows independent visuals to be reviewed first and Turn-related behavior to be considered with step-group later, without losing product requirements.
## Proposal
Completed independent features stay in `feat/chat-presentation-channel`. The remaining unfinished code continues in `.artifacts/worktrees/pr-4565-turnstep` under the repository root, based on the current channel and this progress record, with drafts kept unstaged for combined turn/step and grouping review. The `pr-4565` worktree remains the original PR's behavior and implementation reference; existing `feat/chat-step-groups` experiments are outside this transfer.
This record tracks feature scope, current implementation, and acceptance progress. The [presentation policy channel and process-group architecture](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) retains the architecture and alternatives. This record neither replaces it nor treats its proposals as implemented. Because it does not fully supersede that decision, retain the architecture note without archival or deletion.
This record maintains an English/Chinese pair. Completion, implementation, staging or commit state, and acceptance are separate dimensions. Incomplete items remain marked incomplete even when drafts exist, and stay in the inventory. Tests and documentation are added during CI repair; browser snapshots and performance acceptance are tracked separately. Written code, staging, commits, and successful builds do not establish complete functional or performance acceptance.
### Status definitions
| Dimension | State | Meaning |
|---|---|---|
| Completion | Complete | Migration code has been reviewed feature by feature and committed to this channel; behavior and performance evidence are separate |
| Completion | Incomplete | Outside the channel's completed migration scope; may have an unstaged draft or no implementation |
| Implementation | Existing capability | Already present in this channel |
| Implementation | Code written | Implementation exists in the relevant worktree; does not imply completion, staging, commit, or acceptance |
| Implementation | Awaiting migration | Included in non-grouping scope but not yet implemented |
| Implementation | Grouping phase | Requirement retained; depends on grouping structure or interactions and is not connected to this channel yet |
| Staging or commit | Unstaged | Changes remain in the worktree, including untracked files |
| Staging or commit | Partially staged | Only some feature changes are staged; the exact scope must be stated |
| Staging or commit | Staged | All pending code changes for the feature are staged but not committed |
| Staging or commit | Committed | Feature code is included in current HEAD |
| Acceptance | Pending | Requires behavior, node identity, update-scope, or browser performance evidence; Git state and compilation cannot substitute |
Verify staging against the feature's diff hunks, not whole files. Inspect the worktree, index, and HEAD when updating status. Staging some fields in a shared file does not stage every feature depending on that file.
### Capabilities already in this channel
| Capability | compact | detailed | expanded | Completion | Implementation | Staging or commit | Acceptance |
|---|---|---|---|---|---|---|---|
| Three settings and legacy-value reading | Compact | Detailed; legacy `normal` reads as this mode without write-back | Expanded | Complete | Existing capability | Committed | Pending |
| Per-Turn folding eligibility | A closed Turn with its start loaded and a final reply can fold process content; cancellation/failure extensions are item 6 | Same | Same | Complete | Existing capability | Committed | Pending |
| Collapsed settled-reasoning summary | Think title only | First line | First line | Complete | Existing capability | Committed | Pending |
| Retain summary DOM across mode changes | CSS hides summary and separator | Show existing summary and separator | Same | Complete | Existing capability | Committed | Pending |
| Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending |
| Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending |
All three modes retain whole-Turn folding. `expanded` removes group-level folding, headers, and height limits while retaining group identity and member parents; it does not disable whole-Turn folding or automatically expand reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically.
### Migration inventory without grouping dependencies
“All three modes” means the behavior applies in every mode, not that switching modes changes it. “Without grouping dependencies” means the draft introduces no step-group, not that every item must be accepted before grouping. Numbers follow the feature review inventory; item 0's compilation wiring is complete and committed, so it is not listed as a product feature. Incomplete drafts remain in `pr-4565-turnstep`, outside this channel's completed features. Behavior and performance acceptance remain pending for the features below.
| Number | Feature | compact | detailed | expanded | Completion | Implementation | Staging or commit | Destination and considerations |
|---|---|---|---|---|---|---|---|---|
| 1 | Chinese tool titles | All three modes | All three modes | All three modes | Complete | Code written | Committed | Bash/Pwsh: “运行命令”; Grep: “搜索文件内容”; Glob: “查找文件”; Skill: “加载技能”. Only UI dictionaries change; model tool names do not |
| 2 | Tool-row interaction visuals | All three modes | All three modes | All three modes | Complete | Code written | Committed | Hover darkens titles and summaries in tool, Bash, and command rows; shared `DisclosureRow` and independent Bash rows point the expanded chevron upward |
| 3 | Bash stopped-state screen-reader text | All three modes | All three modes | All three modes | Complete | Code written | Committed | Complete the stopped-state accessible name without changing execution or cancellation semantics |
| 4 | Running tool/command text shimmer | All three modes | All three modes | All three modes | Complete | Code written | Both 4A shimmer and 4B accompanying optimizations committed | 4A includes a fixed `TextShimmer` span, CSS activation, tool/command integration, replacement of old sweep styles, and reduced-motion support, excluding step/turn. 4B adds business-row computation caches, stable callbacks, and JSX references |
| 5 | Streaming reasoning summary | Update after a paragraph's first line completes | Same | Same | Complete | Code written | Committed | `ReasoningRow` retains the original PR's `latestCompletedParagraphFirstLine(text)` and computes from full text. Retain summary DOM, stable props, and right-edge fading; add no Definition parsing state or cross-layer summary fields |
| 6 | Keep process expanded after cancellation/failure | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft uses `keepOpen` in Turn process projection; folding eligibility and stopped/failed behavior await Turn/grouping review |
| 7 | Whole-Turn status and duration control | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors the control to loaded `turn/start` after opening inputs, isolates timing in `LiveTurnProcessLabel`, and replaces the bottom activity bar; positioning and status await review |
| 8 | Non-human trigger notifications | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft identifies waking inputs from next-turn and first-step next-step claims and projects `turn-trigger`; Turn starts, notification ownership, and expanded content await review |
| 9 (permission/system) | Hide permission-command and system-prompt rows | All three modes | All three modes | All three modes | Complete | Code written | Committed | `isVisibleChatNode` centralizes filtering for the existing Chat list, navigation, and process presentation. Command and request-prompt Definitions do not own these filters; persistence and Trajectory are unchanged |
| 9 (context) | Hide ordinary Context rows | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Paired with item 8's waking notifications to retain source explanations; final filter ownership awaits Turn/grouping review |
| 10 | Turn action-bar positioning | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors `turn-tail` after `turn/end`; reply ownership for feedback/copy and the real Turn-end seq for branching await combined review |
| 11 | Action-bar persistent visibility | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft projects `footer` per Turn in the Chat builder: persistently visible when the latest Turn ends in a reply, otherwise on hover/focus; tail detection will be considered with grouping |
The current base already enables developer tools by default; do not migrate that again. Retain the protection that waits for Host acceptance of settings instead of applying the original PR's missing-value fallback. Its `Switch` change only affects a comment and is not a migrated feature.
### Subsequent review batches
Review batches are independent of implementation and staging status. Review remaining drafts in `pr-4565-turnstep`, kept unstaged; a draft does not complete a migration. Transfer only this channel's remaining code, without automatically incorporating `feat/chat-step-groups` experiments or installing dependencies and compiling in the destination worktree.
| Batch | Features | Review scope |
|---|---|---|
| Independent feature, committed | 4A: text shimmer | `TextShimmer`, row integration, and browser painting cost; performance acceptance pending |
| Independent optimization, committed | 4B: accompanying memoization and reference stability | `ToolRow`, `BashRow`, and `GenericCommandCard` computation caches, callbacks, and JSX references; not required for shimmer itself; performance acceptance pending |
| Independent feature, committed | Permission-command and system-prompt hiding from item 9 | Retain the original PR's centralized visibility decision; without step-group, the main list connects it in `orderedVisibleChatNodes`, independent of waking-message classification |
| Incomplete, transferred to turnstep worktree | 6, 7, 10, 11 | Whole-Turn folding eligibility, status/clock, action-bar position, and tail detection, considered with Turn/group relationships |
| Incomplete, transferred to turnstep worktree | 8 and ordinary Context hiding from item 9 | Trigger notifications depend on Turn starts and Inbox claims; Context hiding accompanies notifications so waking reasons remain visible |
### Requirements retained for the grouping phase
All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. The [Definition-owned grouping foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) is implemented; that does not establish completion of these product behaviors or performance requirements.
| Feature | compact | detailed | expanded | Current state |
|---|---|---|---|---|
| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows without group-level folding; retain member parents | Incomplete (grouping phase) |
| Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes |
| Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) |
| Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) |
| Header update cadence | Preserve category title's minimum display duration | Also handle detail updates | Not applicable | Incomplete (grouping phase) |
| Height-limited scrolling within a group | Limit expanded height, scroll independently, fade edges | Same | Not applicable | Incomplete (grouping phase) |
| Group and whole-Turn folding interaction | Hide groups when the Turn folds | Same | Whole-Turn folding only | Incomplete; retain node identity and separately decide whether internal expansion resets |
| Pagination, streaming settlement, and group identity | Stable existing headers and members; handle partial Turns separately | Same | Show the same business nodes directly | Incomplete (grouping phase) |
Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments.
### Definition-owned segmentation and presentation
Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number.
| Ordered visible input | Business action |
|---|---|
| Owning Turn changes | Close the pending group. |
| No Turn ownership | Close the group and emit an independent Node; never merge across it. |
| turn-process control | Emit independently without closing the pending process. |
| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. |
| Assistant reasoning | Append the reasoning part to the pending group. |
| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. |
| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. |
| Turn end | Close that Turn's pending groups even without Node upserts. |
Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time.
Group headers select data independently from member arrays. All modes retain the same Group container and member parents:
| Mode | Group header | Body |
|---|---|---|
| Compact | Short activity summary | Group open/closed state controls visibility. |
| Detailed | Summary plus live command/path/query details | Same group open/closed state. |
| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. |
This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to this business integration.
| Business implementation | Responsibility |
|---|---|
| process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. |
| AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. |
## Performance constraints
| Scenario | Property to preserve | Required evidence |
|---|---|---|
| Switching among modes | Business-node keys do not change; modes do not re-register Definitions; only relevant visibility/presentation fields update | Node identity and update-scope checks |
| Incremental reasoning/tool content | Update only the owning node without scanning full history; compute reasoning summary from current full text during rendering | Update scope and long-text cost; the current summary algorithm does not reduce `ReasoningRow` render count |
| Whole-Turn running clock | Only live clock content updates each second, without refreshing members, historical rows, or list | Update scope and timer disposal |
| Summary visibility | Keep lightweight summaries mounted; do not mount every full Markdown body for mode switching | DOM identity, initial loading, memory observations |
| Pagination | Update new nodes and nodes with changed facts; completed Turns do not refresh together because of global pagination state | Multi-page interaction and update-scope checks |
| State transitions | Stable text/icon/callback references when content is unchanged; do not clear internal state through remounting | Interaction state and component lifecycle checks |
Retaining DOM reduces mounting and unmounting cost, not style calculation, layout, or painting, and can increase retained memory. Changing display properties alone is not a guarantee against jank. The aim is lower switching peaks with acceptable initial-load, pagination, and streaming costs.
## Alternatives considered
**Replay the original PR as one batch.** Rejected: grouping, node splitting, whole-Turn status, and independent visuals would mix, obscuring each feature's performance effects and overwriting local subscriptions and node reuse already completed in this channel.
**Unmount and recreate summaries or process nodes on mode changes.** Rejected for summary mode changes. Stable mounts allow repeated switching to change only display state; initial costs for new nodes still require separate validation.
**Keep every Markdown body mounted for smooth switching.** Rejected: lightweight summaries and full bodies differ in memory, DOM, and rendering cost. Bodies remain mounted according to manual expansion.
**Remount renderers when folding a Turn to reset tool cards.** Do not migrate directly. It conflicts with stable nodes and preserved interaction state. If reset behavior remains necessary, define its state owner and trigger explicitly.
## Acceptance criteria
Update implementation, staging, and commit status separately as work progresses. Completion follows feature review and commits; acceptance follows actual validation. Retain incomplete items across branch splits and worktree transfers. Before grouping work starts, check every product requirement and mode mapping above.
| Acceptance item | Current state |
|---|---|
| Complete behavior validation of existing channel capabilities | Relevant unit and browser regressions pass; this excludes unmigrated grouping features and does not establish performance acceptance |
| Non-grouping feature migration | Completed scope is in the inventory; incomplete drafts await review in `pr-4565-turnstep`; functional and performance validation remain incomplete |
| DOM identity and update scope during mode changes | Unit tests verify summary/expanded-body DOM identity and policy-field subscriptions; browsers verify persistence of all modes and whole-Turn folding; whole-page update scope and performance remain pending |
| Long-session initial loading, pagination, streaming, and memory | Pending; the original PR's historical measurements do not validate this implementation |
| Unit, browser, and recorded-snapshot coverage | 785 relevant unit tests pass; read-only replay of 35 browser files yields 178 passing tests and 4 conditional skips; UI expectations are updated and Session JSONL is unchanged |
| Grouping and the Detailed/Expanded distinction | Incomplete (grouping phase) |
Validation evidence: after rebasing onto master, this channel passed full `pnpm run build`, lint, duplication checks, and all 41 documentation checks. New presentation policy, shimmer, and visibility filtering, plus the settings and Skill rows reported by CI, pass 100% coverage checks over their selected source scope. Test repairs, UI snapshots, and bilingual pairing are committed together as CI fixes. No install, build, or tests ran in `pr-4565-turnstep`; this channel's results do not establish acceptance for that worktree. A real-model GUI demo and performance measurements remain outstanding.
## Risks
- `expanded` and `detailed` currently present identically; the original PR's distinction exists only after grouping is implemented.
- Whole-Turn duration replaces the existing tool/message-count title, changing product information. Migration must preserve status, accessible names, and Turns with no process content, not merely restyle the control.
- Hiding ordinary Context before adding trigger notifications removes background waking reasons; connect both items in the inventory's order.
- The original footer decision depends on grouping indexes. Rework ownership per feature rather than introducing the whole grouping infrastructure for a footer.
- Group identity, membership, and builder materialization in the experiments still require evaluation; this record does not treat the experiment as final architecture.
@@ -1,176 +0,0 @@
# Agent Note: Chat 工作过程展示迁移进度
Status: proposed
[English](2026-09-20-chat-work-details-migration.md) | 中文
## 问题
PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先评审独立视觉功能,再结合 step-group 统一考虑轮次相关行为,避免遗漏原有产品需求。
## 方案
已完成的独立功能保留在 `feat/chat-presentation-channel`。剩余未完成代码的后续工作区是仓库根目录下的 `.artifacts/worktrees/pr-4565-turnstep`,以当前通道及本进度记录为基线,草稿保持未暂存;后续结合 turn/step 与分组整体评审。`pr-4565` worktree 继续作为原 PR 的行为和实现参考;`feat/chat-step-groups` 的既有分组实验不在本次搬迁范围内。
本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。
本记录维护中英文配对。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试和文档随 CI 修复补齐,浏览器快照与性能验收单独记录;代码写好、进入暂存区、提交或构建通过,都不等同于完整功能或性能验收。
### 状态定义
| 维度 | 状态 | 含义 |
|---|---|---|
| 完成 | 已完成 | 功能迁移代码已逐项审阅并提交到当前通道;行为与性能证据另行记录 |
| 完成 | 未完成 | 尚未纳入当前通道的已完成迁移范围;可以已有未暂存草稿,也可以尚未编写 |
| 实现 | 当前代码已有 | 当前通道的既有基础能力 |
| 实现 | 代码已写好 | 对应工作区中已有实现;不代表已完成、已暂存、已提交或通过验收 |
| 实现 | 待迁移 | 已纳入非分组迁移范围,当前尚未实现 |
| 实现 | 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 |
| 暂存或提交 | 未暂存 | 该功能的改动仍在工作区,包括尚未跟踪的新文件 |
| 暂存或提交 | 部分暂存 | 该功能只有部分改动进入暂存区,必须注明具体范围 |
| 暂存或提交 | 已暂存 | 该功能当前待提交的代码改动均已进入暂存区,尚未提交 |
| 暂存或提交 | 已提交 | 该功能代码已包含在当前 HEAD 中 |
| 验收 | 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用 Git 状态或编译结果代替 |
暂存状态按产品功能对应的差异片段核对,不按整个文件推断。更新状态时同时查看工作区、暂存区和 HEAD;共享文件只暂存部分字段,不能将依赖该文件的所有功能都标为已暂存。
### 当前通道已有的能力
| 能力 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 验收状态 |
|---|---|---|---|---|---|---|---|
| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 按轮判断整轮折叠资格 | 已加载起点且存在最终回复的结束轮次,可收起过程内容;停止和失败的扩展行为见迁移项 6 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
三档都保留整轮折叠。`expanded` 取消组级折叠、组头和限高,保留组身份与成员父级;并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
### 不依赖分组的迁移清单
下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。“不依赖分组”指实现草稿未引入 step-group,不代表都要先于分组接受。编号沿用逐项评审清单;编号 0 的编译接线修正已完成并提交,不作为产品功能另列。未完成项的草稿留在 `pr-4565-turnstep`,不是当前通道已完成的功能。下列功能的行为与性能均待验收。
| 编号 | 功能 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 迁移落点与注意事项 |
|---|---|---|---|---|---|---|---|---|
| 1 | 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 |
| 2 | 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 工具行、Bash 行和命令行悬停时标题及摘要加深;共享 `DisclosureRow` 和独立 Bash 行展开箭头朝上 |
| 3 | Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 补齐停止状态的可访问名称,不改变执行或取消语义 |
| 4 | 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 4A 扫光与 4B 附带优化均已提交 | 4A 包含 `TextShimmer` 固定 span 与 CSS 启停、工具及命令行接入、旧扫光样式替换和减少动态效果支持,不含 step/turn;4B 为业务行额外的计算缓存、稳定回调及 JSX 引用 |
| 5 | 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 已完成 | 代码已写好 | 已提交 | `ReasoningRow` 沿用原 PR 的 `latestCompletedParagraphFirstLine(text)`,直接从完整文本计算;摘要 DOM、稳定 props 和右侧渐隐保留,不增加 Definition 解析状态或跨层摘要字段 |
| 6 | 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿在 Turn 过程投影中使用 `keepOpen`;轮次与分组阶段再评审折叠资格和停止、失败行为 |
| 7 | 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将控件锚定到已加载的 `turn/start`、排在开场输入之后,以 `LiveTurnProcessLabel` 单独计时,并替换底部运行条;整体定位与状态待评审 |
| 8 | 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿使用 next-turn 及首步 next-step 领取事实识别唤醒输入,投影 `turn-trigger`;轮次起点、通知归属和展开内容待评审 |
| 9(permission/系统) | 隐藏 permission 命令与系统提示词行 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | `isVisibleChatNode` 集中判断,现有 Chat 列表、导航和过程展示复用;命令与请求提示词 Definition 不承载这些过滤规则,持久化数据与 Trajectory 不变 |
| 9(context) | 隐藏普通上下文行 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 与 8 的唤醒通知配套,保留来源解释;过滤规则的最终归属留到轮次与分组阶段 |
| 10 | 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将 `turn-tail` 锚定到 `turn/end` 之后;反馈、复制的回复归属和分支的真实轮次结束序号待整体评审 |
| 11 | 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿由 Chat builder 按 Turn 投影 `footer`;最新轮以回复结尾时常显,其余悬停或聚焦显示,尾部判定与分组一起考虑 |
开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。
### 后续评审批次
评审批次独立于实现和暂存状态。未完成草稿的后续评审统一在 `pr-4565-turnstep`,保持未暂存;已有草稿不代表迁移已完成。仅搬迁当前通道的剩余代码,不自动合入 `feat/chat-step-groups` 的实验,也不在目标工作区安装依赖或编译。
| 批次 | 功能 | 评审范围 |
|---|---|---|
| 独立功能,已提交 | 4A:文字扫光 | `TextShimmer` 本身、各行的调用方式和浏览器绘制成本;性能待验收 |
| 独立优化,已提交 | 4B:附带的 memo 与引用稳定化 | `ToolRow`、`BashRow` 和 `GenericCommandCard` 的计算缓存、回调和 JSX 引用;不是扫光功能的必要条件,性能待验收 |
| 独立功能,已提交 | 9 中的 permission 命令与系统提示词隐藏 | 沿用原 PR 的集中可见性判断;当前无 step-group,主列表改在 `orderedVisibleChatNodes` 接入,不依赖唤醒消息分类 |
| 未完成,转入 turnstep 工作区 | 6、7、10、11 | 整轮折叠资格、状态与时钟、操作栏位置和尾部判定,统一考虑轮次与分组关系 |
| 未完成,转入 turnstep 工作区 | 8、9 中的普通 context 隐藏 | 触发通知依赖轮次起点和 Inbox 领取判定;隐藏普通上下文与通知配套,避免先隐藏唤醒原因 |
### 分组阶段保留的需求
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。[Definition 拥有分组的基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)已经实现,但不代表这些产品行为或性能要求已经完成。
| 功能 | compact | detailed | expanded | 当前状态 |
|---|---|---|---|---|
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行不做组级折叠,保留成员父级 | 未完成(分组阶段) |
| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 |
| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) |
| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) |
| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 未完成(分组阶段) |
| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 未完成(分组阶段) |
| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 未完成;保留节点身份,内部开合状态是否重置单独确定 |
| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 未完成(分组阶段) |
分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。
### Definition 拥有的分段与展示
业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。
| 有序可见输入 | 业务动作 |
|---|---|
| 所属 Turn 改变 | 关闭待完成组。 |
| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 |
| turn-process 控件 | 独立输出,不关闭待完成过程。 |
| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 |
| Assistant 推理 | 把 reasoning 部分加入待完成组。 |
| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 |
| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 |
| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 |
分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。
组头独立于成员数组选择 data,所有模式保留同一 Group 容器及成员父级:
| 模式 | 组头 | 正文 |
|---|---|---|
| Compact | 简短活动摘要 | 按组开合状态显隐。 |
| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 |
| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 |
以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于本业务接入。
| 业务实现 | 职责 |
|---|---|
| process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 |
| AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 |
## 性能约束
| 场景 | 要保留的性质 | 所需证据 |
|---|---|---|
| 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 |
| 推理或工具内容增量更新 | 只更新所属节点,不扫描完整历史;推理摘要在渲染时从当前完整文本计算 | 更新范围与长内容计算开销;当前摘要算法不减少 `ReasoningRow` 的渲染次数 |
| 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 |
| 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 |
| 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 |
| 状态切换 | 文字、图标、回调引用在内容未变时稳定;不通过重挂载清空内部状态 | 交互状态与组件生命周期检查 |
保留 DOM 只减少挂载和卸载成本,不消除样式计算、布局与绘制,也会增加常驻内存。不能将“只改显示属性”写成“不会卡顿”的保证。性能目标是降低切换峰值,并保持首次加载、分页和流式更新的成本可接受。
## 备选方案与取舍
**整批重放原 PR。** 不采用。分组、节点拆分、整轮状态和独立视觉功能混在一起,难以把性能变化对应到具体功能,也会覆盖当前通道已完成的局部订阅与节点复用。
**切换模式时卸载、重新创建摘要或过程节点。** 摘要模式切换不采用该方式。稳定挂载让来回切换只改变显示状态;新增节点的初始成本仍须单独检查。
**为了切换平滑,把所有 Markdown 正文常驻。** 不采用。轻量摘要与完整正文的内存、DOM 和渲染成本不同;正文继续按手动展开状态挂载。
**收起整轮时重挂载 renderer,以重置工具卡。** 不直接迁移。该实现与稳定节点及保留交互状态的方向冲突;若后续仍需要重置行为,应明确状态所有者和触发规则。
## 验收标准
每项代码写好、暂存或提交后,分别更新对应状态;完成状态按逐项评审和提交记录更新,验收只按实际验证记录更新。未完成项保留,不因拆分分支或搬迁工作区而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。
| 验收项 | 当前状态 |
|---|---|
| 当前通道已有能力的完整行为验证 | 已通过本次相关单元和浏览器回归;不包含未迁移的分组功能,也不代表性能验收 |
| 非分组功能迁移 | 完成范围见迁移清单;未完成草稿留到 `pr-4565-turnstep` 继续评审,功能与性能验证仍未完成 |
| 模式切换时的 DOM 身份和刷新范围 | 单元测试已验证摘要、手动展开正文的 DOM 身份和策略字段订阅;浏览器已验证三档持久化及整轮折叠,整页刷新范围与性能仍待验收 |
| 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 |
| 新行为的单元、浏览器与录制快照覆盖 | 相关单元测试 785 项通过;35 个浏览器文件只读回放,178 项通过、4 项按条件跳过;UI 预期输出已更新,Session JSONL 未改动 |
| 分组功能与两档差异 | 未完成(分组阶段) |
验证记录:当前通道在 rebase master 后通过完整 `pnpm run build`、lint、重复代码检查和完整文档检查(41 项)。新增展示策略与扫光、可见性过滤,以及 CI 报告的设置行和 Skill 行覆盖率已通过对应源码范围的 100% 检查。测试修复、UI 快照和中英配对随 CI 修复一并提交;`pr-4565-turnstep` 未执行安装、构建或测试,当前通道的结果不代表该工作区已验收。真实模型 GUI 演示和性能测量仍待补。
## 风险
- 当前 `expanded` 与 `detailed` 的展示相同;只有分组阶段完成后,两档才具备原 PR 设计的区别。
- 整轮用时控件替换现有工具和消息计数标题,属于产品信息变化;迁移时不能只换样式而遗漏状态、可访问名称或空过程轮次。
- 普通上下文隐藏若先于触发通知,会让后台唤醒原因消失;两项需按清单顺序衔接。
- 原 PR 的页脚判定与分组索引耦合,需要按功能重写归属,不能为页脚单独引入整套分组基础设施。
- 分组实验中的组身份、成员归属和 builder 物化路线仍需继续评估;本记录不将实验实现列为最终架构。
+2
View File
@@ -75,6 +75,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`acorn`](https://github.com/acornjs/acorn) | MIT |
| [`ajv`](https://github.com/ajv-validator/ajv) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
| [`buffer`](https://github.com/feross/buffer) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
| [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 |
@@ -188,6 +189,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+6
View File
@@ -50,6 +50,10 @@ flowchart LR
cfg --> plugin_dsh_base_config_editor
plugin_dsh_base_settings["settings<br/>@deepseek-ai/dsh-settings"]
cfg --> plugin_dsh_base_settings
plugin_dsh_base_authorization["authorization<br/>@deepseek-ai/dsh-authorization"]
cfg --> plugin_dsh_base_authorization
plugin_dsh_base_deepseek_account["deepseek-account<br/>@deepseek-ai/dsh-deepseek-account-platform"]
cfg --> plugin_dsh_base_deepseek_account
plugin_dsh_base_credentials["credentials<br/>@deepseek-ai/dsh-credentials-local"]
cfg --> plugin_dsh_base_credentials
plugin_dsh_base_llm_pi_ai["llm-pi-ai<br/>@deepseek-ai/dsh-llm-pi-ai"]
@@ -213,6 +217,8 @@ flowchart LR
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
| `config-editor` | `@deepseek-ai/dsh-config-editor` |
| `settings` | `@deepseek-ai/dsh-settings` |
| `authorization` | `@deepseek-ai/dsh-authorization` |
| `deepseek-account` | `@deepseek-ai/dsh-deepseek-account-platform` |
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.1.6-alpha.2",
"version": "0.1.7-alpha.1",
"publishConfig": {
"access": "public"
},
+4 -2
View File
@@ -36,7 +36,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
process.send({ type: 'booting', packageManager: options.packageManager });
return new Promise((resolve, reject) => process.once('disconnect', () => {
if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
resolve({ ctx: { plugin: async () => {}, effect: () => {}, on: () => {},
resolve({ ctx: { plugin: async () => {}, effect: () => {}, on: () => {}, inject: () => {},
connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
shutdown: { shutdown: async () => writeFileSync(${JSON.stringify(join(root, 'stopped'))}, 'stopped') } });
}));
@@ -69,8 +69,10 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
expect(boot.packageManager.env.ELECTRON_RUN_AS_NODE).toBe('1')
expect(boot.packageManager.env.PATH).toBe(`${nodeBin}${delimiter}${process.env.PATH ?? ''}`)
child.disconnect()
expect(await exited).toBe(fail ? 1 : 0)
const exitCode = await exited
await drained
expect(child.signalCode).toBeNull()
expect(exitCode, stderr).toBe(fail ? 1 : 0)
expect(stderr).not.toContain('ERR_IPC_CHANNEL_CLOSED')
expect(stderr).not.toContain('Unhandled')
if (fail) expect(stderr).toContain('fixture boot failure')
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.1.6-alpha.2",
"version": "0.1.7-alpha.1",
"private": true,
"license": "MIT",
"type": "module",
@@ -19,6 +19,7 @@
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^",
"@deepseek-ai/dsh-skill-office": "workspace:^"
"@deepseek-ai/dsh-skill-office": "workspace:^",
"@deepseek-ai/dsh-deepseek-account": "workspace:^"
}
}
+5
View File
@@ -5,10 +5,12 @@ import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
import { runProfile } from '@deepseek-ai/dsh/profile-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
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 { installDesktopUpdateTaskControl } from './update-tasks.ts'
import { installPlatformSessionPublisher } from './platform-session.ts'
import { installOfficeEngineResolution } from './office-engine.ts'
async function main(): Promise<void> {
@@ -71,6 +73,9 @@ async function main(): Promise<void> {
source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
})
installPlatformSessionPublisher(ctx, (session) => {
if (process.connected) process.send?.({ type: 'platform-session', session })
})
const url = ctx.connection.authenticatedUrl(`http://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) })
}
+36
View File
@@ -0,0 +1,36 @@
/** Keeps Electron's private Platform credentials bound to the active account provider. */
import type { Context } from '@deepseek-ai/cordis'
import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
/**
* Publish account sessions for each provider lifetime, clearing them on removal or stream termination.
* @param ctx - Host context owning the account dependency subscription.
* @param publish - Synchronous private IPC delivery; never forwards credentials to the renderer.
*/
export function installPlatformSessionPublisher(ctx: Context, publish: (session: PlatformSession | null) => void): void {
ctx.inject(['deepseekAccount'], (accountCtx) => {
const account = accountCtx.deepseekAccount
accountCtx.effect(() => {
const lifetime = new AbortController()
const updates = (async () => {
try {
for await (const _state of account.watch(lifetime.signal)) {
if (lifetime.signal.aborted) break
const session = await account.getPlatformSession()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Disposal can abort during the credential read.
if (!lifetime.signal.aborted) publish(session)
}
} catch {
if (!lifetime.signal.aborted) accountCtx.logger('desktop-platform').warn('Account session subscription failed')
} finally {
if (!lifetime.signal.aborted) publish(null)
}
})()
return async () => {
lifetime.abort()
publish(null)
await updates
}
})
})
}
@@ -0,0 +1,105 @@
import { Context } from '@deepseek-ai/cordis'
import { DeepSeekAccount, type AccountView, type PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
import { expect, it } from 'vitest'
import { installPlatformSessionPublisher } from '../src/platform-session.ts'
const state: AccountView = {
status: 'credential-stored', attempt: null,
links: { usageUrl: 'https://platform.example/usage', topUpUrl: 'https://platform.example/top_up' },
}
class Account extends DeepSeekAccount {
fail = false
ended = Promise.withResolvers<undefined>()
reading = Promise.withResolvers<undefined>()
finished = Promise.withResolvers<undefined>()
read: () => Promise<PlatformSession | null> = async () => ({ origin: 'https://platform.example', token: 'test' })
override async getState() { return state }
override async getProfile() { return null }
override async getBalance() { return null }
override async startSignIn() { return state }
override async cancelSignIn() { return state }
override async signOut() { return state }
override async resolveToken() { return undefined }
override async getPlatformSession() { this.reading.resolve(undefined); return this.read() }
override async *watch(signal: AbortSignal) {
const abort = () => { this.ended.resolve(undefined) }
signal.addEventListener('abort', abort, { once: true })
try {
yield state
await this.ended.promise
if (this.fail) throw new Error('fixture subscription failure')
} finally {
signal.removeEventListener('abort', abort)
this.finished.resolve(undefined)
}
}
}
it('clears removed credentials and publishes the replacement provider session', async () => {
const ctx = new Context()
const sessions: (PlatformSession | null)[] = []
let delivered = Promise.withResolvers<undefined>()
installPlatformSessionPublisher(ctx, (session) => { sessions.push(session); delivered.resolve(undefined) })
try {
const first = await ctx.plugin(Account)
await delivered.promise
expect(sessions.at(-1)?.token).toBe('test')
await first.dispose()
expect(sessions.at(-1)).toBeNull()
delivered = Promise.withResolvers<undefined>()
await ctx.plugin((scope) => {
const replacement = new Account(scope)
replacement.read = async () => ({ origin: 'https://platform.example', token: 'replacement' })
})
await delivered.promise
expect(sessions.at(-1)?.token).toBe('replacement')
} finally {
await ctx.fiber.dispose()
}
})
it.each([false, true])('clears credentials when the account stream ends (failure: %s)', async (fail) => {
const ctx = new Context()
const cleared = Promise.withResolvers<undefined>()
installPlatformSessionPublisher(ctx, (session) => { if (session === null) cleared.resolve(undefined) })
try {
await ctx.plugin(Account)
const account = ctx.get('deepseekAccount')
expect(account).toBeInstanceOf(Account)
if (!(account instanceof Account)) throw new Error('missing account fixture')
await account.reading.promise
account.fail = fail
account.ended.resolve(undefined)
await cleared.promise
await account.finished.promise
} finally {
await ctx.fiber.dispose()
}
})
it('suppresses a credential read that completes during provider disposal', async () => {
const ctx = new Context()
const pending = Promise.withResolvers<PlatformSession | null>()
const cleared = Promise.withResolvers<undefined>()
const sessions: (PlatformSession | null)[] = []
installPlatformSessionPublisher(ctx, (session) => { sessions.push(session); if (session === null) cleared.resolve(undefined) })
try {
const provider = await ctx.plugin((scope) => {
const account = new Account(scope)
account.read = () => pending.promise
})
const account = ctx.deepseekAccount
if (!(account instanceof Account)) throw new Error('missing account fixture')
await account.reading.promise
const disposed = provider.dispose()
await cleared.promise
pending.resolve({ origin: 'https://platform.example', token: 'stale' })
await disposed
expect(sessions).toEqual([null])
await account.finished.promise
} finally {
pending.resolve(null)
await ctx.fiber.dispose()
}
})
+3
View File
@@ -25,6 +25,9 @@
},
{
"path": "../../packages/host/webserver"
},
{
"path": "../../packages/credentials/deepseek-account"
}
]
}
+4
View File
@@ -7,6 +7,10 @@ DOWNLOAD_TEST_ORIGIN=
# Generate once per release batch; retain it for retries and versions testing upgrades within that batch.
DOWNLOAD_TEST_RELEASE_ID=
# Optional npm registry for the bundled dsh runtime install; defaults to https://registry.npmjs.org/.
# A local mirror such as https://registry.npmmirror.com speeds up repeated local packages.
# DSH_DESKTOP_NPM_REGISTRY=https://registry.npmmirror.com
# Workspace tarball workers; omit to use 4. Must be a positive integer.
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
# Optional independent proxy origins; empty values preserve inherited networking.
+5
View File
@@ -7,6 +7,11 @@ DOWNLOAD_TEST_ORIGIN=
# Generate once per release batch; retain it for retries and versions testing upgrades within that batch.
DOWNLOAD_TEST_RELEASE_ID=
# Optional npm registry for the bundled dsh runtime install; defaults to https://registry.npmjs.org/.
# A local mirror such as https://registry.npmmirror.com speeds up repeated local packages.
# DSH_DESKTOP_NPM_REGISTRY=https://registry.npmmirror.com
# The selected mandatory-update origin is required, including unsigned and preparation builds.
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 16bf5fd64fc4ea8bfe03e6e84e37cb87115bd74b
README.zh.md: 500240148cc8fb0c90f6029caaa4fdf57881c83d
README.md: 711e65fb5feaea601800218801d138a0df9b3f97
README.zh.md: 183d195a9af485c9ffe5a3e5bf65e3a459582b93
+34 -1
View File
@@ -10,6 +10,10 @@ Desktop’s local native directory flow opens an Electron folder dialog attached
Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations.
Embedded Platform documents use a dedicated non-persistent WebContentsView session. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar.
Desktop Host Platform API requests use the same `x-client-platform` mapping as update-policy requests. The account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package).
Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description.
Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools.
@@ -68,7 +72,7 @@ On macOS the custom application menu also declares the standard File, Window, an
The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
1. The main window loads the shared Web loading page offscreen from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
2. Before Host startup, Desktop validates the runtime descriptor and prepares the profile without modifying installed packages, declarations, or the lockfile; it removes only the `.dsh-module-fallback` projections earlier link-backend launches wrote, and startup never runs pnpm. Packages inside the profile keep native precedence, and installation package names resolve through the runtime resolution ([lookup order](../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md); [cleanup removal](../../.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.md)).
3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration.
@@ -85,6 +89,8 @@ Recovery waits for Host shutdown before changing plugin activation. The native r
## Develop
The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files.
`dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without resolving dsh from npm:
```sh
@@ -101,6 +107,21 @@ pnpm run start:desktop
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
### Startup onboarding
Repeated launches and `dsh://open` keep the workspace hidden until the startup credential check or a welcome action permits entry. Entering from Welcome places keyboard focus on the document without selecting a sidebar control; Tab navigation remains available.
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](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626). 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 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.
### Welcome window appearance
The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
## Package
<a id="release-versions"></a>
@@ -378,10 +399,22 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
## Known limitations
- Account sign-in is not connected; the Sign in button is disabled. Windows material rendering still requires platform QA.
- Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
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.
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.
The account provider’s `embeddedPageDist` configuration adds a `dist` query parameter to embedded Usage and Top-up URLs. Its default is empty; private frontend branch selectors belong in the local profile patch. It does not change API URLs or credential delivery.
## Dev Note
Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification).
+34 -1
View File
@@ -10,6 +10,10 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件
Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。
内嵌 Platform 文档使用独立且不持久化的 WebContentsView 会话。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。
Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-client-platform` 映射。账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。
桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。
按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。
@@ -68,7 +72,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
1. 主窗口在 profile 准备或后端启动前,从打包静态资源于屏幕外加载共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
2. 启动 Host 前,Desktop 校验运行时描述符并准备 profile,不改动已安装的包、依赖声明与锁文件;只删除早期 Link 后端启动写下的 `.dsh-module-fallback` 投影,启动从不运行 pnpm。profile 内的包保持原生优先级,本体包名通过 runtime resolution 解析([查找顺序](../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md);[清理移除](../../.agents/notes/implemented/simplification/2026-09-19-remove-desktop-profile-core-cleanup.zh.md))。
3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。
@@ -85,6 +89,8 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
## 开发
开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
`dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不从 npm 解析 dsh:
```sh
@@ -101,6 +107,21 @@ pnpm run start:desktop
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
### 启动引导
重复启动和 `dsh://open` 会保持工作区隐藏,直到启动凭据检查或欢迎页操作允许进入。从 Welcome 进入时,键盘焦点落在文档上,不选中侧边栏控件;Tab 导航仍可使用。
Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 [API Key 页面](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626)。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
等待浏览器登录时,欢迎页提供当前待授权请求的链接复制入口、加载指示和取消操作;剪贴板写入失败后可以重试复制,复制结果提示在两秒后恢复;已复制状态下链接禁用,恢复后可再次点击。Welcome 文字使用 Montserrat Light 并回退到系统字体,底部大按钮保留系统字体,文字按钮使用 Montserrat Light。英文欢迎正文及产品名均为 24px,中文欢迎正文为 24px、产品名为 26px。登录操作按钮宽 240px,文字为 14px。授权状态标题使用 20px Montserrat Regular 字重。API Key 页的标题为 20px,返回操作为 14px,次级按钮底边距窗口底部 84px。
### 欢迎窗口外观
欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
## 打包
<a id="release-versions"></a>
@@ -378,10 +399,22 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
## 已知限制
- 账号登录尚未接入;登录按钮禁用。Windows 材质效果仍需平台验证。
- 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。 所属应用文档刷新或替换、渲染进程终止以及窗口关闭也会销毁原生视图,不依赖 React 清理。
私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin、token 和已解析的语言。
账号提供者的 `embeddedPageDist` 配置为内嵌用量和充值页面 URL 添加 `dist` 查询参数。默认值为空;私有前端分支选择值应写在本地 profile patch 中。此配置不改变 API 地址或凭证传递方式。
## 开发备注
上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。
@@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib'
/** Electron-builder fields asserted by the Desktop release tests. */
export interface DesktopElectronBuilderConfig {
readonly appId: string
readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }]
readonly directories: {
readonly output: string
}
+14 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.1.6-alpha.2",
"version": "0.1.7-alpha.1",
"private": true,
"license": "MIT",
"type": "module",
@@ -37,7 +37,9 @@
},
"dependencies": {
"electron-updater": "^6.8.9",
"semver": "^7.8.5"
"semver": "^7.8.5",
"ws": "^8.21.0",
"@deepseek-ai/dsh-api-gateway": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:^",
@@ -58,6 +60,15 @@
"js-yaml": "^4.2.0",
"koffi": "^3.1.0",
"pnpm": "11.7.0",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"@deepseek-ai/dsh-deepseek-account": "workspace:^",
"@types/ws": "^8.18.1",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"vite": "^6.0.0",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^"
}
}
@@ -0,0 +1,44 @@
<svg width="472" height="40" viewBox="0 0 472 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
:root {
--dsw-alias-label-primary: #0f1115;
--dsw-alias-label-primary-inverted: #fff;
}
@media (prefers-color-scheme: dark) {
:root {
--dsw-alias-label-primary: #f9fafb;
--dsw-alias-label-primary-inverted: #353638;
}
}
</style>
<path d="M213.743 26.2447H212.377V24.1272H213.743C214.589 24.1272 215.443 23.9163 215.994 23.3304C216.545 22.7444 216.753 21.8455 216.753 20.9471C216.753 20.0487 216.554 19.1498 215.994 18.5643C215.435 17.9783 214.589 17.7674 213.743 17.7674C212.897 17.7674 212.042 17.9783 211.491 18.5643C210.94 19.1503 210.732 20.0487 210.732 20.9471V29.6435H208.336V15.6582H210.732V16.5488H211.171C211.219 16.4939 211.266 16.4473 211.314 16.4006C211.914 15.8535 212.832 15.6582 213.734 15.6582C215.148 15.6582 216.561 16.01 217.488 16.9866C218.414 17.9632 218.741 19.4632 218.741 20.9558C218.741 22.4485 218.405 23.9406 217.488 24.925C216.569 25.9094 215.148 26.2457 213.743 26.2457V26.2447Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M177.282 16.0355H178.647V18.153H177.282C176.436 18.153 175.581 18.3638 175.03 18.9498C174.479 19.5358 174.272 20.4347 174.272 21.3331C174.272 22.2315 174.472 23.1299 175.03 23.7159C175.589 24.3018 176.436 24.5127 177.282 24.5127C178.128 24.5127 178.983 24.3018 179.534 23.7159C180.085 23.1299 180.293 22.2315 180.293 21.3331V12.6289H182.688V26.6224H180.293V25.7318H179.853C179.805 25.7862 179.758 25.8334 179.709 25.88C179.11 26.4266 178.192 26.6224 177.29 26.6224C175.876 26.6224 174.463 26.2711 173.537 25.2945C172.611 24.3179 172.283 22.8175 172.283 21.3253C172.283 19.8331 172.618 18.3405 173.537 17.3561C174.463 16.3795 175.876 16.0355 177.282 16.0355Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M194.706 21.1444V21.9962H188.325V20.3009H192.557C192.462 19.6838 192.238 19.1056 191.839 18.6834C191.264 18.0741 190.377 17.8554 189.499 17.8554C188.621 17.8554 187.735 18.0741 187.16 18.6834C186.585 19.2927 186.377 20.2227 186.377 21.1449C186.377 22.0671 186.584 23.0044 187.16 23.6059C187.735 24.2074 188.621 24.4343 189.499 24.4343C190.378 24.4343 191.264 24.2157 191.839 23.6059C191.918 23.5199 191.99 23.4261 192.062 23.3323H194.426C194.219 24.067 193.891 24.7312 193.396 25.2467C192.438 26.2545 190.961 26.6218 189.499 26.6218C188.038 26.6218 186.561 26.2622 185.602 25.2467C184.644 24.2312 184.293 22.6842 184.293 21.1449C184.293 19.6056 184.636 18.0508 185.602 17.043C186.569 16.0353 188.038 15.668 189.499 15.668C190.961 15.668 192.438 16.0275 193.396 17.043C194.362 18.0585 194.706 19.6056 194.706 21.1449V21.1444Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M206.731 21.1444V21.9962H200.351V20.3009H204.583C204.487 19.6838 204.264 19.1056 203.864 18.6834C203.289 18.0741 202.403 17.8554 201.525 17.8554C200.647 17.8554 199.76 18.0741 199.185 18.6834C198.61 19.2927 198.402 20.2227 198.402 21.1449C198.402 22.0671 198.61 23.0044 199.185 23.6059C199.76 24.2074 200.647 24.4343 201.525 24.4343C202.403 24.4343 203.289 24.2157 203.864 23.6059C203.944 23.5199 204.016 23.4261 204.087 23.3323H206.452C206.244 24.067 205.916 24.7312 205.421 25.2467C204.463 26.2545 202.986 26.6218 201.525 26.6218C200.064 26.6218 198.586 26.2622 197.628 25.2467C196.67 24.2312 196.318 22.6842 196.318 21.1449C196.318 19.6056 196.662 18.0508 197.628 17.043C198.594 16.0353 200.063 15.668 201.525 15.668C202.986 15.668 204.463 16.0275 205.421 17.043C206.387 18.0585 206.731 19.6056 206.731 21.1449V21.1444Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M225.57 26.6228C227.031 26.6228 228.508 26.4119 229.466 25.8104C230.424 25.2088 230.776 24.2944 230.776 23.3882C230.776 22.482 230.432 21.5598 229.466 20.9661C228.508 20.3723 227.031 20.1532 225.57 20.1532C224.946 20.1532 224.371 20.0672 223.964 19.8408C223.557 19.6061 223.405 19.2548 223.405 18.903C223.405 18.5512 223.549 18.1921 223.964 17.9652C224.371 17.7306 225.003 17.6528 225.625 17.6528C226.247 17.6528 226.879 17.7388 227.287 17.9652C227.694 18.1999 227.845 18.5512 227.845 18.903H230.281C230.281 17.9963 229.97 17.0746 229.1 16.4809C228.229 15.8871 226.896 15.668 225.57 15.668C224.245 15.668 222.911 15.8788 222.041 16.4809C221.17 17.0824 220.858 17.9963 220.858 18.903C220.858 19.8097 221.17 20.7314 222.041 21.3251C222.911 21.9189 224.244 22.138 225.57 22.138C226.257 22.138 227.016 22.224 227.47 22.4505C227.926 22.6774 228.086 23.0364 228.086 23.3882C228.086 23.74 227.926 24.0991 227.47 24.326C227.016 24.5524 226.321 24.6384 225.634 24.6384C224.948 24.6384 224.245 24.5524 223.797 24.326C223.35 24.0996 223.182 23.74 223.182 23.3882H220.371C220.371 24.2949 220.714 25.2166 221.681 25.8104C222.647 26.4041 224.109 26.6228 225.57 26.6228H225.57Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M242.801 21.1444V21.9962H236.421V20.3009H240.653C240.557 19.6838 240.333 19.1056 239.934 18.6834C239.359 18.0741 238.473 17.8554 237.594 17.8554C236.715 17.8554 235.829 18.0741 235.254 18.6834C234.68 19.2927 234.473 20.2227 234.473 21.1449C234.473 22.0671 234.68 23.0044 235.254 23.6059C235.829 24.2074 236.716 24.4343 237.594 24.4343C238.472 24.4343 239.359 24.2157 239.934 23.6059C240.014 23.5199 240.086 23.4261 240.158 23.3323H242.521C242.313 24.067 241.986 24.7312 241.491 25.2467C240.533 26.2545 239.056 26.6218 237.594 26.6218C236.132 26.6218 234.656 26.2622 233.698 25.2467C232.739 24.2312 232.389 22.6842 232.389 21.1449C232.389 19.6056 232.732 18.0508 233.698 17.043C234.664 16.0353 236.133 15.668 237.594 15.668C239.055 15.668 240.533 16.0275 241.491 17.043C242.457 18.0585 242.8 19.6056 242.8 21.1449L242.801 21.1444Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M254.827 21.1444V21.9962H248.446V20.3009H252.679C252.583 19.6838 252.359 19.1056 251.96 18.6834C251.385 18.0741 250.499 17.8554 249.62 17.8554C248.741 17.8554 247.856 18.0741 247.281 18.6834C246.706 19.2927 246.498 20.2227 246.498 21.1449C246.498 22.0671 246.706 23.0044 247.281 23.6059C247.856 24.2074 248.742 24.4343 249.62 24.4343C250.498 24.4343 251.385 24.2157 251.96 23.6059C252.04 23.5199 252.111 23.4261 252.184 23.3323H254.547C254.339 24.067 254.012 24.7312 253.518 25.2467C252.559 26.2545 251.082 26.6218 249.62 26.6218C248.159 26.6218 246.682 26.2622 245.724 25.2467C244.766 24.2312 244.414 22.6842 244.414 21.1449C244.414 19.6056 244.757 18.0508 245.724 17.043C246.69 16.0353 248.159 15.668 249.62 15.668C251.081 15.668 252.558 16.0275 253.518 17.043C254.484 18.0585 254.827 19.6056 254.827 21.1449V21.1444Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M258.827 12.6289H256.432V26.6224H258.827V12.6289Z" fill="var(--dsw-alias-label-primary)"/>
<path d="M262.917 20.8169L266.844 26.6222H263.882L259.953 20.8169L263.882 16.1523H266.844L262.917 20.8169Z" fill="var(--dsw-alias-label-primary)"/>
<g clip-path="url(#clip0_2141_5628)">
<path d="M168.384 12.9517C168.138 12.8317 168.033 13.0607 167.889 13.1767C167.84 13.2142 167.799 13.2632 167.757 13.3082C167.399 13.6912 166.98 13.9427 166.433 13.9127C165.632 13.8677 164.949 14.1192 164.345 14.7312C164.217 13.9767 163.79 13.5262 163.141 13.2372C162.802 13.0872 162.458 12.9367 162.22 12.6102C162.054 12.3776 162.009 12.1186 161.926 11.8631C161.873 11.7091 161.82 11.5516 161.643 11.5251C161.45 11.4951 161.375 11.6566 161.299 11.7916C160.997 12.3436 160.88 12.9517 160.892 13.5677C160.918 14.9532 161.503 16.0568 162.666 16.8418C162.798 16.9318 162.832 17.0218 162.79 17.1533C162.711 17.4238 162.617 17.6863 162.534 17.9568C162.481 18.1293 162.402 18.1673 162.217 18.0918C161.579 17.8253 161.028 17.4308 160.541 16.9543C159.714 16.1548 158.967 15.2722 158.035 14.5812C157.816 14.4197 157.597 14.2697 157.37 14.1267C156.419 13.2032 157.495 12.4446 157.744 12.3546C158.004 12.2606 157.834 11.9376 156.993 11.9416C156.151 11.9451 155.381 12.2271 154.4 12.6027C154.256 12.6592 154.105 12.7002 153.951 12.7342C153.06 12.5652 152.135 12.5277 151.169 12.6367C149.35 12.8392 147.897 13.6992 146.829 15.1672C145.546 16.9318 145.244 18.9368 145.613 21.0279C146.002 23.232 147.127 25.0565 148.855 26.4831C150.648 27.9621 152.713 28.6871 155.068 28.5481C156.499 28.4656 158.091 28.2741 159.888 26.7536C160.341 26.9791 160.816 27.0691 161.605 27.1366C162.212 27.1931 162.797 27.1066 163.25 27.0126C163.96 26.8626 163.911 26.2056 163.654 26.0851C161.575 25.1165 162.031 25.5105 161.616 25.1915C162.673 23.9415 164.287 21.7194 164.785 18.6938C164.834 18.3598 164.896 17.8893 164.889 17.6188C164.885 17.4538 164.923 17.3898 165.111 17.3708C165.632 17.3108 166.138 17.1683 166.603 16.9128C167.95 16.1768 168.494 14.9677 168.622 13.5187C168.641 13.2972 168.618 13.0682 168.384 12.9517ZM156.642 25.9951C154.626 24.4105 153.649 23.889 153.245 23.9115C152.868 23.934 152.936 24.366 153.019 24.6475C153.106 24.9255 153.219 25.117 153.377 25.361C153.487 25.5225 153.562 25.7625 153.268 25.9431C152.619 26.3446 151.49 25.808 151.437 25.7815C150.124 25.008 149.025 23.987 148.251 22.5905C147.504 21.2464 147.07 19.8044 146.998 18.2653C146.979 17.8938 147.089 17.7623 147.459 17.6948C147.946 17.6048 148.448 17.5858 148.934 17.6573C150.992 17.9578 152.743 18.8773 154.211 20.3344C155.049 21.1639 155.683 22.1554 156.336 23.124C157.03 24.1525 157.777 25.1325 158.729 25.9361C159.065 26.2176 159.333 26.4316 159.589 26.5896C158.816 26.6761 157.525 26.6946 156.642 25.9961V25.9951ZM157.608 19.7814C157.608 19.6164 157.74 19.4849 157.906 19.4849C157.943 19.4849 157.977 19.4924 158.008 19.5034C158.049 19.5184 158.087 19.5409 158.117 19.5749C158.17 19.6274 158.2 19.7024 158.2 19.7814C158.2 19.9464 158.068 20.0779 157.902 20.0779C157.736 20.0779 157.608 19.9464 157.608 19.7814ZM160.608 21.3204C160.416 21.3994 160.223 21.4669 160.038 21.4744C159.751 21.4894 159.438 21.3729 159.268 21.2304C159.004 21.0089 158.815 20.8849 158.736 20.4984C158.702 20.3334 158.721 20.0779 158.751 19.9314C158.819 19.6159 158.744 19.4134 158.521 19.2294C158.34 19.0793 158.11 19.0378 157.857 19.0378C157.762 19.0378 157.676 18.9963 157.611 18.9628C157.506 18.9103 157.419 18.7788 157.502 18.6173C157.528 18.5648 157.657 18.4373 157.687 18.4148C158.03 18.2193 158.426 18.2833 158.793 18.4298C159.132 18.5688 159.389 18.8238 159.759 19.1844C160.136 19.6199 160.204 19.7399 160.419 20.0669C160.589 20.3224 160.744 20.5849 160.85 20.8854C160.914 21.0729 160.831 21.2264 160.608 21.3204Z" fill="var(--dsw-alias-label-primary)"/>
</g>
<rect x="274.674" y="13.5" width="52" height="14" rx="2" fill="var(--dsw-alias-label-primary)"/>
<g clip-path="url(#clip1_2141_5628)">
<path d="M278.174 16.9328H279.406V24.1378H278.174V16.9328ZM281.826 16.9328H283.058V24.1378H281.826V16.9328ZM278.691 21.0248V19.9908H282.519V21.0248H278.691Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M285.723 22.4328L285.998 21.4538H288.528L288.858 22.4328H285.723ZM285.613 24.1378H284.381L286.603 16.9328H287.527L287.472 17.7468L286.273 21.9158H286.295L285.613 24.1378ZM290.365 24.1378H289.067L288.396 21.9488L288.407 21.9378L287.197 17.7468L287.252 16.9328H288.143L290.365 24.1378Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M292.172 16.9328H294.393C295.178 16.9328 295.768 17.1161 296.164 17.4828C296.56 17.8421 296.758 18.3334 296.758 18.9568C296.758 19.2208 296.722 19.4664 296.649 19.6938C296.575 19.9211 296.451 20.1264 296.274 20.3098C296.098 20.4931 295.857 20.6508 295.549 20.7828C295.248 20.9074 294.867 21.0064 294.405 21.0798V21.3218H292.172V20.6398L293.349 20.4858C293.957 20.4051 294.416 20.2988 294.724 20.1668C295.032 20.0348 295.24 19.8771 295.351 19.6938C295.461 19.5031 295.516 19.2941 295.516 19.0668C295.516 18.7001 295.409 18.4178 295.197 18.2198C294.984 18.0218 294.65 17.9228 294.195 17.9228H292.172V16.9328ZM291.72 16.9328H292.953V24.1378H291.72V16.9328ZM297.243 24.0938V24.1378H295.691L294.35 22.3228C294.196 22.1101 294.056 21.9414 293.932 21.8168C293.807 21.6848 293.671 21.5894 293.524 21.5308C293.378 21.4648 293.198 21.4208 292.986 21.3988C292.773 21.3694 292.502 21.3511 292.172 21.3438V21.1458H294.405C294.559 21.2118 294.694 21.2851 294.812 21.3658C294.936 21.4464 295.061 21.5454 295.186 21.6628C295.318 21.7801 295.464 21.9378 295.625 22.1358L297.243 24.0938Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M298.906 17.5708L298.917 16.9328H299.786L302.91 23.5108V24.1378H302.03L298.906 17.5708ZM303.35 24.1378H302.294L302.206 16.9328H303.35V24.1378ZM299.566 24.1378H298.422V16.9328H299.478L299.566 24.1378Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M305.289 16.9328H306.532V24.1378H305.289V16.9328ZM305.421 17.9668V16.9328H310.184V17.9668H305.421ZM305.421 24.1378V23.1038H310.228V24.1378H305.421ZM305.421 21.0138V19.9908H309.7V21.0138H305.421Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M314.378 23.2578C314.869 23.2578 315.221 23.1661 315.434 22.9828C315.654 22.7994 315.764 22.5464 315.764 22.2238C315.764 22.0478 315.731 21.8974 315.665 21.7728C315.599 21.6481 315.478 21.5344 315.302 21.4318C315.133 21.3218 314.884 21.2154 314.554 21.1128L313.817 20.8818C313.172 20.6764 312.706 20.4051 312.42 20.0678C312.134 19.7304 311.991 19.3014 311.991 18.7808C311.991 18.4288 312.086 18.1024 312.277 17.8018C312.468 17.5011 312.754 17.2591 313.135 17.0758C313.516 16.8924 313.989 16.8008 314.554 16.8008C314.957 16.8008 315.324 16.8301 315.654 16.8888C315.991 16.9401 316.365 17.0171 316.776 17.1198L316.6 18.1758C316.16 18.0511 315.768 17.9631 315.423 17.9118C315.086 17.8531 314.789 17.8238 314.532 17.8238C314.063 17.8238 313.729 17.9081 313.531 18.0768C313.333 18.2381 313.234 18.4398 313.234 18.6818C313.234 18.8578 313.267 19.0154 313.333 19.1548C313.399 19.2868 313.516 19.4078 313.685 19.5178C313.861 19.6278 314.11 19.7341 314.433 19.8368L315.192 20.0788C315.852 20.2768 316.321 20.5334 316.6 20.8488C316.879 21.1568 317.018 21.5858 317.018 22.1358C317.018 22.5611 316.915 22.9351 316.71 23.2578C316.505 23.5804 316.204 23.8334 315.808 24.0168C315.419 24.2001 314.935 24.2918 314.356 24.2918C313.953 24.2918 313.538 24.2478 313.113 24.1598C312.688 24.0718 312.226 23.9434 311.727 23.7748L311.991 22.7188C312.482 22.8948 312.926 23.0304 313.322 23.1258C313.725 23.2138 314.077 23.2578 314.378 23.2578Z" fill="var(--dsw-alias-label-primary-inverted)"/>
<path d="M321.135 23.2578C321.626 23.2578 321.978 23.1661 322.191 22.9828C322.411 22.7994 322.521 22.5464 322.521 22.2238C322.521 22.0478 322.488 21.8974 322.422 21.7728C322.356 21.6481 322.235 21.5344 322.059 21.4318C321.89 21.3218 321.641 21.2154 321.311 21.1128L320.574 20.8818C319.929 20.6764 319.463 20.4051 319.177 20.0678C318.891 19.7304 318.748 19.3014 318.748 18.7808C318.748 18.4288 318.843 18.1024 319.034 17.8018C319.225 17.5011 319.511 17.2591 319.892 17.0758C320.273 16.8924 320.746 16.8008 321.311 16.8008C321.714 16.8008 322.081 16.8301 322.411 16.8888C322.748 16.9401 323.122 17.0171 323.533 17.1198L323.357 18.1758C322.917 18.0511 322.525 17.9631 322.18 17.9118C321.843 17.8531 321.546 17.8238 321.289 17.8238C320.82 17.8238 320.486 17.9081 320.288 18.0768C320.09 18.2381 319.991 18.4398 319.991 18.6818C319.991 18.8578 320.024 19.0154 320.09 19.1548C320.156 19.2868 320.273 19.4078 320.442 19.5178C320.618 19.6278 320.867 19.7341 321.19 19.8368L321.949 20.0788C322.609 20.2768 323.078 20.5334 323.357 20.8488C323.636 21.1568 323.775 21.5858 323.775 22.1358C323.775 22.5611 323.672 22.9351 323.467 23.2578C323.262 23.5804 322.961 23.8334 322.565 24.0168C322.176 24.2001 321.692 24.2918 321.113 24.2918C320.71 24.2918 320.295 24.2478 319.87 24.1598C319.445 24.0718 318.983 23.9434 318.484 23.7748L318.748 22.7188C319.239 22.8948 319.683 23.0304 320.079 23.1258C320.482 23.2138 320.834 23.2578 321.135 23.2578Z" fill="var(--dsw-alias-label-primary-inverted)"/>
</g>
<defs>
<clipPath id="clip0_2141_5628">
<rect width="23.16" height="17.0435" fill="var(--dsw-alias-label-primary-inverted)" transform="translate(145.467 11.5215)"/>
</clipPath>
<clipPath id="clip1_2141_5628">
<rect width="46" height="14" fill="var(--dsw-alias-label-primary-inverted)" transform="translate(277.674 13.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,11 @@
/* Shared tint for full-window Desktop surfaces; native material owns the blur. */
:root {
/* Native macOS vibrancy supplies the blur; this is only the foreground tint. */
--dsw-desktop-window-tint: color-mix(in srgb, rgb(255 255 255) 40%, transparent);
}
@media (prefers-color-scheme: dark) {
:root:not([data-ds-theme-source]) { --dsw-desktop-window-tint: color-mix(in srgb, rgb(24 25 28) 50%, transparent); }
}
body[data-ds-dark-theme] { --dsw-desktop-window-tint: color-mix(in srgb, rgb(24 25 28) 50%, transparent); }
+340
View File
@@ -0,0 +1,340 @@
@import url('./assets/window-material.css');
/* Figma 2121:39336 / 2138:44626, Platform modes. Native material supplies the blur. */
:root {
color-scheme: light dark;
--dsw-alias-label-primary: #0f1115;
--dsw-alias-label-secondary: #61666b;
--dsw-alias-label-tertiary: #81858c;
--dsw-alias-label-primary-inverted: #fff;
--dsw-alias-brand-primary: var(--dsw-alias-label-primary);
--dsw-alias-button-primary-hover: #43454a;
--dsw-alias-button-elevated-fill: #fff;
--dsw-alias-button-floating-hover: #f1f3f5;
--dsw-alias-border-l2: #0000001a;
--dsw-alias-border-l4: #00000029;
--dsw-alias-label-caption: #adb2b8;
--welcome-tint: var(--dsw-desktop-window-tint);
--ds-transition-duration: 0.2s;
--ds-transition-duration-slow: 0.3s;
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--dsw-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--dsw-font-family-brand: 'Montserrat', var(--dsw-font-family);
}
@media (prefers-color-scheme: dark) {
:root {
--dsw-alias-label-primary: #f9fafb;
--dsw-alias-label-secondary: #cfd3d6;
--dsw-alias-label-tertiary: #adb2b8;
--dsw-alias-label-primary-inverted: #353638;
--dsw-alias-button-primary-hover: #ebeef2;
--dsw-alias-button-elevated-fill: #43454a;
--dsw-alias-button-floating-hover: #353638;
--dsw-alias-border-l2: #ffffff1f;
--dsw-alias-border-l4: #ffffff33;
--dsw-alias-label-caption: #81858c;
}
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 6px 16px;
background: var(--welcome-tint);
color: var(--dsw-alias-label-primary);
font-family: var(--dsw-font-family-brand);
font-weight: 300;
user-select: none;
-webkit-font-smoothing: antialiased;
}
.titlebar {
flex: 0 0 36px;
app-region: drag;
}
.welcome {
display: grid;
flex: 1;
min-height: 0;
grid-template-rows: 40px minmax(0, 1fr) 104px;
justify-items: center;
align-items: center;
padding: 24px 48px 76px;
}
.welcome > .tagline,
.welcome > .key-form,
.welcome > #auth-page {
grid-row: 2;
grid-column: 1;
}
.brand {
display: block;
flex: none;
width: 100%;
height: 40px;
user-select: none;
-webkit-user-drag: none;
}
.tagline {
margin: 0;
color: var(--dsw-alias-label-secondary);
font-size: 24px;
font-weight: 300;
line-height: 40px;
text-align: center;
white-space: nowrap;
}
.tagline h1,
.tagline p {
margin: 0;
font: inherit;
}
.tagline em {
color: var(--dsw-alias-label-primary);
font-family: var(--dsw-font-family-brand);
font-size: 26px;
font-style: normal;
font-weight: 500;
}
:lang(en) .tagline em {
font-size: inherit;
}
.actions {
position: relative;
grid-row: 3;
grid-column: 1;
align-self: stretch;
display: flex;
flex: none;
flex-direction: column;
gap: 16px;
width: 240px;
}
.actions button {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 10px;
font-family: var(--dsw-font-family);
font-size: 14px;
font-weight: 500;
line-height: 22px;
opacity: 1;
app-region: no-drag;
cursor: pointer;
transition:
background-color var(--ds-transition-duration) var(--ds-ease-in-out),
border-color var(--ds-transition-duration) var(--ds-ease-in-out),
opacity var(--ds-transition-duration) var(--ds-ease-in-out),
color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.actions button:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.actions button:focus-visible {
outline: 2px solid var(--dsw-alias-label-primary);
outline-offset: 3px;
}
.primary {
background: var(--dsw-alias-brand-primary);
color: var(--dsw-alias-label-primary-inverted);
}
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.actions .secondary {
border-color: var(--dsw-alias-border-l2);
background: var(--dsw-alias-button-elevated-fill);
color: var(--dsw-alias-label-primary);
}
.actions .back {
position: absolute;
top: calc(100% + 16px);
left: 50%;
transform: translateX(-50%);
min-width: 58px;
height: 28px;
padding: 4px 10px;
border: 0;
border-radius: 24px;
corner-shape: round;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-family: var(--dsw-font-family-brand);
font-weight: 300;
font-size: 14px;
line-height: 20px;
}
/* Platform text actions use color-only hover, preserving the native backdrop. */
.actions .back:hover:not(:disabled) {
color: var(--dsw-alias-brand-primary);
}
.actions .back:disabled {
color: var(--dsw-alias-label-caption);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.actions button {
transition: none;
}
}
.actions .secondary:hover:not(:disabled) {
background: var(--dsw-alias-button-floating-hover);
}
.key-form {
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
width: 440px;
}
.key-heading {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.key-heading h1 {
margin: 0;
font-size: 20px;
font-weight: 300;
line-height: 32px;
letter-spacing: 0.01em;
}
.key-heading p {
margin: 0;
color: var(--dsw-alias-label-secondary);
font-size: 16px;
font-weight: 300;
line-height: 22px;
letter-spacing: 0.02em;
}
.key-field {
position: relative;
width: 100%;
}
/* Text inputs match :focus-visible after pointer focus too; their border carries focus. */
.key-field input {
display: block;
width: 100%;
height: 48px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l4);
border-radius: 12px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font-family: inherit;
font-weight: inherit;
font-size: 14px;
line-height: 24px;
user-select: text;
}
.key-field input:focus {
border-color: var(--dsw-alias-brand-primary);
}
.key-field input::placeholder {
color: var(--dsw-alias-label-caption);
opacity: 1;
}
.key-error {
position: absolute;
top: 100%;
width: 100%;
margin: 8px 0 0;
color: var(--dsw-alias-label-primary);
font-size: 14px;
line-height: 22px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.auth-expired { gap: 12px; padding-top: 28px; }
#auth-status { font-size: 20px; line-height: 24px; font-weight: 400; letter-spacing: normal; }
.auth-expired p { line-height: 16px; letter-spacing: normal; }
.auth-waiting { gap: 12px; padding-top: 28px; }
.auth-waiting p { line-height: 16px; letter-spacing: normal; }
.auth-waiting .copy-link {
margin-top: 20px;
padding: 0;
width: 240px;
min-height: 17px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-family: inherit;
font-weight: inherit;
font-size: 14px;
line-height: 20px;
cursor: pointer;
app-region: no-drag;
text-decoration: underline;
}
#auth-loading:disabled { opacity: 1; }
.actions .welcome-loading { color: inherit; }
.auth-waiting .copy-link:disabled { cursor: default; }
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'">
<title></title>
<link rel="stylesheet" href="../lib/welcome/brand-font.css">
<link rel="stylesheet" href="welcome.css">
<link rel="stylesheet" href="../lib/welcome/welcome.css">
<script src="../lib/welcome/welcome.js" defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -4,7 +4,7 @@ import { accessSync, constants, readFileSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseEnv } from 'node:util'
import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs'
import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs'
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
import { createWindowsTokenSigner } from './windows-sign.mjs'
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
@@ -13,7 +13,7 @@ import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
@@ -78,6 +78,7 @@ function requireReadableFile(environment, name) {
*/
export function validateDesktopPackageEnvironment(environment, target, options = {}) {
resolveDesktopAppId(environment)
resolveNpmRegistry(environment)
resolveDesktopPolicyEnvironment(environment)
if (target.platform === 'darwin') resolveMacOSPackageSettings(environment)
else resolveWindowsPackageSettings(environment)
@@ -7,6 +7,9 @@ export const MACOS_SIGNING_IDENTITY_ENV: 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY'
/** Environment variable that supplies the expected Apple Developer Team ID. */
export const MACOS_TEAM_ID_ENV: 'DSH_DESKTOP_MACOS_TEAM_ID'
/** Environment variable that selects the npm registry used for the bundled runtime install. */
export const NPM_REGISTRY_ENV: 'DSH_DESKTOP_NPM_REGISTRY'
/** Public identity expected on a macOS release. */
export interface MacOSSigningEnvironment {
readonly signingIdentity: string
@@ -46,6 +49,13 @@ export type MacOSNotarizationEnvironment =
*/
export function resolveDesktopAppId(env: NodeJS.ProcessEnv): string
/**
* Resolve the npm registry used to materialize the bundled runtime and its external dependencies.
* @param env - Packaging environment.
* @returns Registry origin; the public registry unless a local mirror is configured.
*/
export function resolveNpmRegistry(env: NodeJS.ProcessEnv): string
/**
* Resolve and validate the public identity expected on a macOS release.
* @param env - Packaging environment.
@@ -9,6 +9,11 @@ export const MACOS_SIGNING_IDENTITY_ENV = 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY'
/** Environment variable that supplies the expected Apple Developer Team ID. */
export const MACOS_TEAM_ID_ENV = 'DSH_DESKTOP_MACOS_TEAM_ID'
/** Environment variable that selects the npm registry used for the bundled runtime install. */
export const NPM_REGISTRY_ENV = 'DSH_DESKTOP_NPM_REGISTRY'
const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'
const APPLE_API_KEY_ENV = 'APPLE_API_KEY'
const APPLE_API_KEY_ID_ENV = 'APPLE_API_KEY_ID'
const APPLE_API_ISSUER_ENV = 'APPLE_API_ISSUER'
@@ -32,6 +37,24 @@ function requireEnvironmentValue(env, name) {
return value
}
/**
* Resolve the npm registry used to materialize the bundled runtime and its external dependencies.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @returns {string} Registry origin; the public registry unless a local mirror is configured.
*/
export function resolveNpmRegistry(env) {
const configured = env[NPM_REGISTRY_ENV]?.trim() ?? ''
if (configured === '') return DEFAULT_NPM_REGISTRY
let url
try { url = new URL(configured) }
catch { throw new Error(`desktop release environment: ${NPM_REGISTRY_ENV} must be an HTTPS origin`) }
if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== ''
|| (url.pathname !== '/' && url.pathname !== '')) {
throw new Error(`desktop release environment: ${NPM_REGISTRY_ENV} must be an HTTPS origin without credentials, path, query, or fragment`)
}
return url.origin
}
/**
* Resolve and validate the application identifier shared by every platform target.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
+7
View File
@@ -8,6 +8,7 @@ import { parseArgs } from 'node:util'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import type { DesktopRelease } from '../src/release.ts'
import { prepareDevelopmentProject } from './development-project.ts'
import { prepareDevelopmentApp } from './development-app.ts'
import { preparePrimaryRuntime } from './prepare-primary-runtime.ts'
const APP_ROOT = resolve(import.meta.dirname, '..')
@@ -72,6 +73,12 @@ async function launchElectron(): Promise<void> {
}
console.log(`desktop development: DSH_HOME=${home}`)
console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`)
if (process.platform === 'darwin') {
const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData,
mainPort, rendererPort, hostPort, openDevtools: environment.DSH_DESKTOP_OPEN_DEVTOOLS! })
await run(executable, [], APP_ROOT, environment)
return
}
await run(electron, [
`--inspect=127.0.0.1:${String(mainPort)}`,
`--remote-debugging-port=${String(rendererPort)}`,
+84
View File
@@ -0,0 +1,84 @@
/** macOS development bundle that loads the current workspace through Electron. */
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
/** Workspace and runtime settings captured for Launch Services cold starts. */
export interface DevelopmentAppOptions {
readonly electron: string
readonly appRoot: string
readonly directory: string
readonly home: string
readonly userData: string
readonly mainPort: number
readonly rendererPort: number
readonly hostPort: number
readonly openDevtools: string
}
function quote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` }
/**
* Prepare and register a disposable .app without changing the installed Electron package.
* @param options - Current workspace, Electron binary, and private development locations.
* @returns executable used by the supported desktop development launcher.
*/
export function prepareDevelopmentApp(options: DevelopmentAppOptions): string {
const source = dirname(dirname(dirname(options.electron)))
const bundle = join(options.directory, 'Harness Dev.app')
const executable = join(bundle, 'Contents', 'MacOS', 'HarnessDev')
const stamp = join(bundle, 'Contents', 'Resources', 'dsh-development.json')
const launcher = developmentLauncher(options, bundle)
const identity = JSON.stringify({ ...options, launcher, plist: readFileSync(join(source, 'Contents', 'Info.plist'), 'utf8') })
if (!existsSync(stamp) || readFileSync(stamp, 'utf8') !== identity) {
rmSync(bundle, { recursive: true, force: true })
execFileSync('/usr/bin/ditto', [source, bundle])
const plist = join(bundle, 'Contents', 'Info.plist')
const values = {
CFBundleIdentifier: `com.deepseek.harness.dev.${createHash('sha256').update(options.appRoot).digest('hex').slice(0, 12)}`,
CFBundleName: 'Harness Dev',
CFBundleDisplayName: 'Harness Dev',
CFBundleExecutable: 'HarnessDev',
CFBundleURLTypes: [{ CFBundleURLName: 'DeepSeek Harness', CFBundleURLSchemes: ['dsh'], CFBundleTypeRole: 'Viewer' }],
}
for (const [key, value] of Object.entries(values)) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-json', JSON.stringify(value), plist])
}
writeFileSync(executable, launcher, { mode: 0o755 })
writeFileSync(stamp, identity, { mode: 0o600 })
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', bundle], { stdio: 'pipe' })
}
execFileSync('/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister', ['-f', bundle])
return executable
}
/**
* Create the development executable used by both the CLI and Launch Services.
* @param options - Persisted development locations and debugging settings.
* @param bundle - Generated application bundle.
* @returns shell program with literal arguments and environment values.
*/
export function developmentLauncher(options: DevelopmentAppOptions, bundle: string): string {
const environment = {
DSH_HOME: options.home,
DSH_DESKTOP_DEV_APP: '1',
DSH_DESKTOP_HOST_INSPECT_PORT: String(options.hostPort),
DSH_DESKTOP_OPEN_DEVTOOLS: options.openDevtools,
ELECTRON_ENABLE_LOGGING: '1',
}
const args = [
join(bundle, 'Contents', 'MacOS', 'Electron'),
`--inspect=127.0.0.1:${String(options.mainPort)}`,
`--remote-debugging-port=${String(options.rendererPort)}`,
`--user-data-dir=${options.userData}`,
options.appRoot,
]
return [
'#!/bin/sh',
...Object.entries(environment).map(([key, value]) => `export ${key}=${quote(value)}`),
`cd ${quote(options.appRoot)}`,
`exec ${args.map(quote).join(' ')} "$@"`,
'',
].join('\n')
}
+31 -1
View File
@@ -20,6 +20,7 @@ import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runt
interface PackageManifest {
readonly name?: string
readonly version?: string
readonly dependencies?: Readonly<Record<string, string>>
}
/** Inputs whose locations differ between the launcher and isolated tests. */
@@ -86,6 +87,32 @@ function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): str
return names
}
/** Configured workspace plugins must resolve from the profile even when pnpm does not hoist them. */
function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot: string): string[] {
const names: string[] = []
const visited = new Set<string>()
const visit = (directory: string): void => {
const source = realpathSync(directory)
if (visited.has(source)) return
visited.add(source)
const manifest = readManifest(join(source, 'package.json'))
for (const [name, specifier] of Object.entries(manifest.dependencies ?? {})) {
if (!specifier.startsWith('workspace:')) continue
const dependency = join(source, 'node_modules', ...name.split('/'))
if (!existsSync(dependency)) {
throw new Error(`desktop development: ${name} is missing from ${source}; run pnpm install`)
}
const destination = join(destinationRoot, ...name.split('/'))
removeOwnedPath(destination)
linkDirectory(dependency, destination)
names.push(name)
visit(dependency)
}
}
for (const directory of roots) visit(directory)
return names
}
/**
* Replace one disposable project with links to the current built workspace.
* @param options - Project destination, CLI package, and release identity.
@@ -117,7 +144,10 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s
createDevelopmentProjectMetadata(options.projectDir, options.release)
const destinationModules = join(options.projectDir, 'node_modules')
mkdirSync(destinationModules, { recursive: true })
const names = mirrorDependencyLinks(options.dependencyDir, destinationModules)
const names = [
...mirrorDependencyLinks(options.dependencyDir, destinationModules),
...mirrorWorkspaceDependencies([options.cliDir, options.hostDir], destinationModules),
]
const dshLink = join(destinationModules, '@deepseek-ai', 'dsh')
removeOwnedPath(dshLink)
linkDirectory(options.cliDir, dshLink)
@@ -98,6 +98,7 @@ export function createElectronBuilderConfig(
const packaged = resolveDesktopBuildCommit(env)
return {
appId,
protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }],
extraMetadata: {
dshDesktopAppId: appId,
dshMandatoryUpdatePolicy: policy,
@@ -125,9 +126,12 @@ export function createElectronBuilderConfig(
},
files: [
'lib/main.js',
'lib/welcome/**/*',
'lib/preload-app.cjs',
'lib/preload-mandatory.cjs',
'lib/preload-platform-account.cjs',
'lib/preload-update-dialog.cjs',
'lib/preload-welcome.cjs',
'renderer/**/*',
'package.json',
{ from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
@@ -3,14 +3,15 @@
/**
* Import and authorize the required p12 before work; delete the owned keychain after work settles.
* Children receive only its path, never the p12 password. Existing login keychains are not unlocked.
* The owned keychain is prepended to the user search list for the run and the previous list is restored.
* Abrupt process termination requires the CI runner to clean its temporary directory.
* @param environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD.
* @param action All signing work, settled before cleanup.
* @param run Apple command executor.
* @param run Apple command executor returning command standard output.
* @returns Resolves after work and cleanup; rejects on setup, work, or cleanup failure.
*/
export function withMacOSSigningKeychain(
environment: NodeJS.ProcessEnv,
action: (environment: NodeJS.ProcessEnv) => Promise<void>,
run?: (command: string, args: string[]) => void,
run?: (command: string, args: string[]) => string,
): Promise<void>
@@ -9,23 +9,40 @@ import { join } from 'node:path'
* Execute a credential-bearing Apple command without exposing arguments or tool output on failure.
* @param {string} command Absolute executable path.
* @param {string[]} args Command arguments, potentially containing secrets.
* @returns {void}
* @returns {string} Command standard output, needed when the caller reads a security query.
*/
function execute(command, args) {
try { execFileSync(command, args, { stdio: 'pipe', timeout: 120_000 }) }
try {
return execFileSync(command, args, { stdio: 'pipe', timeout: 120_000, encoding: 'utf8' })
}
catch (error) {
// execFile errors contain the command line, including private-key passwords.
throw new Error(`desktop macOS signing: ${command} ${args[0]} failed; check certificate, password, and signing access`)
}
}
/**
* Read the user's keychain search list so this invocation can extend and later restore it.
* @param {(command: string, args: string[]) => string} run Apple command executor.
* @returns {string[]} Keychain paths on the user search list.
*/
function readUserKeychains(run) {
const output = run('/usr/bin/security', ['list-keychains', '-d', 'user'])
if (typeof output !== 'string') {
throw new Error('desktop macOS signing: the user keychain search list could not be read')
}
return output.split('\n').map(line => line.trim().replace(/^"|"$/gu, '')).filter(path => path !== '')
}
/**
* Import and authorize the required p12 before work; delete the owned keychain after work settles.
* Children receive only its path, never the p12 password. Existing login keychains are not unlocked.
* codesign resolves an identity only from a keychain on the user search list, so the owned keychain
* is prepended during the run and the previous list is restored before deletion.
* Abrupt process termination requires the CI runner to clean its temporary directory.
* @param {NodeJS.ProcessEnv} environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD.
* @param {(environment: NodeJS.ProcessEnv) => Promise<void>} action All signing work, settled before cleanup.
* @param {(command: string, args: string[]) => void} run Apple command executor.
* @param {(command: string, args: string[]) => string} run Apple command executor.
* @returns {Promise<void>} Resolves after work and cleanup; rejects on setup, work, or cleanup failure.
*/
export async function withMacOSSigningKeychain(environment, action, run = execute) {
@@ -38,6 +55,7 @@ export async function withMacOSSigningKeychain(environment, action, run = execut
/** @param {string[]} args Security command arguments. */
const security = args => run('/usr/bin/security', args)
let created = false
let previousKeychains
try {
security(['create-keychain', '-p', password, keychain])
created = true
@@ -45,6 +63,10 @@ export async function withMacOSSigningKeychain(environment, action, run = execut
security(['set-keychain-settings', keychain])
security(['import', certificate, '-k', keychain, '-P', exportPassword, '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'])
security(['set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k', password, keychain])
// codesign ignores --keychain unless that file is also on the search list
// (https://github.com/electron-userland/electron-builder/issues/1457).
previousKeychains = readUserKeychains(run)
security(['list-keychains', '-d', 'user', '-s', keychain, ...previousKeychains])
const probe = join(directory, 'probe')
run('/bin/cp', ['/usr/bin/true', probe])
run('/usr/bin/codesign', ['--force', '--sign', `Developer ID Application: ${environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY}`, '--keychain', keychain, '--timestamp', '--options', 'runtime', probe])
@@ -54,7 +76,10 @@ export async function withMacOSSigningKeychain(environment, action, run = execut
delete childEnvironment.CSC_KEY_PASSWORD
await action(childEnvironment)
} finally {
try { if (created) security(['delete-keychain', keychain]) }
try {
if (previousKeychains !== undefined) security(['list-keychains', '-d', 'user', '-s', ...previousKeychains])
if (created) security(['delete-keychain', keychain])
}
finally { rmSync(directory, { recursive: true, force: true }) }
}
}
+4 -2
View File
@@ -24,6 +24,7 @@ import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.t
import {
resolveDesktopAppId,
resolveMacOSSigningEnvironment,
resolveNpmRegistry,
} from './desktop-release-environment.mjs'
import {
signMacOSRuntime,
@@ -69,6 +70,7 @@ function runPnpm(args: readonly string[]): Promise<void> {
return new Promise((resolvePromise, reject) => {
const [command, ...commandArgs] = args
if (command === undefined) throw new Error('desktop runtime: pnpm command is required')
const registry = resolveNpmRegistry(process.env)
const config = join(PNPM_BUILD_STATE, 'config')
const userConfig = join(config, 'npmrc')
mkdirSync(config, { recursive: true })
@@ -76,7 +78,7 @@ function runPnpm(args: readonly string[]): Promise<void> {
const child = spawn(NODE, [
'--expose-internals',
PNPM,
'--config.registry=https://registry.npmjs.org/',
`--config.registry=${registry}`,
`--config.store-dir=${STORE_ROOT}`,
'--config.enable-global-virtual-store=false',
`--config.userconfig=${userConfig}`,
@@ -88,7 +90,7 @@ function runPnpm(args: readonly string[]): Promise<void> {
...Object.fromEntries(Object.entries(process.env).filter(([name]) => (
name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
))),
NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
NPM_CONFIG_REGISTRY: registry,
NPM_CONFIG_STORE_DIR: STORE_ROOT,
NPM_CONFIG_USERCONFIG: userConfig,
...desktopNodeEnvironment(NODE, join(RUNTIME_ROOT, 'bin'), {}),
+112
View File
@@ -0,0 +1,112 @@
/** Native account commands and Gateway state stream; no renderer receives credentials. */
import { randomUUID } from 'node:crypto'
import WebSocket from 'ws'
import { parseRemoteStreamServerMessage, REMOTE_STREAM_MUX_PATH } from '@deepseek-ai/dsh-api-gateway/stream-protocol'
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Authenticated unary caller shared with native onboarding. */
export type AccountInvoke = (request: { namespace: string; method: string; args: Record<string, unknown> }) => Promise<unknown>
/** Decode the UI-safe state received across HTTP or WebSocket. @param value - wire value. @returns account projection. */
export function accountView(value: unknown): AccountView {
if (typeof value !== 'object' || value === null || !('status' in value)
|| !['signed-out', 'credential-stored'].includes(String(value.status)) || !('attempt' in value)) {
throw new Error('desktop account: invalid state')
}
if (!('links' in value) || typeof value.links !== 'object' || value.links === null
|| !('usageUrl' in value.links) || typeof value.links.usageUrl !== 'string'
|| !('topUpUrl' in value.links) || typeof value.links.topUpUrl !== 'string') {
throw new Error('desktop account: invalid platform links')
}
validateBrowserDestination(value.links.usageUrl)
validateBrowserDestination(value.links.topUpUrl)
const attempt = value.attempt
if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string'
|| !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase))
|| ('authorizeUrl' in attempt && typeof attempt.authorizeUrl !== 'string')
|| ('expiresAt' in attempt && (typeof attempt.expiresAt !== 'number' || !Number.isFinite(attempt.expiresAt)))
|| ('errorCode' in attempt && !['network', 'protocol', 'expired', 'storage'].includes(String(attempt.errorCode))))) {
throw new Error('desktop account: invalid attempt')
}
if (attempt !== null && 'authorizeUrl' in attempt) {
validateBrowserDestination(String(attempt.authorizeUrl))
}
const parsed = value as AccountView
return {
status: parsed.status,
links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl },
attempt: parsed.attempt === null ? null : {
id: parsed.attempt.id, phase: parsed.attempt.phase,
...parsed.attempt.authorizeUrl === undefined ? {} : { authorizeUrl: parsed.attempt.authorizeUrl },
...parsed.attempt.expiresAt === undefined ? {} : { expiresAt: parsed.attempt.expiresAt },
...parsed.attempt.errorCode === undefined ? {} : { errorCode: parsed.attempt.errorCode },
},
}
}
/** Only HTTP loopback or HTTPS destinations can leave the native app. */
function validateBrowserDestination(value: string): void {
const url = new URL(value)
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
if (url.username || url.password || !(url.protocol === 'https:' || (loopback && url.protocol === 'http:'))) {
throw new Error('desktop account: invalid browser destination')
}
}
/** Native account operations and explicitly owned stream lifetime. */
export interface DesktopAccountBackend {
/** @returns current account snapshot. */
state(): Promise<AccountView>
/** @param locale - active desktop UI language. @returns new or already-running login attempt. */
start(locale: string): Promise<AccountView>
/** @param id - attempt to cancel. @returns cancellation or completed commit state. */
cancel(id: SignInAttemptId): Promise<AccountView>
/** @returns state after local sign-out. */
signOut(): Promise<AccountView>
/** @param listener - state recipient. @param failed - stream failure recipient. @returns stream disposer. */
watch(listener: (state: AccountView) => void, failed: () => void): () => void
}
/**
* Connect native account operations to the standard authenticated Web backend.
* @param origin - Host Web origin.
* @param invoke - validated unary RPC caller.
* @param cookies - Electron session cookie reader.
* @returns account operations; watch callers own their subscriptions.
*/
export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise<string>): DesktopAccountBackend {
const call = async (method: string, args: Record<string, unknown> = {}): Promise<AccountView> =>
accountView(await invoke({ namespace: 'account', method, args }))
return {
state: () => call('getState'), start: locale => call('startSignIn', { locale, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: () => call('signOut'),
watch(listener, failed) {
let closed = false
let socket: WebSocket | undefined
let retry: ReturnType<typeof setTimeout> | undefined
const connect = (): void => {
const streamId = randomUUID()
void cookies().then((cookie) => {
if (closed) return
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
socket.on('open', () => socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })))
socket.on('message', (data) => {
try {
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
if (frame.type === 'item') listener(accountView(frame.value))
else socket?.close()
} catch { socket?.close() }
})
socket.on('error', () => { socket?.close() })
socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
}).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
}
connect()
return () => { closed = true; clearTimeout(retry); socket?.close() }
},
}
}
+208
View File
@@ -0,0 +1,208 @@
/** Desktop welcome presentation; account and credential operations stay in the preload. */
import { useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives/src/StateDot.tsx'
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import type { WelcomeApi } from '../welcome-api.ts'
type Page = 'entry' | 'key' | 'account'
/**
* Render the standalone welcome flow using shell-owned operations and localized copy.
* @param props.api - isolated preload API; no account credentials reach the renderer.
* @returns welcome pages with fixed bottom actions.
*/
export function Welcome({ api }: { api: WelcomeApi }) {
const { messages: m } = api
const [page, setPage] = useState<Page>('entry')
const pageRef = useRef<Page>('entry')
const [attempt, setAttempt] = useState<AccountView['attempt']>(null)
const attemptRef = useRef<AccountView['attempt']>(null)
const [starting, setStarting] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [copyFeedback, setCopyFeedback] = useState<{ status: 'idle' | 'busy' | 'copied' | 'failed' }>({ status: 'idle' })
const copyState = copyFeedback.status
const [draft, setDraft] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const busyRef = useRef(false)
const mounted = useRef(true)
const revision = useRef(0)
const input = useRef<HTMLInputElement>(null)
const keyButton = useRef<HTMLButtonElement>(null)
const focusEntry = useRef(false)
function navigate(next: Page) {
pageRef.current = next
setPage(next)
}
function showAccount(state: AccountView) {
if (pageRef.current === 'key' || (state.attempt === null && pageRef.current === 'entry')) return
attemptRef.current = state.attempt
setAttempt(state.attempt)
setStarting(false)
setCopyFeedback({ status: 'idle' })
navigate(state.attempt?.phase === 'cancelled' ? 'entry' : 'account')
}
useEffect(() => {
mounted.current = true
document.documentElement.lang = api.id
document.title = m.welcomeTitle
const stop = api.onAccountState((state) => {
revision.current++
showAccount(state)
})
return () => { mounted.current = false; stop() }
}, [api, m.welcomeTitle])
useEffect(() => {
if (page === 'key') input.current?.focus()
else if (page === 'entry' && focusEntry.current) {
focusEntry.current = false
keyButton.current?.focus()
}
}, [page])
useEffect(() => {
if (copyState !== 'copied' && copyState !== 'failed') return
const timer = setTimeout(() => { setCopyFeedback({ status: 'idle' }) }, 2000)
return () => { clearTimeout(timer) }
}, [copyFeedback])
async function saveKey(event: FormEvent) {
event.preventDefault()
if (busyRef.current) return
const value = draft.trim()
if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value)
|| ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) {
setError(value === '' ? m.welcomeKeyBlank : m.welcomeKeyInvalid)
input.current?.focus()
return
}
busyRef.current = true
setBusy(true)
setError('')
try {
const result = await api.saveApiKey(value)
if (!mounted.current) return
if (result.ok) setDraft('')
else setError(m.welcomeKeyFailed)
} catch {
if (mounted.current) setError(m.welcomeKeyFailed)
} finally {
busyRef.current = false
if (mounted.current) setBusy(false)
}
}
async function skip() {
if (busyRef.current) return
busyRef.current = true
setBusy(true)
try {
await api.skip()
if (mounted.current) setDraft('')
} catch {
if (mounted.current) setError(m.welcomeContinueFailed)
} finally {
busyRef.current = false
if (mounted.current) setBusy(false)
}
}
async function start() {
navigate('account')
setStarting(true)
setAttempt(null)
attemptRef.current = null
const current = ++revision.current
try {
const state = await api.startSignIn()
if (mounted.current && revision.current === current) showAccount(state)
} catch {
if (mounted.current && revision.current === current) setStarting(false)
}
}
async function cancel() {
if (cancelling || attemptRef.current === null) return
setCancelling(true)
const current = ++revision.current
try {
const state = await api.cancelSignIn(attemptRef.current.id)
if (mounted.current && revision.current === current) showAccount(state)
} catch {
// The current attempt remains visible so cancellation can be retried.
} finally {
if (mounted.current) setCancelling(false)
}
}
async function copyLink() {
const current = attemptRef.current
if (current?.phase !== 'waiting-browser' || (copyState === 'busy' || copyState === 'copied')) return
setCopyFeedback({ status: 'busy' })
try {
await api.copySignInLink(current.id)
if (mounted.current && attemptRef.current === current) setCopyFeedback({ status: 'copied' })
} catch {
if (mounted.current && attemptRef.current === current) setCopyFeedback({ status: 'failed' })
}
}
const phase = starting ? 'initializing' : attempt?.phase ?? 'failed'
const waiting = phase === 'waiting-browser'
const failed = phase === 'expired' || phase === 'failed'
const title = phase === 'initializing' ? m.welcomeAuthStarting
: waiting ? m.welcomeAuthWaiting : phase === 'expired' ? m.welcomeAuthExpired
: phase === 'failed' ? m.welcomeAuthFailed : m.welcomeAuthExchanging
const heading = page === 'entry' ? 'welcome-heading' : page === 'key' ? 'key-title' : 'auth-status'
return <>
<div className="titlebar" aria-hidden="true" />
<main className="welcome" aria-labelledby={heading}>
<img className="brand" src="assets/welcome-brand.svg" alt={m.welcomeBrand} width="472" height="40" />
<div id="tagline" className="tagline" hidden={page !== 'entry'}>
<h1 id="welcome-heading"><span>{m.welcomeTaglineBefore}</span><em>{m.welcomeTaglineBrand}</em><span>{m.welcomeTaglineAfter}</span></h1>
<p id="welcome-description">{m.welcomeDescription}</p>
</div>
<form id="key-form" className="key-form" hidden={page !== 'key'} noValidate onSubmit={(event) => { void saveKey(event) }} aria-busy={busy}>
<header className="key-heading"><h1 id="key-title">{m.welcomeKeyTitle}</h1><p id="key-description">{m.welcomeKeyDescription}</p></header>
<div className="key-field">
<label className="visually-hidden" htmlFor="key-input">{m.welcomeKeyPlaceholder}</label>
<input ref={input} id="key-input" type="password" autoComplete="off" autoCapitalize="off" spellCheck={false} required
aria-describedby="key-description key-error" aria-invalid={error !== ''} placeholder={m.welcomeKeyPlaceholder}
value={draft} disabled={busy} onChange={(event) => { setDraft(event.target.value); setError('') }} />
<p id="key-error" className="key-error" role="alert" hidden={error === ''}>{error}</p>
</div>
</form>
<section id="auth-page" className={`key-heading ${waiting ? 'auth-waiting' : phase === 'expired' ? 'auth-expired' : ''}`}
hidden={page !== 'account'} aria-live="polite">
<h1 id="auth-status">{title}</h1>
<p id="auth-description" hidden={!waiting && phase !== 'expired'}>{waiting ? m.welcomeAuthWaitingDescription : m.welcomeAuthExpiredDescription}</p>
<button id="auth-copy" className="copy-link" type="button" hidden={!waiting} disabled={!waiting || (copyState === 'busy' || copyState === 'copied')} onClick={() => { void copyLink() }}>
{copyState === 'copied' ? m.welcomeAuthCopied : copyState === 'failed' ? m.welcomeAuthCopyFailed : m.welcomeAuthCopyLink}
</button>
</section>
<div id="auth-actions" className="actions" hidden={page !== 'account'}>
<button id="auth-loading" className="primary" type="button" hidden={failed} disabled aria-label={m.welcomeAuthExchanging}>
<StateDot state="ongoing" size={16} className="welcome-loading" />
</button>
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void start() }}>{m.welcomeAuthRetry}</button>
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { navigate('key') }}>{m.welcomeApiKey}</button>
<button id="auth-cancel" className="secondary" type="button" hidden={failed}
disabled={cancelling || phase === 'committing' || phase === 'succeeded' || (phase === 'initializing' && !attempt?.id)}
onClick={() => { void cancel() }}>{m.welcomeAuthCancel}</button>
</div>
<div id="entry-actions" className="actions" hidden={page !== 'entry'}>
<button id="sign-in" className="primary" type="button" onClick={() => { void start() }}>{m.welcomeSignIn}</button>
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { navigate('key') }}>{m.welcomeApiKey}</button>
</div>
<div id="key-actions" className="actions" hidden={page !== 'key'}>
<button id="save-key" className="primary" type="submit" form="key-form" disabled={busy || draft.trim() === ''}>{m.welcomeKeySave}</button>
<button id="skip-key" className="secondary" type="button" disabled={busy} onClick={() => { void skip() }}>{m.welcomeKeyLater}</button>
<button id="back-to-login" className="back" type="button" disabled={busy} onClick={() => {
if (busyRef.current) return
setDraft(''); setError(''); focusEntry.current = true; navigate('entry')
}}>{m.welcomeKeyBack}</button>
</div>
</main>
</>
}
+17
View File
@@ -0,0 +1,17 @@
/** Mount the local React renderer before Electron reveals the welcome window. */
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import { Welcome } from './WelcomePage.tsx'
import type { WelcomeApi } from '../welcome-api.ts'
declare global {
interface Window {
dshWelcome: WelcomeApi
}
}
const container = document.getElementById('root')
if (container === null) throw new Error('desktop welcome: missing React root')
const root = createRoot(container)
flushSync(() => { root.render(<Welcome api={window.dshWelcome} />) })
window.addEventListener('pagehide', () => { root.unmount() }, { once: true })
+30 -1
View File
@@ -2,6 +2,7 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
import { desktopNodeEnvironment } from './node-environment.ts'
interface ReadyEvent {
@@ -15,7 +16,12 @@ interface FatalEvent {
readonly message: string
}
type DesktopHostEvent = ReadyEvent | FatalEvent | { readonly type: 'shutdown-complete' } | {
interface PlatformSessionEvent {
readonly type: 'platform-session'
readonly session: PlatformSession | null
}
type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {
readonly type: 'update-tasks'
readonly requestId: number
readonly active: boolean
@@ -32,6 +38,23 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
return true
case 'ready':
return typeof candidate.url === 'string'
case 'platform-session': {
const session = candidate.session
if (session === null) return true
if (typeof session !== 'object' || !('origin' in session) || !('token' in session)
|| typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false
if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false
if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null
|| Array.isArray(session.requestHeaders)
|| Object.entries(session.requestHeaders).some(([name, value]) => typeof value !== 'string'
|| name !== name.toLowerCase() || /[\r\n]/.test(value)
|| ['authorization', 'x-dsh-auth-token', 'host', 'content-length', 'transfer-encoding', 'connection', 'content-type'].includes(name)))) return false
try {
const url = new URL(session.origin)
return url.origin === session.origin && !url.username && !url.password
&& (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
} catch { return false }
}
case 'fatal':
return typeof candidate.message === 'string'
case 'update-tasks':
@@ -91,6 +114,7 @@ export class DesktopHostProcess {
* @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
* `office-skills` resources fail Host startup.
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
* @param onPlatformSession - Private credential updates for embedded Platform views.
*/
constructor(
private readonly node: string,
@@ -101,6 +125,8 @@ export class DesktopHostProcess {
private readonly onFailure?: (error: Error) => void,
private readonly primaryRuntime?: string,
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
private readonly onPlatformSession?: (session: PlatformSession | null) => void,
) {}
/**
@@ -134,6 +160,7 @@ export class DesktopHostProcess {
return
}
if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })
else if (message.type === 'platform-session') this.onPlatformSession?.(message.session)
else if (message.type === 'shutdown-complete') {
if (this.stopping) this.shutdownCompleted = true
else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown'))
@@ -192,6 +219,7 @@ export class DesktopHostProcess {
const child = this.child
if (child === undefined) return
this.stopping = true
this.onPlatformSession?.(null)
if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
const exited = this.exitPromise ?? Promise.resolve()
const graceful = await exitsWithin(exited, 10_000)
@@ -210,6 +238,7 @@ export class DesktopHostProcess {
}
private fail(error: Error): void {
this.onPlatformSession?.(null)
this.readyReject(error)
for (const query of this.taskQueries.values()) query.reject(error)
this.taskQueries.clear()
+3
View File
@@ -6,11 +6,14 @@ import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-br
/** IPC channel names kept private to the desktop application bundle. */
export const DESKTOP_IPC = {
boot: 'dsh-desktop:boot',
enterWorkspace: 'dsh-desktop:enter-workspace',
bootFailed: 'dsh-desktop:boot-failed',
browserAcquire: 'dsh-desktop:browser-acquire',
browserRelease: 'dsh-desktop:browser-release',
browserOpenRequested: 'dsh-desktop:browser-open-requested',
directoryPick: 'dsh-desktop:directory-pick',
localeBootstrap: 'dsh-desktop:locale-bootstrap',
localeChanged: 'dsh-desktop:locale-changed',
updatesStatus: 'dsh-desktop:updates-status',
updatesOpen: 'dsh-desktop:updates-open',
updatesPresentation: 'dsh-desktop:updates-presentation',
+80
View File
@@ -25,7 +25,39 @@ export const en = {
restartApplication: 'Restart',
recoveryOperationFailed: 'The recovery operation failed',
disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
welcomeTitle: 'DeepSeek Harness',
welcomeBrand: 'DeepSeek Harness',
welcomeTaglineBefore: 'Welcome to ',
welcomeTaglineBrand: 'DeepSeek Harness',
welcomeTaglineAfter: '',
welcomeDescription: 'Build potential. Explore intelligence.',
welcomeAuthStarting: 'Opening sign in…',
welcomeAuthWaiting: 'Browser didn’t open automatically?',
welcomeAuthWaitingDescription: 'Copy the sign-in link and open it in your browser to sign in.',
welcomeAuthExchanging: 'Completing sign in…',
welcomeAuthExpired: 'Sign in timed out',
welcomeAuthExpiredDescription: 'Sign in again to continue',
welcomeAuthFailed: 'Could not complete sign in. Please try again.',
welcomeAuthCopyLink: 'Copy sign-in link',
welcomeAuthCopied: 'Copied',
welcomeAuthCopyFailed: 'Could not copy. Try again.',
welcomeAuthCancel: 'Cancel',
welcomeAuthRetry: 'Sign in again',
welcomeSignIn: 'Sign in',
welcomeApiKey: 'Add API Key',
welcomeKeyTitle: 'Add an API key to get started',
welcomeKeyDescription: 'Configure official DeepSeek models to start using Harness',
welcomeKeyPlaceholder: 'Enter API key',
welcomeKeySave: 'Save and continue',
welcomeKeyLater: 'Set up later',
welcomeKeyBack: 'Back to sign in',
welcomeKeyBlank: 'Enter an API key.',
welcomeKeyInvalid: 'Enter the API key itself, without quotes, spaces, or an environment-variable assignment.',
welcomeKeyFailed: 'Could not save the API key. Please try again.',
welcomeContinueFailed: 'Could not open the workspace. Please try again.',
checkUpdatesMenu: 'Check for Updates…',
reloadPageMenu: 'Reload Page',
restartAppHostMenu: 'Restart App and Host',
updateCheckFailedTitle: 'Update Check Failed',
updateCheckFailed: 'Could not check for updates. Please try again later.',
updateDownloadFailed: 'Could not download the update. Please try again.',
@@ -120,7 +152,39 @@ export const zh = {
restartApplication: '重启',
recoveryOperationFailed: '恢复操作失败',
disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
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 Key',
welcomeKeyTitle: '添加一个 API Key 开始使用',
welcomeKeyDescription: '配置 DeepSeek 官方模型,即可开始使用',
welcomeKeyPlaceholder: '输入 API 密钥',
welcomeKeySave: '保存并继续',
welcomeKeyLater: '稍后配置',
welcomeKeyBack: '返回登录',
welcomeKeyBlank: '请输入 API 密钥。',
welcomeKeyInvalid: '请仅输入 API 密钥,不要包含引号、空格或环境变量赋值。',
welcomeKeyFailed: '无法保存 API 密钥,请重试。',
welcomeContinueFailed: '无法打开工作区,请重试。',
checkUpdatesMenu: '检查更新…',
reloadPageMenu: '刷新页面',
restartAppHostMenu: '重启应用与 Host',
updateCheckFailedTitle: '更新检查失败',
updateCheckFailed: '检查更新失败,请稍后重试。',
updateDownloadFailed: '下载更新失败,请重试。',
@@ -200,6 +264,22 @@ export function resolveDesktopLocale(locale: string): DesktopLocale {
: { id: 'en', messages: en }
}
/**
* Choose a built-in dictionary from the shared preference, then ordered OS languages.
* @param preference - explicit locale.preference, or null when no language was selected.
* @param languages - operating-system languages in preference order.
* @returns the supported dictionary, falling back to English.
*/
export function resolveDesktopStartupLocale(preference: string | null, languages: readonly string[]): DesktopLocale {
const selected = preference?.toLowerCase()
if (selected === 'zh' || selected === 'en') return resolveDesktopLocale(selected)
for (const language of languages) {
const primary = language.toLowerCase().split('-')[0]
if (primary === 'zh' || primary === 'en') return resolveDesktopLocale(primary)
}
return resolveDesktopLocale('en')
}
/** Replace named placeholders in one locale-owned message. */
export function formatDesktopMessage(
message: string,
+232 -43
View File
@@ -7,11 +7,13 @@ import { fileURLToPath } from 'node:url'
import {
app,
BrowserWindow,
clipboard,
dialog,
ipcMain,
Menu,
powerMonitor,
nativeTheme,
net,
protocol,
session,
shell,
@@ -21,15 +23,19 @@ import {
import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
import { installMicrophonePermissions } from './microphone-permissions.ts'
import { DesktopBackendController } from './backend-controller.ts'
import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
import { formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
import { claimDesktopSingleInstance } from './single-instance.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
import { DesktopFatalRecovery } from './fatal-recovery.ts'
import { openWelcomeWindow } from './welcome-window.ts'
import { WELCOME_IPC, needsWelcome } from './welcome-api.ts'
import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts'
import { DesktopUpdateJournal } from './update-journal.ts'
import { DesktopUpdatePreparationError } from './update-error.ts'
import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts'
@@ -241,27 +247,32 @@ async function main(): Promise<void> {
let startup: Promise<void> | undefined
let workspaceRecovery: Promise<void> | undefined
let mainWindow: BrowserWindow | undefined
let welcomeWindow: BrowserWindow | undefined
let enteredWorkspace = false
let shellInstallerOwnsQuit = false
let requireCleanStop = false
let updateStoppedHost = false
let updateStopFailure: DesktopHostUncleanExitError | undefined
let updateState: DesktopUpdateState = { phase: 'idle' }
const systemLanguages = app.getPreferredSystemLanguages()
let locale = resolveDesktopStartupLocale(null, systemLanguages)
windowsLanguage = locale.id
let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined
let mandatoryUI: DesktopMandatoryUpdateWindow | undefined
let policyAuth: DesktopPolicyTestAuth | undefined
const isQuitting = (): boolean => quitting
const currentMainWindow = (): BrowserWindow | undefined => mainWindow
const ordinaryDialogs = new Set<AbortController>()
const locale = resolveDesktopLocale(app.getLocale())
const messages = locale.messages
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), locale)
const currentDialogWindow = (): BrowserWindow | undefined => welcomeWindow ?? mainWindow
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale)
const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
const controller = new AbortController()
ordinaryDialogs.add(controller)
try {
if (mainWindow === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false }
return await updateDialog.show(mainWindow, { ...options, signal: controller.signal })
const parent = currentDialogWindow()
if (parent === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false }
return await updateDialog.show(parent, { ...options, signal: controller.signal })
}
finally { ordinaryDialogs.delete(controller) }
}
@@ -271,6 +282,11 @@ async function main(): Promise<void> {
let hostCookie: string | undefined
const browserGuests = new DesktopBrowserGuests(() => hostUrl)
let injections: readonly unknown[] = []
let welcomeBackend: DesktopWelcomeBackend | undefined
let stopAccount: (() => void) | undefined
let openedAttempt: string | undefined
let returnedAttempt: string | undefined
let previousAccountStatus: string | undefined
const assertProductSender = (event: IpcMainInvokeEvent): void => {
assertDesktopSender(event, ['app'])
if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
@@ -293,13 +309,15 @@ async function main(): Promise<void> {
navigation = next
return next.promise
}
const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs'),
() => locale.id === 'zh-CN' ? 'zh_CN' : 'en_US')
const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
hostInspectPort, process.env, onFailure,
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
resources)
resources, (next) => { platformView.setSession(next) })
return {
start: async () => {
const ready = await host.start()
@@ -307,6 +325,31 @@ async function main(): Promise<void> {
hostUrl = ready.url
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
injections = ready.injections
welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
stopAccount?.()
stopAccount = welcomeBackend.account.watch((state) => {
if (quitting) return
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
const attempt = state.attempt
if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) {
openedAttempt = attempt.id
void shell.openExternal(attempt.authorizeUrl).catch(() => undefined)
}
if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) {
returnedAttempt = attempt.id
focusPrimaryWindow()
}
if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined)
if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') {
void readWelcomeState().then((value) => {
if (!value.hasApiKey && !quitting) { enteredWorkspace = false; return showWelcome() }
return undefined
}).catch(() => undefined)
}
previousAccountStatus = state.status
}, () => {
// The stream reconnects; a transport failure does not change account state.
})
},
stop: async () => {
try { await host.stop(requireCleanStop) }
@@ -328,8 +371,8 @@ async function main(): Promise<void> {
if (isMandatory()) { mandatoryUI?.sync(); return Promise.resolve() }
let shown = updateErrors.get(state)
if (shown === undefined) {
shown = ordinaryMessageBox({ type: 'error', title: messages.updateFailedTitle,
message: desktopUpdateErrorSummary(state, messages),
shown = ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle,
message: desktopUpdateErrorSummary(state, locale.messages),
technicalDetails: state.technicalDetails ?? state.message ?? '' }).then(() => {})
updateErrors.set(state, shown)
}
@@ -368,6 +411,10 @@ async function main(): Promise<void> {
return state
}
const readWelcomeState = async () => {
if (backend.host === undefined || welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
return welcomeBackend.read()
}
stopForRecovery = () => backend.close()
const reconcileBackend = (): Promise<void> => {
@@ -376,6 +423,7 @@ async function main(): Promise<void> {
await backend.start(async () => {
await manager.applyRelease()
})
if (backend.host !== undefined) await openInitialWindow()
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
// The existing Web document resumes through the boot IPC response.
})().catch((error: unknown) => {
@@ -392,27 +440,28 @@ async function main(): Promise<void> {
await workspaceRecovery
await startup?.catch(() => undefined)
const host = backend.host
if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', messages.updateTasksUnavailable)
if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
const active = await host.updateTasks('inspect')
const confirmation: Electron.MessageBoxOptions = {
type: active ? 'warning' : 'info', title: messages.updateTitle,
message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: updates.state.version ?? '' }),
detail: active ? messages.updateActiveTasksDetail
: messages.updateDownloadedDetail,
buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart],
type: active ? 'warning' : 'info', title: locale.messages.updateTitle,
message: active ? locale.messages.updateActiveTasks : formatDesktopMessage(locale.messages.updateDownloadedTitle, { version: updates.state.version ?? '' }),
detail: active ? locale.messages.updateActiveTasksDetail
: locale.messages.updateDownloadedDetail,
buttons: active ? [locale.messages.updateStopTasks, locale.messages.updateLater] : [locale.messages.installAndRestart],
defaultId: 1, cancelId: 1,
}
if (isMandatory()) {
if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false
} else {
if (mainWindow === undefined) return false
const result = await updateDialog.show(mainWindow, confirmation)
const parent = currentDialogWindow()
if (parent === undefined) return false
const result = await updateDialog.show(parent, confirmation)
if (result.response !== 0 || isMandatory()) return false
}
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', messages.updateTasksUnavailable)
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
try {
const stillActive = await host.updateTasks('lock')
if (stillActive && !active) throw new DesktopUpdatePreparationError('tasks-changed', messages.updateTasksChanged)
if (stillActive && !active) throw new DesktopUpdatePreparationError('tasks-changed', locale.messages.updateTasksChanged)
mandatoryUI?.preparingRestart(stillActive)
requireCleanStop = true
updateStopFailure = undefined
@@ -420,7 +469,7 @@ async function main(): Promise<void> {
updateStoppedHost = true
// The backend's async cleanup callback can assign this after the reset above.
const stopFailure = updateStopFailure as DesktopHostUncleanExitError | undefined
if (stopFailure !== undefined) throw new DesktopUpdatePreparationError('stop-failed', messages.updateStopFailed, stopFailure.message)
if (stopFailure !== undefined) throw new DesktopUpdatePreparationError('stop-failed', locale.messages.updateStopFailed, stopFailure.message)
updateJournal?.action('install-confirmed')
shellInstallerOwnsQuit = true
} catch (error) {
@@ -445,8 +494,7 @@ async function main(): Promise<void> {
protocol.handle(SCHEME, (request) => {
const url = new URL(request.url)
// Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets.
// Removing this route leaves those shell windows and embedded documents empty while their parent remains blocked.
// 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 === 'app') {
if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/')
@@ -502,11 +550,50 @@ async function main(): Promise<void> {
callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
})
const assertMainApplication = (event: IpcMainInvokeEvent): BrowserWindow => {
const owner = mainWindow
if (owner === undefined || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame
|| !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command')
return owner
}
ipcMain.on(PLATFORM_IPC.bootstrap, (event) => {
try { event.returnValue = platformView.bootstrap(event) }
catch { event.returnValue = null }
})
ipcMain.handle(PLATFORM_IPC.open, (event, page: unknown, bounds: unknown) => {
const owner = assertMainApplication(event)
if (page !== 'usage' && page !== 'top-up') throw new Error('Invalid Platform page')
return platformView.open(owner, page, platformBounds(bounds))
})
ipcMain.handle(PLATFORM_IPC.bounds, (event, bounds: unknown) => {
assertMainApplication(event)
platformView.setBounds(platformBounds(bounds))
})
ipcMain.handle(PLATFORM_IPC.close, (event) => { assertMainApplication(event); platformView.close() })
// Only the main window may synchronize its palette with the native material.
ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
if (mainWindow === undefined || event.sender !== mainWindow.webContents) return
if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source
})
ipcMain.handle(DESKTOP_IPC.localeBootstrap, async (event) => {
if (event.sender !== mainWindow?.webContents || event.senderFrame !== mainWindow.webContents.mainFrame
|| new URL(event.senderFrame.url).origin !== new URL(applicationUrl).origin) {
throw new Error('desktop welcome: rejected locale request from an unowned frame')
}
if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
return { languages: systemLanguages, preference: await welcomeBackend.readLocalePreference() }
})
ipcMain.on(DESKTOP_IPC.localeChanged, (event, next: unknown) => {
const window = mainWindow
if (window === undefined || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame
|| typeof next !== 'string') return
const current = resolveDesktopStartupLocale(next, systemLanguages)
if (current.id === locale.id) return
locale = current
platformView.notifyLocaleChanged()
windowsLanguage = locale.id
installMenu()
})
ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
assertProductSender(event)
return presentDesktopUpdate(updates.state)
@@ -539,8 +626,9 @@ async function main(): Promise<void> {
if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
controller = new AbortController()
ordinaryDialogs.add(controller)
progress = mainWindow === undefined ? Promise.resolve() : updateDialog.show(mainWindow, { type: 'info', title: messages.updateCheckTitle,
message: messages.updateChecking, buttons: [messages.later], cancelId: 0, signal: controller.signal })
const parent = currentDialogWindow()
progress = parent === undefined ? Promise.resolve() : updateDialog.show(parent, { type: 'info', title: locale.messages.updateCheckTitle,
message: locale.messages.updateChecking, buttons: [locale.messages.later], cancelId: 0, signal: controller.signal })
if (!joinedPolicyAuthentication) {
void checkPolicyManually('deferred').catch((error: unknown) => { console.error(error) })
}
@@ -549,8 +637,8 @@ async function main(): Promise<void> {
if (isMandatory()) { mandatoryUI?.focus(); return }
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
await ordinaryMessageBox({ type: 'info', title: messages.updateCheckTitle,
message: formatDesktopMessage(messages.updateCurrent, { version: app.getVersion() }) })
await ordinaryMessageBox({ type: 'info', title: locale.messages.updateCheckTitle,
message: formatDesktopMessage(locale.messages.updateCurrent, { version: app.getVersion() }) })
return
}
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
@@ -562,9 +650,9 @@ async function main(): Promise<void> {
}
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
if (manual) {
const result = await ordinaryMessageBox({ title: messages.updateCheckTitle, message: messages.updateAvailable,
detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }),
buttons: [messages.updateDownload], cancelId: 1 })
const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle, message: locale.messages.updateAvailable,
detail: formatDesktopMessage(locale.messages.updateDetail, { version: state.version ?? '' }),
buttons: [locale.messages.updateDownload], cancelId: 1 })
if (result.response !== 0) return
}
if (!isMandatory() && state.version !== undefined) {
@@ -605,16 +693,16 @@ async function main(): Promise<void> {
}
const runPolicyAuthentication = async () => {
if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
const parent = mandatoryUI?.confirmationWindow ?? mainWindow
const parent = mandatoryUI?.confirmationWindow ?? currentDialogWindow()
if (parent === undefined) return undefined
const consent = await updateDialog.show(parent, { type: 'info', title: messages.policyLoginTitle,
message: messages.policyLoginRequired, buttons: [messages.policyLogin, messages.later], cancelId: 1 })
const consent = await updateDialog.show(parent, { type: 'info', title: locale.messages.policyLoginTitle,
message: locale.messages.policyLoginRequired, buttons: [locale.messages.policyLogin, locale.messages.later], cancelId: 1 })
if (consent.response !== 0 || isQuitting()) return undefined
const outcome = await policyAuth.login()
if (isQuitting() || outcome === 'cancelled') return undefined
if (outcome === 'failed') {
await updateDialog.show(parent, { type: 'error', title: messages.policyLoginTitle,
message: messages.policyLoginFailed, buttons: [messages.updateAcknowledge], cancelId: 0 })
await updateDialog.show(parent, { type: 'error', title: locale.messages.policyLoginTitle,
message: locale.messages.policyLoginFailed, buttons: [locale.messages.updateAcknowledge], cancelId: 0 })
return undefined
}
// Drain a pre-login request before asking the server to evaluate the new cookies.
@@ -668,6 +756,15 @@ async function main(): Promise<void> {
{ label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
{ type: 'separator' },
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
...development ? [
{ type: 'separator' as const },
{ label: currentDesktopLocale().messages.reloadPageMenu, role: 'reload' as const },
{ label: currentDesktopLocale().messages.restartAppHostMenu, click: () => {
if (quitting) return
app.relaunch()
app.quit()
} },
] : [],
{ type: 'separator' },
...hideCommands,
{ role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication }
@@ -677,10 +774,13 @@ async function main(): Promise<void> {
{ role: 'toggleDevTools', visible: false },
{ role: 'toggleDevTools', visible: false, accelerator: 'F12' },
]
Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{
label: darwin ? app.name : currentDesktopLocale().messages.application,
submenu: [...applicationItems(), ...devToolsItems],
}, ...platformMenus]))
const installMenu = (): void => {
Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{
label: darwin ? app.name : currentDesktopLocale().messages.application,
submenu: [...applicationItems(), ...devToolsItems],
}, ...platformMenus]))
}
installMenu()
if (process.platform === 'win32') {
ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
@@ -732,7 +832,7 @@ async function main(): Promise<void> {
}
const createMainWindow = (): BrowserWindow => {
const window = createWindow(appPreload, true, true)
const window = createWindow(appPreload, false, true)
mainWindow = window
browserGuests.bind(window)
window.on('focus', automaticCheck)
@@ -753,20 +853,107 @@ async function main(): Promise<void> {
})
return window
}
const enterWorkspace = async (): Promise<void> => {
if (quitting) return
const window = mainWindow ?? createMainWindow()
await navigateMain(applicationUrl)
if (isQuitting() || recovery.active || window.isDestroyed()) return
window.show()
enteredWorkspace = true
if (welcomeWindow !== undefined) {
welcomeWindow.close()
window.webContents.send(DESKTOP_IPC.enterWorkspace)
}
welcomeWindow = undefined
if (development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
window.webContents.openDevTools({ mode: 'detach' })
}
}
let openingWelcome: Promise<void> | undefined
const showWelcome = (): Promise<void> => {
if (quitting) return Promise.resolve()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
welcomeWindow.show()
welcomeWindow.focus()
return Promise.resolve()
}
openingWelcome ??= (async () => {
welcomeWindow = await openWelcomeWindow(locale, {
startSignIn: async () => {
if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
return welcomeBackend.account.start(locale.id)
},
cancelSignIn: async (id) => {
if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
return welcomeBackend.account.cancel(id)
},
copySignInLink: async (id) => {
const state = await welcomeBackend?.account.state()
if (state?.attempt?.id !== id || state.attempt.phase !== 'waiting-browser' || state.attempt.authorizeUrl === undefined) {
throw new Error('desktop welcome: login link is unavailable')
}
await clipboard.writeText(state.attempt.authorizeUrl)
},
saveApiKey: async (apiKey) => {
if (backend.host === undefined || welcomeBackend === undefined) return { ok: false }
const saved = await welcomeBackend.save(apiKey)
if (!saved.ok) return saved
await enterWorkspace()
return { ok: true }
},
skip: enterWorkspace,
})
const window = welcomeWindow
window.once('closed', () => {
void welcomeBackend?.account.state().then((state) => {
if (state.attempt !== null && !enteredWorkspace) return welcomeBackend?.account.cancel(state.attempt.id)
return undefined
}).catch(() => undefined)
})
window.once('closed', () => {
if (welcomeWindow === window) welcomeWindow = undefined
if (!enteredWorkspace && !recovery.active) mainWindow?.close()
})
if (isQuitting() || recovery.active || enteredWorkspace) window.close()
else mainWindow?.hide()
})().finally(() => { openingWelcome = undefined })
return openingWelcome
}
const openInitialWindow = async (): Promise<void> => {
if (quitting || recovery.active) return
const state = await readWelcomeState()
if (isQuitting() || backend.state.phase !== 'ready') return
locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages)
windowsLanguage = locale.id
installMenu()
if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) {
await showWelcome()
} else {
await enterWorkspace()
}
}
focusPrimaryWindow = () => {
if (quitting) return
if (isMandatory()) { mandatoryUI?.focus(); return }
const window = mainWindow
const window = welcomeWindow ?? mainWindow
if (window === undefined || window.isDestroyed()) {
try { createMainWindow() } catch (error) { reportFatal(error); return }
void navigateMain(applicationUrl).catch(reportFatal)
void (backend.state.phase === 'ready' ? openInitialWindow() : navigateMain(applicationUrl)).catch(reportFatal)
return
}
// Startup and sign-out select the visible window before activation may reveal the workspace.
if (window === mainWindow && !enteredWorkspace) return
if (window.isMinimized()) window.restore()
window.show()
window.focus()
}
if (app.isPackaged || process.env.DSH_DESKTOP_DEV_APP === '1') app.setAsDefaultProtocolClient('dsh')
app.on('open-url', (event, url) => {
event.preventDefault()
if (url === 'dsh://open' || url === 'dsh://open/') focusPrimaryWindow()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
})
@@ -784,6 +971,8 @@ async function main(): Promise<void> {
if (quitting) return
event.preventDefault()
quitting = true
stopAccount?.()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide()
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
updateSchedule.dispose()
updateDialog.dispose()
@@ -803,7 +992,7 @@ async function main(): Promise<void> {
if (policyConfig !== undefined) {
if (policyConfig.authentication === 'feishu-test') {
policyAuth = new DesktopPolicyTestAuth(policyConfig.origin, policyConfig.allowedAuthOrigins, locale,
() => mandatoryUI?.confirmationWindow ?? mainWindow,
() => mandatoryUI?.confirmationWindow ?? currentDialogWindow(),
(event) => { console.info(`desktop policy authentication: ${event}`); updateJournal?.action(`policy-login-${event}`) })
}
const bundleId = app.isPackaged
@@ -813,7 +1002,7 @@ async function main(): Promise<void> {
if (!['win32', 'darwin'].includes(process.platform) || !['x64', 'arm64'].includes(process.arch)) throw new Error('desktop policy: unsupported platform')
let wasBlocking = false
mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, {
platform: process.platform === 'win32' ? 'desktop-win' : 'desktop-mac', arch: process.arch as 'x64' | 'arm64',
platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64',
version: app.getVersion(), bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(),
bundleId, locale: locale.id,
}, (state) => {
+4 -3
View File
@@ -1,10 +1,11 @@
/** Mandatory-update policy, independent of local business traffic and updater artifacts. */
import { valid } from 'semver'
import { desktopClientHeaders } from '@deepseek-ai/dsh-deepseek-account'
/** Installed release identity; no field is supplied by a renderer. */
export interface DesktopPolicyIdentity {
readonly platform: 'desktop-win' | 'desktop-mac'
readonly platform: 'win32' | 'darwin'
readonly version: string
readonly bundledDshVersion: string
readonly bundleId: string
@@ -152,9 +153,9 @@ export class DesktopMandatoryUpdatePolicy {
private readonly random: () => number = Math.random,
) {
if (valid(identity.version) === null || valid(identity.bundledDshVersion) === null || identity.bundleId.trim() === ''
|| (identity.platform === 'desktop-win' && identity.arch !== 'x64')) throw new Error('desktop policy: invalid installed client identity')
|| (identity.platform === 'win32' && identity.arch !== 'x64')) throw new Error('desktop policy: invalid installed client identity')
this.headers = Object.freeze({
'x-client-platform': identity.platform, 'x-client-version': identity.version,
...desktopClientHeaders(identity.platform), 'x-client-version': identity.version,
'x-client-bundle-id': identity.bundleId, 'x-client-locale': identity.locale,
'x-client-arch': identity.arch, 'x-client-update-channel': 'nightly',
'x-client-bundled-dsh-version': identity.bundledDshVersion,
+12
View File
@@ -0,0 +1,12 @@
/** Shared names for the desktop Platform bridge. */
/** Private desktop channels; the Platform renderer receives bootstrap and locale updates. */
export const PLATFORM_IPC = {
bootstrap: 'dsh-platform:bootstrap',
localeChanged: 'dsh-platform:locale-changed',
open: 'dsh-platform:open',
bounds: 'dsh-platform:bounds',
close: 'dsh-platform:close',
} as const
/** Resolved Platform language; Desktop resolves the system preference before sending it. */
export type PlatformLocale = 'en_US' | 'zh_CN'
+199
View File
@@ -0,0 +1,199 @@
/** Isolated Platform documents owned by the desktop account lifetime. */
import type { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { WebContentsView, session, shell, type View, type WebFrameMain } from 'electron'
import { mergePlatformCookies, type PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
import { PLATFORM_IPC, type PlatformLocale } from './platform-ipc.ts'
export { PLATFORM_IPC } from './platform-ipc.ts'
/** Bounds in desktop content coordinates, supplied by the owned application renderer. */
export interface PlatformBounds { x: number; y: number; width: number; height: number }
/**
* Decode the renderer rectangle before allocating a native view.
* @param value - IPC payload.
* @returns finite, nonnegative integer coordinates.
*/
export function platformBounds(value: unknown): PlatformBounds {
if (typeof value !== 'object' || value === null) throw new Error('Invalid Platform bounds')
const row = value as Record<string, unknown>
const result: PlatformBounds = { x: 0, y: 0, width: 0, height: 0 }
for (const key of ['x', 'y', 'width', 'height'] as const) {
const n = row[key]
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0 || n > 100_000) throw new Error('Invalid Platform bounds')
result[key] = Math.round(n)
}
return result
}
type PlatformOwner = Pick<EventEmitter, 'on' | 'removeListener'> & {
webContents: Pick<EventEmitter, 'on' | 'removeListener'>
contentView: Pick<View, 'addChildView' | 'removeChildView'>
isDestroyed(): boolean
}
type PlatformSender = { sender: object; senderFrame: Pick<WebFrameMain, 'url'> | null }
/** Native view and its credential snapshot are discarded together. */
export class DesktopPlatformView {
private account: PlatformSession | null = null
private view: WebContentsView | undefined
private owner: PlatformOwner | undefined
private releaseOwner: (() => void) | undefined
private generation = 0
/**
* @param preload - bundled sandboxed Platform preload path.
* @param getLocale - current resolved Desktop language.
*/
constructor(private readonly preload: string, private readonly getLocale: () => PlatformLocale) {}
/** @param next - private Host credential snapshot; replacement invalidates the current document. */
setSession(next: PlatformSession | null): void {
if (next?.token === this.account?.token && next?.origin === this.account?.origin
&& next?.embeddedPageDist === this.account?.embeddedPageDist
&& JSON.stringify(next?.requestHeaders) === JSON.stringify(this.account?.requestHeaders)) return
this.close()
this.account = next
}
/**
* Create an in-memory browser session after account preparation has completed.
* @param owner - application window containing the view.
* @param page - explicit supported Platform page.
* @param bounds - owned renderer rectangle.
* @returns when the document finishes loading.
*/
async open(owner: PlatformOwner, page: 'usage' | 'top-up', bounds: PlatformBounds): Promise<void> {
this.close()
const account = this.account
if (account === null) throw new Error('Platform account unavailable')
const generation = this.generation
const browserSession = session.fromPartition(`dsh-platform-${randomUUID()}`)
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
browserSession.setPermissionCheckHandler(() => false)
const deploymentHeaders = account.requestHeaders ?? {}
const injectedRequests = new Set<number>()
browserSession.webRequest.onCompleted((details) => { injectedRequests.delete(details.id) })
browserSession.webRequest.onErrorOccurred((details) => { injectedRequests.delete(details.id) })
browserSession.webRequest.onBeforeSendHeaders((details, callback) => {
let headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value]))
if (new URL(details.url).origin === account.origin) {
injectedRequests.add(details.id)
const cookie = headers.cookie ?? ''
Object.assign(headers, deploymentHeaders)
if (deploymentHeaders.cookie !== undefined) headers.cookie = mergePlatformCookies(cookie, deploymentHeaders.cookie)
} else if (injectedRequests.has(details.id)) {
// Redirected subresources must not carry deployment headers to another origin.
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !Object.hasOwn(deploymentHeaders, name)))
}
callback({ requestHeaders: headers })
})
const view = new WebContentsView({ webPreferences: {
session: browserSession, preload: this.preload, sandbox: true, contextIsolation: true,
additionalArguments: [`--dsh-platform-origin=${account.origin}`],
nodeIntegration: false, webSecurity: true,
} })
this.view = view
this.owner = owner
const closeOwnedView = () => { if (this.view === view) this.close() }
const navigateOwner = (_event: Electron.Event, _url: string, isInPlace: boolean, isMainFrame: boolean) => {
if (isMainFrame && !isInPlace) closeOwnedView()
}
// Renderer document replacement does not run React effect cleanup.
owner.webContents.on('did-start-navigation', navigateOwner)
owner.webContents.on('render-process-gone', closeOwnedView)
owner.webContents.on('destroyed', closeOwnedView)
owner.on('closed', closeOwnedView)
this.releaseOwner = () => {
owner.webContents.removeListener('did-start-navigation', navigateOwner)
owner.webContents.removeListener('render-process-gone', closeOwnedView)
owner.webContents.removeListener('destroyed', closeOwnedView)
owner.removeListener('closed', closeOwnedView)
}
// External payment and documentation pages open without the embedded session or token.
view.webContents.setWindowOpenHandler(({ url }) => {
const destination = new URL(url)
if (destination.protocol === 'https:' && !destination.username && !destination.password) {
void shell.openExternal(url).catch(() => {
// An OS browser-launch failure leaves the embedded page available for retry.
})
}
return { action: 'deny' }
})
const allowNavigation = (url: string): boolean => {
try {
const parsed = new URL(url)
return parsed.origin === account.origin && !parsed.username && !parsed.password
} catch { return false }
}
view.webContents.on('will-navigate', (event, url) => { if (!allowNavigation(url)) event.preventDefault() })
view.webContents.on('will-redirect', (event, url) => { if (!allowNavigation(url)) event.preventDefault() })
view.webContents.on('will-attach-webview', (event) => { event.preventDefault() })
view.webContents.on('preload-error', () => { if (this.view === view) this.close() })
view.webContents.on('render-process-gone', () => { if (this.view === view) this.close() })
view.setVisible(false)
owner.contentView.addChildView(view)
view.setBounds(bounds)
try {
const url = new URL(page === 'usage' ? '/usage' : '/top_up', account.origin)
if (account.embeddedPageDist) url.searchParams.set('dist', account.embeddedPageDist)
await view.webContents.loadURL(url.href)
} catch (error) {
// A Platform document may replace its own URL before the first load settles (for example to
// consume an embedded deployment parameter); that aborts loadURL with ERR_ABORTED instead of
// reporting a failed document, so the owned view stays open like the application window.
const aborted = error instanceof Error && 'code' in error && error.code === 'ERR_ABORTED'
if (!aborted) {
if (generation === this.generation) this.close()
throw error
}
}
if (generation === this.generation && this.view === view) view.setVisible(true)
}
/** @param bounds - current application viewport rectangle. */
setBounds(bounds: PlatformBounds): void { this.view?.setBounds(bounds) }
/**
* Return prepared credentials and resolved language only to the current Platform main frame.
* @param event - Electron-provided sender identity.
* @returns credentials and current language copied into the isolated preload.
*/
bootstrap(event: PlatformSender): Pick<PlatformSession, 'origin' | 'token'> & { locale: PlatformLocale } {
const view = this.view
const account = this.account
if (view === undefined || account === null || event.sender !== view.webContents
|| event.senderFrame !== view.webContents.mainFrame || new URL(event.senderFrame.url).origin !== account.origin) {
throw new Error('Rejected Platform bootstrap')
}
return { origin: account.origin, token: account.token, locale: this.getLocale() }
}
/** Notify the current document after the Desktop language changes. */
notifyLocaleChanged(): void {
const view = this.view
if (view !== undefined && !view.webContents.isDestroyed()) {
view.webContents.send(PLATFORM_IPC.localeChanged, this.getLocale())
}
}
/** Destroy the document before releasing its temporary browser storage. */
close(): void {
this.generation++
const view = this.view
this.view = undefined
this.releaseOwner?.()
this.releaseOwner = undefined
if (view === undefined) return
if (this.owner !== undefined && !this.owner.isDestroyed()) this.owner.contentView.removeChildView(view)
this.owner = undefined
const browserSession = view.webContents.session
if (!view.webContents.isDestroyed()) view.webContents.close()
void browserSession.clearStorageData().catch(() => {
// The non-persistent partition is unreachable after its only view is closed.
})
}
}
+21
View File
@@ -2,6 +2,7 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts'
import { PLATFORM_IPC } from './platform-ipc.ts'
import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts'
import { syncNativeTheme } from './preload-theme.ts'
import { syncWindowsAppearance } from './preload-windows.ts'
@@ -25,6 +26,14 @@ function createProductApi(): DshDesktopProductApi {
}
if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
ipcRenderer.on(DESKTOP_IPC.enterWorkspace, () => {
const body = document.body
const previous = body.getAttribute('tabindex')
body.tabIndex = -1
body.focus({ preventScroll: true })
if (previous === null) body.removeAttribute('tabindex')
else body.setAttribute('tabindex', previous)
})
syncWindowsAppearance()
if (process.platform === 'win32') installMandatoryUpdateOverlay()
contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', {
@@ -40,6 +49,11 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown>,
failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise<void>,
})
contextBridge.exposeInMainWorld('dshPlatform', {
open: (page: 'usage' | 'top-up', bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.invoke(PLATFORM_IPC.open, page, bounds),
setBounds: (bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.invoke(PLATFORM_IPC.bounds, bounds),
close: () => ipcRenderer.invoke(PLATFORM_IPC.close),
})
}
markDocumentPlatform()
@@ -47,3 +61,10 @@ syncWindowFullscreen()
syncNativeTheme()
// Main-process IPC also verifies the owning window and top frame.
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 })
if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
contextBridge.exposeInMainWorld('__DSH_LOCALE__', {
read: () => ipcRenderer.invoke(DESKTOP_IPC.localeBootstrap),
onChange: (locale: string) => { ipcRenderer.send(DESKTOP_IPC.localeChanged, locale) },
})
}
@@ -0,0 +1,45 @@
/** Platform initialization copies credentials once; token getters never perform IPC. */
import { contextBridge, ipcRenderer } from 'electron'
import { PLATFORM_IPC, type PlatformLocale } from './platform-ipc.ts'
const originArgument = '--dsh-platform-origin='
const allowedOrigin = process.argv.find(argument => argument.startsWith(originArgument))?.slice(originArgument.length)
if (process.isMainFrame && location.origin === allowedOrigin) {
let token: string | undefined
let locale: PlatformLocale | undefined
const localeListeners = new Set<(locale: PlatformLocale) => void>()
ipcRenderer.on(PLATFORM_IPC.localeChanged, (_event, value: unknown) => {
if (value !== 'en_US' && value !== 'zh_CN') return
locale = value
for (const listener of localeListeners) {
try { listener(value) } catch (error) { console.error('Platform locale listener failed', error) }
}
})
try {
const value: unknown = ipcRenderer.sendSync(PLATFORM_IPC.bootstrap)
if (typeof value === 'object' && value !== null && 'token' in value && 'origin' in value
&& typeof value.token === 'string' && value.token.length > 0 && value.origin === location.origin
&& 'locale' in value && (value.locale === 'en_US' || value.locale === 'zh_CN')) {
token = value.token
locale = value.locale
}
} catch {
// Initialization failure retains embedded mode so Platform cannot use browser credentials.
}
contextBridge.exposeInMainWorld('dsh', {
protocolVersion: 1,
displayMode: 'embedded',
getLocale: (): PlatformLocale => {
if (locale === undefined) throw new Error('Platform locale initialization failed')
return locale
},
onLocaleChange: (listener: (locale: PlatformLocale) => void): (() => void) => {
localeListeners.add(listener)
return () => { localeListeners.delete(listener) }
},
getAuthToken: (): string => {
if (token === undefined) throw new Error('Platform initialization failed')
return token
},
})
}
+24
View File
@@ -0,0 +1,24 @@
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Localized welcome copy and write-only credential actions. */
import { contextBridge, ipcRenderer } from 'electron'
import { resolveDesktopLocale } from './locale.ts'
import { WELCOME_IPC, type WelcomeApi, type WelcomeSaveResult } from './welcome-api.ts'
const prefix = '--dsh-welcome-locale='
const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length)
if (locale === undefined) throw new Error('desktop welcome: missing window locale')
const api: WelcomeApi = {
...resolveDesktopLocale(locale),
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise<AccountView>,
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>,
copySignInLink: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.copyLink, id) as Promise<void>,
onAccountState: (listener) => {
const receive = (_event: Electron.IpcRendererEvent, state: AccountView): void =>{ listener(state) }
ipcRenderer.on(WELCOME_IPC.state, receive)
return () => { ipcRenderer.removeListener(WELCOME_IPC.state, receive) }
},
saveApiKey: (value: string) => ipcRenderer.invoke(WELCOME_IPC.saveApiKey, value) as Promise<WelcomeSaveResult>,
skip: () => ipcRenderer.invoke(WELCOME_IPC.skip) as Promise<void>,
}
contextBridge.exposeInMainWorld('dshWelcome', api)
+8 -7
View File
@@ -50,9 +50,9 @@ export class DesktopUpdateDialog {
/**
* @param preload - Bundled isolated preload.
* @param locale - Shell-owned copy.
* @param locale - Shell-owned copy or a reader of the current UI language.
*/
constructor(private readonly preload: string, private readonly locale: DesktopLocale) {
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale)) {
ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null })
ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => {
this.owned(event)
@@ -72,7 +72,8 @@ export class DesktopUpdateDialog {
* @returns A displayed response, or cancellation on replacement, abort, close, or load failure; backdrop dismissal fades independently.
*/
show(parent: BrowserWindow, options: UpdateDialogOptions): Promise<MessageBoxReturnValue> {
const buttons = options.buttons ?? [this.locale.messages.updateAcknowledge]
const locale = typeof this.locale === 'function' ? this.locale() : this.locale
const buttons = options.buttons ?? [locale.messages.updateAcknowledge]
const cancelId = options.cancelId ?? buttons.length - 1
if (this.disposed || options.signal?.aborted === true || parent.isDestroyed()) {
return Promise.resolve({ response: cancelId, checkboxChecked: false })
@@ -82,12 +83,12 @@ export class DesktopUpdateDialog {
clearTimeout(this.closing)
this.closing = undefined
const existing = this.window
const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? this.locale.messages.updateTitle, false)
const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
this.window = window
this.parent = parent
const view: UpdateDialogView = { revision: ++this.revision, locale: this.locale.id, title: options.title ?? '', message: options.message,
detail: options.detail ?? '', buttons, cancelId, closeLabel: this.locale.messages.updateClose,
technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: this.locale.messages.updateTechnicalDetails }
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose,
technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: locale.messages.updateTechnicalDetails }
return new Promise((resolve) => {
const abort = (): void => { finish(cancelId) }
const finish = (response: number, retain = false): void => {
+60
View File
@@ -0,0 +1,60 @@
/** Operations available to the isolated native welcome renderer. */
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { DesktopLocale } from './locale.ts'
/** Private native welcome channels, installed only while its window exists. */
export const WELCOME_IPC = {
saveApiKey: 'dsh-welcome:save-api-key',
skip: 'dsh-welcome:skip',
start: 'dsh-welcome:start',
cancel: 'dsh-welcome:cancel',
copyLink: 'dsh-welcome:copy-link',
state: 'dsh-welcome:state',
} as const
/** Credential writes return a safe outcome without exposing Host diagnostics. */
export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
/** Host-owned operations used by the welcome window. */
export interface WelcomeOperations {
/** @returns account state after starting a login attempt. */
startSignIn(): Promise<AccountView>
/** @param id - attempt to cancel. @returns the settled state. */
cancelSignIn(id: SignInAttemptId): Promise<AccountView>
/** @param id - current waiting attempt whose authorization URL is copied to the system clipboard. */
copySignInLink(id: SignInAttemptId): Promise<void>
/**
* Store the official provider's key before entering the workspace.
* @param value - validated, trimmed API key.
* @returns whether the write completed, without private error details.
*/
saveApiKey(value: string): Promise<WelcomeSaveResult>
/**
* Enter the workspace without writing an onboarding-completion setting.
* @returns completion after the workspace opens.
*/
skip(): Promise<void>
}
/** The renderer receives localized copy, login operations, and safe account snapshots. */
export type WelcomeApi = DesktopLocale & WelcomeOperations & {
/** @param listener - safe account snapshot recipient. @returns subscription disposer. */
onAccountState(listener: (state: AccountView) => void): () => void
}
/** Authentication facts supplied at cold start or after a completed sign-out. */
export interface WelcomeAuthentication {
readonly loggedIn: boolean
readonly hasApiKey: boolean
}
/**
* Decide whether a startup or sign-out requires the welcome entry.
* @param authentication - current account and independently stored API-key facts.
* @returns true only when neither authentication route is configured.
*/
export function needsWelcome(authentication: WelcomeAuthentication): boolean {
return !authentication.loggedIn && !authentication.hasApiKey
}
+136
View File
@@ -0,0 +1,136 @@
/** Native welcome operations using the shared Web authentication and RPC APIs. */
import { randomUUID } from 'node:crypto'
import { desktopAccountBackend, type DesktopAccountBackend } from './account-backend.ts'
/** Metadata needed before the native entry or workspace becomes visible. */
export interface WelcomeState {
readonly loggedIn: boolean
readonly hasApiKey: boolean
readonly writable: boolean
readonly localePreference: string | null
}
/** Narrow operations available to the native welcome flow. */
export interface DesktopWelcomeBackend {
readonly account: DesktopAccountBackend
/** @returns Configured-key presence and the shared language preference, without credential values. */
read(): Promise<WelcomeState>
/** @returns The saved UI language without account or provider requests. */
readLocalePreference(): Promise<string | null>
/**
* @param apiKey - User-entered official provider key.
* @returns A safe write outcome without provider diagnostics.
*/
save(apiKey: string): Promise<{ ok: boolean }>
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Authenticate the native HTTP client through the Web application's launch URL.
* @param authenticatedUrl - URL supplied by the running Desktop Host.
* @param send - Electron session fetch, retaining the Web authentication cookie.
* @returns metadata reads and write-only credential operations over standard RPC.
*/
export async function connectDesktopWelcome(
authenticatedUrl: string,
send: (input: string, init?: RequestInit) => Promise<Response>,
cookies: () => Promise<string> = () => Promise.resolve(''),
): Promise<DesktopWelcomeBackend> {
const origin = new URL(authenticatedUrl).origin
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
await authenticated.body?.cancel()
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
const invoke = async (request: { namespace: string; method: string; args: Record<string, unknown> }): Promise<unknown> => {
const rpcId = randomUUID()
const method = `${request.namespace}/${request.method}`
const response = await send(new URL(`/api/${method}`, origin).href, {
method: 'POST', credentials: 'include', redirect: 'error',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
})
if (!response.ok) throw new Error('desktop welcome: Web request failed')
const envelope: unknown = await response.json()
if (!record(envelope) || envelope.type !== 'server-response' || envelope.rpcId !== rpcId
|| !record(envelope.result) || envelope.result.ok !== true) {
throw new Error('desktop welcome: Web RPC failed')
}
return envelope.result.value
}
const account = desktopAccountBackend(origin, invoke, cookies)
const settingsAndReference = async () => {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
const official: unknown = settings.namespaces.find((item: unknown) => record(item) && item.ns === 'llm-deepseek')
if (official === undefined) return { settings: { namespaces: settings.namespaces }, ref: undefined }
if (!record(official) || !record(official.value) || typeof official.value.apiKeyEnv !== 'string') {
throw new Error('desktop welcome: missing official DeepSeek credential reference')
}
return { settings: { namespaces: settings.namespaces }, ref: official.value.apiKeyEnv }
}
const localePreference = (namespaces: unknown[]): string | null => {
const locale: unknown = namespaces.find((item: unknown) => record(item) && item.ns === 'locale')
if (!record(locale) || !record(locale.value)
|| (locale.value.preference !== undefined && typeof locale.value.preference !== 'string')) {
throw new Error('desktop welcome: invalid locale preference')
}
return locale.value.preference ?? null
}
const read = async (): Promise<WelcomeState> => {
const { settings, ref } = await settingsAndReference()
const providers = await invoke({ namespace: 'llm', method: 'listConfigurableProviders', args: {} })
if (!Array.isArray(providers)) throw new Error('desktop welcome: invalid provider directory')
const namespaces = settings.namespaces
const refs = providers.flatMap((provider: unknown) => {
if (!record(provider) || typeof provider.settingsNs !== 'string' || !Array.isArray(provider.settingsPath)) {
throw new Error('desktop welcome: invalid provider settings address')
}
const namespace: unknown = namespaces.find((item: unknown) => record(item) && item.ns === provider.settingsNs)
let value: unknown = record(namespace) ? namespace.value : undefined
for (const key of provider.settingsPath as unknown[]) {
if (typeof key !== 'string') throw new Error('desktop welcome: invalid provider settings path')
value = record(value) ? value[key] : undefined
}
return record(value) && typeof value.apiKeyEnv === 'string' ? [value.apiKeyEnv] : []
})
const unique = [...new Set([...(ref === undefined ? [] : [ref]), ...refs])]
const states: Record<string, unknown> = {}
// credentials.describe accepts at most 64 references per request.
for (let offset = 0; offset < unique.length; offset += 64) {
const batch = await invoke({ namespace: 'credentials', method: 'describe', args: { refs: unique.slice(offset, offset + 64) } })
if (!record(batch)) throw new Error('desktop welcome: invalid credential metadata')
Object.assign(states, batch)
}
if (ref !== undefined && !record(states[ref])) throw new Error('desktop welcome: missing credential metadata')
return {
loggedIn: (await account.state()).status === 'credential-stored',
hasApiKey: Object.values(states).some(value => record(value) && value.configured === true),
writable: ref !== undefined && record(states[ref]) && states[ref].writable === true,
localePreference: localePreference(namespaces),
}
}
return {
account,
read,
async readLocalePreference() {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
return localePreference(settings.namespaces)
},
async save(apiKey) {
if (!/^[\x21-\x7e]+$/.test(apiKey)) return { ok: false }
try {
const { ref } = await settingsAndReference()
if (ref === undefined) return { ok: false }
await invoke({ namespace: 'credentials', method: 'set', args: { ref, value: apiKey } })
return { ok: true }
} catch {
// Provider diagnostics may contain credentials; the native form owns failure copy.
return { ok: false }
}
},
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Native welcome window and its presentation-only renderer. */
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { app, BrowserWindow, ipcMain, type BrowserWindowConstructorOptions, type IpcMainInvokeEvent } from 'electron'
import type { DesktopLocale } from './locale.ts'
import { WELCOME_IPC, type WelcomeOperations } from './welcome-api.ts'
/**
* Resolve the fixed-size welcome window's native material and controls.
* @param platform - operating system hosting Electron.
* @param locale - shell-owned localized copy.
* @returns sandboxed window options with a locale-only preload.
*/
export function welcomeWindowOptions(platform: NodeJS.Platform, locale: DesktopLocale): BrowserWindowConstructorOptions {
return {
width: 600,
height: 700,
useContentSize: true,
center: true,
resizable: false,
maximizable: false,
fullscreenable: false,
show: false,
title: locale.messages.welcomeTitle,
backgroundColor: platform === 'darwin' || platform === 'win32' ? '#00000000' : '#FFFFFF',
...(platform === 'darwin' ? {
titleBarStyle: 'hidden',
trafficLightPosition: { x: 21, y: 21 },
vibrancy: 'menu',
visualEffectState: 'active',
} as const : {}),
...(platform === 'win32' ? {
titleBarStyle: 'hidden',
titleBarOverlay: { color: '#00000000', symbolColor: '#0F1115', height: 42 },
backgroundMaterial: 'acrylic',
} as const : {}),
webPreferences: {
preload: fileURLToPath(new URL('./preload-welcome.cjs', import.meta.url)),
additionalArguments: [`--dsh-welcome-locale=${locale.id}`],
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
},
}
}
let disposeActiveHandlers: (() => void) | undefined
/**
* Open the process's sole welcome window with desktop-owned operations.
* Replaces IPC ownership immediately; the caller closes the previous native window.
* @param locale - shell-owned localized copy.
* @param operations - credential write and this-launch-only skip actions.
* @returns the visible window; a failed load destroys it before rejecting.
*/
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise<BrowserWindow> {
const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale))
disposeActiveHandlers?.()
let active = true
const disposeHandlers = (): void => {
if (!active) return
active = false
for (const channel of [WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink]) {
ipcMain.removeHandler(channel)
}
disposeActiveHandlers = undefined
}
disposeActiveHandlers = disposeHandlers
const assertSender = (event: IpcMainInvokeEvent): void => {
if (!active || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) {
throw new Error('desktop welcome: rejected action from an unowned frame')
}
}
ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
assertSender(event)
if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value)) return { ok: false }
return operations.saveApiKey(value)
})
ipcMain.handle(WELCOME_IPC.skip, async (event) => {
assertSender(event)
await operations.skip()
})
ipcMain.handle(WELCOME_IPC.start, async (event) => { assertSender(event); return operations.startSignIn() })
ipcMain.handle(WELCOME_IPC.cancel, async (event, id: unknown) => {
assertSender(event)
if (typeof id !== 'string') throw new Error('desktop welcome: invalid attempt')
return operations.cancelSignIn(id as SignInAttemptId)
})
ipcMain.handle(WELCOME_IPC.copyLink, async (event, id: unknown) => {
assertSender(event)
if (typeof id !== 'string') throw new Error('desktop welcome: invalid attempt')
return operations.copySignInLink(id as SignInAttemptId)
})
window.once('closed', disposeHandlers)
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', (event) => { event.preventDefault() })
try {
await window.loadFile(join(app.getAppPath(), 'renderer', 'welcome.html'))
} catch (error) {
disposeHandlers()
if (!window.isDestroyed()) window.destroy()
throw error
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile.
if (active && !window.isDestroyed()) window.show()
return window
}
@@ -0,0 +1,26 @@
import { expect, it } from 'vitest'
import { accountView, desktopAccountBackend } from '../src/account-backend.ts'
it('projects only safe account fields and refuses non-browser authorization schemes', () => {
const state = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', token: 'not-for-the-renderer',
attempt: { id: 'test', phase: 'waiting-browser', authorizeUrl: 'https://platform.deepseek.com/dsh/authorize', codeVerifier: 'private' } }
expect(accountView(state)).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: {
id: 'test', phase: 'waiting-browser', authorizeUrl: 'https://platform.deepseek.com/dsh/authorize',
} })
for (const authorizeUrl of ['file:///tmp/example', 'javascript:alert(1)', 'http://example.com/login']) {
expect(() => accountView({ ...state, attempt: { ...state.attempt, authorizeUrl } })).toThrow()
}
expect(() => accountView({ ...state, links: { usageUrl: 'file:///tmp/example', topUpUrl: 'https://example.com/top_up' } })).toThrow()
expect(() => accountView({ ...state, attempt: { ...state.attempt, expiresAt: 'tomorrow' } })).toThrow()
expect(() => accountView({ ...state, attempt: { ...state.attempt, errorCode: 'raw-server-message' } })).toThrow()
})
it('uses account Remote commands without returning additional wire fields', async () => {
const requests: unknown[] = []
const backend = desktopAccountBackend('http://127.0.0.1:1234', (request) => {
requests.push(request)
return Promise.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null, token: 'private' })
}, () => Promise.resolve(''))
expect(await backend.start('en')).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })
expect(requests).toEqual([{ namespace: 'account', method: 'startSignIn', args: { locale: 'en', callbackOrigin: 'http://127.0.0.1:1234', loginSource: 'desktop' } }])
})
@@ -139,6 +139,18 @@ describe('Desktop local packaging configuration', () => {
}).not.toThrow()
})
it('accepts one local npm registry mirror and rejects other registry forms', () => {
const release = { ...POLICY, DSH_DESKTOP_APP_ID: RELEASE.DSH_DESKTOP_APP_ID }
expect(() => {
validateDesktopPackageEnvironment({ ...release, DSH_DESKTOP_NPM_REGISTRY: 'https://registry.npmmirror.com/' }, WINDOWS, { unsigned: true })
}).not.toThrow()
for (const value of ['http://registry.example.com/', 'https://registry.example.com/path', 'https://user:[email protected]/', 'not-a-url']) {
expect(() => {
validateDesktopPackageEnvironment({ ...release, DSH_DESKTOP_NPM_REGISTRY: value }, WINDOWS, { unsigned: true })
}).toThrow(/DSH_DESKTOP_NPM_REGISTRY/u)
}
})
it('rejects incomplete macOS identity and credentials and checks referenced files without contacting Apple', async () => {
expect(() => {
validateDesktopPackageEnvironment(RELEASE, MACOS)
@@ -0,0 +1,23 @@
/** Cold-start launcher preserves workspace paths without evaluating shell syntax. */
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, onTestFinished } from 'vitest'
import { developmentLauncher } from '../scripts/development-app.ts'
it.skipIf(process.platform === 'win32')('passes literal workspace paths and cold-start settings to Electron', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-development-app-'))
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
const bundle = join(root, "Harness ' $(false).app")
const binary = join(bundle, 'Contents', 'MacOS', 'Electron')
mkdirSync(join(bundle, 'Contents', 'MacOS'), { recursive: true })
writeFileSync(binary, '#!/bin/sh\nprintf "%s\\n" "$DSH_HOME" "$DSH_DESKTOP_DEV_APP" "$DSH_DESKTOP_OPEN_DEVTOOLS" "$@"\n', { mode: 0o755 })
const launcher = join(root, 'launcher')
const home = join(root, "home ' $(false)")
writeFileSync(launcher, developmentLauncher({ electron: binary, appRoot: root, directory: root,
home, userData: join(root, 'browser data'), mainPort: 9229, rendererPort: 9222, hostPort: 9230, openDevtools: '0' }, bundle))
const result = execFileSync('/bin/sh', [launcher, '--test-launch-argument'], { encoding: 'utf8' })
expect(result.trimEnd().split('\n')).toEqual([home, '1', '0', '--inspect=127.0.0.1:9229',
'--remote-debugging-port=9222', `--user-data-dir=${join(root, 'browser data')}`, root, '--test-launch-argument'])
})
+22 -1
View File
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -31,6 +31,27 @@ afterEach(() => {
})
describe('desktop development project', () => {
it('includes declared workspace packages missing from the hoist directory in the runtime inventory', () => {
const root = temporaryRoot()
const cli = join(root, 'cli')
const host = join(root, 'host')
const dependency = join(root, 'unhoisted')
const hoisted = join(root, 'hoisted')
mkdirSync(join(cli, 'node_modules'), { recursive: true })
mkdirSync(join(host, 'lib'), { recursive: true })
mkdirSync(dependency)
mkdirSync(hoisted)
writeFileSync(join(cli, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', version: '1.2.3', dependencies: { unhoisted: 'workspace:^' } }))
writeFileSync(join(host, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-desktop-host', version: '1.2.3' }))
writeFileSync(join(host, 'lib/index.js'), '')
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'unhoisted', version: '1.2.3' }))
symlinkSync(dependency, join(cli, 'node_modules/unhoisted'), process.platform === 'win32' ? 'junction' : 'dir')
const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release() })
expect(realpathSync(join(project, 'node_modules/unhoisted'))).toBe(realpathSync(dependency))
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { sharedPackages: unknown[] }
expect(descriptor.sharedPackages).toContainEqual({ name: 'unhoisted', version: '1.2.3', path: 'node_modules/unhoisted' })
})
it('manages development plugins without modifying the linked workspace packages', async () => {
const root = temporaryRoot()
const cli = join(root, 'apps', 'cli')
@@ -0,0 +1,8 @@
DeepSeek Harness
DeepSeek Harness
Add an API key to get started
Configure official DeepSeek models to start using Harness
Enter API key [password]
Save and continue [disabled]
Set up later
Back to sign in
@@ -0,0 +1,6 @@
DeepSeek Harness
DeepSeek Harness
Sign in timed out
Sign in again
Add API Key
Sign in again to continue
@@ -0,0 +1,7 @@
DeepSeek Harness
DeepSeek Harness
Browser didn’t open automatically?
Copy sign-in link
Completing sign in… [disabled]
Cancel
Copy the sign-in link and open it in your browser to sign in.
@@ -0,0 +1,6 @@
DeepSeek Harness
DeepSeek Harness
Welcome to DeepSeek Harness
Build potential. Explore intelligence.
Sign in
Add API Key
@@ -0,0 +1,8 @@
DeepSeek Harness
DeepSeek Harness
添加一个 API Key 开始使用
配置 DeepSeek 官方模型,即可开始使用
输入 API 密钥 [password]
保存并继续 [disabled]
稍后配置
返回登录
@@ -0,0 +1,6 @@
DeepSeek Harness
DeepSeek Harness
登录已超时
重新登录
添加 API Key
请重新登录后继续操作
@@ -0,0 +1,7 @@
DeepSeek Harness
DeepSeek Harness
没有自动打开浏览器?
复制登录链接
正在完成登录… [disabled]
取消
复制登录链接,用浏览器手动打开完成登录
@@ -0,0 +1,6 @@
DeepSeek Harness
DeepSeek Harness
欢迎使用 DeepSeek Harness
组装无限可能,共探智能上限
登录
添加 API Key
+1 -1
View File
@@ -240,7 +240,7 @@ async function main() {
const config = resolveDesktopPolicyConfig({ origin: server.url, allowedPageOrigins: ['https://downloads.example.com'],
intervalMs: 600_000, timeoutMs: 1000 }, true)
let modal
const policy = new DesktopMandatoryUpdatePolicy(config, { platform: 'desktop-win', arch: 'x64',
const policy = new DesktopMandatoryUpdatePolicy(config, { platform: 'win32', arch: 'x64',
version: '1.0.0', bundledDshVersion: '1.0.0', bundleId: 'com.deepseek.dsh', locale: 'zh-CN' }, () => { modal?.sync() })
modal = new DesktopMandatoryUpdateWindow({
preload: fileURLToPath(new URL('../../lib/preload-mandatory.cjs', import.meta.url)),
+11
View File
@@ -182,3 +182,14 @@ describe('desktop host process', () => {
await expect(host.start()).rejects.toThrow(message)
})
})
it('carries Platform credentials over private IPC and clears them on shutdown', async () => {
const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' } }); process.send({ type: 'ready'"))
const changed = vi.fn()
const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, undefined, undefined, undefined, changed)
hosts.push(host)
await host.start()
expect(changed).toHaveBeenCalledWith({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test' })
await host.stop()
expect(changed).toHaveBeenLastCalledWith(null)
})
@@ -56,4 +56,27 @@ describe('installer preparation preserves application dependencies', () => {
vi.restoreAllMocks()
}
})
it('packages every preload entry point the shell loads', async () => {
const { readdirSync, readFileSync } = await import('node:fs')
const sourceDirectory = new URL('../src/', import.meta.url)
const referenced = new Set<string>()
for (const entry of readdirSync(sourceDirectory, { withFileTypes: true })) {
if (!entry.isFile()) continue
for (const match of readFileSync(new URL(entry.name, sourceDirectory), 'utf8').matchAll(/preload-[a-z-]+\.cjs/gu)) referenced.add(match[0])
}
expect(referenced.size).toBeGreaterThan(0)
const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs')
const config = createElectronBuilderConfig({
DSH_DESKTOP_APP_ID: 'com.example.installer',
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://harness-test.deepseek.com',
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)',
DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234',
APPLE_KEYCHAIN_PROFILE: 'installer-test',
}, 'darwin', 'arm64')
const packaged = new Set(config.files.filter((entry): entry is string => typeof entry === 'string'))
for (const name of referenced) expect(packaged.has(`lib/${name}`)).toBe(true)
})
})
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts'
import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, zh } from '../src/locale.ts'
describe('desktop locale dictionaries', () => {
it('ships the same key set in English and Chinese', () => {
@@ -14,4 +14,14 @@ describe('desktop locale dictionaries', () => {
.toBe('[email protected] {missing}')
})
it('prefers an explicit supported choice, then the first supported system language', () => {
expect(resolveDesktopStartupLocale('zh', ['en-US']).id).toBe('zh-CN')
expect(resolveDesktopStartupLocale('EN', ['zh-CN']).id).toBe('en')
expect(resolveDesktopStartupLocale(null, ['ja-JP', 'zh-Hant', 'en-US']).id).toBe('zh-CN')
expect(resolveDesktopStartupLocale(null, ['en-US', 'zh-CN']).id).toBe('en')
expect(resolveDesktopStartupLocale(null, ['ja-JP']).id).toBe('en')
expect(resolveDesktopStartupLocale(null, []).id).toBe('en')
expect(resolveDesktopStartupLocale('ja', ['zh-CN']).id).toBe('zh-CN')
})
})
@@ -41,6 +41,7 @@ describe('desktop macOS release signature', () => {
it('loads release identifiers from the environment and requires code signing', async () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
expect(config.protocols).toEqual([{ name: 'DeepSeek Harness', schemes: ['dsh'] }])
expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone')
expect(config.extraResources).toHaveLength(2)
@@ -4,10 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
import { withMacOSSigningKeychain } from '../scripts/macos-signing-keychain.mjs'
const environment = { CSC_LINK: '/signing.p12', CSC_KEY_PASSWORD: 'export-secret', DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example (TEAMID1234)' }
const USER_KEYCHAINS = ['/Users/example/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']
const keychainListOutput = `${USER_KEYCHAINS.map(path => ` "${path}"`).join('\n')}\n`
const runKeychainList = () => keychainListOutput
describe('temporary macOS signing identity', () => {
it('scopes signing to the imported identity and removes credentials before invoking the build', async () => {
const run = vi.fn<(command: string, args: string[]) => void>()
const run = vi.fn<(command: string, args: string[]) => string>(runKeychainList)
let keychain = ''
await withMacOSSigningKeychain(environment, async (env) => {
keychain = env.CSC_KEYCHAIN!
@@ -24,11 +27,21 @@ describe('temporary macOS signing identity', () => {
const partition = run.mock.calls.find(([, args]) => args[0] === 'set-key-partition-list')![1]
expect(partition[partition.indexOf('-k') + 1]).toBe(create[2])
expect(create[2]).not.toBe(environment.CSC_KEY_PASSWORD)
const searchListCalls = run.mock.calls.map(([, args]) => args).filter(args => args[0] === 'list-keychains')
expect(searchListCalls).toEqual([
['list-keychains', '-d', 'user'],
['list-keychains', '-d', 'user', '-s', keychain, ...USER_KEYCHAINS],
['list-keychains', '-d', 'user', '-s', ...USER_KEYCHAINS],
])
expect(run.mock.calls.findIndex(([, args]) => args[0] === 'list-keychains')).toBeLessThan(run.mock.calls.findIndex(([, args]) => args[0] === '--force'))
})
it.each(['import', '--force', 'build'])('cleans up and prevents subsequent work after %s fails', async (stage) => {
const action = vi.fn(async () => { if (stage === 'build') throw Error('build failure') })
const run = vi.fn((_command: string, args: string[]) => { if (args[0] === stage) throw Error('tool failure') })
const run = vi.fn<(command: string, args: string[]) => string>((_command, args) => {
if (args[0] === stage) throw Error('tool failure')
return keychainListOutput
})
await expect(withMacOSSigningKeychain(environment, action, run)).rejects.toThrow(/failure/u)
const keychain = run.mock.calls[0]![1].at(-1)!
expect(run.mock.calls.at(-1)?.[1]).toEqual(['delete-keychain', keychain])
@@ -46,7 +59,10 @@ describe('temporary macOS signing identity', () => {
await bothReady
expect(paths.every(path => existsSync(dirname(path)))).toBe(true)
}
await Promise.all([withMacOSSigningKeychain(environment, action, () => {}), withMacOSSigningKeychain(environment, action, () => {})])
await Promise.all([
withMacOSSigningKeychain(environment, action, runKeychainList),
withMacOSSigningKeychain(environment, action, runKeychainList),
])
expect(new Set(paths).size).toBe(2)
expect(paths.every(path => !existsSync(dirname(path)))).toBe(true)
})
+80 -2
View File
@@ -1,3 +1,4 @@
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
@@ -105,6 +106,7 @@ const harness = await vi.hoisted(async () => {
class FakeHost {
readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false)
url = 'http://127.0.0.1:3080/?token=test'
fetch = vi.fn(async () => Response.json({ hasApiKey: true, writable: true, localePreference: null }))
readonly ready = deferred()
readonly exited = deferred()
readonly stopping = deferred()
@@ -126,10 +128,12 @@ const harness = await vi.hoisted(async () => {
name: 'Desktop test',
whenReady: () => Promise.resolve(),
getLocale: (): string => 'en-US',
getPreferredSystemLanguages: () => ['en-US'],
getVersion: () => '1.0.0',
getAppPath: (): string => 'desktop-test-app',
setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(),
requestSingleInstanceLock: () => true,
setAsDefaultProtocolClient: vi.fn(),
exit: vi.fn(),
relaunch: vi.fn(),
quit: vi.fn(() => {
@@ -141,11 +145,18 @@ const harness = await vi.hoisted(async () => {
}
}),
})
let accountListener: ((state: AccountView) => void) | undefined
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
return {
failWindow(error: Error) { windowFailure = error },
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme,
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
watchAccount: (listener: (state: AccountView) => void) => {
accountListener = listener
return () => { accountListener = undefined }
},
publishAccount(state: AccountView) { accountListener?.(state) },
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
get updateState() { return updateState },
set updateState(value: DesktopUpdateState) { updateState = value },
@@ -173,6 +184,7 @@ const harness = await vi.hoisted(async () => {
set pluginsEnabled(value: boolean) { pluginsEnabled = value },
set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value },
reset() {
accountListener = undefined
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
powerMonitor.removeAllListeners()
app.isPackaged = true
@@ -209,6 +221,7 @@ vi.mock('electron', () => ({
dialog: harness.dialog,
shell: { openExternal: harness.openExternal },
nativeTheme: harness.nativeTheme,
net: { fetch: vi.fn() },
ipcMain: {
on: harness.ipcOn,
handle: (channel: string, handler: InvokeHandler) => {
@@ -265,6 +278,14 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class
readonly install = harness.updateInstall
readonly dispose = vi.fn()
} }))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
readLocalePreference: async () => null,
read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>,
save: async () => ({ ok: true }),
account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) },
}),
}))
function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown {
const handler = harness.handlers.get(channel)
@@ -304,6 +325,7 @@ beforeEach(() => {
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
})
@@ -387,6 +409,8 @@ describe('desktop main startup', () => {
vi.stubGlobal('process', { ...process, platform })
harness.app.isPackaged = packaged
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([locale])
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([locale])
await readyForUpdate()
const submenu = applicationMenuItems()
const options = harness.app.setAboutPanelOptions.mock.calls[0]![0]
@@ -578,6 +602,18 @@ describe('desktop main startup', () => {
expect(harness.hosts).toHaveLength(0)
})
it('serves packaged shell documents instead of rejecting the shell origin', async () => {
const { serveWebDocument } = await import('../src/web-document.ts')
vi.mocked(serveWebDocument).mockResolvedValue(new Response('shell document'))
await import('../src/main.ts')
await harness.preparing.promise
const handler = harness.protocolHandle.mock.calls[0]![1]
const request = new Request('dsh-app://shell/update-dialog.html')
expect(await (await handler(request)).text()).toBe('shell document')
expect(serveWebDocument).toHaveBeenCalledWith(request, join('desktop-test-app', 'renderer'))
expect((await handler(new Request('dsh-app://foreign/index.html'))).status).toBe(404)
})
it('relays the macOS fullscreen state on transitions and after each load', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
await import('../src/main.ts')
@@ -741,6 +777,7 @@ describe('desktop main startup', () => {
it.each(['en-US', 'zh-CN'])('localizes macOS visibility and quit commands without changing the application name (%s)', async (locale) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([locale])
const originalName = harness.app.name
harness.app.name = '@deepseek-ai/dsh-desktop'
try {
@@ -1460,12 +1497,13 @@ describe('desktop main startup', () => {
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
})
it('shows the loading window before profile preparation and starts one actual Host', async () => {
it('prepares recovery offscreen and starts one Host before choosing the first visible window', async () => {
await import('../src/main.ts')
await harness.preparing.promise
expect(harness.windows).toHaveLength(1)
const window = harness.windows[0]!
expect(window.options.show).toBe(true)
expect(window.options.show).toBe(false)
expect(window.show).not.toHaveBeenCalled()
expect(window.urls).toEqual(['dsh-app://app/'])
expect(harness.hosts).toHaveLength(0)
const retry = invoke(DESKTOP_IPC.boot)
@@ -1519,6 +1557,28 @@ describe('desktop main startup', () => {
expect(harness.hosts).toHaveLength(1)
})
it('keeps recovery visible when the backend fails during the welcome preference read', async () => {
const preferences = Promise.withResolvers<Response>()
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
const host = harness.hosts[0]!
host.fetch.mockReturnValueOnce(preferences.promise)
const startup = expect(Promise.resolve(invoke(DESKTOP_IPC.boot))).rejects.toThrow('Desktop Host is unavailable')
host.ready.resolve()
await vi.waitFor(() => { expect(host.fetch).toHaveBeenCalledOnce() })
host.exited.resolve()
host.onFailure!(new Error('backend exited during startup preferences'))
await harness.dialogShown.promise
preferences.resolve(Response.json({ hasApiKey: true, localePreference: null }))
await startup
expect(harness.windows[0]!.urls).not.toContain('http://127.0.0.1:3080/?token=test')
const failureDialog = harness.dialog.showMessageBox.mock.calls[0]![0] as { detail: string }
expect(failureDialog.detail).toContain('backend exited during startup preferences')
expect(harness.windows[0]!.show).not.toHaveBeenCalled()
})
it('waits for a pending child to exit on quit without late window navigation', async () => {
await import('../src/main.ts')
await harness.preparing.promise
@@ -1539,3 +1599,21 @@ describe('desktop main startup', () => {
expect(harness.windows).toHaveLength(1)
})
})
it.each(['failed', 'expired'] as const)('focuses DSH once when browser authorization becomes %s', async (phase) => {
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
const window = harness.windows[0]!
window.focus.mockClear()
const state: AccountView = {
status: 'signed-out', links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
attempt: { id: 'test-failed-attempt' as NonNullable<AccountView['attempt']>['id'], phase },
}
harness.publishAccount(state)
harness.publishAccount(state)
expect(window.focus).toHaveBeenCalledTimes(1)
})
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DesktopMandatoryUpdatePolicy, desktopPolicyPage, resolveDesktopPolicyConfig, type DesktopPolicyState } from '../src/mandatory-update-policy.ts'
const identity = { platform: 'desktop-win', arch: 'x64', version: '0.1.5-rc.1', bundledDshVersion: '0.1.5-rc.1',
const identity = { platform: 'win32', arch: 'x64', version: '0.1.5-rc.1', bundledDshVersion: '0.1.5-rc.1',
bundleId: 'com.deepseek.dsh', locale: 'zh-CN' } as const
const force = { code: 40005, data: { show_content: { title: '<b>Update</b>', detail: 'Required upgrade' },
desktop_app_link: 'https://downloads.example.com/desktop?os=win' } }
@@ -0,0 +1,83 @@
/** The built sandbox preload provides a synchronous getter after one synchronous initialization. */
import { EventEmitter } from 'node:events'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { expect, it, vi } from 'vitest'
interface Bridge {
getAuthToken(): string
displayMode: string
getLocale(): string
onLocaleChange(listener: (locale: string) => void): () => void
}
function load(mainFrame: boolean, response: unknown, origin = 'https://platform.deepseek.com') {
const bridges = new Map<string, Bridge>()
const invoke = vi.fn(() => response)
const ipc = Object.assign(new EventEmitter(), { sendSync: invoke })
runInNewContext(readFileSync(new URL('../lib/preload-platform-account.cjs', import.meta.url), 'utf8'), {
require: (id: string) => {
if (id !== 'electron') throw new Error('Sandbox module unavailable')
return {
contextBridge: { exposeInMainWorld: (key: string, value: Bridge) => bridges.set(key, value) },
ipcRenderer: ipc,
}
},
process: { isMainFrame: mainFrame, argv: ['--dsh-platform-origin=https://platform.deepseek.com'] }, location: new URL('/usage', origin), exports: {},
})
return { bridge: bridges.get('dsh'), invoke, ipc }
}
it('provides a synchronous getter immediately after exactly one bootstrap IPC', () => {
const { bridge, invoke } = load(true, { origin: 'https://platform.deepseek.com', token: 'fixture-secret', locale: 'zh_CN' })
expect(bridge!.displayMode).toBe('embedded')
expect(bridge!.getAuthToken()).toBe('fixture-secret')
expect(bridge!.getAuthToken()).toBe('fixture-secret')
expect(bridge).not.toHaveProperty('ready')
expect(invoke).toHaveBeenCalledTimes(1)
})
it('does not expose the credential bridge to subframes', () => {
const { bridge, invoke } = load(false, null)
expect(bridge).toBeUndefined()
expect(invoke).not.toHaveBeenCalled()
})
it('rejects a mismatched issuer before exposing credentials', () => {
const { bridge } = load(true, { origin: 'https://other.example', token: 'fixture-secret', locale: 'zh_CN' })
expect(bridge!.displayMode).toBe('embedded')
expect(() => bridge!.getAuthToken()).toThrow()
})
it('fails closed when the main process rejects bootstrap', () => {
const { bridge } = load(true, null)
expect(bridge!.displayMode).toBe('embedded')
expect(() => bridge!.getAuthToken()).toThrow()
})
it('exposes no bridge when the document leaves the allowed origin', () => {
const { bridge, invoke } = load(true, null, 'https://other.example')
expect(bridge).toBeUndefined()
expect(invoke).not.toHaveBeenCalled()
})
it('caches language changes before subscription and removes disposed listeners', () => {
const { bridge, ipc, invoke } = load(true, { origin: 'https://platform.deepseek.com', token: 'fixture-secret', locale: 'zh_CN' })
expect(bridge!.getLocale()).toBe('zh_CN')
ipc.emit('dsh-platform:locale-changed', {}, 'en_US')
expect(bridge!.getLocale()).toBe('en_US')
const listener = vi.fn()
const dispose = bridge!.onLocaleChange(listener)
ipc.emit('dsh-platform:locale-changed', {}, 'zh_CN')
expect(listener).toHaveBeenCalledWith('zh_CN')
ipc.emit('dsh-platform:locale-changed', {}, 'invalid')
expect(bridge!.getLocale()).toBe('zh_CN')
expect(listener).toHaveBeenCalledOnce()
dispose()
ipc.emit('dsh-platform:locale-changed', {}, 'en_US')
expect(listener).toHaveBeenCalledOnce()
expect(bridge!.getLocale()).toBe('en_US')
expect(invoke).toHaveBeenCalledOnce()
})
it('rejects invalid bootstrap languages', () => {
const { bridge } = load(true, { origin: 'https://platform.deepseek.com', token: 'fixture-secret', locale: 'invalid' })
expect(() => bridge!.getAuthToken()).toThrow()
expect(() => bridge!.getLocale()).toThrow()
})
+263
View File
@@ -0,0 +1,263 @@
import { EventEmitter } from 'node:events'
import { afterEach, expect, it, vi } from 'vitest'
import { DesktopPlatformView, platformBounds } from '../src/platform-view.ts'
const state = vi.hoisted(() => ({
views: [] as unknown[], sessions: [] as unknown[], openExternal: vi.fn(async () => {}), loadFailure: undefined as Error | undefined,
}))
vi.mock('electron', () => ({
shell: { openExternal: state.openExternal },
session: { fromPartition: vi.fn((partition: string) => {
const value = {
partition, webRequest: { onBeforeSendHeaders: vi.fn(), onCompleted: vi.fn(), onErrorOccurred: vi.fn() },
setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(),
clearStorageData: vi.fn(async () => {}),
}
state.sessions.push(value)
return value
}) },
WebContentsView: class {
webContents = Object.assign(new EventEmitter(), {
mainFrame: { url: 'https://platform.deepseek.com/usage' },
session: { clearStorageData: vi.fn(async () => {}) },
setWindowOpenHandler: vi.fn(),
loadURL: vi.fn(async (_url: string) => { if (state.loadFailure !== undefined) throw state.loadFailure }),
isDestroyed: () => false, close: vi.fn(), send: vi.fn(),
})
setVisible = vi.fn()
setBounds = vi.fn()
constructor() { state.views.push(this) }
},
}))
afterEach(() => { state.views.length = 0; state.sessions.length = 0; state.loadFailure = undefined; vi.clearAllMocks() })
function setup() {
const removeChildView = vi.fn()
const owner = Object.assign(new EventEmitter(), {
webContents: new EventEmitter(), contentView: { addChildView: vi.fn(), removeChildView }, isDestroyed: () => false,
})
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => 'en_US')
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
return { manager, owner, removeChildView }
}
function view() {
return state.views.at(-1) as {
setVisible: ReturnType<typeof vi.fn>
webContents: EventEmitter & {
mainFrame: { url: string }
loadURL: ReturnType<typeof vi.fn>
setWindowOpenHandler: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
send: ReturnType<typeof vi.fn>
} }
}
const bounds = { x: 10, y: 20, width: 800, height: 600 }
it('bootstraps only the owned main frame and never puts the token in a URL', async () => {
const { manager, owner } = setup()
await manager.open(owner, 'usage', bounds)
const sender = view().webContents
const event = { sender, senderFrame: sender.mainFrame }
expect(manager.bootstrap(event)).toEqual({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', locale: 'en_US' })
expect(sender.loadURL).toHaveBeenCalledWith('https://platform.deepseek.com/usage')
expect(() => manager.bootstrap({ ...event, senderFrame: { url: sender.mainFrame.url } })).toThrow()
expect(() => manager.bootstrap({ ...event, sender: {} })).toThrow()
sender.mainFrame.url = 'https://other.example/usage'
expect(() => manager.bootstrap(event)).toThrow()
manager.close()
})
it('destroys old documents on sign-out or credential replacement', async () => {
const { manager, owner } = setup()
await manager.open(owner, 'usage', bounds)
const first = view().webContents
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'replacement' })
expect(first.close).toHaveBeenCalledOnce()
expect(() => manager.bootstrap({ sender: first, senderFrame: first.mainFrame })).toThrow()
await manager.open(owner, 'top-up', bounds)
expect(view().webContents.loadURL).toHaveBeenCalledWith('https://platform.deepseek.com/top_up')
const second = view().webContents
manager.setSession(null)
expect(second.close).toHaveBeenCalledOnce()
await expect(manager.open(owner, 'usage', bounds)).rejects.toThrow()
})
it('keeps the view when the Platform document replaces its own URL during the first load', async () => {
const { manager, owner } = setup()
state.loadFailure = Object.assign(new Error("ERR_ABORTED (-3) loading 'https://platform.deepseek.com/usage'"), { code: 'ERR_ABORTED' })
await manager.open(owner, 'usage', bounds)
expect(view().setVisible).toHaveBeenCalledWith(true)
expect(view().webContents.close).not.toHaveBeenCalled()
state.loadFailure = Object.assign(new Error("ERR_FAILED (-2) loading 'https://platform.deepseek.com/usage'"), { code: 'ERR_FAILED' })
await expect(manager.open(owner, 'usage', bounds)).rejects.toThrow('ERR_FAILED')
expect(view().webContents.close).toHaveBeenCalledOnce()
})
it('blocks cross-origin navigation and redirects', async () => {
const { manager, owner } = setup()
await manager.open(owner, 'usage', bounds)
for (const name of ['will-navigate', 'will-redirect']) {
const event = { preventDefault: vi.fn() }
view().webContents.emit(name, event, 'https://platform.deepseek.com.evil/usage')
expect(event.preventDefault).toHaveBeenCalledOnce()
event.preventDefault.mockClear()
view().webContents.emit(name, event, 'https://platform.deepseek.com/top_up')
expect(event.preventDefault).not.toHaveBeenCalled()
}
manager.close()
})
it.each([null, {}, { ...bounds, width: NaN }, { ...bounds, x: -1 }, { ...bounds, y: Infinity }])('rejects malformed IPC rectangles', (value) => {
expect(() => platformBounds(value)).toThrow()
})
it('opens HTTPS payment links in the system browser without an embedded child window', async () => {
const { manager, owner } = setup()
await manager.open(owner, 'top-up', bounds)
const handler = view().webContents.setWindowOpenHandler.mock.calls[0]![0] as (details: { url: string }) => { action: string }
expect(handler({ url: 'https://payment.example/order' })).toEqual({ action: 'deny' })
expect(state.openExternal).toHaveBeenCalledWith('https://payment.example/order')
vi.mocked(state.openExternal).mockClear()
for (const url of ['file:///tmp/test', 'javascript:alert(1)', 'https://user:[email protected]/order']) {
expect(handler({ url })).toEqual({ action: 'deny' })
}
expect(state.openExternal).not.toHaveBeenCalled()
manager.close()
})
it('reveals a loaded document only after loading finishes', async () => {
const { manager, owner } = setup()
const loading = manager.open(owner, 'usage', bounds)
const active = view()
expect(active.setVisible.mock.calls).toEqual([[false]])
await loading
expect(active.setVisible.mock.calls).toEqual([[false], [true]])
manager.close()
})
it('does not reveal a document closed before its load settles', async () => {
const { manager, owner } = setup()
const loading = manager.open(owner, 'usage', bounds)
const active = view()
manager.close()
await loading
expect(active.setVisible.mock.calls).toEqual([[false]])
})
it('injects deployment headers only at the Platform origin and excludes them from bootstrap', async () => {
const { manager, owner } = setup()
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret',
requestHeaders: { cookie: 'route=new; gate=private', 'x-private-gate': 'private', 'x-client-platform': 'desktop-mac' } })
await manager.open(owner, 'usage', bounds)
const browserSession = state.sessions.at(-1) as { webRequest: { onBeforeSendHeaders: ReturnType<typeof vi.fn> } }
const intercept = browserSession.webRequest.onBeforeSendHeaders.mock.calls[0]![0] as (
details: { id: number; url: string; requestHeaders: Record<string, string> },
callback: (value: { requestHeaders: Record<string, string> }) => void,
) => void
const callback = vi.fn()
for (const path of ['/usage', '/top_up', '/api/v0/users/get_user_summary']) {
intercept({ id: 1, url: `https://platform.deepseek.com${path}`, requestHeaders: { Cookie: 'route=old; browser=keep', Accept: 'application/json' } }, callback)
expect(callback).toHaveBeenLastCalledWith({ requestHeaders: {
cookie: 'route=new; browser=keep; gate=private', accept: 'application/json', 'x-private-gate': 'private', 'x-client-platform': 'desktop-mac',
} })
}
intercept({ id: 1, url: 'https://other.example/api', requestHeaders: {
cookie: 'route=new; gate=private', 'x-private-gate': 'private', 'x-client-platform': 'desktop-mac', accept: 'application/json',
} }, callback)
expect(callback).toHaveBeenLastCalledWith({ requestHeaders: { accept: 'application/json' } })
intercept({ id: 2, url: 'https://other.example/api', requestHeaders: { cookie: 'payment=session', constructor: 'keep' } }, callback)
expect(callback).toHaveBeenLastCalledWith({ requestHeaders: { cookie: 'payment=session', constructor: 'keep' } })
const sender = view().webContents
expect(manager.bootstrap({ sender, senderFrame: sender.mainFrame }))
.toEqual({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', locale: 'en_US' })
manager.close()
})
it.each(['usage', 'top-up'] as const)('selects the configured frontend deployment for %s', async (page) => {
const { manager, owner } = setup()
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'feat/test&other=value' })
await manager.open(owner, page, bounds)
const url = new URL(view().webContents.loadURL.mock.calls[0]![0] as string)
expect(url.origin).toBe('https://platform.deepseek.com')
expect(url.pathname).toBe(page === 'usage' ? '/usage' : '/top_up')
expect([...url.searchParams]).toEqual([['dist', 'feat/test&other=value']])
const previous = view().webContents
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret', embeddedPageDist: 'another' })
expect(previous.close).toHaveBeenCalledOnce()
manager.close()
})
it('removes the native view when its application document reloads, without renderer cleanup', async () => {
const { manager, owner, removeChildView } = setup()
await manager.open(owner, 'usage', bounds)
const child = view()
owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true)
expect(removeChildView).toHaveBeenCalledWith(child)
expect(child.webContents.close).toHaveBeenCalledOnce()
expect(owner.webContents.listenerCount('did-start-navigation')).toBe(0)
expect(owner.listenerCount('closed')).toBe(0)
})
it('retains the view on same-document and subframe navigation', async () => {
const { manager, owner } = setup()
await manager.open(owner, 'usage', bounds)
owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/#account', true, true)
owner.webContents.emit('did-start-navigation', {}, 'about:blank', false, false)
expect(view().webContents.close).not.toHaveBeenCalled()
manager.close()
})
it.each(['render-process-gone', 'destroyed', 'closed'])('removes the view on owner %s', async (event) => {
const { manager, owner } = setup()
await manager.open(owner, 'usage', bounds)
const child = view()
;(event === 'closed' ? owner : owner.webContents).emit(event)
expect(child.webContents.close).toHaveBeenCalledOnce()
expect(owner.webContents.listenerCount('destroyed')).toBe(0)
expect(owner.webContents.listenerCount('render-process-gone')).toBe(0)
})
it('does not reveal a pending view after the owner reloads or remove a replacement view', async () => {
const { manager, owner } = setup()
const loading = manager.open(owner, 'usage', bounds)
const previous = view()
owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true)
await manager.open(owner, 'top-up', bounds)
const current = view()
await loading
expect(previous.setVisible.mock.calls).toEqual([[false]])
expect(current.setVisible).toHaveBeenLastCalledWith(true)
expect(current.webContents.close).not.toHaveBeenCalled()
expect(owner.webContents.listenerCount('did-start-navigation')).toBe(1)
manager.close()
expect(owner.webContents.listenerCount('did-start-navigation')).toBe(0)
})
it('bootstraps the current language and updates an open view without reloading', async () => {
const { owner } = setup()
let locale: 'en_US' | 'zh_CN' = 'zh_CN'
const manager = new DesktopPlatformView('/bundled/preload.cjs', () => locale)
manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
manager.notifyLocaleChanged()
await manager.open(owner, 'usage', bounds)
const sender = view().webContents
expect(manager.bootstrap({ sender, senderFrame: sender.mainFrame }).locale).toBe('zh_CN')
locale = 'en_US'
manager.notifyLocaleChanged()
expect(sender.send).toHaveBeenCalledWith('dsh-platform:locale-changed', 'en_US')
expect(sender.loadURL).toHaveBeenCalledOnce()
manager.close()
sender.send.mockClear()
manager.notifyLocaleChanged()
expect(sender.send).not.toHaveBeenCalled()
await manager.open(owner, 'top-up', bounds)
const reopened = view().webContents
expect(manager.bootstrap({ sender: reopened, senderFrame: reopened.mainFrame }).locale).toBe('en_US')
manager.close()
})
+21
View File
@@ -1,3 +1,4 @@
import { JSDOM } from 'jsdom'
import { afterEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
import { syncWindowsAppearance } from '../src/preload-windows.ts'
@@ -99,6 +100,26 @@ it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://examp
},
)
it('moves welcome-entry focus to the document without changing keyboard tab order', async () => {
const dom = new JSDOM('<body><button>Sidebar</button><input></body>')
try {
vi.stubGlobal('document', dom.window.document)
vi.stubGlobal('location', new URL('dsh-app://app/'))
await import('../src/preload-app.ts')
const enter = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.enterWorkspace)![1] as () => void
const button = dom.window.document.querySelector('button')!
button.focus()
enter()
expect(dom.window.document.activeElement).toBe(dom.window.document.body)
expect(dom.window.document.body.hasAttribute('tabindex')).toBe(false)
expect(button.tabIndex).toBe(0)
dom.window.document.body.setAttribute('tabindex', '-1')
button.focus()
enter()
expect(dom.window.document.body.getAttribute('tabindex')).toBe('-1')
} finally { dom.window.close() }
})
it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) {
+38
View File
@@ -0,0 +1,38 @@
/** Built preloads must execute with only Electron's sandbox-supported require. */
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { describe, expect, it, vi } from 'vitest'
const preload = (name: string): string => fileURLToPath(new URL(`../lib/${name}.cjs`, import.meta.url))
describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => {
it.each(['preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => {
const exposed = new Map<string, Record<string, unknown>>()
const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' }))
const send = vi.fn()
const electron = {
contextBridge: { exposeInMainWorld: (key: string, value: Record<string, unknown>) => { exposed.set(key, value) } },
ipcRenderer: { invoke, send, on: vi.fn(), off: vi.fn() },
}
runInNewContext(readFileSync(preload(name), 'utf8'), {
require: (id: string) => {
if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`)
return electron
},
process: { argv: ['electron', '--dsh-welcome-locale=en'] },
location: new URL('dsh-app://app/'),
document: { documentElement: { dataset: {} } },
exports: {},
})
if (name === 'preload-app') {
const bridge = exposed.get('__DSH_LOCALE__') as { read(): unknown; onChange(locale: string): void }
bridge.read()
expect(invoke).toHaveBeenCalledWith('dsh-desktop:locale-bootstrap')
bridge.onChange('zh')
expect(send).toHaveBeenCalledWith('dsh-desktop:locale-changed', 'zh')
} else {
expect(exposed.has('dshWelcome')).toBe(true)
}
})
})
+20 -3
View File
@@ -1,7 +1,7 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { BrowserWindow } from 'electron'
import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts'
import { resolveDesktopLocale } from '../src/locale.ts'
import { resolveDesktopLocale, type DesktopLocale } from '../src/locale.ts'
const fixture = await vi.hoisted(async () => {
const { EventEmitter } = await import('node:events')
@@ -43,9 +43,9 @@ afterEach(() => {
fixture.handlers.clear()
})
function setup() {
function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLocale('zh-CN')) {
const parent = new fixture.FakeWindow({})
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', resolveDesktopLocale('zh-CN'))
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale)
const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, {
message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }),
})
@@ -191,3 +191,20 @@ it.runIf(process.platform === 'darwin')('blocks parent keyboard input and redire
await pending
expect(f.parent.webContents.listenerCount('before-input-event')).toBe(0)
})
it('reads the current locale for each presentation', async () => {
let locale = resolveDesktopLocale('en')
const f = setup(() => locale)
for (const language of ['zh-CN', 'en']) {
locale = resolveDesktopLocale(language)
const pending = f.show()
const window = fixture.windows.at(-1)!
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
expect(fixture.handlers.get(UPDATE_DIALOG_IPC.status)!(event)).toMatchObject({
locale: locale.id, closeLabel: locale.messages.updateClose,
technicalDetailsLabel: locale.messages.updateTechnicalDetails,
})
dialogs!.cancel()
await pending
}
})
+177
View File
@@ -0,0 +1,177 @@
/** Built Desktop Host acceptance; run after the repository build, without provider credentials. */
import { createHash } from 'node:crypto'
import { createServer } from 'node:http'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DesktopHostProcess } from '../src/host-process.ts'
import { DesktopProjectManager } from '../src/project-manager.ts'
import { resolveDesktopPaths } from '../src/paths.ts'
import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts'
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
import { prepareDevelopmentProject } from '../scripts/development-project.ts'
const repository = fileURLToPath(new URL('../../../', import.meta.url))
const builtHost = join(repository, 'apps/desktop-host/lib/index.js')
afterEach(() => { vi.unstubAllEnvs() })
function version(path: string): string {
return (JSON.parse(readFileSync(path, 'utf8')) as { version: string }).version
}
async function mockPlatform() {
let origin = ''
let init: Record<string, string> = {}
let failExchange = false
const server = createServer((req, res) => {
if (req.headers['x-client-platform'] !== (process.platform === 'win32' ? 'desktop-win' : 'desktop-mac')) { res.writeHead(400).end(); return }
if (req.headers.cookie !== 'test_gate=synthetic') { res.writeHead(403).end(); return }
if (req.url === '/auth-api/v0/users/logout' && req.method === 'POST') {
if (req.headers['x-dsh-auth-token'] !== 'dsh_mock_composition_test') { res.writeHead(401).end(); return }
res.writeHead(503).end()
return
}
req.setEncoding('utf8')
let body = ''
req.on('data', (chunk: string) => { body += chunk })
req.on('end', () => {
const input = JSON.parse(body) as Record<string, string>
let value: unknown
if (req.url?.endsWith('auth_init')) {
init = input
if (input.locale !== 'en_US') { res.writeHead(400).end(); return }
value = { authorize_url: `${origin}/dsh/authorize?authorize_id=test`, expires_in: 600, authorize_id: 'test' }
} else if (req.url?.endsWith('auth_cancel')) {
const challenge = createHash('sha256').update(input.code_verifier ?? '').digest('base64url')
if (input.authorize_id !== 'test' || challenge !== init.code_challenge) { res.writeHead(400).end(); return }
value = null
} else {
if (failExchange) { res.writeHead(503).end(); return }
const challenge = createHash('sha256').update(input.code_verifier ?? '').digest('base64url')
if (challenge !== init.code_challenge || input.redirect_uri !== init.redirect_uri) {
res.writeHead(400).end()
return
}
value = { user: null, token: 'dsh_mock_composition_test', authorized_url: `${origin}/dsh/authorized?result=test&locale=zh_CN` }
}
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ code: 0, data: { biz_code: 0, biz_data: value } }))
})
})
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('missing mock listener')
origin = `http://127.0.0.1:${String(address.port)}`
return {
origin,
failExchange: (value: boolean) => { failExchange = value },
callback: () => `${init.redirect_uri}?code=test&state=${init.state}`,
close: () => new Promise<void>((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }),
}
}
describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
it('persists explicit API keys and browser account login independently across Host restarts', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-welcome-'))
let host: DesktopHostProcess | undefined
const platform = await mockPlatform()
try {
for (const name of Object.keys(process.env)) {
if (/KEY|TOKEN|SECRET|PASSWORD/u.test(name)) vi.stubEnv(name, undefined)
}
const home = join(root, 'home')
mkdirSync(home)
vi.stubEnv('DSH_HOME', home)
vi.stubEnv('DSH_TELEMETRY_MODE', 'DISABLED')
const project = prepareDevelopmentProject({
projectDir: join(root, 'project'),
cliDir: join(repository, 'apps/cli'),
hostDir: join(repository, 'apps/desktop-host'),
dependencyDir: join(repository, 'node_modules/.pnpm/node_modules'),
release: {
schemaVersion: 1,
version: version(join(repository, 'apps/desktop/package.json')),
pnpmVersion: version(join(repository, 'apps/desktop/node_modules/pnpm/package.json')),
nodeVersion: process.versions.node,
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
},
})
cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true })
const paths = resolveDesktopPaths(home)
const manager = new DesktopProjectManager(paths, {
dsh: project,
})
await manager.applyRelease()
writeFileSync(join(paths.profile, 'cordis.patch.yml'), `- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n- id: deepseek-account\n config:\n${process.platform === 'linux' ? ' desktopPlatform: darwin\n' : ''} platformOrigin: ${platform.origin}\n allowLoopbackHttp: true\n requestHeaders:\n Cookie: test_gate=synthetic\n`)
let backend: DesktopWelcomeBackend
let hostOrigin = ''
const restart = async (): Promise<void> => {
await host?.stop()
host = new DesktopHostProcess(process.execPath, project, paths.profile)
const { url } = await host.start()
hostOrigin = new URL(url).origin
let cookie = ''
const send: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers)
if (cookie !== '') headers.set('cookie', cookie)
const response = await fetch(input, { ...init, headers, redirect: 'manual' })
if (response.status !== 303) return response
cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
await response.body?.cancel()
return fetch(new URL(response.headers.get('location')!, url), { headers: { cookie } })
}
backend = await connectDesktopWelcome(url, send, () => Promise.resolve(cookie))
}
const status = async () => backend.read()
const fingerprint = (): string => createHash('sha256')
.update(readFileSync(join(home, '.credentials.yaml'))).digest('hex')
await restart()
expect(await status()).toMatchObject({ hasApiKey: false, localePreference: null })
const before = fingerprint()
await host!.stop()
writeFileSync(join(paths.profile, 'cordis.patch.yml'),
readFileSync(join(paths.profile, 'cordis.patch.yml'), 'utf8') + '- id: locale\n config:\n preference: zh\n')
await restart()
expect(await status()).toMatchObject({ hasApiKey: false, localePreference: 'zh' })
expect(fingerprint()).toBe(before)
expect(await backend!.save('sk-local-onboarding-test')).toEqual({ ok: true })
expect(await status()).toMatchObject({ hasApiKey: true, localePreference: 'zh' })
await restart()
expect(await status()).toMatchObject({ hasApiKey: true })
await backend!.account.start('en')
await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser')
expect(new URL(platform.callback()).origin).toBe(hostOrigin)
platform.failExchange(true)
const failed = await fetch(platform.callback(), { redirect: 'manual' })
expect(failed.status).toBe(204)
expect(await backend!.account.state()).toMatchObject({ status: 'signed-out', attempt: { phase: 'failed' } })
expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: false })
platform.failExchange(false)
await backend!.account.start('en')
await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser')
const response = await fetch(platform.callback(), { redirect: 'manual' })
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe(`${platform.origin}/dsh/authorized?result=test&locale=zh_CN&login_source=desktop`)
expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: true })
await restart()
expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: true })
// A failed remote logout must not block local sign-out through the real Host composition.
await backend!.account.signOut()
expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: false })
await backend!.account.start('en')
await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser')
const waiting = await backend!.account.state()
const late = platform.callback()
await backend!.account.cancel(waiting.attempt!.id)
expect((await fetch(late, { redirect: 'manual' })).status).not.toBe(302)
expect(await status()).toMatchObject({ loggedIn: false })
} finally {
await host?.stop()
await platform.close()
rmSync(root, { recursive: true, force: true })
}
})
})
+97
View File
@@ -0,0 +1,97 @@
/** Native onboarding reuses authenticated Web RPC and never reads credential values. */
import { describe, expect, it, vi } from 'vitest'
import { connectDesktopWelcome } from '../src/welcome-backend.ts'
function transport(preference?: string) {
const keys = new Map<string, string>()
const namespaces = [
{ ns: 'llm-deepseek', value: { apiKeyEnv: 'CUSTOM_DEEPSEEK_KEY' } },
{ ns: 'llm-pi-ai', value: { profiles: { example: { apiKeyEnv: 'EXAMPLE_API_KEY' } } } },
{ ns: 'locale', value: preference === undefined ? {} : { preference } },
]
const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async (_input, init) => {
if (init?.method !== 'POST') return new Response('index')
const { rpcId, method, payload } = JSON.parse(init.body as string) as {
rpcId: string
method: string
payload: { args: { ref: string; value: string; refs: string[] } }
}
let value: unknown
if (method === 'account/getState') value = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }
else if (method === 'settings/describe') value = { namespaces }
else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }]
else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value)
else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }]))
return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } })
})
return { send, keys, namespaces }
}
const url = 'http://127.0.0.1:19387/?token=fixture'
describe('desktop welcome Web operations', () => {
it('authenticates through Web and stores only through the configured credential reference', async () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
expect(await backend.save('sk-example')).toEqual({ ok: true })
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null })
for (const [input, init] of host.send.mock.calls.slice(1)) {
expect(input).toMatch(/^http:\/\/127\.0\.0\.1:19387\/api\//u)
expect(init).toMatchObject({ credentials: 'include', redirect: 'error' })
}
})
it('reads the explicit language preference and recognizes another provider key', async () => {
const host = transport('zh')
host.keys.set('EXAMPLE_API_KEY', 'sk-other')
const backend = await connectDesktopWelcome(url, host.send)
expect(await backend.read()).toMatchObject({ localePreference: 'zh', hasApiKey: true })
host.keys.clear()
expect(await backend.read()).toMatchObject({ hasApiKey: false })
expect(host.send.mock.calls.every(([, init]) => !(init?.body as string | undefined)?.includes('credentials/set'))).toBe(true)
})
it('reads language without querying account or model providers', async () => {
const host = transport('zh')
const backend = await connectDesktopWelcome(url, host.send)
host.send.mockClear()
expect(await backend.readLocalePreference()).toBe('zh')
expect(host.send).toHaveBeenCalledOnce()
expect(host.send.mock.calls[0]![0]).toContain('/api/settings/describe')
})
it('allows profiles without the official provider and retains custom key detection', async () => {
const host = transport()
host.namespaces.splice(0, 1)
const backend = await connectDesktopWelcome(url, host.send)
expect(await backend.read()).toMatchObject({ hasApiKey: false, writable: false })
host.keys.set('EXAMPLE_API_KEY', 'custom-key')
expect(await backend.read()).toMatchObject({ hasApiKey: true, writable: false })
expect(await backend.save('official-key')).toEqual({ ok: false })
expect(host.keys.has('undefined')).toBe(false)
})
it.each(['', 'bad key', 'key\n'])('rejects malformed keys without contacting a provider: %s', async (value) => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
host.send.mockClear()
expect(await backend.save(value)).toEqual({ ok: false })
expect(host.send).not.toHaveBeenCalled()
})
it('keeps provider diagnostics out of failures and rejects unmatched RPC envelopes', async () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
host.send.mockRejectedValueOnce(new Error('private credential sk-must-not-leak'))
expect(await backend.save('sk-example')).toEqual({ ok: false })
host.send.mockResolvedValueOnce(Response.json({ type: 'server-response', rpcId: 'other', result: { ok: true } }))
await expect(backend.read()).rejects.toThrow('Web RPC failed')
})
it('refuses an unauthenticated Web launch', async () => {
const send = vi.fn<Parameters<typeof connectDesktopWelcome>[1]>(async () => new Response(null, { status: 401 }))
await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed')
})
})
@@ -0,0 +1,262 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { Welcome } from '../src/client/WelcomePage.tsx'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveDesktopLocale } from '../src/locale.ts'
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import type { WelcomeSaveResult } from '../src/welcome-api.ts'
const html = readFileSync(join(import.meta.dirname, '../renderer/welcome.html'), 'utf8')
afterEach(cleanup)
function mount(language = 'zh-CN') {
cleanup()
const stopAccount = vi.fn()
const api = {
onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount),
startSignIn: vi.fn(async (): Promise<AccountView> => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
cancelSignIn: vi.fn(async (): Promise<AccountView> => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
copySignInLink: vi.fn(async () => undefined),
...resolveDesktopLocale(language),
saveApiKey: vi.fn<(value: string) => Promise<WelcomeSaveResult>>().mockResolvedValue({ ok: true }),
skip: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
}
const mounted = render(<Welcome api={api} />)
const input = document.querySelector('input')!
const button = (id: string) => document.querySelector<HTMLButtonElement>(id)!
const enterKey = (value: string) => {
fireEvent.change(input, { target: { value } })
}
const submit = () => fireEvent.submit(document.querySelector('form')!)
const copy = () => {
const heading = document.querySelector('main')!.getAttribute('aria-labelledby')!
return [
document.title, document.querySelector('img')!.alt, document.getElementById(heading)!.textContent,
...heading === 'welcome-heading' ? [document.querySelector('#welcome-description')!.textContent] : [],
...heading === 'key-title' ? [document.querySelector('#key-description')!.textContent, `${input.placeholder} [password]`] : [],
...[...document.querySelectorAll('button')].filter(item => item.closest('[hidden]') === null)
.map(item => `${item.textContent || item.getAttribute('aria-label')}${item.disabled ? ' [disabled]' : ''}`),
'',
].join('\n')
}
return { document, api, input, button, enterKey, submit, copy, unmount: mounted.unmount, stopAccount }
}
describe('desktop welcome presentation', () => {
it.each(['zh-CN', 'en'])('renders the %s entry and API-key step', async (language) => {
const view = mount(language)
expect(view.document.documentElement.lang).toBe(language)
expect(view.document.querySelector('img')!.getAttribute('src')).toBe('assets/welcome-brand.svg')
await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`)
fireEvent.click(view.button('#api-key'))
expect(view.document.activeElement).toBe(view.input)
expect(view.input.type).toBe('password')
await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}-api-key.expected.txt`)
})
it('sends one trimmed key, prevents competing actions, and clears it after saving', async () => {
const view = mount()
const saved = Promise.withResolvers<WelcomeSaveResult>()
view.api.saveApiKey.mockReturnValue(saved.promise)
fireEvent.click(view.button('#api-key'))
view.enterKey(' sk-desktop-example ')
view.submit()
view.submit()
fireEvent.click(view.button('#skip-key'))
fireEvent.click(view.button('#back-to-login'))
expect(view.api.saveApiKey).toHaveBeenCalledExactlyOnceWith('sk-desktop-example')
expect(view.api.skip).not.toHaveBeenCalled()
expect(view.button('#save-key').disabled).toBe(true)
expect(view.button('#save-key').textContent).toBe(view.api.messages.welcomeKeySave)
expect(view.button('#back-to-login').disabled).toBe(true)
expect(view.document.querySelector<HTMLElement>('#key-form')!.hidden).toBe(false)
saved.resolve({ ok: true })
await vi.waitFor(() => { expect(view.input.value).toBe('') })
expect(view.document.body.textContent).not.toContain('sk-desktop-example')
})
it.each(['', 'bad key', '密钥', 'DEEPSEEK_API_KEY=sk-example', '"sk-example"', '`sk-example`'])(
'rejects invalid input before sending it: %s', (value) => {
const view = mount()
fireEvent.click(view.button('#api-key'))
view.enterKey(value)
view.submit()
expect(view.api.saveApiKey).not.toHaveBeenCalled()
expect(view.document.querySelector<HTMLElement>('#key-error')!.hidden).toBe(false)
expect(view.input.getAttribute('aria-invalid')).toBe('true')
},
)
it('retains an unsaved draft and allows retry after a refused save', async () => {
const view = mount()
view.api.saveApiKey.mockResolvedValue({ ok: false })
fireEvent.click(view.button('#api-key'))
view.enterKey('sk-retry')
view.submit()
await vi.waitFor(() => { expect(view.button('#save-key').disabled).toBe(false) })
expect(view.input.value).toBe('sk-retry')
expect(view.document.querySelector('#key-error')!.textContent).toBe(view.api.messages.welcomeKeyFailed)
view.api.saveApiKey.mockResolvedValue({ ok: true })
view.submit()
await vi.waitFor(() => { expect(view.input.value).toBe('') })
})
it('skips without saving and starts a fresh renderer at the entry again', async () => {
const view = mount()
const skipped = Promise.withResolvers<undefined>()
view.api.skip.mockReturnValue(skipped.promise)
fireEvent.click(view.button('#api-key'))
view.enterKey('sk-not-saved')
fireEvent.click(view.button('#skip-key'))
try {
expect(view.button('#save-key').textContent).toBe(view.api.messages.welcomeKeySave)
expect(view.button('#skip-key').textContent).toBe(view.api.messages.welcomeKeyLater)
expect(view.button('#save-key').disabled).toBe(true)
expect(view.button('#skip-key').disabled).toBe(true)
expect(view.button('#back-to-login').disabled).toBe(true)
fireEvent.click(view.button('#skip-key'))
view.submit()
expect(view.api.skip).toHaveBeenCalledOnce()
expect(view.api.saveApiKey).not.toHaveBeenCalled()
} finally {
skipped.resolve(undefined)
}
await vi.waitFor(() => { expect(view.input.value).toBe('') })
expect(view.api.skip).toHaveBeenCalledOnce()
expect(view.api.saveApiKey).not.toHaveBeenCalled()
expect(mount().document.querySelector<HTMLElement>('#key-form')!.hidden).toBe(true)
})
it('returns to the entry without saving and clears the draft and validation error', () => {
const view = mount()
fireEvent.click(view.button('#api-key'))
view.enterKey('invalid key')
view.submit()
fireEvent.click(view.button('#back-to-login'))
expect(view.document.querySelector<HTMLElement>('#key-form')!.hidden).toBe(true)
expect(view.document.activeElement).toBe(view.button('#api-key'))
expect(view.input.value).toBe('')
expect(view.api.saveApiKey).not.toHaveBeenCalled()
expect(view.api.skip).not.toHaveBeenCalled()
fireEvent.click(view.button('#api-key'))
expect(view.input.value).toBe('')
expect(view.document.querySelector<HTMLElement>('#key-error')!.hidden).toBe(true)
expect(view.button('#save-key').disabled).toBe(true)
})
it('keeps visible copy in the shell dictionaries and denies network access', () => {
expect([...html.matchAll(/>([^<]*\p{L}[^<]*)</gu)]).toEqual([])
expect(html).toContain("default-src 'none'")
expect(html).toContain("form-action 'none'")
})
})
it.each(['zh-CN', 'en'])('renders %s timeout with manual retry and API-key alternative', async (language) => {
const view = mount(language)
const receive = (state: AccountView) => { act(() => { view.api.onAccountState.mock.calls[0]![0](state) }) }
receive({ status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, attempt: { id: 'expired' as NonNullable<AccountView['attempt']>['id'], phase: 'expired' } })
expect(view.button('#auth-retry').hidden).toBe(false)
expect(view.button('#auth-api-key').hidden).toBe(false)
expect(view.api.startSignIn).not.toHaveBeenCalled()
await expect(view.copy() + view.document.querySelector('#auth-description')!.textContent + '\n').toMatchFileSnapshot(`./expected/welcome/${language}-timeout.expected.txt`)
fireEvent.click(view.button('#auth-api-key'))
expect(view.document.querySelector('#auth-page')!.hasAttribute('hidden')).toBe(true)
expect(view.document.querySelector('#key-form')!.hasAttribute('hidden')).toBe(false)
})
it.each(['zh-CN', 'en'])('renders %s browser fallback and copies only the active login link', async (language) => {
const view = mount(language)
const receive = (state: AccountView) => { act(() => { view.api.onAccountState.mock.calls[0]![0](state) }) }
const waiting: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' },
attempt: { id: 'waiting' as NonNullable<AccountView['attempt']>['id'], phase: 'waiting-browser', authorizeUrl: 'https://example.test/login' } }
receive(waiting)
await expect(view.copy() + view.document.querySelector('#auth-description')!.textContent + '\n')
.toMatchFileSnapshot(`./expected/welcome/${language}-waiting.expected.txt`)
fireEvent.click(view.button('#auth-copy'))
await vi.waitFor(() => { expect(view.button('#auth-copy').textContent).toBe(view.api.messages.welcomeAuthCopied) })
expect(view.api.copySignInLink).toHaveBeenCalledWith('waiting')
await vi.waitFor(() => { expect(view.button('#auth-copy').disabled).toBe(false) }, { timeout: 3000 })
view.api.copySignInLink.mockRejectedValueOnce(new Error('clipboard unavailable'))
fireEvent.click(view.button('#auth-copy'))
await vi.waitFor(() => { expect(view.button('#auth-copy').textContent).toBe(view.api.messages.welcomeAuthCopyFailed) })
expect(view.button('#auth-cancel').disabled).toBe(false)
receive({ ...waiting, attempt: { ...waiting.attempt!, phase: 'exchanging' } })
expect(view.button('#auth-copy').hidden).toBe(true)
expect(view.button('#auth-loading').hidden).toBe(false)
receive({ ...waiting, attempt: { ...waiting.attempt!, phase: 'cancelled' } })
expect(view.document.querySelector('main')!.classList.contains('waiting-page')).toBe(false)
})
it('keeps a newer account notification when the start response arrives late', async () => {
const view = mount()
const started = Promise.withResolvers<AccountView>()
view.api.startSignIn.mockReturnValueOnce(started.promise)
fireEvent.click(view.button('#sign-in'))
expect(view.button('#auth-cancel').disabled).toBe(true)
const state: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' },
attempt: { id: 'attempt' as NonNullable<AccountView['attempt']>['id'], phase: 'expired' } }
act(() => { view.api.onAccountState.mock.calls[0]![0](state) })
await act(async () => { started.resolve({ ...state, attempt: { ...state.attempt!, phase: 'waiting-browser' } }); await started.promise })
expect(view.document.querySelector('#auth-status')!.textContent).toBe(view.api.messages.welcomeAuthExpired)
expect(view.button('#auth-retry').hidden).toBe(false)
})
it('does not restore a copied-link status after leaving the waiting phase', async () => {
const view = mount()
const copied = Promise.withResolvers<undefined>()
view.api.copySignInLink.mockReturnValueOnce(copied.promise)
const waiting: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' },
attempt: { id: 'attempt' as NonNullable<AccountView['attempt']>['id'], phase: 'waiting-browser' } }
act(() => { view.api.onAccountState.mock.calls[0]![0](waiting) })
fireEvent.click(view.button('#auth-copy'))
expect(view.button('#auth-copy').disabled).toBe(true)
act(() => { view.api.onAccountState.mock.calls[0]![0]({ ...waiting, attempt: { ...waiting.attempt!, phase: 'exchanging' } }) })
await act(async () => { copied.resolve(undefined); await copied.promise })
expect(view.button('#auth-copy').hidden).toBe(true)
expect(view.button('#auth-copy').textContent).toBe(view.api.messages.welcomeAuthCopyLink)
})
it('keeps the key draft while account notifications arrive and releases the subscription on unmount', () => {
const view = mount()
fireEvent.click(view.button('#api-key'))
view.enterKey('sk-draft')
act(() => { view.api.onAccountState.mock.calls[0]![0]({ status: 'signed-out', links: { usageUrl: '', topUpUrl: '' },
attempt: { id: 'expired' as NonNullable<AccountView['attempt']>['id'], phase: 'expired' } }) })
expect(view.document.querySelector<HTMLElement>('#key-form')!.hidden).toBe(false)
expect(view.input.value).toBe('sk-draft')
view.unmount()
expect(view.stopAccount).toHaveBeenCalledOnce()
})
it.each(['copied', 'failed'] as const)('restores the copy action after %s feedback and cleans up on unmount', async (result) => {
vi.useFakeTimers()
try {
const view = mount('en')
if (result === 'failed') view.api.copySignInLink.mockRejectedValue(new Error('clipboard unavailable'))
const waiting: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' },
attempt: { id: 'waiting' as NonNullable<AccountView['attempt']>['id'], phase: 'waiting-browser' } }
act(() => { view.api.onAccountState.mock.calls[0]![0](waiting) })
const feedback = result === 'copied' ? view.api.messages.welcomeAuthCopied : view.api.messages.welcomeAuthCopyFailed
const copy = async () => { await act(async () => { fireEvent.click(view.button('#auth-copy')) }) }
await copy()
expect(view.button('#auth-copy').textContent).toBe(feedback)
expect(view.button('#auth-copy').disabled).toBe(result === 'copied')
await act(async () => { await vi.advanceTimersByTimeAsync(1500) })
await copy()
expect(view.api.copySignInLink).toHaveBeenCalledTimes(result === 'copied' ? 1 : 2)
await act(async () => { await vi.advanceTimersByTimeAsync(result === 'copied' ? 499 : 1999) })
expect(view.button('#auth-copy').textContent).toBe(feedback)
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
expect(view.button('#auth-copy').textContent).toBe(view.api.messages.welcomeAuthCopyLink)
expect(view.button('#auth-copy').disabled).toBe(false)
await copy()
expect(view.api.copySignInLink).toHaveBeenCalledTimes(result === 'copied' ? 2 : 3)
view.unmount()
expect(vi.getTimerCount()).toBe(0)
} finally {
cleanup()
vi.useRealTimers()
}
})
+213
View File
@@ -0,0 +1,213 @@
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
/** Welcome startup uses the Host before transitioning to the workspace. */
import { afterEach, expect, it, vi } from 'vitest'
import type { BrowserWindowConstructorOptions } from 'electron'
import type { DesktopLocale } from '../src/locale.ts'
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import type { WelcomeOperations } from '../src/welcome-api.ts'
import { DESKTOP_IPC } from '../src/ipc.ts'
const state = vi.hoisted(() => ({
appListeners: new Map<string, (...args: unknown[]) => void>(),
dialogLocale: undefined as (() => DesktopLocale) | undefined,
beforeRead: vi.fn(async () => {}),
beforeWelcome: vi.fn(async () => {}),
copy: vi.fn(),
accountState: vi.fn<() => Promise<AccountView>>().mockResolvedValue({
status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' },
}),
quit: vi.fn(),
startHost: vi.fn().mockResolvedValue({ url: 'http://127.0.0.1:3080/?token=test', injections: [] }),
stopHost: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
loadWorkspace: vi.fn<(url: string) => Promise<void>>().mockResolvedValue(undefined),
showWorkspace: vi.fn(),
closeWelcome: vi.fn(),
welcomeLocale: undefined as DesktopLocale | undefined,
preference: 'zh',
handlers: new Map<string, (...args: unknown[]) => unknown>(),
listeners: new Map<string, (...args: unknown[]) => void>(),
contents: undefined as { mainFrame: { url: string } } | undefined,
windowOptions: undefined as BrowserWindowConstructorOptions | undefined,
menu: vi.fn(),
operations: undefined as WelcomeOperations | undefined,
}))
vi.mock('electron', () => ({
clipboard: { writeText: state.copy },
app: {
isPackaged: false,
name: 'Harness',
requestSingleInstanceLock: () => true,
setAsDefaultProtocolClient: vi.fn(),
whenReady: () => Promise.resolve(),
getLocale: () => 'en',
getVersion: () => '1.0.0',
setAboutPanelOptions: vi.fn(),
getAppPath: () => '/development-app',
getPreferredSystemLanguages: () => ['en-US'],
on: (name: string, callback: (...args: unknown[]) => void) => { state.appListeners.set(name, callback) },
quit: state.quit,
exit: vi.fn(),
},
powerMonitor: { on: vi.fn(), off: vi.fn() },
BrowserWindow: class {
constructor(options: BrowserWindowConstructorOptions) { state.windowOptions = options }
private ready: (() => void) | undefined
webContents = { mainFrame: { url: 'dsh-app://app/' }, setWindowOpenHandler: vi.fn(), on: vi.fn(), once: vi.fn(), send: vi.fn(), openDevTools: vi.fn() }
static getAllWindows() { return [] }
once(name: string, callback: () => void) { if (name === 'ready-to-show') this.ready = callback; return this }
on() { return this }
isDestroyed() { return false }
isMinimized() { return false }
restore = vi.fn()
focus = vi.fn()
hide = vi.fn()
show = state.showWorkspace
async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
},
net: { fetch: vi.fn() },
nativeTheme: { themeSource: 'system' },
session: { defaultSession: {
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() },
} },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
ipcMain: {
handle: (name: string, callback: (...args: unknown[]) => unknown) => { state.handlers.set(name, callback) },
on: (name: string, callback: (...args: unknown[]) => void) => { state.listeners.set(name, callback) },
},
dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
Menu: { buildFromTemplate: state.menu, setApplicationMenu: vi.fn() },
}))
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) }))
vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class {
applyRelease = vi.fn(async () => {})
canRecoverProfile = vi.fn(() => true)
} }))
vi.mock('../src/host-process.ts', () => ({
DesktopHostProcess: class {
start = state.startHost
stop = state.stopHost
fetch() {
return Promise.resolve(Response.json({ loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference }))
}
},
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
readLocalePreference: async () => state.preference,
read: async () => {
await state.beforeRead()
return { loggedIn: false, hasApiKey: false, writable: true, localePreference: state.preference }
},
save: async () => ({ ok: true }),
account: { watch: () => () => {}, state: state.accountState },
}),
}))
vi.mock('node:fs/promises', async importOriginal => ({
...await importOriginal<typeof import('node:fs/promises')>(),
readFile: vi.fn(async () => '{}'),
}))
vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class {
constructor(_preload: string, locale: () => DesktopLocale) { state.dialogLocale = locale }
dispose() {}
} }))
vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class {
state = { phase: 'idle' }
check = vi.fn(async () => this.state)
dispose = vi.fn()
} }))
vi.mock('../src/welcome-window.ts', () => ({
openWelcomeWindow: async (locale: DesktopLocale, operations: WelcomeOperations) => {
state.welcomeLocale = locale
state.operations = operations
await state.beforeWelcome()
return { once: vi.fn(), close: state.closeWelcome }
},
}))
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('starts the Host for welcome onboarding and opens the workspace on skip without quitting', async () => {
vi.useFakeTimers()
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh')
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0')
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
const reading = Promise.withResolvers<undefined>()
const loading = Promise.withResolvers<undefined>()
state.beforeRead.mockReturnValueOnce(reading.promise)
state.beforeWelcome.mockReturnValueOnce(loading.promise)
const activate = () => {
state.appListeners.get('second-instance')!()
state.appListeners.get('open-url')!({ preventDefault: vi.fn() }, 'dsh://open')
}
await import('../src/main.ts')
await vi.waitFor(() => { expect(state.beforeRead).toHaveBeenCalledOnce() })
try {
activate()
expect(state.showWorkspace).not.toHaveBeenCalled()
reading.resolve(undefined)
await vi.waitFor(() => { expect(state.beforeWelcome).toHaveBeenCalledOnce() })
activate()
expect(state.showWorkspace).not.toHaveBeenCalled()
} finally {
reading.resolve(undefined)
loading.resolve(undefined)
}
await vi.waitFor(() => { expect(state.operations).toBeDefined() })
expect(state.startHost).toHaveBeenCalledOnce()
expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('dsh-app://app/')
expect(state.showWorkspace).not.toHaveBeenCalled()
state.loadWorkspace.mockClear()
expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' })
expect(state.dialogLocale!().id).toBe('zh-CN')
const attemptId = 'login' as NonNullable<AccountView['attempt']>['id']
const account: AccountView = { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' },
attempt: { id: attemptId, phase: 'waiting-browser', authorizeUrl: 'https://example.test/login' } }
state.accountState.mockResolvedValue(account)
await state.operations!.copySignInLink(attemptId)
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login')
await expect(state.operations!.copySignInLink('stale' as typeof attemptId)).rejects.toThrow('login link is unavailable')
state.accountState.mockResolvedValue({ ...account, attempt: { id: attemptId, phase: 'expired' } })
await expect(state.operations!.copySignInLink(attemptId)).rejects.toThrow('login link is unavailable')
expect(state.copy).toHaveBeenCalledOnce()
await state.operations!.skip()
expect(state.loadWorkspace).not.toHaveBeenCalled()
expect(state.showWorkspace).toHaveBeenCalledOnce()
expect(state.windowOptions).toMatchObject({
...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 }, vibrancy: 'sidebar' } : {}),
webPreferences: { contextIsolation: true, sandbox: true },
})
expect(state.closeWelcome).toHaveBeenCalledOnce()
activate()
expect(state.showWorkspace).toHaveBeenCalledTimes(3)
expect(state.quit).not.toHaveBeenCalled()
expect(state.stopHost).not.toHaveBeenCalled()
const contents = state.contents as { mainFrame: { url: string }; send: ReturnType<typeof vi.fn> }
expect(contents.send).toHaveBeenCalledWith(DESKTOP_IPC.enterWorkspace)
const event = { sender: contents, senderFrame: contents.mainFrame }
const bootstrap = state.handlers.get(DESKTOP_IPC.localeBootstrap)!
expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'zh' })
state.preference = 'en'
expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'en' })
const changed = state.listeners.get(DESKTOP_IPC.localeChanged)!
const initialMenus = state.menu.mock.calls.length
changed({ ...event, senderFrame: {} }, 'en')
changed(event, 42)
expect(state.menu).toHaveBeenCalledTimes(initialMenus)
changed(event, 'en')
expect(state.dialogLocale!().id).toBe('en')
expect(state.menu).toHaveBeenCalledTimes(initialMenus + 1)
expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'en' })
})
Loaded 100 of 951 files, more files were not shown because too many files have changed in this diff. Show more