Merge remote-tracking branch 'origin/master' into fix/desktop-dev-runtime-target

This commit is contained in:
mektpoy committed 2026-09-20 16:13:36 +08:00
commit 40edba6a8d
68 files changed
+644 -152

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/bug-fix/2026-09-16-desktop-window-menus.md
2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd
2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa
2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47
2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253
@@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem
On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus.
Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
## Alternatives considered
@@ -28,8 +28,8 @@ Electron's role labels are English string literals inside Electron (`lib/browser
## Consequences
macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop.
macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale.
## Testing
A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`.
`apps/desktop/tests/main-startup.spec.ts` verifies the declared menu roles per platform and records English and Chinese application-label snapshots. Role-based menu items execute natively, so a programmatic `click()` and the Vitest Electron mock cannot exercise the shortcuts. The isolated Electron 44 demo confirms that explicit application labels appear in native menus; the template snapshots do not qualify native shortcut execution.
@@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。
这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
## 考虑过的替代方案
@@ -28,8 +28,8 @@ macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之
## 影响
macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。
macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
## 测试
`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。
`apps/desktop/tests/main-startup.spec.ts` 验证各平台声明的菜单 role,并记录中英文应用标签快照。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 Vitest 的 Electron mock 都无法触达这些快捷键。独立 Electron 44 demo 确认显式应用标签会显示在原生菜单中;模板快照不能验证原生快捷键的执行。
@@ -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-17-developer-tools-settings.md
2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2
2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c
2026-09-17-developer-tools-settings.md: 7819b2a2df3c9eaf2db1e1271d7b419768affaff
2026-09-17-developer-tools-settings.zh.md: 5ff9f2c1e2380f086969cc392bf649edd13e2893
@@ -10,9 +10,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
## Decision
The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
@@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil
Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
@@ -10,9 +10,9 @@ Status: implemented
## Decision
设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
@@ -26,4 +26,4 @@ Status: implemented
基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
@@ -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/feature/2026-09-20-developer-tools-default-on.md
2026-09-20-developer-tools-default-on.md: 42c89faed11b966ac8ea0882236af9bf13557702
2026-09-20-developer-tools-default-on.zh.md: a684844f5ba53a0bebdd1c3a3a3ade1ed150cc31
@@ -0,0 +1,31 @@
# Agent Note: Developer tools default on after preference resolution
Status: implemented
English | [中文](2026-09-20-developer-tools-default-on.zh.md)
## Problem
New installations need access to developer views, preset selection, change summaries and interactive HTML previews without first finding a Settings switch. A saved disabled preference must still prevent those features from activating during startup, including script execution that cannot be undone after it occurs.
## Decision
The Host schema defaults `ui-developer-tools.enabled` to `true`. Remote browsers with process-local preferences also start enabled. Saved Host values take precedence, including `false`. This supersedes only the initial default-off choice in [Shared developer-tool settings](2026-09-17-developer-tools-settings.md); that note retains ownership of shared persistence and rendering policy.
Host-backed clients publish disabled until an accepted schema-resolved value arrives. Loading, failed initial reads and an unavailable namespace do not grant interactive-preview capabilities. The Host supplies the default for an absent stored field; the client does not interpret an absent response as consent to enable scripts. An accepted value remains authoritative during later refreshes.
Only the Trajectory tab follows the developer-tools preference; third-party Views remain available. The conversation shell retains the explicit Trajectory id because the requested rule names that View. No current requirement makes arbitrary diagnostic Views share the switch.
## Alternatives considered
**Keep the default off.** This minimizes the initial interface but requires a separate opt-in for the features new installations now expose. Users can still save the disabled preference.
**Default the Host client to enabled before settings arrive.** This makes rendering immediate, but briefly activates features for users who disabled them. Unmounting a scripted HTML iframe cannot undo scripts or requests already executed.
**Add a generic developer-tool flag to View registration.** This expands the plugin API and allows other Views to join a switch whose current requirement is specific to Trajectory. A future requirement can justify that API separately.
## Consequences
Fresh profiles expose developer features after settings resolve; users with a saved disabled choice retain the simpler presentation. New users also receive interactive HTML previews with the capabilities documented by the [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md). Host initialization can briefly withhold developer controls even when the eventual preference is enabled.
Settings tests cover initial withholding, unavailable namespaces, explicit disabled values and process-local defaults. HTML renderer coverage checks that pending and disabled preferences neither read related files nor create script-enabled frames. Browser settings coverage verifies the default and a disabled choice across reload; document-preview replay pins the explicitly disabled scenario.
@@ -0,0 +1,31 @@
# Agent Note: 偏好解析后默认开启开发者工具
Status: implemented
[English](2026-09-20-developer-tools-default-on.md) | 中文
## Problem
新安装需要直接使用开发者视图、预设选择、改动摘要和交互式 HTML 预览,无需先寻找设置开关。已保存的关闭偏好仍必须阻止这些功能在启动期间激活,包括发生后无法撤销的脚本执行。
## Decision
Host schema 将 `ui-developer-tools.enabled` 默认为 `true`。使用进程内偏好的远程浏览器也从开启状态启动。已保存的 Host 值优先,包括 `false`。这仅取代[共享开发者工具设置](2026-09-17-developer-tools-settings.zh.md)中最初默认关闭的选择;该记录继续负责共享持久化和渲染策略。
使用 Host 偏好的客户端在收到经过 schema 解析并接受的值之前发布关闭状态。加载中、首次读取失败和命名空间不可用均不授予交互式预览能力。缺少存储字段时由 Host 提供默认值;客户端不会把缺少响应解释为允许开启脚本。后续刷新期间,已接受的值继续有效。
只有 Trajectory 标签页受开发者工具偏好控制;第三方 View 保持可用。对话外壳保留显式 Trajectory id,因为当前需求明确指定此 View。没有现行需求要求任意诊断 View 共用该开关。
## Alternatives considered
**保持默认关闭。** 这可简化初始界面,但新安装现在直接提供的功能将需要额外启用。用户仍可保存关闭偏好。
**在设置到达前让 Host 客户端默认开启。** 这可以立即渲染,却会短暂激活用户已关闭的功能。卸载可执行脚本的 HTML iframe 无法撤销已经执行的脚本或请求。
**在 View 注册中增加通用开发者工具标记。** 这会扩展插件 API,并允许其他 View 加入当前仅针对 Trajectory 的开关。未来需求可以单独论证该 API。
## Consequences
新 profile 在设置解析后展示开发者功能;已保存关闭选择的用户保留简化展示。新用户也会获得带有[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)所述能力的交互式 HTML 预览。即使最终偏好为开启,Host 初始化期间也可能短暂不展示开发者控件。
设置测试覆盖首次等待、命名空间不可用、显式关闭值和进程内默认值。HTML 渲染器覆盖验证等待中及关闭偏好均不读取关联文件,也不创建允许脚本的 frame。浏览器设置覆盖验证默认值和关闭选择在刷新后保持;文档预览回放固定显式关闭的场景。
@@ -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/process/2026-09-17-windows-runtime-signature-cache.md
2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9
2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad
2026-09-17-windows-runtime-signature-cache.md: e9229e47929664984a92499e54f87bb2166bde9d
2026-09-17-windows-runtime-signature-cache.zh.md: 53b6182170d807843345fa90f962ee18807f2cf2
@@ -20,6 +20,8 @@ Serialize each complete signing stage and cache-maintenance operation with an ex
Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request.
Within the stage lock, restore distinct cache-hit targets with a bounded worker pool and complete their post-verification before serially signing misses. Stop dispatch on the first restore, verification or audit failure and drain active workers before releasing ownership. Four workers are the configurable default: warm-cache experiments on 134 primary-runtime and 228 application files reduce combined median restore-plus-post-verification time from 226.18 seconds at one worker to 63.22 seconds at four; eight takes 42.18 seconds but increases measured CPU by 25.5% and peak committed memory from 387 to 631 MiB. These three-repeat measurements use exact reconstructed unsigned inputs, real Windows verification and a rejecting hardware callback; they exclude full packaging, cold trust state and hardware misses.
The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation.
## Alternatives considered
@@ -20,6 +20,8 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。
在阶段锁内,使用有界工作池恢复不同的缓存命中目标,完成后置验签后才串行签署未命中项。首次恢复、验签或审计失败时停止派发,并在释放所有权前等待在途任务结束。可配置的默认并发为四:对 134 个主要运行时文件及 228 个应用文件的热缓存实验中,恢复加后置验签的合计中位耗时从单任务的 226.18 秒降至四任务的 63.22 秒;八任务为 42.18 秒,但实测 CPU 消耗增加 25.5%,峰值提交内存从 387 增至 631 MiB。这些三轮测量使用精确重建的未签名输入、真实 Windows 验签和拒绝硬件调用的回调;不包含完整打包、冷信任状态或硬件未命中。
[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。
## Alternatives considered
+3
View File
@@ -20,6 +20,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN=
# Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1.
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1
# Cache-hit restore and verification workers (1-8). Hardware signing remains serial.
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
# Optional upload configuration; packaging does not require these credentials.
# DOWNLOAD_TEST_COS_BUCKET=
# DOWNLOAD_TEST_COS_SECRET_ID=
+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: 860867eef3ae1da8c89d8292f00afa9ad2a20872
README.zh.md: ac9a4ddbfff1ad96950fbda6e14ea2632480ea03
README.md: f3025746c57e620b1796e1920b95ce428155ce70
README.zh.md: 8bee1971826d301eeabcc83eb550f13bb6988551
+4 -2
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
@@ -232,7 +232,9 @@ Runtime signing shares complete signed files across the current Windows account'
Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery.
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately.
Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required.
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time.
Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from <absolute-old-cache>` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory <absolute-cache>` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly.
+4 -2
View File
@@ -4,7 +4,7 @@
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
@@ -232,7 +232,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。
缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。
使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from <absolute-old-cache>` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory <absolute-cache>` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。
@@ -10,10 +10,11 @@ import { createWindowsTokenSigner } from './windows-sign.mjs'
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
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|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)$/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
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
@@ -79,6 +80,7 @@ export function validateDesktopPackageEnvironment(environment, target, options =
resolveDesktopAppId(environment)
resolveDesktopPolicyEnvironment(environment)
if (target.platform === 'darwin') resolveMacOSPackageSettings(environment)
else resolveWindowsPackageSettings(environment)
if (options.unsigned) return
if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
if (target.platform === 'win32') {
+1
View File
@@ -30,6 +30,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR',
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY',
] as const
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
'DOWNLOAD_TEST_COS_SECRET_ID',
+4 -1
View File
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'
import { createWindowsTokenSigner } from './windows-sign.mjs'
import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs'
import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs'
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
@@ -46,6 +47,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string,
async function main(): Promise<void> {
if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64')
const { signatureCacheConcurrency } = resolveWindowsPackageSettings(process.env)
const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
if (!runDirectory) throw new Error('primary runtime signing requires a supervised packaging run')
await readFile(join(runDirectory, 'run.json'))
@@ -67,7 +69,8 @@ async function main(): Promise<void> {
await prepareWindowsSignatureCacheDirectory(cacheRoot)
const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint,
sign, inspect: inspectWindowsRuntimeSignature, record })
const options = { thumbprint, sign: cachedSign, record }
const options = { thumbprint, sign: cachedSign, record,
cache: { restore: cachedSign.restore, concurrency: signatureCacheConcurrency } }
record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity })
try {
if (process.argv.includes('--dsh')) {
@@ -0,0 +1,6 @@
/**
* Validate the file-owned Windows cache worker limit before packaging starts.
* @param environment Windows packaging settings loaded from .env.windows.
* @returns Worker limit from one to eight, defaulting to four when omitted; invalid values throw.
*/
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number }
@@ -0,0 +1,14 @@
/** Resolve file-owned Windows signature-cache concurrency before packaging starts. */
/**
* Validate the cache worker limit without accessing signing hardware.
* @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings.
* @returns {{signatureCacheConcurrency: number}} Worker limit, defaulting to four when omitted.
*/
export function resolveWindowsPackageSettings(environment) {
const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4'
if (!/^[1-8]$/u.test(value)) {
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY must be an integer from 1 to 8')
}
return { signatureCacheConcurrency: Number(value) }
}
@@ -31,6 +31,11 @@ export interface WindowsCodeSigningOptions {
sign: ReturnType<typeof import('./windows-sign.mjs').createWindowsTokenSigner>
inspect?: typeof inspectWindowsRuntimeSignature
record: (event: object) => void
/** Hardware-free restores on distinct targets; concurrency is a resolved positive integer. All restores and their verification drain before serial signing starts. */
cache?: {
restore: import('./windows-signature-cache.mjs').WindowsCachedSigner['restore']
concurrency: number
}
}
/**
@@ -44,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise<string[]>
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param root Materialized directory to sign.
* @param options Signer, certificate identity and audit sink.
* @returns Resolves after verified sequential signing; rejects without retries.
* @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries.
*/
export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise<void>
@@ -101,7 +101,7 @@ async function inspectWindowsCode(files, inspect) {
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
* @param {string} root - Owned final runtime directory.
* @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink.
* @returns {Promise<void>} Resolves only after sequential signatures and verification; no retries.
* @returns {Promise<void>} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting.
*/
export async function signWindowsCode(root, options) {
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
@@ -116,14 +116,36 @@ export async function signWindowsCode(root, options) {
else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`)
}
options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length })
for (const path of unsigned) {
await options.sign({ path, hash: 'sha256', isNest: false })
async function verifySigned(path) {
const signature = await inspect(path)
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
throw new Error(`Windows code: signing verification failed: ${path}`)
}
options.record({ type: 'windows-code-signature-verified', path, ...signature })
}
const restored = new Set()
if (options.cache) {
let next = 0
let failure
const worker = async () => {
while (failure === undefined && next < unsigned.length) {
const path = unsigned[next++]
try {
if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) {
await verifySigned(path)
restored.add(path)
}
} catch (error) { failure ??= { error } }
}
}
await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker))
if (failure !== undefined) throw failure.error
}
for (const path of unsigned) {
if (restored.has(path)) continue
await options.sign({ path, hash: 'sha256', isNest: false })
await verifySigned(path)
}
}
/**
@@ -18,7 +18,7 @@ export interface WindowsSignatureCacheOptions {
*/
export function signatureCacheIdentity(files: readonly string[]): Promise<string>
/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */
/** Counters include failed attempts; times sum per-file work. Verification covers restored and newly signed files; restore time includes its verification and staging cleanup. */
export interface WindowsSignatureCacheSummary {
root: string
identity: string
@@ -34,8 +34,14 @@ export interface WindowsSignatureCacheSummary {
signingMs: number
}
/** Serial cache callback with a snapshot of this instance's completed and failed operations. */
/** Serial signing callback with a separate hardware-free restore operation and shared counters. */
export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> & {
/**
* Restore a hit without accessing hardware; distinct targets may run concurrently under the stage lock.
* @param configuration Unsigned target owned exclusively by the caller.
* @returns True after verified replacement, false for an absent entry without counting a signing miss; corrupt entries reject. The caller drains all restores before signing or reporting failure.
*/
restore(configuration: Parameters<ReturnType<typeof createWindowsTokenSigner>>[0]): Promise<boolean>
/** @returns Current counters and elapsed times; does not wait for queued operations. */
summary(): WindowsSignatureCacheSummary
}
@@ -43,7 +49,7 @@ export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> &
/**
* Reuse complete signed files only after input, integrity, identity and timestamp checks.
* @param options Cache policy, supervised signer, public-key verifier and audit sink.
* @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries.
* @returns Serial fail-stop signer with hardware-free restores; invalid cache entries are never repaired.
*/
export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner
@@ -125,7 +125,7 @@ export async function maintainSignatureCache(root, clear = false) {
/**
* Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy.
* @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations.
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp.
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop signer plus hardware-free restores; cache hits still verify trust and timestamp.
*/
export function createCachedSigner(options) {
let pending = Promise.resolve()
@@ -142,7 +142,7 @@ export function createCachedSigner(options) {
if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record')
return record
}
async function sign(configuration) {
async function sign(configuration, restoreOnly = false) {
if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported')
const inputDigest = digest(await regularFile(configuration.path))
const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity }))
@@ -173,8 +173,9 @@ export function createCachedSigner(options) {
} finally { statistics.restoreMs += performance.now() - start }
statistics.hits++
options.record({ type: 'signature-cache-hit', key, path: configuration.path })
return
return true
}
if (restoreOnly) return false
statistics.misses++
options.record({ type: 'signature-cache-miss', key, path: configuration.path })
const start = performance.now()
@@ -210,9 +211,10 @@ export function createCachedSigner(options) {
} finally { await rm(staging, { recursive: true, force: true }) }
}
const signer = configuration => {
pending = pending.then(() => sign(configuration))
pending = pending.then(async () => { await sign(configuration) })
return pending
}
signer.restore = configuration => sign(configuration, true)
signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits })
return signer
}
+8
View File
@@ -3,6 +3,10 @@
export const en = {
application: 'Application',
aboutMenu: 'About DeepSeek Harness',
hideApplication: 'Hide DeepSeek Harness',
hideOtherApplications: 'Hide Others',
showAllApplications: 'Show All',
quitApplication: 'Quit DeepSeek Harness',
edit: 'Edit',
menuBar: 'Application menu',
delete: 'Delete',
@@ -94,6 +98,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
application: '应用',
aboutMenu: '关于 DeepSeek Harness',
hideApplication: '隐藏 DeepSeek Harness',
hideOtherApplications: '隐藏其他',
showAllApplications: '显示全部',
quitApplication: '退出 DeepSeek Harness',
edit: '编辑',
menuBar: '应用菜单',
delete: '删除',
+6 -2
View File
@@ -616,12 +616,15 @@ async function main(): Promise<void> {
})
// A custom application menu replaces Electron's default menu, so macOS needs
// its standard menus and application hide commands declared explicitly.
// Keep app.name stable: Electron derives its default userData directory from it.
const darwin = process.platform === 'darwin'
const platformMenus: MenuItemConstructorOptions[] = darwin
? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
: [{ role: 'editMenu' }]
const hideCommands: MenuItemConstructorOptions[] = darwin
? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }]
? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication },
{ role: 'hideOthers', label: currentDesktopLocale().messages.hideOtherApplications },
{ role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }]
: []
const applicationItems = (): MenuItemConstructorOptions[] => [
{ label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
@@ -629,7 +632,8 @@ async function main(): Promise<void> {
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
{ type: 'separator' },
...hideCommands,
{ role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
{ role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication }
: process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
]
Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
label: darwin ? app.name : currentDesktopLocale().messages.application,
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
import { resolveWindowsPackageSettings } from '../scripts/windows-package-settings.mjs'
const WINDOWS = { platform: 'win32', arch: 'x64' } as const
const MACOS = { platform: 'darwin', arch: 'arm64' } as const
@@ -21,6 +22,30 @@ async function withDirectory(action: (directory: string) => Promise<void>): Prom
}
describe('Desktop local packaging configuration', () => {
it('takes cache concurrency from the Windows file and defaults to four without ambient overrides', async () => {
await withDirectory(async (directory) => {
const parent = { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '8' }
await writeFile(join(directory, '.env.windows'), '')
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(4)
await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=2\n')
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(2)
})
})
it.each(['1', '2', '4', '8'])('accepts cache concurrency %s', (value) => {
const settings = resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })
expect(settings.signatureCacheConcurrency).toBe(Number(value))
})
it.each(['', '0', '-1', '9', '1.5', 'Infinity', '01'])('rejects invalid cache concurrency %s', (value) => {
expect(() => resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })).toThrow('integer from 1 to 8')
for (const options of [{ unsigned: true }, { prepareOnly: true }]) {
expect(() => {
validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }, WINDOWS, options)
}).toThrow('integer from 1 to 8')
}
})
it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => {
await withDirectory(async (directory) => {
await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n')
@@ -0,0 +1,18 @@
[
{
"role": "hide",
"label": "Hide DeepSeek Harness"
},
{
"role": "hideOthers",
"label": "Hide Others"
},
{
"role": "unhide",
"label": "Show All"
},
{
"role": "quit",
"label": "Quit DeepSeek Harness"
}
]
@@ -0,0 +1,18 @@
[
{
"role": "hide",
"label": "隐藏 DeepSeek Harness"
},
{
"role": "hideOthers",
"label": "隐藏其他"
},
{
"role": "unhide",
"label": "显示全部"
},
{
"role": "quit",
"label": "退出 DeepSeek Harness"
}
]
+16
View File
@@ -673,6 +673,22 @@ describe('desktop main startup', () => {
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
})
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)
const originalName = harness.app.name
harness.app.name = '@deepseek-ai/dsh-desktop'
try {
await import('../src/main.ts')
await harness.preparing.promise
const commands = applicationMenuItems().filter(item =>
item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit')
await expect(JSON.stringify(commands, null, 2) + '\n')
.toMatchFileSnapshot(`./expected/application-menu-${locale}.json`)
expect(harness.app.name).toBe('@deepseek-ai/dsh-desktop')
} finally { harness.app.name = originalName }
})
it('attaches Host socket credentials only to the owned application origin and window', async () => {
await import('../src/main.ts')
await harness.preparing.promise
@@ -29,7 +29,8 @@ vi.mock('node:fs', async importOriginal => ({
afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() })
const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test',
DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }
DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin',
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '2' }
function supervisor(failure?: string) {
vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs')
@@ -56,6 +57,10 @@ it('requires one signing preflight before building, then records only the comple
for (const call of run.run.mock.calls) {
if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) {
expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY')
}
if (call[0].startsWith('run sign:primary-runtime')) {
expect(call[3].env).toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', '2')
}
}
expect(writeFileSync).toHaveBeenCalledOnce()
@@ -26,6 +26,86 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise<string
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
it('bounds overlapping cache restores and verifies all hits before serial hardware misses', async () => {
const root = await fixture(['a.exe', 'b.exe', 'c.exe', 'd.exe'])
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const signed = new Set<string>()
const verified = new Set<string>()
let active = 0
let peak = 0
let hits = 0
const sign = vi.fn(async ({ path }: { path: string }) => {
expect(active).toBe(0)
expect(hits).toBe(2)
expect(verified.has(join(root, 'a.exe')) && verified.has(join(root, 'b.exe'))).toBe(true)
signed.add(path)
})
const operation = signWindowsCode(root, { thumbprint, record: () => {}, sign,
inspect: async (path) => {
if (!signed.has(path)) return unsigned
verified.add(path)
return valid
},
cache: { concurrency: 2, restore: async ({ path }) => {
active++
peak = Math.max(peak, active)
if (active === 2) entered.resolve(undefined)
await release.promise
active--
if (path.endsWith('a.exe') || path.endsWith('b.exe')) { signed.add(path); hits++; return true }
return false
} },
})
try {
await Promise.race([entered.promise, operation])
expect(sign).not.toHaveBeenCalled()
} finally { release.resolve(undefined); await operation }
expect(peak).toBe(2)
expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name)))
})
it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => {
const root = await fixture(['a.exe', 'b.exe', 'c.exe'])
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const failed = Promise.withResolvers<undefined>()
const restored = new Set<string>()
const calls: string[] = []
const sign = vi.fn(async () => {})
let settled = false
const operation = signWindowsCode(root, { thumbprint, sign,
record: (event) => {
if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') {
failed.resolve(undefined)
throw new Error('audit failed')
}
},
inspect: async (path) => {
if (!restored.has(path)) return unsigned
if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') }
return valid
},
cache: { concurrency: 2, restore: async ({ path }) => {
calls.push(path)
if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false }
await entered.promise
if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') }
restored.add(path)
return true
} },
}).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error })
try {
await Promise.race([failed.promise, operation])
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
expect(sign).not.toHaveBeenCalled()
} finally { release.resolve(undefined); await operation }
expect(await operation).toEqual(new Error(`${failure} failed`))
expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name)))
expect(sign).not.toHaveBeenCalled()
})
it('selects real PE code, including .node, without signing foreign native modules or data', async () => {
const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary'])
await mkdir(join(root, 'nested'))
@@ -23,6 +23,22 @@ async function fixture(t: TestContext) {
return { root, path, cache, state, options, request }
}
test('restore misses leave inputs and hardware untouched, then share a cached payload across targets', async (t) => {
const f = await fixture(t)
const signer = createCachedSigner(f.options)
assert.equal(await signer.restore(f.request), false)
assert.equal(f.state.hardwareCalls, 0)
assert.equal(signer.summary().misses, 0)
assert.equal(await readFile(f.path, 'utf8'), 'original')
await signer(f.request)
const targets = [join(f.root, 'first.node'), join(f.root, 'second.node')]
await Promise.all(targets.map(path => writeFile(path, 'original')))
assert.deepEqual(await Promise.all(targets.map(path => signer.restore({ ...f.request, path }))), [true, true])
assert.deepEqual(await Promise.all(targets.map(path => readFile(path, 'utf8'))), ['signed:original', 'signed:original'])
assert.equal(f.state.hardwareCalls, 1)
assert.equal(signer.summary().hits, 2)
})
test('unchanged bytes restored in another file avoid another hardware call', async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
@@ -71,7 +87,7 @@ test('different content or signing policy misses the cache', async (t) => {
assert.equal(f.state.hardwareCalls, 3)
})
for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => {
for (const restoreOnly of [false, true]) for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input (restore-only: ${restoreOnly})`, async (t) => {
const f = await fixture(t)
await createCachedSigner(f.options)(f.request)
await writeFile(f.path, 'original')
@@ -79,7 +95,7 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops
assert(entry)
await writeFile(join(f.cache, entry, corrupt), 'corrupt')
const signer = createCachedSigner(f.options)
await assert.rejects(signer(f.request))
await assert.rejects(restoreOnly ? signer.restore(f.request) : signer(f.request))
assert.equal(signer.summary().validationFailures, 1)
assert.equal(signer.summary().avoidedSigningCalls, 0)
assert.equal(f.state.hardwareCalls, 1)
+9 -11
View File
@@ -99,7 +99,7 @@ interface CapturedFixture {
}
export interface AssembledRemoteOptions {
/** Supply an enabled Host preference for diagnostic View scenarios. */
/** Override the schema-resolved Host preference for developer-tool scenarios. */
readonly developerTools?: boolean
/** Return the fixture's image-dimension admission error from Session prompt. */
readonly rejectPrompt?: boolean
@@ -142,16 +142,14 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
const mock = RemoteMock.create().load(remoteDefaultResponses)
mock.load({
unary: {
'settings/describe': options.developerTools === true
? ok({
...fixture.settingsDescribe.value,
namespaces: [...fixture.settingsDescribe.value.namespaces, {
ns: 'ui-developer-tools',
schema: { type: 'object', dict: { enabled: { type: 'boolean' } } },
value: { enabled: true }, applies: 'live', secrets: [], revision: 0,
}],
})
: structuredClone(fixture.settingsDescribe),
'settings/describe': ok({
...fixture.settingsDescribe.value,
namespaces: [...fixture.settingsDescribe.value.namespaces, {
ns: 'ui-developer-tools',
schema: { type: 'object', dict: { enabled: { type: 'boolean' } } },
value: { enabled: options.developerTools ?? true }, applies: 'live', secrets: [], revision: 0,
}],
}),
'credentials/describe': structuredClone(fixture.credentialsDescribe),
'session/modelCatalog': structuredClone(fixture.modelCatalog),
'agentPresets/list': structuredClone(fixture.agentPresets),
+20 -9
View File
@@ -1,4 +1,4 @@
/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */
/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { chromium } from 'playwright'
@@ -7,7 +7,7 @@ import { launchWebScaffold } from './scaffold.ts'
import { newEnglishPage } from './support.ts'
it('persists developer tools in the Host settings document and restores the accepted choice', async () => {
const scaffold = await launchWebScaffold({ developerTools: false })
const scaffold = await launchWebScaffold()
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
@@ -15,15 +15,26 @@ it('persists developer tools in the Host settings document and restores the acce
await page.goto(scaffold.authenticatedUrl)
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const toggle = page.getByRole('switch', { name: 'Developer tools' })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await toggle.getAttribute('aria-checked')).toBe('true')
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:')
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true')
await page.reload()
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false')
await page.reload()
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
const pickerToggle = page.getByRole('switch', { name: 'Allow switching Agent modes' })
await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1)
expect(await pickerToggle.count()).toBe(0)
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true })
await expect.poll(() => pickerToggle.count()).toBe(1)
await expect.poll(() => pickerToggle.isEnabled()).toBe(true)
const pickerEnabled = await pickerToggle.getAttribute('aria-checked')
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false })
await expect.poll(() => pickerToggle.count()).toBe(0)
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true })
await expect.poll(() => pickerToggle.isEnabled()).toBe(true)
await expect.poll(() => pickerToggle.getAttribute('aria-checked')).toBe(pickerEnabled)
})
+1 -1
View File
@@ -365,7 +365,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
await successShot(page, 'html-basic')
sections.push([
'## Basic HTML', '',
'- Developer tools: off by default on Web and desktop',
'- Developer tools: disabled for this scenario',
'- Sandbox: no permissions',
`- Inline script: ${await basicHtml.locator('#result').innerText()}`,
`- Local script: ${await basicHtml.locator('#local-result').innerText()}`,
+3 -3
View File
@@ -302,7 +302,7 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/** The scaffold enables developer tools unless false preserves the shipped default. */
/** Override the developer-tools preference; omitted uses the shipped default. */
developerTools?: boolean
/** Enable the real Open In rows with deterministic launch-environment facts. */
openInAppEnvironment?: LaunchEnvironmentSnapshot
@@ -818,8 +818,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
await ctx.loader.await()
await auditStartupEntries(ctx, 'web e2e scaffold')
if (options.developerTools !== false) {
await ctx.settings.update('ui-developer-tools', { enabled: true })
if (options.developerTools !== undefined) {
await ctx.settings.update('ui-developer-tools', { enabled: options.developerTools })
}
if (options.welcomeNoticePending !== true) {
await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
@@ -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 packages/client/ui-agent-preset/README.md
README.md: c7a87da591c1cafc433fbb3bb5fa216d94ab85ef
README.zh.md: a54b47baed132e790fc4f44ad1155f48a19c430b
README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda
README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1
+3 -3
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition.
Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default while Developer tools is enabled; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition.
## Table of Contents
@@ -25,9 +25,9 @@ Use this package to choose the agent preset for a new Web GUI session, see the a
<a id="use-this-package"></a>
## Use this package
The new-session picker additionally requires Developer tools to be enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy.
The new-session picker and the management section’s “Allow switching Agent modes” row are visible only while Developer tools is enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy.
Mount this plugin alongside the settings and conversation packages; the management section then shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history.
Mount this plugin alongside the settings and conversation packages; once Developer tools is enabled, the management section shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history.
### Managing the roster
+3 -3
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。
使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器在通用设置开启开发者工具时默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。
## 目录
@@ -25,9 +25,9 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
新会话选择器还要求在通用设置中开启开发者工具。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。
新会话选择器和管理区域中的「允许切换agent模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。
与设置和对话包一起挂载本插件;管理分区随后显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。
与设置和对话包一起挂载本插件;通用设置开启开发者工具后,管理分区会显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。
### 管理名单
@@ -16,7 +16,7 @@ import {
Button, IconBrowseOutlineRegular, IconCopyOutlineRegular, IconFolderOpenOutlineRegular,
IconPlusOutlineRegular, IconTrashOutlineRegular, Modal, Switch, Tag, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'
@@ -27,6 +27,8 @@ export interface AgentPresetSectionInjected {
hooks: {
/** Page snapshot bound by the renderer as useAgentPresetSection. */
agentPresetSection: SnapshotStore<AgentPresetSectionState>
/** Shared preference controlling the picker-policy row. */
developerTools: ObservableSnapshot<boolean>
}
/** Read the roster; called once when the section first renders. */
load: () => Promise<void>
@@ -181,6 +183,7 @@ function CardDescription({ text }: { text: string }): ReactNode {
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
const { useAgentPresetSection, t, load } = props
const state = useAgentPresetSection(snapshot => snapshot)
const developerTools = props.useDeveloperTools(enabled => enabled)
const viewedId = state.view?.id
const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId)
const viewedTitle = state.view === null
@@ -236,21 +239,23 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
<p className={css.intro}>{t('sectionIntro')}</p>
<div className={css.pickerPreference}>
<div className={css.pickerPreferenceCopy}>
<span className={css.pickerPreferenceTitleRow}>
<span className={css.pickerPreferenceTitle}>{t('showPicker')}</span>
<Tag>{t('showPickerBeta')}</Tag>
</span>
<p className={css.pickerPreferenceDescription}>{t('showPickerDescription')}</p>
{developerTools && (
<div className={css.pickerPreference}>
<div className={css.pickerPreferenceCopy}>
<span className={css.pickerPreferenceTitleRow}>
<span className={css.pickerPreferenceTitle}>{t('showPicker')}</span>
<Tag>{t('showPickerBeta')}</Tag>
</span>
<p className={css.pickerPreferenceDescription}>{t('showPickerDescription')}</p>
</div>
<Switch
checked={state.showPicker}
label={t('showPicker')}
disabled={state.status !== 'ready' || state.policySaving}
onChange={(next) => { void props.setPickerVisible(next) }}
/>
</div>
<Switch
checked={state.showPicker}
label={t('showPicker')}
disabled={state.status !== 'ready' || state.policySaving}
onChange={(next) => { void props.setPickerVisible(next) }}
/>
</div>
)}
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
{([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
const group = state.rows
@@ -203,7 +203,7 @@ export function apply(ctx: ClientContext): void {
}
const sectionInjected = (): AgentPresetSectionInjected => ({
hooks: { agentPresetSection: section.store },
hooks: { agentPresetSection: section.store, developerTools: ctx.settingsScope.developerTools.enabled },
load: () => section.load(),
view: (id: string) => section.view(id),
closeView: () => { section.closeView() },
@@ -17,6 +17,8 @@ import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
const READY: AgentPresetSectionState = {
status: 'ready',
error: null,
@@ -35,6 +37,10 @@ const READY: AgentPresetSectionState = {
revealedPaths: {},
}
function unusedHook(): never {
throw new Error('This section does not read global slot sources')
}
/**
* Render the section over a fixed snapshot, with every action a spy.
* @param state - the snapshot to render.
@@ -42,7 +48,7 @@ const READY: AgentPresetSectionState = {
*/
function renderSection(
state: Partial<AgentPresetSectionState> = {},
options: { creator?: boolean } = {},
options: { creator?: boolean; developerTools?: boolean } = {},
) {
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
const actions = {
@@ -63,11 +69,18 @@ function renderSection(
makeDefault: vi.fn(() => Promise.resolve()),
setPickerVisible: vi.fn(() => Promise.resolve()),
}
const props = {
const props: AgentPresetSectionProps = {
...actions,
usePanelInfo: unusedHook,
useSessions: unusedHook,
useSessionStatus: unusedHook,
useSessionRetainInfo: unusedHook,
useWorkspaces: unusedHook,
useResource: unusedHook,
useAgentPresetSection: bindSnapshotSelector(store),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetSectionProps
useDeveloperTools: bindSnapshotSelector(createSnapshotStore(options.developerTools ?? true)),
t: key => translations.get(key) ?? key,
}
render(<AgentPresetSection {...props} />)
return actions
}
@@ -82,6 +95,13 @@ function rowFor(id: string): HTMLElement {
}
describe('the preset list', () => {
it('hides the complete picker-policy row while developer tools are off', () => {
renderSection({}, { developerTools: false })
expect(screen.queryByRole('switch', { name: en.showPicker })).toBeNull()
expect(screen.queryByText(en.showPickerDescription)).toBeNull()
expect(screen.queryByText(en.showPickerBeta)).toBeNull()
})
it('reads the roster once when it first renders', async () => {
const actions = renderSection()
@@ -348,14 +368,8 @@ describe('the preset list', () => {
})
it('renders nothing when the deployment composes no presets', () => {
const { container } = render(<AgentPresetSection {...({
useAgentPresetSection: bindSnapshotSelector(
createSnapshotStore<AgentPresetSectionState>({ ...READY, status: 'unavailable', rows: [] })),
t: (key: keyof typeof en) => en[key],
load: vi.fn(() => Promise.resolve()),
} as unknown as AgentPresetSectionProps)} />)
expect(container.firstChild).toBeNull()
renderSection({ status: 'unavailable', rows: [] })
expect(screen.queryByRole('heading')).toBeNull()
})
it('offers a retry when the roster could not be read', () => {
@@ -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 packages/client/ui-conversation/README.md
README.md: fef3a6551b517af2ac636687883864525aac70ee
README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5
README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd
README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe
+1 -1
View File
@@ -40,7 +40,7 @@ The shared image slot props keep display choices separate from durable reference
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
With Developer tools off, the shell exposes only Chat and hides the View tab bar. Turning it off while another View is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes registered Views available again. View owners receive the available roster so their navigation actions can follow the same visibility.
With Developer tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Developer tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
+1 -1
View File
@@ -40,7 +40,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
关闭开发者工具时,外壳仅展示 Chat 并隐藏 View 标签栏。在其他 View 激活时关闭开关会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,已注册的 View 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
关闭开发者工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭开发者工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
@@ -35,7 +35,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { InputBar } from './skeleton/InputBar.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { DEFAULT_VIEW_ID, resolveActiveView } from './view-selection.ts'
import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts'
import { en, NS, zh, type ConversationKey } from './locales.ts'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
@@ -159,7 +159,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- list registration validates id at load. */
if (entry.options.id === undefined) continue
if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id !== DEFAULT_VIEW_ID) continue
if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id === DEVELOPER_TOOLS_VIEW_ID) continue
tabs.push({
id: entry.options.id,
label: resolveSlotLabel(entry.options.label) ?? entry.options.id,
@@ -1,8 +1,11 @@
import type { ViewTab } from './contract/views.ts'
/** Chat remains available when developer tools are disabled. */
/** Fallback View selected when no stored preference resolves to a registered View. */
export const DEFAULT_VIEW_ID = 'chat'
/** The one View the Developer tools preference gates; other Views stay registered by their owners. */
export const DEVELOPER_TOOLS_VIEW_ID = 'trajectory'
/**
* Resolve a preferred registered View, then Chat, without choosing another View.
* @param tabs - currently registered Views.
@@ -201,24 +201,46 @@ describe('Conversation inject API', () => {
const unsubscribe = source.subscribe(changed)
expect(source.getSnapshot()).toBeUndefined()
const removeDefinition = b.runtime.ctx.uiConversation.views.register({
target: 'custom-inspector',
target: 'trajectory',
toolCallFocus: callId => `tool:${callId}`,
create: () => ({ empty: null, replace: () => null, apply: () => null }),
})
expect(source.getSnapshot()).toBeUndefined()
const removeView = b.slots.register(
{ name: 'conversation.view', id: 'custom-inspector' }, (() => null) as never,
{ name: 'conversation.view', id: 'trajectory' }, (() => null) as never,
)
await b.runtime.flush()
const inspect = source.getSnapshot()!
expect(inspect).toBeTypeOf('function')
expect(source.getSnapshot()).toBe(inspect)
inspect('call-1')
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'custom-inspector', focus: 'tool:call-1' })
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'trajectory', focus: 'tool:call-1' })
await b.runtime.ctx.settingsScope.developerTools.setEnabled(false)
expect(source.getSnapshot()).toBeUndefined()
await b.runtime.ctx.settingsScope.developerTools.setEnabled(true)
expect(source.getSnapshot()).toBe(inspect)
// Trajectory still owns inspection while both Views are visible; hiding
// it must leave a third-party View's inspection capability reachable.
const removePipelineDefinition = b.runtime.ctx.uiConversation.views.register({
target: 'pipeline',
toolCallFocus: callId => `stage:${callId}`,
create: () => ({ empty: null, replace: () => null, apply: () => null }),
})
const removePipelineView = b.slots.register(
{ name: 'conversation.view', id: 'pipeline' }, (() => null) as never,
)
await b.runtime.flush()
expect(source.getSnapshot()).toBe(inspect)
await b.runtime.ctx.settingsScope.developerTools.setEnabled(false)
const pipelineInspect = source.getSnapshot()!
expect(pipelineInspect).toBeTypeOf('function')
pipelineInspect('call-2')
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'pipeline', focus: 'stage:call-2' })
await b.runtime.ctx.settingsScope.developerTools.setEnabled(true)
removePipelineView()
removePipelineDefinition()
await b.runtime.flush()
expect(source.getSnapshot()).toBe(inspect)
removeView()
await b.runtime.flush()
expect(source.getSnapshot()).toBeUndefined()
@@ -44,18 +44,18 @@ function entry(
}
describe('target-neutral Conversation apply wiring', () => {
it('offers only Chat while developer tools are disabled and restores registered views when enabled', async () => {
it('hides only the Trajectory View while developer tools are disabled and restores it when enabled', async () => {
const b = await bench()
const header = b.runtime.slots.entries('conversation.session.header')[0]!
const source = (header.inject!() as { hooks: { conversationViews: ObservableSnapshot<readonly ViewTab[]> } }).hooks.conversationViews
for (const id of ['chat', 'trajectory']) {
for (const id of ['chat', 'trajectory', 'probe']) {
b.runtime.slots.register({ name: 'conversation.view', id, label: id }, (() => null) as never)
}
await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory']) })
await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe']) })
b.developerTools.set(false)
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat'])
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'probe'])
b.developerTools.set(true)
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory'])
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe'])
await b.runtime.dispose()
})
it('waits for the layout-owned conversation declaration before registering its subtree', async () => {
@@ -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 packages/client/ui-settings-general/README.md
README.md: cf8bb5a75aa90842108646b57ceec67ada8ae711
README.zh.md: 7df3eee13df7bd041cc5f09288d1b7fe28acded6
README.md: ebdb22adc071b5df435fddd6620dd982d866aa36
README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7
@@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request.
These limits define what the shell itself provides versus what features must supply; they are current package constraints.
- **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences.
- **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688).
<a id="dev-note"></a>
### Dev Note
@@ -103,6 +103,7 @@ kind: "package-reference"
这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。
- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。
- **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。
<a id="dev-note"></a>
### 开发备注
+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 packages/client/ui-settings/README.md
README.md: d70cb35878d77c1d3e928aa222a2c7e96874ea2c
README.zh.md: 87687ac3ccc7f19d7b34f5724eba260a02e92044
README.md: d688442cb003f980b037354f76997c18a2ca0be2
README.zh.md: 92e2026d04f188854fd6205fe4d35e8c9c8e57b2
+2 -2
View File
@@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re-
### Binding a namespace
`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording.
`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. Host-backed clients keep developer features disabled until the first accepted schema-resolved value arrives; missing or failed initial responses do not enable them. Later refreshes retain the last accepted choice.
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
@@ -53,7 +53,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of
### The describe mirror
The Host half registers the `ui-developer-tools` schema and its default-off value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
The Host half registers the `ui-developer-tools` schema and its default-on value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
### Scope derivation
+2 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 绑定命名空间
`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。
`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 使用 Host 偏好的客户端在首个经过 schema 解析并接受的值到达前保持开发者功能关闭;首次响应缺失或失败不会启用它们。后续刷新保留已接受的选择。
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
@@ -53,7 +53,7 @@ kind: "package-reference"
### Describe 镜像
Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认关闭值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认开启值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
### Scope 派生
@@ -3,11 +3,11 @@ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-c
import type { DeveloperToolsSettings } from '../developer-tools-settings.ts'
import type { SettingsScope } from './settings-contract.ts'
/** Shared preference; no accepted value means developer tools are disabled. */
/** Shared preference; Host-backed features stay disabled until an accepted value arrives. */
export class DeveloperToolsPreference {
/** Accepted enablement, observable through renderer-bound hooks. */
readonly enabled: ObservableSnapshot<boolean>
private readonly local = createSnapshotStore(false)
private readonly local = createSnapshotStore(true)
/**
* @param scope - settings-owned namespace controller.
@@ -10,7 +10,7 @@ export interface DeveloperToolsSettings {
enabled: boolean
}
/** New installations and missing values use the basic interface. */
/** New installations and missing values enable the full interface. */
export const DeveloperToolsSettingsSchema: z<DeveloperToolsSettings> = z.object({
enabled: z.boolean().default(false),
enabled: z.boolean().default(true),
})
@@ -39,6 +39,39 @@ describe('developer tools settings', () => {
expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false)
})
it.each([false, true])('withholds features during a delayed Host read before accepting %s', async (enabled) => {
const ctx = new Context()
const accepted = { ok: true, value: {
writable: true, hasDocument: true, namespaces: [{
ns: DEVELOPER_TOOLS_NAMESPACE,
schema: DeveloperToolsSettingsSchema.toJSON(),
value: { enabled }, revision: 1, applies: 'live', secrets: [],
}],
} }
const pending = Promise.withResolvers<typeof accepted>()
onTestFinished(async () => { pending.resolve(accepted); await ctx.fiber.dispose() })
new TestRemote(ctx, { settings: { describe: () => pending.promise } })
await ctx.plugin({ inject, apply: clientApply }).await()
const preference = ctx.settingsScope.developerTools
const changed = vi.fn()
const dispose = preference.enabled.subscribe(changed)
onTestFinished(dispose)
expect(preference.enabled.getSnapshot()).toBe(false)
pending.resolve(accepted)
await ctx.settingsScope.describe().ensure()
expect(preference.enabled.getSnapshot()).toBe(enabled)
expect(changed).toHaveBeenCalledTimes(enabled ? 1 : 0)
})
it('keeps features disabled when the initial Host read fails', async () => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
new TestRemote(ctx, { settings: { describe: () => Promise.reject(new Error('disconnected')) } })
await ctx.plugin({ inject, apply: clientApply }).await()
await ctx.settingsScope.describe().ensure()
expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false)
})
it('shares one remote-browser preference across consumers and disposes it with the plugin', async () => {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
@@ -49,13 +82,14 @@ describe('developer tools settings', () => {
await fiber.await()
const preference = ctx.settingsScope.developerTools
expect(fiber.ctx.settingsScope.developerTools.enabled).toBe(preference.enabled)
expect(preference.enabled.getSnapshot()).toBe(true)
await preference.setEnabled(true)
expect(fiber.ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(true)
expect(describeCall).not.toHaveBeenCalled()
await fiber.dispose()
expect(ctx.get('settingsScope')).toBeUndefined()
})
it('defaults off, persists valid choices and removes its schema on disposal', async () => {
it('defaults on, keeps an explicit false, persists valid choices and removes its schema on disposal', async () => {
const ctx = new Context()
const provider = ctx.plugin(MemorySettings)
onTestFinished(() => provider.dispose())
@@ -63,6 +97,8 @@ describe('developer tools settings', () => {
const fiber = ctx.plugin({ apply })
onTestFinished(() => fiber.dispose())
await fiber.await()
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true })
await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: false })
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: false })
await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: true })
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true })
@@ -71,19 +107,19 @@ describe('developer tools settings', () => {
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(DEVELOPER_TOOLS_NAMESPACE)
})
it('stays off until accepted settings arrive and follows external changes', async () => {
it('stays off until accepted settings arrive, then follows a stored false and external changes', async () => {
const host = stubSettingsScope<DeveloperToolsSettings>()
const preference = new DeveloperToolsPreference(host.scope)
expect(preference.enabled.getSnapshot()).toBe(false)
const notify = vi.fn()
const dispose = preference.enabled.subscribe(notify)
host.publish({ status: 'ready', value: { enabled: false } })
expect(preference.enabled.getSnapshot()).toBe(false)
host.publish({ status: 'ready', value: { enabled: true } })
expect(notify).not.toHaveBeenCalled()
await preference.setEnabled(true)
expect(host.set).toHaveBeenCalledWith('enabled', true)
host.publish({ value: { enabled: true } })
expect(preference.enabled.getSnapshot()).toBe(true)
expect(notify).toHaveBeenCalledOnce()
await preference.setEnabled(false)
expect(host.set).toHaveBeenCalledWith('enabled', false)
host.publish({ value: { enabled: false } })
expect(preference.enabled.getSnapshot()).toBe(false)
dispose()
})
})
@@ -94,13 +130,14 @@ it('keeps remote browser choices local and publishes only changed values', async
const preference = new DeveloperToolsPreference(host.scope)
const notify = vi.fn()
const dispose = preference.enabled.subscribe(notify)
expect(preference.enabled.getSnapshot()).toBe(false)
await preference.setEnabled(true)
expect(preference.enabled.getSnapshot()).toBe(true)
await preference.setEnabled(true)
expect(notify).toHaveBeenCalledOnce()
await preference.setEnabled(false)
expect(preference.enabled.getSnapshot()).toBe(false)
await preference.setEnabled(false)
expect(notify).toHaveBeenCalledOnce()
await preference.setEnabled(true)
expect(preference.enabled.getSnapshot()).toBe(true)
expect(notify).toHaveBeenCalledTimes(2)
expect(host.set).not.toHaveBeenCalled()
dispose()
})
@@ -117,3 +154,10 @@ it('ignores host revisions that do not change enablement', () => {
expect(notify).toHaveBeenCalledOnce()
dispose()
})
it('keeps an unavailable Host namespace disabled', () => {
const host = stubSettingsScope<DeveloperToolsSettings>()
const preference = new DeveloperToolsPreference(host.scope)
host.publish({ status: 'unavailable' })
expect(preference.enabled.getSnapshot()).toBe(false)
})
@@ -3,6 +3,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { DeveloperToolsPreference } from '@deepseek-ai/dsh-client-ui-settings/src/client/developer-tools.ts'
import type { DeveloperToolsSettings } from '@deepseek-ai/dsh-client-ui-settings/src/developer-tools-settings.ts'
import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client'
import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -153,6 +156,31 @@ describe('HtmlBody', () => {
expect(screen.getByRole('alert').textContent).toBe(en.failed)
})
it('follows the shared developer-tools preference from loading through a stored false to enabled', async () => {
const host = stubSettingsScope<DeveloperToolsSettings>()
const preference = new DeveloperToolsPreference(host.scope)
const readRelated = vi.fn<HtmlBodyProps['readRelated']>().mockResolvedValue({ ok: true, value: {
absolutePath: '/workspace/asset.js', version: 'asset-v1', offset: 0, eof: true,
data: btoa('window.loaded = true'),
} })
const scripted: HtmlBodyProps = {
...props('<p>Preview</p><script src="./asset.js"></script>'),
useInteractivePreview: bindSnapshotSelector(preference.enabled),
readRelated,
}
const view = render(<HtmlBody {...scripted} />)
expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('')
expect(readRelated).not.toHaveBeenCalled()
act(() => { host.publish({ status: 'ready', value: { enabled: false } }) })
expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('')
expect(readRelated).not.toHaveBeenCalled()
act(() => { host.publish({ value: { enabled: true } }) })
const advanced = await screen.findByTitle(en.frame)
expect(advanced.getAttribute('sandbox')).toBe('allow-scripts')
expect(readRelated).toHaveBeenCalledOnce()
view.unmount()
})
it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => {
const initial = props()
const view = render(<HtmlBody {...initial} />)
+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 packages/client/ui-sidebar/README.md
README.md: 1c5306b8b3efa5d7908cfd33206acefef20fc3a8
README.zh.md: d72da040252f391bcc1468c9e29adf6a5ef85ef0
README.md: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e
README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5
+1 -1
View File
@@ -41,7 +41,7 @@ The top expand button hosts the optional, non-interactive `sidebar.toggle.badge`
During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation.
On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region.
On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. The sidebar toggle and New Session bubbles open below the caption, where the Desktop-owned menu text cannot cover them; an occupying `sidebar.toggle.badge` chooses its own bubble side.
### macOS desktop
+1 -1
View File
@@ -41,7 +41,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。
在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。
在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。侧栏开关与新建会话的悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们;占用 `sidebar.toggle.badge` 的控件自行决定气泡展开方向。
### macOS 桌面
@@ -107,6 +107,10 @@ export function SidebarRoot({
}, [collapsed])
const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar')
const wide = windowsTitlebar ? !collapsed : !collapsed || !settled
// The Windows caption menus occupy the strip to the right of these controls
// (that is what --dsh-windows-menu-start reserves), so a right-side bubble
// lands under their text. Below the caption is the only clear side.
const captionTooltipSide = windowsTitlebar ? 'bottom' : 'right'
// Freeze the content at its expanded width while it fades out (collapsed
// && wide): the sliding column then clips it instead of reflowing it. The
@@ -169,7 +173,7 @@ export function SidebarRoot({
// (the expand affordance, figma sidebar-hover flow). Expanded it is a plain
// panel icon.
const toggle = (
<Tooltip label={collapsed ? t('toggle.open') : t('toggle.collapse')} delayMs={500}>
<Tooltip label={collapsed ? t('toggle.open') : t('toggle.collapse')} delayMs={500} side={captionTooltipSide}>
<button
type="button"
className={clsx(css.iconButton, css.toggle)}
@@ -247,7 +251,7 @@ export function SidebarRoot({
</div>
{/* Expanded, the button carries its own label — tooltip only on the rail. */}
<Tooltip label={t('session.new.label')} delayMs={500} disabled={wide}>
<Tooltip label={t('session.new.label')} delayMs={500} disabled={wide} side={captionTooltipSide}>
<button
type="button"
className={css.newSession}
@@ -245,3 +245,38 @@ it('wires the shell.leading controls to the shared sidebar actions', () => {
expect(toggleSidebar).toHaveBeenCalledOnce()
expect(startSession).toHaveBeenCalledOnce()
})
describe('Windows caption tooltips', () => {
afterEach(() => { document.documentElement.removeAttribute('data-windows-titlebar') })
const hover = (button: HTMLElement): void => {
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(500) })
}
it.each([false, true])(
'drops the sidebar toggle bubble below the caption (collapsed=%s)',
(collapsed) => {
vi.useFakeTimers()
document.documentElement.setAttribute('data-windows-titlebar', '')
mountShell({ collapsed, width: collapsed ? 0 : 300 })
hover(screen.getByRole('button', { name: collapsed ? 'Open sidebar' : 'Collapse sidebar' }))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
},
)
it('drops the collapsed New Session bubble below the caption as well', () => {
vi.useFakeTimers()
document.documentElement.setAttribute('data-windows-titlebar', '')
mountShell({ collapsed: true, width: 0 })
hover(screen.getByRole('button', { name: 'New session' }))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
})
it('keeps the ordinary Web bubble beside its anchor', () => {
vi.useFakeTimers()
mountShell({ collapsed: true, width: 0 })
hover(screen.getByRole('button', { name: 'Open sidebar' }))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('right')
})
})
-4
View File
@@ -481,10 +481,6 @@
"packages/client/ui-agent-preset/tests/section-store.client.spec.ts": {
"23e04af2a900d7a905ff7657de842e9833384a2d4c4afba33964477e43d8ba52": 1
},
"packages/client/ui-agent-preset/tests/section.client.spec.tsx": {
"4cc9284f9e4d9df997ed042df717f143f3763130fdfb9e142226da4b5b4c60e6": 1,
"67694f8f44eb8347cb38c39d368f4f0ce1b78bd33a6a57a55cf4664f3eaa7f98": 1
},
"packages/client/ui-agent-preset/tests/settings-store.client.spec.ts": {
"c0e852e6b0e2a8a040b65723f8ab1d650d9cb06c26be7f3a0709ddb9f7525595": 1,
"d6a04d954aef7a8edd403910851015c0ce1597f6f300292b5158a8bf993fc1d8": 1
@@ -17,7 +17,7 @@
## Basic HTML
- Developer tools: off by default on Web and desktop
- Developer tools: disabled for this scenario
- Sandbox: no permissions
- Inline script: pending
- Local script: pending