From 753eff67b137724831e13e4ac0be152dfb9cf998 Mon Sep 17 00:00:00 2001
From: lsdsjy <1356263+lsdsjy@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:30:20 +0800
Subject: [PATCH 001/288] feat(desktop,client): add the native welcome window
---
...-desktop-welcome-window-material.i18n.yaml | 6 +
...6-09-08-desktop-welcome-window-material.md | 37 +++
...9-08-desktop-welcome-window-material.zh.md | 37 +++
apps/desktop/README.md | 35 ++-
apps/desktop/README.zh.md | 35 ++-
.../desktop/renderer/assets/welcome-brand.svg | 44 +++
apps/desktop/renderer/welcome.css | 284 ++++++++++++++++++
apps/desktop/renderer/welcome.html | 38 +++
apps/desktop/renderer/welcome.js | 113 +++++++
apps/desktop/scripts/dev.ts | 5 +-
apps/desktop/scripts/development-project.ts | 32 +-
apps/desktop/src/ipc.ts | 2 +
apps/desktop/src/locale.ts | 72 +++++
apps/desktop/src/main.ts | 202 ++++++++++++-
apps/desktop/src/preload-app.ts | 13 +
apps/desktop/src/preload-welcome.ts | 15 +
apps/desktop/src/welcome-api.ts | 45 +++
apps/desktop/src/welcome-backend.ts | 117 ++++++++
apps/desktop/src/welcome-window.ts | 86 ++++++
.../desktop/tests/development-project.spec.ts | 23 +-
.../expected/welcome/en-api-key.expected.txt | 8 +
.../tests/expected/welcome/en.expected.txt | 5 +
.../welcome/zh-CN-api-key.expected.txt | 8 +
.../tests/expected/welcome/zh-CN.expected.txt | 5 +
apps/desktop/tests/locale.spec.ts | 12 +-
apps/desktop/tests/main-startup.spec.ts | 65 +++-
apps/desktop/tests/preload.e2e.ts | 37 +++
apps/desktop/tests/welcome-flow.e2e.ts | 92 ++++++
apps/desktop/tests/welcome-host.spec.ts | 76 +++++
apps/desktop/tests/welcome-renderer.spec.ts | 150 +++++++++
apps/desktop/tests/welcome-startup.spec.ts | 134 +++++++++
apps/desktop/tests/welcome-window.spec.ts | 130 ++++++++
apps/desktop/tsdown.config.ts | 5 +-
apps/web/tests/desktop-locale.e2e.ts | 83 +++++
.../desktop-locale/automatic.expected.md | 50 +++
.../desktop-locale/selected.expected.md | 50 +++
.../onboarding-native/models.expected.md | 35 +++
.../onboarding-native/cordis.patch.yml | 3 +
apps/web/tests/onboarding-native.e2e.ts | 70 +++++
apps/web/tsconfig.json | 2 +
docs/config-catalog.i18n.yaml | 4 +-
docs/config-catalog.md | 15 +-
docs/config-catalog.zh.md | 15 +-
packages/client/locale/README.i18n.yaml | 4 +-
packages/client/locale/README.md | 2 +
packages/client/locale/README.zh.md | 2 +
.../client/locale/src/client/bootstrap.ts | 31 ++
packages/client/locale/src/client/index.ts | 51 +++-
.../client/locale/tests/apply.client.spec.ts | 52 +++-
.../locale/tests/bootstrap.client.spec.ts | 75 +++++
.../ui-settings-models/README.i18n.yaml | 4 +-
packages/client/ui-settings-models/README.md | 2 +
.../client/ui-settings-models/README.zh.md | 2 +
.../client/ui-settings-models/package.json | 8 +-
.../ui-settings-models/src/client/index.ts | 20 +-
.../client/ui-settings-models/src/index.ts | 24 +-
.../src/onboarding-config.ts | 17 ++
.../tests/apply.client.spec.ts | 45 ++-
.../client/ui-settings-models/tsconfig.json | 2 +
.../ui-trajectory/tests/views.client.spec.tsx | 4 +-
pnpm-lock.yaml | 7 +
tsconfig.host.json | 3 +
vitest.e2e.config.ts | 4 +-
63 files changed, 2588 insertions(+), 66 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md
create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md
create mode 100644 apps/desktop/renderer/assets/welcome-brand.svg
create mode 100644 apps/desktop/renderer/welcome.css
create mode 100644 apps/desktop/renderer/welcome.html
create mode 100644 apps/desktop/renderer/welcome.js
create mode 100644 apps/desktop/src/preload-welcome.ts
create mode 100644 apps/desktop/src/welcome-api.ts
create mode 100644 apps/desktop/src/welcome-backend.ts
create mode 100644 apps/desktop/src/welcome-window.ts
create mode 100644 apps/desktop/tests/expected/welcome/en-api-key.expected.txt
create mode 100644 apps/desktop/tests/expected/welcome/en.expected.txt
create mode 100644 apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt
create mode 100644 apps/desktop/tests/expected/welcome/zh-CN.expected.txt
create mode 100644 apps/desktop/tests/preload.e2e.ts
create mode 100644 apps/desktop/tests/welcome-flow.e2e.ts
create mode 100644 apps/desktop/tests/welcome-host.spec.ts
create mode 100644 apps/desktop/tests/welcome-renderer.spec.ts
create mode 100644 apps/desktop/tests/welcome-startup.spec.ts
create mode 100644 apps/desktop/tests/welcome-window.spec.ts
create mode 100644 apps/web/tests/desktop-locale.e2e.ts
create mode 100644 apps/web/tests/expected/desktop-locale/automatic.expected.md
create mode 100644 apps/web/tests/expected/desktop-locale/selected.expected.md
create mode 100644 apps/web/tests/expected/onboarding-native/models.expected.md
create mode 100644 apps/web/tests/fixtures/onboarding-native/cordis.patch.yml
create mode 100644 apps/web/tests/onboarding-native.e2e.ts
create mode 100644 packages/client/locale/src/client/bootstrap.ts
create mode 100644 packages/client/locale/tests/bootstrap.client.spec.ts
create mode 100644 packages/client/ui-settings-models/src/onboarding-config.ts
diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml
new file mode 100644
index 0000000000..42ff4b7b0f
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md
+2026-09-08-desktop-welcome-window-material.md: b0cb8a0bdd8abcf472597d5bfceaa175195718de
+2026-09-08-desktop-welcome-window-material.zh.md: f917ad503c4348d9a44a862ba6d005c1e5ff0198
diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md
new file mode 100644
index 0000000000..b0cb8a0bdd
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md
@@ -0,0 +1,37 @@
+# Agent Note: Desktop welcome window material
+
+Status: implemented
+
+English | [中文](2026-09-08-desktop-welcome-window-material.zh.md)
+
+## Problem
+
+The desktop welcome design blurs the desktop behind its entire window. Chromium backdrop filters only sample content inside the renderer. Credential setup also needs an explicit startup owner so a native skip is not followed by a second Web dialog.
+
+## Decision
+
+The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) owns native material and window controls. Its local renderer supplies the entry and API-key form. A narrow preload supplies typed shell copy, a write-only key operation, and a skip operation; IPC rejects other windows and subframes. Each sandboxed preload is bundled independently because Electron’s restricted require cannot load sibling chunks. The Electron main process authenticates through the shared Web launch URL and resolves the official provider's reference through the existing settings and credential RPC methods. The [thin Web wrapper](2026-09-10-desktop-web-wrapper.md) owns the HTTP server; onboarding adds no Host endpoint or child IPC operation. Responses contain metadata or a safe outcome, never a key or private provider diagnostics.
+
+Cold startup opens the entry when no model API key is configured. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. The authentication decision accepts independent account and API-key facts, but account login and logout events have no connected provider yet, so startup supplies the current unsigned-in state.
+
+The development launcher accepts `--preview-welcome` to force the entry while using the complete Desktop startup and operations. It shares the normal development home, profile, browser data, and single-instance lock. The flag affects only the initial page decision; saving and skipping use the production callbacks. Packaged startup does not activate that switch. The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins.
+
+Desktop reads the shared `locale.preference` before opening the welcome window. The Client awaits an isolated preload read of that preference and the same OS language order before mounting, then reports resolved locale changes to the shell. Only an explicit Settings selection writes the preference; automatic detection stays provisional. The shell ships English and Simplified Chinese dictionaries, so a Client-only language pack falls back to a supported OS language in native windows.
+
+The welcome flow shares the Desktop backend controller with startup recovery. The main recovery document loads offscreen while the Host starts; a failure reveals it, and successful startup chooses the welcome window or workspace after reading credentials and locale. Recovery actions retain the controller’s serialized retries and awaited child shutdown. The application preload exposes recovery controls only to shell documents and the locale bridge only to application documents.
+
+## Alternatives considered
+
+**A CSS-only backdrop filter.** It cannot blur other application windows or the desktop, so it cannot provide the required native effect.
+
+**Persisting a completed-onboarding flag.** A skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials.
+
+**Keeping both native and Web credential dialogs.** Their separate completion state would ask for a key twice after a native skip. The composition assigns credential onboarding to one owner.
+
+**Drawing replacement traffic lights.** Native controls preserve platform window behavior and accessibility. Their dimensions and outer window corners follow the OS rather than reproducing Figma geometry exactly.
+
+**Separate preview callbacks.** A visual-only skip cannot demonstrate entry into the workspace and can hide broken startup dependencies. The preview uses the same callbacks and Host as the product.
+
+## Consequences
+
+Native blur strength and font fallback vary by system. macOS uses titlebar vibrancy behind a transparent renderer. The design specifies an 80% white fill and a 50px backdrop blur; native materials already supply their own tint, so those values do not map directly to an additional CSS overlay. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and a built-Host acceptance test proves credential persistence across restarts. Account sign-out integration remains dependent on an account provider; its decision must preserve independent API keys. Onboarding presentation produces no Session events.
diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md
new file mode 100644
index 0000000000..f917ad503c
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: 桌面欢迎窗口材质
+
+Status: implemented
+
+[English](2026-09-08-desktop-welcome-window-material.md) | 中文
+
+## 问题
+
+桌面欢迎界面的设计要求整个窗口模糊其背后的桌面。Chromium 背景滤镜只能采样渲染器内部的内容。凭证配置还需要明确的启动归属,避免跳过原生引导后又出现 Web 弹窗。
+
+## 决策
+
+[Electron 欢迎窗口](../../../../apps/desktop/src/welcome-window.ts)负责原生材质和窗口控件。本地渲染器提供入口和 API Key 表单。窄接口 preload 提供强类型桌面文案、仅写密钥操作和跳过操作;IPC 拒绝其他窗口和子 frame。每个沙箱 preload 都独立打包,因为 Electron 受限的 require 无法加载同目录中的拆分模块。Electron 主进程通过共享 Web 启动 URL 认证,并通过现有设置和凭证 RPC 方法解析官方提供方的引用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)负责 HTTP 服务;引导不增加 Host 端点或子进程 IPC 操作。响应只包含元数据或安全结果,不包含密钥或提供方的私有诊断。
+
+冷启动在没有已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。认证判断接受独立的账号与 API Key 状态,但账号登录和退登事件尚无已接入的提供方,因此启动传入当前未登录状态。
+
+开发启动器接受 `--preview-welcome`,在使用完整 Desktop 启动流程和操作的同时强制显示入口。它共用普通开发环境的 home、profile、浏览器数据和单实例锁。该标志只影响初始页面判断;保存和跳过使用产品回调。打包后的启动不会激活该开关。生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。
+
+Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在挂载前等待隔离 preload 返回该偏好和同一系统语言顺序,之后将解析后的语言变更通知桌面壳。只有设置中的明确选择会写入偏好;自动检测保持临时状态。桌面壳自带英文和简体中文词典,因此仅在 Client 中提供的语言包会在原生窗口回退到受支持的系统语言。
+
+欢迎流程与启动恢复共用 Desktop 后端控制器。Host 启动期间,主窗口在屏幕外加载恢复文档;失败时显示该文档,成功后读取凭证和语言,再选择欢迎窗口或工作区。恢复操作保留控制器的串行重试与等待子进程退出的行为。应用 preload 只向壳文档提供恢复控件,只向应用文档提供语言桥接。
+
+## 考虑过的替代方案
+
+**仅使用 CSS 背景滤镜。** 它无法模糊其他应用窗口或桌面,因此不能提供所需的原生效果。
+
+**持久化已完成引导标记。** 跳过只对当前进程有效;保存该标记会在用户仍无凭证时抑制下次冷启动的选择。
+
+**同时保留原生与 Web 凭证弹窗。** 两者独立的完成状态会在跳过原生引导后再次要求输入密钥。组合将凭证引导交给单一归属方。
+
+**绘制替代红绿灯。** 原生控件保留平台窗口行为和无障碍能力。它们的尺寸与窗口外部圆角遵循操作系统,不严格复制 Figma 几何参数。
+
+**独立的预览回调。** 纯视觉预览中的跳过无法证明能进入工作区,还会掩盖启动依赖缺失。预览使用与产品相同的回调和 Host。
+
+## 后果
+
+原生模糊强度和字体回退因系统而异。macOS 在透明渲染器后使用 titlebar vibrancy。设计指定了不透明度 80% 的白色填充和 50px 背景模糊;原生材质自身已有底色,因此这些数值不能直接映射为额外的 CSS 遮罩。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收测试证明凭证可跨重启持久化。账号退登的接入仍依赖账号提供方;其判断必须保留独立的 API Key。引导展示不会生成 Session 事件。
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 531c168c0d..29d8a04b6f 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -10,6 +10,8 @@ Desktop’s local native directory flow opens an Electron folder dialog attached
Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations.
+Embedded Platform documents use a dedicated non-persistent WebContentsView session. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode and a synchronous getAuthToken() that reads preload memory without further IPC. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar.
+
## Key technical decisions
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
@@ -60,7 +62,7 @@ On macOS the custom application menu also declares the standard File, Window, an
The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
-1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
+1. The main window loads the shared Web loading page offscreen from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm.
3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
4. The main application’s Plugins page uses the shared [plugin manager](../../packages/boot/plugin-manager/README.md) against the Desktop profile. Package operations use bundled pnpm with normal user and profile configuration.
@@ -77,6 +79,8 @@ Recovery waits for Host shutdown before changing plugin activation. The native r
## Develop
+The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files.
+
`dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without resolving dsh from npm:
```sh
@@ -93,6 +97,23 @@ pnpm run start:desktop
Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
+
+### Startup onboarding
+
+Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the [API-key page](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626). Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
+
+The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
+
+### Welcome window preview
+
+Build the complete Desktop application and force its welcome page, including when a key is already configured:
+
+```sh
+pnpm run dev:desktop --preview-welcome
+```
+
+The preview uses the same Host, development project, Harness home, browser data, and single-instance lock as normal development startup. It follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and an Old Style serif stack for the tagline. The window uses macOS titlebar vibrancy or Windows acrylic behind a transparent renderer, without an additional white overlay or whole-window opacity reduction. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The flag changes only the initial welcome-page decision. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
+
## Package
@@ -321,10 +342,22 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
## Known limitations
+- Account sign-in is not connected; the Sign in button is disabled. Windows material rendering still requires platform QA.
+
- Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
+Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
+
+An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
+
+Embedded Platform views remain hidden until document loading completes so the renderer loading indicator stays visible. Closing or replacing a pending view prevents it from appearing later.
+
+Private Platform deployment headers are injected by the embedded browser session only for its configured origin, including document and API requests. Cookie overrides merge by name. Cross-origin requests discard deployment headers; bootstrap exposes only origin and token.
+
+The account provider’s `embeddedPageDist` configuration adds a `dist` query parameter to embedded Usage and Top-up URLs. Its default is empty; private frontend branch selectors belong in the local profile patch. It does not change API URLs or credential delivery.
+
## Dev Note
Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification).
diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md
index 6a20f83623..cf6cc0fa88 100644
--- a/apps/desktop/README.zh.md
+++ b/apps/desktop/README.zh.md
@@ -10,6 +10,8 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件
Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。
+内嵌 Platform 文档使用独立且不持久化的 WebContentsView 会话。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode,以及同步读取 preload 内存且不再调用 IPC 的 getAuthToken()。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。
+
## 关键技术决策
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
@@ -60,7 +62,7 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
-1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
+1. 主窗口在 profile 准备或后端启动前,从打包静态资源于屏幕外加载共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。
3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
4. 主应用的“插件”页面通过共享[插件管理器](../../packages/boot/plugin-manager/README.zh.md)操作 Desktop profile。包操作使用内置 pnpm 及正常的用户和 profile 配置。
@@ -78,6 +80,8 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
## 开发
+开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
+
`dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不从 npm 解析 dsh:
```sh
@@ -94,6 +98,23 @@ pnpm run start:desktop
Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
+
+### 启动引导
+
+Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 [API Key 页面](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626)。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
+
+欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
+
+### 欢迎窗口预览
+
+构建完整 Desktop 应用并强制显示欢迎页,即使已有配置好的密钥:
+
+```sh
+pnpm run dev:desktop --preview-welcome
+```
+
+预览与普通开发启动共用 Host、开发项目、Harness home、浏览器数据和单实例锁。它使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及标语使用的 Old Style 衬线字体栈。窗口在透明渲染器后使用 macOS titlebar vibrancy 或 Windows acrylic,不额外叠加白色遮罩,也不降低整个窗口的不透明度。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。该标志只改变首次是否展示欢迎页的判断。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
+
## 打包
@@ -322,10 +343,22 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
## 已知限制
+- 账号登录尚未接入;登录按钮禁用。Windows 材质效果仍需平台验证。
+
- 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
+登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
+
+登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
+
+内嵌 Platform 视图在文档加载完成前保持隐藏,让渲染层加载图标可见。关闭或替换待加载视图后,该视图不会再次出现。
+
+私有 Platform 部署请求头由内嵌浏览器会话注入,仅用于配置来源的文档和 API 请求。Cookie 覆盖按名称合并。跨来源请求移除部署请求头;bootstrap 仅暴露 origin 和 token。
+
+账号提供者的 `embeddedPageDist` 配置为内嵌用量和充值页面 URL 添加 `dist` 查询参数。默认值为空;私有前端分支选择值应写在本地 profile patch 中。此配置不改变 API 地址或凭证传递方式。
+
## 开发备注
上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。
diff --git a/apps/desktop/renderer/assets/welcome-brand.svg b/apps/desktop/renderer/assets/welcome-brand.svg
new file mode 100644
index 0000000000..67c917d0bc
--- /dev/null
+++ b/apps/desktop/renderer/assets/welcome-brand.svg
@@ -0,0 +1,44 @@
+
diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css
new file mode 100644
index 0000000000..2bfd791bb1
--- /dev/null
+++ b/apps/desktop/renderer/welcome.css
@@ -0,0 +1,284 @@
+/* Figma 2121:39336 / 2138:44626, Platform modes. Native material supplies the backdrop tint. */
+:root {
+ color-scheme: light dark;
+ --dsw-alias-label-primary: #0f1115;
+ --dsw-alias-label-secondary: #61666b;
+ --dsw-alias-label-tertiary: #81858c;
+ --dsw-alias-label-primary-inverted: #fff;
+ --dsw-alias-brand-primary: var(--dsw-alias-label-primary);
+ --dsw-alias-button-primary-hover: #43454a;
+ --dsw-alias-button-elevated-fill: #fff;
+ --dsw-alias-button-floating-hover: #f1f3f5;
+ --dsw-alias-border-l2: #0000001a;
+ --dsw-alias-border-l4: #00000029;
+ --dsw-alias-label-caption: #adb2b8;
+ --welcome-tint: transparent;
+ --ds-transition-duration: 0.2s;
+ --ds-transition-duration-slow: 0.3s;
+ --ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --dsw-font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
+ 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ --dsw-font-family-serif: 'EB Garamond', 'Iowan Old Style', 'Palatino Linotype',
+ 'URW Palladio L', P052, serif;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --dsw-alias-label-primary: #f9fafb;
+ --dsw-alias-label-secondary: #cfd3d6;
+ --dsw-alias-label-tertiary: #adb2b8;
+ --dsw-alias-label-primary-inverted: #353638;
+ --dsw-alias-button-primary-hover: #ebeef2;
+ --dsw-alias-button-elevated-fill: #43454a;
+ --dsw-alias-button-floating-hover: #353638;
+ --dsw-alias-border-l2: #ffffff1f;
+ --dsw-alias-border-l4: #ffffff33;
+ --dsw-alias-label-caption: #81858c;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: transparent;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 6px 16px;
+ background: var(--welcome-tint);
+ color: var(--dsw-alias-label-primary);
+ font-family: var(--dsw-font-family);
+ user-select: none;
+ -webkit-font-smoothing: antialiased;
+}
+
+.titlebar {
+ flex: 0 0 36px;
+ app-region: drag;
+}
+
+.welcome {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ padding: 24px 48px 76px;
+}
+
+.welcome.key-page {
+ padding-bottom: 32px;
+}
+
+.brand {
+ display: block;
+ flex: none;
+ width: 100%;
+ height: 40px;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+
+.tagline {
+ margin: 0;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 28px;
+ font-weight: 300;
+ line-height: 48px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.tagline em {
+ color: var(--dsw-alias-label-primary);
+ font-family: var(--dsw-font-family-serif);
+ font-style: italic;
+ font-weight: 600;
+}
+
+.actions {
+ display: flex;
+ flex: none;
+ flex-direction: column;
+ gap: 16px;
+ width: 320px;
+}
+
+.actions button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 44px;
+ padding: 8px 16px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ font-family: inherit;
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 22px;
+ opacity: 1;
+ app-region: no-drag;
+ cursor: pointer;
+ transition:
+ background-color var(--ds-transition-duration) var(--ds-ease-in-out),
+ border-color var(--ds-transition-duration) var(--ds-ease-in-out),
+ opacity var(--ds-transition-duration) var(--ds-ease-in-out),
+ color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
+}
+
+.actions button:disabled {
+ cursor: not-allowed;
+ opacity: 0.4;
+}
+
+.actions button:focus-visible {
+ outline: 2px solid var(--dsw-alias-label-primary);
+ outline-offset: 3px;
+}
+
+.primary {
+ background: var(--dsw-alias-brand-primary);
+ color: var(--dsw-alias-label-primary-inverted);
+}
+
+.primary:hover:not(:disabled) {
+ background: var(--dsw-alias-button-primary-hover);
+}
+
+.actions .secondary {
+ border-color: var(--dsw-alias-border-l2);
+ background: var(--dsw-alias-button-elevated-fill);
+ color: var(--dsw-alias-label-primary);
+}
+
+.actions .back {
+ align-self: center;
+ min-width: 58px;
+ height: 28px;
+ padding: 4px 10px;
+ border: 0;
+ border-radius: 24px;
+ corner-shape: round;
+ background: transparent;
+ color: var(--dsw-alias-label-secondary);
+ font-size: 13px;
+ line-height: 20px;
+}
+
+/* Platform text actions use color-only hover, preserving the native backdrop. */
+.actions .back:hover:not(:disabled) {
+ color: var(--dsw-alias-brand-primary);
+}
+
+.actions .back:disabled {
+ color: var(--dsw-alias-label-caption);
+ opacity: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .actions button {
+ transition: none;
+ }
+}
+
+.actions .secondary:hover:not(:disabled) {
+ background: var(--dsw-alias-button-floating-hover);
+}
+
+.key-form {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 40px;
+ width: 440px;
+}
+
+.key-heading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ text-align: center;
+}
+
+.key-heading h1 {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 500;
+ line-height: 32px;
+ letter-spacing: 0.01em;
+}
+
+.key-heading p {
+ margin: 0;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 22px;
+ letter-spacing: 0.02em;
+}
+
+.key-field {
+ position: relative;
+ width: 100%;
+}
+
+/* Text inputs match :focus-visible after pointer focus too; their border carries focus. */
+.key-field input {
+ display: block;
+ width: 100%;
+ height: 48px;
+ padding: 7px 14px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 12px;
+ outline: none;
+ background: transparent;
+ color: var(--dsw-alias-label-primary);
+ font-family: inherit;
+ font-size: 14px;
+ line-height: 24px;
+ user-select: text;
+}
+
+.key-field input:focus {
+ border-color: var(--dsw-alias-brand-primary);
+}
+
+.key-field input::placeholder {
+ color: var(--dsw-alias-label-caption);
+ opacity: 1;
+}
+
+.key-error {
+ position: absolute;
+ top: 100%;
+ width: 100%;
+ margin: 8px 0 0;
+ color: var(--dsw-alias-label-primary);
+ font-size: 14px;
+ line-height: 22px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
diff --git a/apps/desktop/renderer/welcome.html b/apps/desktop/renderer/welcome.html
new file mode 100644
index 0000000000..83248438c4
--- /dev/null
+++ b/apps/desktop/renderer/welcome.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/renderer/welcome.js b/apps/desktop/renderer/welcome.js
new file mode 100644
index 0000000000..13b9daeaf6
--- /dev/null
+++ b/apps/desktop/renderer/welcome.js
@@ -0,0 +1,113 @@
+/* Desktop entry and API-key form; credentials leave only through the narrow preload. */
+const api = window.dshWelcome
+const { id, messages } = api
+document.documentElement.lang = id
+document.title = messages.welcomeTitle
+document.querySelector('.brand').alt = messages.welcomeBrand
+document.querySelector('#tagline-before').textContent = messages.welcomeTaglineBefore
+document.querySelector('#tagline-brand').textContent = messages.welcomeTaglineBrand
+document.querySelector('#tagline-after').textContent = messages.welcomeTaglineAfter
+document.querySelector('#sign-in').textContent = messages.welcomeSignIn
+document.querySelector('#api-key').textContent = messages.welcomeApiKey
+document.querySelector('#key-title').textContent = messages.welcomeKeyTitle
+document.querySelector('#key-description').textContent = messages.welcomeKeyDescription
+document.querySelector('#key-label').textContent = messages.welcomeKeyPlaceholder
+document.querySelector('#skip-key').textContent = messages.welcomeKeyLater
+document.querySelector('#back-to-login').textContent = messages.welcomeKeyBack
+
+const form = document.querySelector('#key-form')
+const input = document.querySelector('#key-input')
+const error = document.querySelector('#key-error')
+const save = document.querySelector('#save-key')
+const skip = document.querySelector('#skip-key')
+const back = document.querySelector('#back-to-login')
+const welcome = document.querySelector('.welcome')
+let busy = false
+
+input.placeholder = messages.welcomeKeyPlaceholder
+save.textContent = messages.welcomeKeySave
+
+function showError(message) {
+ error.textContent = message
+ error.hidden = message === ''
+ input.setAttribute('aria-invalid', String(message !== ''))
+}
+
+function renderBusy() {
+ input.disabled = busy
+ save.disabled = busy || input.value.trim() === ''
+ skip.disabled = busy
+ back.disabled = busy
+ form.setAttribute('aria-busy', String(busy))
+}
+
+document.querySelector('#api-key').addEventListener('click', () => {
+ document.querySelector('#tagline').hidden = true
+ document.querySelector('#entry-actions').hidden = true
+ form.hidden = false
+ document.querySelector('#key-actions').hidden = false
+ welcome.setAttribute('aria-labelledby', 'key-title')
+ welcome.classList.add('key-page')
+ input.focus()
+})
+
+back.addEventListener('click', () => {
+ if (busy) return
+ input.value = ''
+ showError('')
+ renderBusy()
+ form.hidden = true
+ document.querySelector('#key-actions').hidden = true
+ document.querySelector('#tagline').hidden = false
+ document.querySelector('#entry-actions').hidden = false
+ welcome.setAttribute('aria-labelledby', 'tagline')
+ welcome.classList.remove('key-page')
+ document.querySelector('#api-key').focus()
+})
+
+input.addEventListener('input', () => {
+ showError('')
+ renderBusy()
+})
+
+form.addEventListener('submit', async (event) => {
+ event.preventDefault()
+ if (busy) return
+ const value = input.value.trim()
+ if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value)
+ || ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) {
+ showError(value === '' ? messages.welcomeKeyBlank : messages.welcomeKeyInvalid)
+ input.focus()
+ return
+ }
+ busy = true
+ showError('')
+ renderBusy()
+ try {
+ const result = await api.saveApiKey(value)
+ if (!result.ok) showError(messages.welcomeKeyFailed)
+ else input.value = ''
+ } catch {
+ showError(messages.welcomeKeyFailed)
+ } finally {
+ busy = false
+ renderBusy()
+ }
+})
+
+skip.addEventListener('click', async () => {
+ if (busy) return
+ busy = true
+ renderBusy()
+ try {
+ await api.skip()
+ input.value = ''
+ } catch {
+ showError(messages.welcomeContinueFailed)
+ } finally {
+ busy = false
+ renderBusy()
+ }
+})
+
+renderBusy()
diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts
index 05e1bdbf0e..1ce40ff758 100644
--- a/apps/desktop/scripts/dev.ts
+++ b/apps/desktop/scripts/dev.ts
@@ -54,7 +54,7 @@ async function runPackageScript(script: string, cwd: string): Promise {
await run(process.execPath, [packageManager, 'run', script], cwd)
}
-async function launchElectron(): Promise {
+async function launchElectron(previewWelcome: boolean): Promise {
const require = createRequire(import.meta.url)
const electron: unknown = require('electron')
if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable')
@@ -77,6 +77,7 @@ async function launchElectron(): Promise {
`--remote-debugging-port=${String(rendererPort)}`,
`--user-data-dir=${userData}`,
APP_ROOT,
+ ...previewWelcome ? ['--preview-welcome'] : [],
], APP_ROOT, environment)
}
@@ -110,7 +111,7 @@ async function main(): Promise {
release,
})
await preparePrimaryRuntime()
- await launchElectron()
+ await launchElectron(values['preview-welcome'])
}
await main().catch((error: unknown) => {
diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts
index eddd683161..36c70baff0 100644
--- a/apps/desktop/scripts/development-project.ts
+++ b/apps/desktop/scripts/development-project.ts
@@ -20,6 +20,7 @@ import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runt
interface PackageManifest {
readonly name?: string
readonly version?: string
+ readonly dependencies?: Readonly>
}
/** Inputs whose locations differ between the launcher and isolated tests. */
@@ -86,6 +87,32 @@ function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): str
return names
}
+/** Configured workspace plugins must resolve from the profile even when pnpm does not hoist them. */
+function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot: string): string[] {
+ const names: string[] = []
+ const visited = new Set()
+ const visit = (directory: string): void => {
+ const source = realpathSync(directory)
+ if (visited.has(source)) return
+ visited.add(source)
+ const manifest = readManifest(join(source, 'package.json'))
+ for (const [name, specifier] of Object.entries(manifest.dependencies ?? {})) {
+ if (!specifier.startsWith('workspace:')) continue
+ const dependency = join(source, 'node_modules', ...name.split('/'))
+ if (!existsSync(dependency)) {
+ throw new Error(`desktop development: ${name} is missing from ${source}; run pnpm install`)
+ }
+ const destination = join(destinationRoot, ...name.split('/'))
+ removeOwnedPath(destination)
+ linkDirectory(dependency, destination)
+ names.push(name)
+ visit(dependency)
+ }
+ }
+ for (const directory of roots) visit(directory)
+ return names
+}
+
/**
* Replace one disposable project with links to the current built workspace.
* @param options - Project destination, CLI package, and release identity.
@@ -117,7 +144,10 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s
createDevelopmentProjectMetadata(options.projectDir, options.release)
const destinationModules = join(options.projectDir, 'node_modules')
mkdirSync(destinationModules, { recursive: true })
- const names = mirrorDependencyLinks(options.dependencyDir, destinationModules)
+ const names = [
+ ...mirrorDependencyLinks(options.dependencyDir, destinationModules),
+ ...mirrorWorkspaceDependencies([options.cliDir, options.hostDir], destinationModules),
+ ]
const dshLink = join(destinationModules, '@deepseek-ai', 'dsh')
removeOwnedPath(dshLink)
linkDirectory(options.cliDir, dshLink)
diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts
index e78db93fee..ed884ae99e 100644
--- a/apps/desktop/src/ipc.ts
+++ b/apps/desktop/src/ipc.ts
@@ -7,6 +7,8 @@ export const DESKTOP_IPC = {
boot: 'dsh-desktop:boot',
bootFailed: 'dsh-desktop:boot-failed',
directoryPick: 'dsh-desktop:directory-pick',
+ localeBootstrap: 'dsh-desktop:locale-bootstrap',
+ localeChanged: 'dsh-desktop:locale-changed',
updatesStatus: 'dsh-desktop:updates-status',
updatesOpen: 'dsh-desktop:updates-open',
updatesPresentation: 'dsh-desktop:updates-presentation',
diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts
index 03e6f29a57..3043fa6be0 100644
--- a/apps/desktop/src/locale.ts
+++ b/apps/desktop/src/locale.ts
@@ -21,7 +21,35 @@ export const en = {
restartApplication: 'Restart',
recoveryOperationFailed: 'The recovery operation failed',
disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
+ welcomeTitle: 'DeepSeek Harness',
+ welcomeBrand: 'DeepSeek Harness',
+ welcomeTaglineBefore: 'Go deeper with ',
+ welcomeTaglineBrand: 'DeepSeek',
+ welcomeTaglineAfter: '',
+ welcomeAuthStarting: 'Opening sign in…',
+ welcomeAuthWaiting: 'Continue in your browser',
+ welcomeAuthExchanging: 'Completing sign in…',
+ welcomeAuthExpired: 'Sign in timed out',
+ welcomeAuthExpiredDescription: 'Sign in again to continue',
+ welcomeAuthFailed: 'Could not complete sign in. Please try again.',
+ welcomeAuthReopen: 'Open browser again',
+ welcomeAuthCancel: 'Cancel',
+ welcomeAuthRetry: 'Sign in again',
+ welcomeSignIn: 'Sign in',
+ welcomeApiKey: 'Add API Key',
+ welcomeKeyTitle: 'Add an API key to get started',
+ welcomeKeyDescription: 'Configure official DeepSeek models to start using Harness',
+ welcomeKeyPlaceholder: 'Enter API key',
+ welcomeKeySave: 'Save and continue',
+ welcomeKeyLater: 'Set up later',
+ welcomeKeyBack: 'Back to sign in',
+ welcomeKeyBlank: 'Enter an API key.',
+ welcomeKeyInvalid: 'Enter the API key itself, without quotes, spaces, or an environment-variable assignment.',
+ welcomeKeyFailed: 'Could not save the API key. Please try again.',
+ welcomeContinueFailed: 'Could not open the workspace. Please try again.',
checkUpdatesMenu: 'Check for Updates…',
+ reloadPageMenu: 'Reload Page',
+ restartAppHostMenu: 'Restart App and Host',
updateCheckFailedTitle: 'Update Check Failed',
updateCheckFailed: 'Could not check for updates. Please try again later.',
updateDownloadFailed: 'Could not download the update. Please try again.',
@@ -112,7 +140,35 @@ export const zh = {
restartApplication: '重启',
recoveryOperationFailed: '恢复操作失败',
disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
+ welcomeTitle: 'DeepSeek Harness',
+ welcomeBrand: 'DeepSeek Harness',
+ welcomeTaglineBefore: '与 ',
+ welcomeTaglineBrand: 'DeepSeek',
+ welcomeTaglineAfter: ' 深度对话',
+ welcomeAuthStarting: '正在打开登录…',
+ welcomeAuthWaiting: '请在浏览器中继续',
+ welcomeAuthExchanging: '正在完成登录…',
+ welcomeAuthExpired: '登录已超时',
+ welcomeAuthExpiredDescription: '请重新登录后继续操作',
+ welcomeAuthFailed: '登录未完成,请重试。',
+ welcomeAuthReopen: '重新打开浏览器',
+ welcomeAuthCancel: '取消',
+ welcomeAuthRetry: '重新登录',
+ welcomeSignIn: '登录',
+ welcomeApiKey: '添加 API Key',
+ welcomeKeyTitle: '添加一个 API Key 开始使用',
+ welcomeKeyDescription: '配置 DeepSeek 官方模型,即可开始使用',
+ welcomeKeyPlaceholder: '输入 API 密钥',
+ welcomeKeySave: '保存并继续',
+ welcomeKeyLater: '稍后配置',
+ welcomeKeyBack: '返回登录',
+ welcomeKeyBlank: '请输入 API 密钥。',
+ welcomeKeyInvalid: '请仅输入 API 密钥,不要包含引号、空格或环境变量赋值。',
+ welcomeKeyFailed: '无法保存 API 密钥,请重试。',
+ welcomeContinueFailed: '无法打开工作区,请重试。',
checkUpdatesMenu: '检查更新…',
+ reloadPageMenu: '刷新页面',
+ restartAppHostMenu: '重启应用与 Host',
updateCheckFailedTitle: '更新检查失败',
updateCheckFailed: '检查更新失败,请稍后重试。',
updateDownloadFailed: '下载更新失败,请重试。',
@@ -192,6 +248,22 @@ export function resolveDesktopLocale(locale: string): DesktopLocale {
: { id: 'en', messages: en }
}
+/**
+ * Choose a built-in dictionary from the shared preference, then ordered OS languages.
+ * @param preference - explicit locale.preference, or null when no language was selected.
+ * @param languages - operating-system languages in preference order.
+ * @returns the supported dictionary, falling back to English.
+ */
+export function resolveDesktopStartupLocale(preference: string | null, languages: readonly string[]): DesktopLocale {
+ const selected = preference?.toLowerCase()
+ if (selected === 'zh' || selected === 'en') return resolveDesktopLocale(selected)
+ for (const language of languages) {
+ const primary = language.toLowerCase().split('-')[0]
+ if (primary === 'zh' || primary === 'en') return resolveDesktopLocale(primary)
+ }
+ return resolveDesktopLocale('en')
+}
+
/** Replace named placeholders in one locale-owned message. */
export function formatDesktopMessage(
message: string,
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index f9bfb78409..7b0bd7f807 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -12,6 +12,7 @@ import {
Menu,
powerMonitor,
nativeTheme,
+ net,
protocol,
session,
shell,
@@ -21,14 +22,18 @@ import {
import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
+import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
import { DesktopBackendController } from './backend-controller.ts'
import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
-import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
+import { formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
import { claimDesktopSingleInstance } from './single-instance.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
import { DesktopFatalRecovery } from './fatal-recovery.ts'
+import { openWelcomeWindow } from './welcome-window.ts'
+import { WELCOME_IPC, needsWelcome } from './welcome-api.ts'
+import { connectDesktopWelcome, type DesktopWelcomeBackend } from './welcome-backend.ts'
import { DesktopUpdateJournal } from './update-journal.ts'
import { DesktopUpdatePreparationError } from './update-error.ts'
import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './update-schedule.ts'
@@ -126,8 +131,6 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
titleBarStyle: 'hiddenInset' as const,
trafficLightPosition: { x: 16, y: 18 },
vibrancy: 'sidebar' as const,
- // 'active' keeps the vibrancy material stable when the window blurs;
- // 'followWindow' washes the sidebar out behind an unfocused window.
visualEffectState: 'active' as const,
backgroundColor: '#00000000',
} : {}),
@@ -183,6 +186,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
}
async function main(): Promise {
+ const previewWelcome = !app.isPackaged && process.argv.includes('--preview-welcome')
const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
const resources = runtimeResources()
@@ -194,18 +198,22 @@ async function main(): Promise {
let startup: Promise | undefined
let workspaceRecovery: Promise | undefined
let mainWindow: BrowserWindow | undefined
+ let welcomeWindow: BrowserWindow | undefined
+ let enteredWorkspace = false
let shellInstallerOwnsQuit = false
let requireCleanStop = false
let updateStoppedHost = false
let updateStopFailure: DesktopHostUncleanExitError | undefined
let updateState: DesktopUpdateState = { phase: 'idle' }
+ const systemLanguages = app.getPreferredSystemLanguages()
+ let locale = resolveDesktopStartupLocale(null, systemLanguages)
+ windowsLanguage = locale.id
let mandatoryPolicy: DesktopMandatoryUpdatePolicy | undefined
let mandatoryUI: DesktopMandatoryUpdateWindow | undefined
let policyAuth: DesktopPolicyTestAuth | undefined
const isQuitting = (): boolean => quitting
const currentMainWindow = (): BrowserWindow | undefined => mainWindow
const ordinaryDialogs = new Set()
- const locale = resolveDesktopLocale(app.getLocale())
const messages = locale.messages
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), locale)
const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
@@ -223,6 +231,11 @@ async function main(): Promise {
let hostUrl: string | undefined
let hostCookie: string | undefined
let injections: readonly unknown[] = []
+ let welcomeBackend: DesktopWelcomeBackend | undefined
+ let stopAccount: (() => void) | undefined
+ let openedAttempt: string | undefined
+ let returnedAttempt: string | undefined
+ let previousAccountStatus: string | undefined
const assertProductSender = (event: IpcMainInvokeEvent): void => {
assertDesktopSender(event, ['app'])
if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
@@ -245,13 +258,14 @@ async function main(): Promise {
navigation = next
return next.promise
}
+ const platformView = new DesktopPlatformView(join(app.getAppPath(), 'lib', 'preload-platform-account.cjs'))
const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
hostInspectPort, process.env, onFailure,
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
- development ? 'link' : 'runtime', resources)
+ development ? 'link' : 'runtime', resources, (next) => { platformView.setSession(next) })
return {
start: async () => {
const ready = await host.start()
@@ -259,6 +273,31 @@ async function main(): Promise {
hostUrl = ready.url
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
injections = ready.injections
+ welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
+ stopAccount?.()
+ stopAccount = welcomeBackend.account.watch((state) => {
+ if (quitting) return
+ if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
+ const attempt = state.attempt
+ if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) {
+ openedAttempt = attempt.id
+ void shell.openExternal(attempt.authorizeUrl).catch(() => undefined)
+ }
+ if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) {
+ returnedAttempt = attempt.id
+ focusPrimaryWindow()
+ }
+ if (attempt?.phase === 'succeeded' && welcomeWindow !== undefined) void enterWorkspace().catch(() => undefined)
+ if (previousAccountStatus === 'credential-stored' && state.status === 'signed-out') {
+ void readWelcomeState().then((value) => {
+ if (!value.hasApiKey && !quitting) { enteredWorkspace = false; return showWelcome() }
+ return undefined
+ }).catch(() => undefined)
+ }
+ previousAccountStatus = state.status
+ }, () => {
+ if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, { status: 'signed-out', attempt: null })
+ })
},
stop: async () => {
try { await host.stop(requireCleanStop) }
@@ -320,6 +359,10 @@ async function main(): Promise {
return state
}
+ const readWelcomeState = async () => {
+ if (backend.host === undefined || welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
+ return welcomeBackend.read()
+ }
stopForRecovery = () => backend.close()
const reconcileBackend = (): Promise => {
@@ -328,6 +371,7 @@ async function main(): Promise {
await backend.start(async () => {
await manager.applyRelease(app.isPackaged)
})
+ if (backend.host !== undefined) await openInitialWindow()
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
// The existing Web document resumes through the boot IPC response.
})().catch((error: unknown) => {
@@ -444,11 +488,49 @@ async function main(): Promise {
callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
})
+ const assertMainApplication = (event: IpcMainInvokeEvent): BrowserWindow => {
+ const owner = mainWindow
+ if (owner === undefined || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame
+ || !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command')
+ return owner
+ }
+ ipcMain.on(PLATFORM_IPC.bootstrap, (event) => {
+ try { event.returnValue = platformView.bootstrap(event) }
+ catch { event.returnValue = null }
+ })
+ ipcMain.handle(PLATFORM_IPC.open, (event, page: unknown, bounds: unknown) => {
+ const owner = assertMainApplication(event)
+ if (page !== 'usage' && page !== 'top-up') throw new Error('Invalid Platform page')
+ return platformView.open(owner, page, platformBounds(bounds))
+ })
+ ipcMain.handle(PLATFORM_IPC.bounds, (event, bounds: unknown) => {
+ assertMainApplication(event)
+ platformView.setBounds(platformBounds(bounds))
+ })
+ ipcMain.handle(PLATFORM_IPC.close, (event) => { assertMainApplication(event); platformView.close() })
// Only the main window may synchronize its palette with the native material.
ipcMain.on(DESKTOP_IPC.nativeThemeSet, (event, source: unknown) => {
if (mainWindow === undefined || event.sender !== mainWindow.webContents) return
if (source === 'light' || source === 'dark' || source === 'system') nativeTheme.themeSource = source
})
+ ipcMain.handle(DESKTOP_IPC.localeBootstrap, async (event) => {
+ if (event.sender !== mainWindow?.webContents || event.senderFrame !== mainWindow.webContents.mainFrame
+ || new URL(event.senderFrame.url).origin !== new URL(applicationUrl).origin) {
+ throw new Error('desktop welcome: rejected locale request from an unowned frame')
+ }
+ const current = await readWelcomeState()
+ return { languages: systemLanguages, preference: current.localePreference }
+ })
+ ipcMain.on(DESKTOP_IPC.localeChanged, (event, next: unknown) => {
+ const window = mainWindow
+ if (window === undefined || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame
+ || typeof next !== 'string') return
+ const current = resolveDesktopStartupLocale(next, systemLanguages)
+ if (current.id === locale.id) return
+ locale = current
+ windowsLanguage = locale.id
+ installMenu()
+ })
ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
assertProductSender(event)
return presentDesktopUpdate(updates.state)
@@ -607,14 +689,26 @@ async function main(): Promise {
{ label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
{ type: 'separator' },
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
+ ...development ? [
+ { type: 'separator' as const },
+ { label: currentDesktopLocale().messages.reloadPageMenu, role: 'reload' as const },
+ { label: currentDesktopLocale().messages.restartAppHostMenu, click: () => {
+ if (quitting) return
+ app.relaunch()
+ app.quit()
+ } },
+ ] : [],
{ type: 'separator' },
...hideCommands,
{ role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) },
]
- Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
- label: darwin ? app.name : currentDesktopLocale().messages.application,
- submenu: applicationItems(),
- }, ...platformMenus]))
+ const installMenu = (): void => {
+ Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{
+ label: process.platform === 'darwin' ? app.name : currentDesktopLocale().messages.application,
+ submenu: applicationItems(),
+ }, ...platformMenus]))
+ }
+ installMenu()
if (process.platform === 'win32') {
ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
@@ -666,7 +760,7 @@ async function main(): Promise {
}
const createMainWindow = (): BrowserWindow => {
- const window = createWindow(appPreload, true, true)
+ const window = createWindow(appPreload, false, true)
mainWindow = window
window.on('focus', automaticCheck)
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
@@ -686,13 +780,89 @@ async function main(): Promise {
})
return window
}
+ const enterWorkspace = async (): Promise => {
+ if (quitting) return
+ const window = mainWindow ?? createMainWindow()
+ await navigateMain(applicationUrl)
+ // Window events can change startup state while navigation is pending.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (quitting || recovery.active || window.isDestroyed()) return
+ window.show()
+ enteredWorkspace = true
+ welcomeWindow?.close()
+ welcomeWindow = undefined
+ if (development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
+ window.webContents.openDevTools({ mode: 'detach' })
+ }
+ }
+ let openingWelcome: Promise | undefined
+ const showWelcome = (): Promise => {
+ if (quitting) return Promise.resolve()
+ openingWelcome ??= (async () => {
+ welcomeWindow = await openWelcomeWindow(locale, {
+ startSignIn: async () => {
+ if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
+ return welcomeBackend.account.start(locale.id)
+ },
+ cancelSignIn: async (id) => {
+ if (welcomeBackend === undefined) throw new Error('desktop welcome: backend unavailable')
+ return welcomeBackend.account.cancel(id)
+ },
+ reopenSignIn: async (id) => {
+ const state = await welcomeBackend?.account.state()
+ if (state?.attempt?.id === id && state.attempt.phase === 'waiting-browser' && state.attempt.authorizeUrl !== undefined) {
+ await shell.openExternal(state.attempt.authorizeUrl)
+ }
+ },
+ saveApiKey: async (apiKey) => {
+ if (backend.host === undefined || welcomeBackend === undefined) return { ok: false }
+ const saved = await welcomeBackend.save(apiKey)
+ if (!saved.ok) return saved
+ await enterWorkspace()
+ return { ok: true }
+ },
+ skip: enterWorkspace,
+ })
+ const window = welcomeWindow
+ window.once('closed', () => {
+ void welcomeBackend?.account.state().then((state) => {
+ if (state.attempt !== null && !enteredWorkspace) return welcomeBackend?.account.cancel(state.attempt.id)
+ return undefined
+ }).catch(() => undefined)
+ })
+ window.once('closed', () => {
+ if (welcomeWindow === window) welcomeWindow = undefined
+ if (!enteredWorkspace && !recovery.active) mainWindow?.close()
+ })
+ // Quit and backend failure callbacks can run while the welcome document loads.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (quitting || recovery.active) window.close()
+ else mainWindow?.hide()
+ })().finally(() => { openingWelcome = undefined })
+ return openingWelcome
+ }
+ const openInitialWindow = async (): Promise => {
+ if (quitting || recovery.active) return
+ const state = await readWelcomeState()
+ // Quit or a backend failure can occur while the Host answers the preference request.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (quitting || backend.state.phase !== 'ready') return
+ locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages)
+ windowsLanguage = locale.id
+ installMenu()
+ if (!enteredWorkspace && (previewWelcome || needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey }))) {
+ await showWelcome()
+ } else {
+ await enterWorkspace()
+ }
+ }
focusPrimaryWindow = () => {
if (quitting) return
if (isMandatory()) { mandatoryUI?.focus(); return }
- const window = mainWindow
+ const window = welcomeWindow ?? mainWindow
if (window === undefined || window.isDestroyed()) {
try { createMainWindow() } catch (error) { reportFatal(error); return }
- void navigateMain(applicationUrl).catch(reportFatal)
+ void (backend.state.phase === 'ready' ? openInitialWindow() : navigateMain(applicationUrl)).catch(reportFatal)
return
}
if (window.isMinimized()) window.restore()
@@ -700,6 +870,12 @@ async function main(): Promise {
window.focus()
}
+ if (app.isPackaged || process.env.DSH_DESKTOP_DEV_APP === '1') app.setAsDefaultProtocolClient('dsh')
+ app.on('open-url', (event, url) => {
+ event.preventDefault()
+ if (url === 'dsh://open' || url === 'dsh://open/') focusPrimaryWindow()
+ })
+
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow()
})
@@ -717,6 +893,8 @@ async function main(): Promise {
if (quitting) return
event.preventDefault()
quitting = true
+ stopAccount?.()
+ if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.hide()
if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide()
updateSchedule.dispose()
updateDialog.dispose()
diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts
index 26b7334707..622769cb18 100644
--- a/apps/desktop/src/preload-app.ts
+++ b/apps/desktop/src/preload-app.ts
@@ -2,6 +2,7 @@
import { contextBridge, ipcRenderer } from 'electron'
import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts'
+import { PLATFORM_IPC } from './platform-ipc.ts'
import { markDocumentPlatform } from './preload-platform.ts'
import { syncNativeTheme } from './preload-theme.ts'
import { syncWindowsAppearance } from './preload-windows.ts'
@@ -28,9 +29,21 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise,
failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise,
})
+ contextBridge.exposeInMainWorld('dshPlatform', {
+ open: (page: 'usage' | 'top-up', bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.invoke(PLATFORM_IPC.open, page, bounds),
+ setBounds: (bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.invoke(PLATFORM_IPC.bounds, bounds),
+ close: () => ipcRenderer.invoke(PLATFORM_IPC.close),
+ })
}
markDocumentPlatform()
syncNativeTheme()
// Main-process IPC also verifies the owning window and top frame.
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? product : { protocolVersion: 1 })
+
+if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
+ contextBridge.exposeInMainWorld('__DSH_LOCALE__', {
+ read: () => ipcRenderer.invoke(DESKTOP_IPC.localeBootstrap),
+ onChange: (locale: string) => { ipcRenderer.send(DESKTOP_IPC.localeChanged, locale) },
+ })
+}
diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts
new file mode 100644
index 0000000000..a0ad499dc6
--- /dev/null
+++ b/apps/desktop/src/preload-welcome.ts
@@ -0,0 +1,15 @@
+/** Localized welcome copy and write-only credential actions. */
+
+import { contextBridge, ipcRenderer } from 'electron'
+import { resolveDesktopLocale } from './locale.ts'
+import { WELCOME_IPC, type WelcomeApi, type WelcomeSaveResult } from './welcome-api.ts'
+
+const prefix = '--dsh-welcome-locale='
+const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice(prefix.length)
+if (locale === undefined) throw new Error('desktop welcome: missing window locale')
+const api: WelcomeApi = {
+ ...resolveDesktopLocale(locale),
+ saveApiKey: (value: string) => ipcRenderer.invoke(WELCOME_IPC.saveApiKey, value) as Promise,
+ skip: () => ipcRenderer.invoke(WELCOME_IPC.skip) as Promise,
+}
+contextBridge.exposeInMainWorld('dshWelcome', api)
diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts
new file mode 100644
index 0000000000..85e494ea2c
--- /dev/null
+++ b/apps/desktop/src/welcome-api.ts
@@ -0,0 +1,45 @@
+/** Operations available to the isolated native welcome renderer. */
+
+import type { DesktopLocale } from './locale.ts'
+
+/** Private native welcome channels, installed only while its window exists. */
+export const WELCOME_IPC = {
+ saveApiKey: 'dsh-welcome:save-api-key',
+ skip: 'dsh-welcome:skip',
+} as const
+
+/** Credential writes return a safe outcome without exposing Host diagnostics. */
+export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
+
+/** Host-owned operations used by the welcome window. */
+export interface WelcomeOperations {
+ /**
+ * Store the official provider's key before entering the workspace.
+ * @param value - validated, trimmed API key.
+ * @returns whether the write completed, without private error details.
+ */
+ saveApiKey(value: string): Promise
+ /**
+ * Enter the workspace without writing an onboarding-completion setting.
+ * @returns completion after the workspace opens.
+ */
+ skip(): Promise
+}
+
+/** The renderer receives localized copy and two narrowly scoped operations. */
+export type WelcomeApi = DesktopLocale & WelcomeOperations
+
+/** Authentication facts supplied at cold start or after a completed sign-out. */
+export interface WelcomeAuthentication {
+ readonly loggedIn: boolean
+ readonly hasApiKey: boolean
+}
+
+/**
+ * Decide whether a startup or sign-out requires the welcome entry.
+ * @param authentication - current account and independently stored API-key facts.
+ * @returns true only when neither authentication route is configured.
+ */
+export function needsWelcome(authentication: WelcomeAuthentication): boolean {
+ return !authentication.loggedIn && !authentication.hasApiKey
+}
diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts
new file mode 100644
index 0000000000..2af24c19ae
--- /dev/null
+++ b/apps/desktop/src/welcome-backend.ts
@@ -0,0 +1,117 @@
+/** Native welcome operations using the shared Web authentication and RPC APIs. */
+
+import { randomUUID } from 'node:crypto'
+
+/** Metadata needed before the native entry or workspace becomes visible. */
+export interface WelcomeState {
+ readonly hasApiKey: boolean
+ readonly writable: boolean
+ readonly localePreference: string | null
+}
+
+/** Narrow operations available to the native welcome flow. */
+export interface DesktopWelcomeBackend {
+ /** @returns Configured-key presence and the shared language preference, without credential values. */
+ read(): Promise
+ /**
+ * @param apiKey - User-entered official provider key.
+ * @returns A safe write outcome without provider diagnostics.
+ */
+ save(apiKey: string): Promise<{ ok: boolean }>
+}
+
+function record(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Authenticate the native HTTP client through the Web application's launch URL.
+ * @param authenticatedUrl - URL supplied by the running Desktop Host.
+ * @param send - Electron session fetch, retaining the Web authentication cookie.
+ * @returns metadata reads and write-only credential operations over standard RPC.
+ */
+export async function connectDesktopWelcome(
+ authenticatedUrl: string,
+ send: (input: string, init?: RequestInit) => Promise,
+): Promise {
+ const origin = new URL(authenticatedUrl).origin
+ const authenticated = await send(authenticatedUrl, { credentials: 'include' })
+ await authenticated.body?.cancel()
+ if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
+ const invoke = async (request: { namespace: string; method: string; args: Record }): Promise => {
+ const rpcId = randomUUID()
+ const method = `${request.namespace}/${request.method}`
+ const response = await send(new URL(`/api/${method}`, origin).href, {
+ method: 'POST', credentials: 'include', redirect: 'error',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
+ })
+ if (!response.ok) throw new Error('desktop welcome: Web request failed')
+ const envelope: unknown = await response.json()
+ if (!record(envelope) || envelope.type !== 'server-response' || envelope.rpcId !== rpcId
+ || !record(envelope.result) || envelope.result.ok !== true) {
+ throw new Error('desktop welcome: Web RPC failed')
+ }
+ return envelope.result.value
+ }
+ const settingsAndReference = async () => {
+ const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
+ if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
+ const official = settings.namespaces.find((item: unknown) => record(item) && item.ns === 'llm-deepseek') as unknown
+ if (!record(official) || !record(official.value) || typeof official.value.apiKeyEnv !== 'string') {
+ throw new Error('desktop welcome: missing official DeepSeek credential reference')
+ }
+ return { settings: { namespaces: settings.namespaces }, ref: official.value.apiKeyEnv }
+ }
+ const read = async (): Promise => {
+ const { settings, ref } = await settingsAndReference()
+ const providers = await invoke({ namespace: 'llm', method: 'listConfigurableProviders', args: {} })
+ if (!Array.isArray(providers)) throw new Error('desktop welcome: invalid provider directory')
+ const namespaces = settings.namespaces
+ const refs = providers.flatMap((provider: unknown) => {
+ if (!record(provider) || typeof provider.settingsNs !== 'string' || !Array.isArray(provider.settingsPath)) {
+ throw new Error('desktop welcome: invalid provider settings address')
+ }
+ const namespace = namespaces.find((item: unknown) => record(item) && item.ns === provider.settingsNs) as unknown
+ let value: unknown = record(namespace) ? namespace.value : undefined
+ for (const key of provider.settingsPath as unknown[]) {
+ if (typeof key !== 'string') throw new Error('desktop welcome: invalid provider settings path')
+ value = record(value) ? value[key] : undefined
+ }
+ return record(value) && typeof value.apiKeyEnv === 'string' ? [value.apiKeyEnv] : []
+ })
+ const unique = [...new Set([ref, ...refs])]
+ const states: Record = {}
+ // credentials.describe accepts at most 64 references per request.
+ for (let offset = 0; offset < unique.length; offset += 64) {
+ const batch = await invoke({ namespace: 'credentials', method: 'describe', args: { refs: unique.slice(offset, offset + 64) } })
+ if (!record(batch)) throw new Error('desktop welcome: invalid credential metadata')
+ Object.assign(states, batch)
+ }
+ if (!record(states[ref])) throw new Error('desktop welcome: missing credential metadata')
+ const locale = namespaces.find((item: unknown) => record(item) && item.ns === 'locale') as unknown
+ if (!record(locale) || !record(locale.value)
+ || (locale.value.preference !== undefined && typeof locale.value.preference !== 'string')) {
+ throw new Error('desktop welcome: invalid locale preference')
+ }
+ return {
+ hasApiKey: Object.values(states).some(value => record(value) && value.configured === true),
+ writable: states[ref].writable === true,
+ localePreference: locale.value.preference ?? null,
+ }
+ }
+ return {
+ read,
+ async save(apiKey) {
+ if (!/^[\x21-\x7e]+$/.test(apiKey)) return { ok: false }
+ try {
+ const { ref } = await settingsAndReference()
+ await invoke({ namespace: 'credentials', method: 'set', args: { ref, value: apiKey } })
+ return { ok: true }
+ } catch {
+ // Provider diagnostics may contain credentials; the native form owns failure copy.
+ return { ok: false }
+ }
+ },
+ }
+}
diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts
new file mode 100644
index 0000000000..bf8596b490
--- /dev/null
+++ b/apps/desktop/src/welcome-window.ts
@@ -0,0 +1,86 @@
+/** Native welcome window and its presentation-only renderer. */
+
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { app, BrowserWindow, ipcMain, type BrowserWindowConstructorOptions, type IpcMainInvokeEvent } from 'electron'
+import type { DesktopLocale } from './locale.ts'
+import { WELCOME_IPC, type WelcomeOperations } from './welcome-api.ts'
+
+/**
+ * Resolve the fixed-size welcome window's native material and controls.
+ * @param platform - operating system hosting Electron.
+ * @param locale - shell-owned localized copy.
+ * @returns sandboxed window options with a locale-only preload.
+ */
+export function welcomeWindowOptions(platform: NodeJS.Platform, locale: DesktopLocale): BrowserWindowConstructorOptions {
+ return {
+ width: 600,
+ height: 700,
+ useContentSize: true,
+ center: true,
+ resizable: false,
+ maximizable: false,
+ fullscreenable: false,
+ show: false,
+ title: locale.messages.welcomeTitle,
+ backgroundColor: platform === 'darwin' || platform === 'win32' ? '#00000000' : '#FFFFFF',
+ ...(platform === 'darwin' ? {
+ titleBarStyle: 'hidden',
+ trafficLightPosition: { x: 16, y: 21 },
+ vibrancy: 'titlebar',
+ visualEffectState: 'active',
+ } as const : {}),
+ ...(platform === 'win32' ? {
+ titleBarStyle: 'hidden',
+ titleBarOverlay: { color: '#00000000', symbolColor: '#0F1115', height: 42 },
+ backgroundMaterial: 'acrylic',
+ } as const : {}),
+ webPreferences: {
+ preload: fileURLToPath(new URL('./preload-welcome.cjs', import.meta.url)),
+ additionalArguments: [`--dsh-welcome-locale=${locale.id}`],
+ nodeIntegration: false,
+ contextIsolation: true,
+ sandbox: true,
+ webSecurity: true,
+ },
+ }
+}
+
+/**
+ * Open the process's sole welcome window with desktop-owned operations.
+ * The caller closes an existing welcome window before opening another.
+ * @param locale - shell-owned localized copy.
+ * @param operations - credential write and this-launch-only skip actions.
+ * @returns the visible window; a failed load destroys it before rejecting.
+ */
+export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise {
+ const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale))
+ const assertSender = (event: IpcMainInvokeEvent): void => {
+ if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) {
+ throw new Error('desktop welcome: rejected action from an unowned frame')
+ }
+ }
+ ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
+ assertSender(event)
+ if (typeof value !== 'string' || !/^[\x21-\x7e]+$/.test(value)) return { ok: false }
+ return operations.saveApiKey(value)
+ })
+ ipcMain.handle(WELCOME_IPC.skip, async (event) => {
+ assertSender(event)
+ await operations.skip()
+ })
+ window.once('closed', () => {
+ ipcMain.removeHandler(WELCOME_IPC.saveApiKey)
+ ipcMain.removeHandler(WELCOME_IPC.skip)
+ })
+ window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
+ window.webContents.on('will-navigate', (event) => { event.preventDefault() })
+ try {
+ await window.loadFile(join(app.getAppPath(), 'renderer', 'welcome.html'))
+ } catch (error) {
+ if (!window.isDestroyed()) window.destroy()
+ throw error
+ }
+ if (!window.isDestroyed()) window.show()
+ return window
+}
diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts
index 0fe54ab1a3..07e0a0a5ca 100644
--- a/apps/desktop/tests/development-project.spec.ts
+++ b/apps/desktop/tests/development-project.spec.ts
@@ -1,4 +1,4 @@
-import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -31,6 +31,27 @@ afterEach(() => {
})
describe('desktop development project', () => {
+ it('includes declared workspace packages missing from the hoist directory in the runtime inventory', () => {
+ const root = temporaryRoot()
+ const cli = join(root, 'cli')
+ const host = join(root, 'host')
+ const dependency = join(root, 'unhoisted')
+ const hoisted = join(root, 'hoisted')
+ mkdirSync(join(cli, 'node_modules'), { recursive: true })
+ mkdirSync(join(host, 'lib'), { recursive: true })
+ mkdirSync(dependency)
+ mkdirSync(hoisted)
+ writeFileSync(join(cli, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', version: '1.2.3', dependencies: { unhoisted: 'workspace:^' } }))
+ writeFileSync(join(host, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-desktop-host', version: '1.2.3' }))
+ writeFileSync(join(host, 'lib/index.js'), '')
+ writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'unhoisted', version: '1.2.3' }))
+ symlinkSync(dependency, join(cli, 'node_modules/unhoisted'), process.platform === 'win32' ? 'junction' : 'dir')
+ const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release() })
+ expect(realpathSync(join(project, 'node_modules/unhoisted'))).toBe(realpathSync(dependency))
+ const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { sharedPackages: unknown[] }
+ expect(descriptor.sharedPackages).toContainEqual({ name: 'unhoisted', version: '1.2.3', path: 'node_modules/unhoisted' })
+ })
+
it('manages development plugins without modifying the linked workspace packages', async () => {
const root = temporaryRoot()
const cli = join(root, 'apps', 'cli')
diff --git a/apps/desktop/tests/expected/welcome/en-api-key.expected.txt b/apps/desktop/tests/expected/welcome/en-api-key.expected.txt
new file mode 100644
index 0000000000..25c5a624b5
--- /dev/null
+++ b/apps/desktop/tests/expected/welcome/en-api-key.expected.txt
@@ -0,0 +1,8 @@
+DeepSeek Harness
+DeepSeek Harness
+Add an API key to get started
+Configure official DeepSeek models to start using Harness
+Enter API key [password]
+Save and continue [disabled]
+Set up later
+Back to sign in
diff --git a/apps/desktop/tests/expected/welcome/en.expected.txt b/apps/desktop/tests/expected/welcome/en.expected.txt
new file mode 100644
index 0000000000..b23e770617
--- /dev/null
+++ b/apps/desktop/tests/expected/welcome/en.expected.txt
@@ -0,0 +1,5 @@
+DeepSeek Harness
+DeepSeek Harness
+Go deeper with DeepSeek
+Sign in [disabled]
+Add API Key
diff --git a/apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt b/apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt
new file mode 100644
index 0000000000..676a5505e8
--- /dev/null
+++ b/apps/desktop/tests/expected/welcome/zh-CN-api-key.expected.txt
@@ -0,0 +1,8 @@
+DeepSeek Harness
+DeepSeek Harness
+添加一个 API Key 开始使用
+配置 DeepSeek 官方模型,即可开始使用
+输入 API 密钥 [password]
+保存并继续 [disabled]
+稍后配置
+返回登录
diff --git a/apps/desktop/tests/expected/welcome/zh-CN.expected.txt b/apps/desktop/tests/expected/welcome/zh-CN.expected.txt
new file mode 100644
index 0000000000..5c82dce711
--- /dev/null
+++ b/apps/desktop/tests/expected/welcome/zh-CN.expected.txt
@@ -0,0 +1,5 @@
+DeepSeek Harness
+DeepSeek Harness
+与 DeepSeek 深度对话
+登录 [disabled]
+添加 API Key
diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts
index 99dde404aa..2bdd6673bd 100644
--- a/apps/desktop/tests/locale.spec.ts
+++ b/apps/desktop/tests/locale.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts'
+import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, zh } from '../src/locale.ts'
describe('desktop locale dictionaries', () => {
it('ships the same key set in English and Chinese', () => {
@@ -14,4 +14,14 @@ describe('desktop locale dictionaries', () => {
.toBe('plugin@1.2.3 {missing}')
})
+ it('prefers an explicit supported choice, then the first supported system language', () => {
+ expect(resolveDesktopStartupLocale('zh', ['en-US']).id).toBe('zh-CN')
+ expect(resolveDesktopStartupLocale('EN', ['zh-CN']).id).toBe('en')
+ expect(resolveDesktopStartupLocale(null, ['ja-JP', 'zh-Hant', 'en-US']).id).toBe('zh-CN')
+ expect(resolveDesktopStartupLocale(null, ['en-US', 'zh-CN']).id).toBe('en')
+ expect(resolveDesktopStartupLocale(null, ['ja-JP']).id).toBe('en')
+ expect(resolveDesktopStartupLocale(null, []).id).toBe('en')
+ expect(resolveDesktopStartupLocale('ja', ['zh-CN']).id).toBe('zh-CN')
+ })
+
})
diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts
index d68d076971..f741510e20 100644
--- a/apps/desktop/tests/main-startup.spec.ts
+++ b/apps/desktop/tests/main-startup.spec.ts
@@ -1,3 +1,4 @@
+import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
@@ -96,6 +97,7 @@ const harness = await vi.hoisted(async () => {
class FakeHost {
readonly updateTasks = vi.fn(async (_action: 'inspect' | 'lock' | 'unlock') => false)
url = 'http://127.0.0.1:3080/?token=test'
+ fetch = vi.fn(async () => Response.json({ hasApiKey: true, writable: true, localePreference: null }))
readonly ready = deferred()
readonly exited = deferred()
readonly stopping = deferred()
@@ -117,10 +119,12 @@ const harness = await vi.hoisted(async () => {
name: 'Desktop test',
whenReady: () => Promise.resolve(),
getLocale: (): string => 'en-US',
+ getPreferredSystemLanguages: () => ['en-US'],
getVersion: () => '1.0.0',
getAppPath: () => 'desktop-test-app',
setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(),
requestSingleInstanceLock: () => true,
+ setAsDefaultProtocolClient: vi.fn(),
exit: vi.fn(),
relaunch: vi.fn(),
quit: vi.fn(() => {
@@ -132,10 +136,17 @@ const harness = await vi.hoisted(async () => {
}
}),
})
+ let accountListener: ((state: AccountView) => void) | undefined
return {
failWindow(error: Error) { windowFailure = error },
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor,
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
+
+ watchAccount: (listener: (state: AccountView) => void) => {
+ accountListener = listener
+ return () => { accountListener = undefined }
+ },
+ publishAccount(state: AccountView) { accountListener?.(state) },
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
get updateState() { return updateState },
set updateState(value: DesktopUpdateState) { updateState = value },
@@ -163,6 +174,7 @@ const harness = await vi.hoisted(async () => {
set pluginsEnabled(value: boolean) { pluginsEnabled = value },
set closeWindowsOnQuit(value: boolean) { closeWindowsOnQuit = value },
reset() {
+ accountListener = undefined
windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners()
powerMonitor.removeAllListeners()
app.isPackaged = true
@@ -198,6 +210,7 @@ vi.mock('electron', () => ({
dialog: harness.dialog,
shell: { openExternal: harness.openExternal },
nativeTheme: { themeSource: 'system' },
+ net: { fetch: vi.fn() },
ipcMain: {
on: harness.ipcOn,
handle: (channel: string, handler: InvokeHandler) => {
@@ -252,6 +265,13 @@ vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class
readonly install = harness.updateInstall
readonly dispose = vi.fn()
} }))
+vi.mock('../src/welcome-backend.ts', () => ({
+ connectDesktopWelcome: async () => ({
+ read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise,
+ save: async () => ({ ok: true }),
+ account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) },
+ }),
+}))
function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown {
const handler = harness.handlers.get(channel)
@@ -291,6 +311,7 @@ beforeEach(() => {
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
+ vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
})
@@ -1301,12 +1322,13 @@ describe('desktop main startup', () => {
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
})
- it('shows the loading window before profile preparation and starts one actual Host', async () => {
+ it('prepares recovery offscreen and starts one Host before choosing the first visible window', async () => {
await import('../src/main.ts')
await harness.preparing.promise
expect(harness.windows).toHaveLength(1)
const window = harness.windows[0]!
- expect(window.options.show).toBe(true)
+ expect(window.options.show).toBe(false)
+ expect(window.show).not.toHaveBeenCalled()
expect(window.urls).toEqual(['dsh-app://app/'])
expect(harness.hosts).toHaveLength(0)
const retry = invoke(DESKTOP_IPC.boot)
@@ -1361,6 +1383,27 @@ describe('desktop main startup', () => {
expect(harness.hosts).toHaveLength(1)
})
+ it('keeps recovery visible when the backend fails during the welcome preference read', async () => {
+ const preferences = Promise.withResolvers()
+ await import('../src/main.ts')
+ await harness.preparing.promise
+ harness.prepared.resolve()
+ await harness.hostStarted.promise
+ const host = harness.hosts[0]!
+ host.fetch.mockReturnValueOnce(preferences.promise)
+ const startup = expect(Promise.resolve(invoke(DESKTOP_IPC.boot))).rejects.toThrow('Desktop Host is unavailable')
+ host.ready.resolve()
+ await vi.waitFor(() => { expect(host.fetch).toHaveBeenCalledOnce() })
+ host.exited.resolve()
+ host.onFailure!(new Error('backend exited during startup preferences'))
+ await harness.dialogShown.promise
+ preferences.resolve(Response.json({ hasApiKey: true, localePreference: null }))
+ await startup
+ expect(harness.windows[0]!.urls).not.toContain('http://127.0.0.1:3080/?token=test')
+ expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('backend exited during startup preferences')
+ expect(harness.windows[0]!.show).not.toHaveBeenCalled()
+ })
+
it('waits for a pending child to exit on quit without late window navigation', async () => {
await import('../src/main.ts')
await harness.preparing.promise
@@ -1381,3 +1424,21 @@ describe('desktop main startup', () => {
expect(harness.windows).toHaveLength(1)
})
})
+
+it.each(['failed', 'expired'] as const)('focuses DSH once when browser authorization becomes %s', async (phase) => {
+ await import('../src/main.ts')
+ await harness.preparing.promise
+ harness.prepared.resolve()
+ await harness.hostStarted.promise
+ harness.hosts[0]!.ready.resolve()
+ await Promise.resolve(invoke(DESKTOP_IPC.boot))
+ const window = harness.windows[0]!
+ window.focus.mockClear()
+ const state: AccountView = {
+ status: 'signed-out', links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
+ attempt: { id: 'test-failed-attempt' as NonNullable['id'], phase },
+ }
+ harness.publishAccount(state)
+ harness.publishAccount(state)
+ expect(window.focus).toHaveBeenCalledTimes(1)
+})
diff --git a/apps/desktop/tests/preload.e2e.ts b/apps/desktop/tests/preload.e2e.ts
new file mode 100644
index 0000000000..62c4c26889
--- /dev/null
+++ b/apps/desktop/tests/preload.e2e.ts
@@ -0,0 +1,37 @@
+/** Built preloads must execute with only Electron's sandbox-supported require. */
+import { existsSync, readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { runInNewContext } from 'node:vm'
+import { describe, expect, it, vi } from 'vitest'
+
+const preload = (name: string): string => fileURLToPath(new URL(`../lib/${name}.cjs`, import.meta.url))
+
+describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => {
+ it.each(['preload', 'preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => {
+ const exposed = new Map>()
+ const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' }))
+ const send = vi.fn()
+ const electron = {
+ contextBridge: { exposeInMainWorld: (key: string, value: Record) => { exposed.set(key, value) } },
+ ipcRenderer: { invoke, send, on: vi.fn(), off: vi.fn() },
+ }
+ runInNewContext(readFileSync(preload(name), 'utf8'), {
+ require: (id: string) => {
+ if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`)
+ return electron
+ },
+ process: { argv: ['electron', '--dsh-welcome-locale=en'] },
+ location: new URL('http://127.0.0.1:3080/'),
+ exports: {},
+ })
+ if (name === 'preload-app') {
+ const bridge = exposed.get('__DSH_LOCALE__') as { read(): unknown; onChange(locale: string): void }
+ bridge.read()
+ expect(invoke).toHaveBeenCalledWith('dsh-desktop:locale-bootstrap')
+ bridge.onChange('zh')
+ expect(send).toHaveBeenCalledWith('dsh-desktop:locale-changed', 'zh')
+ } else {
+ expect(exposed.has(name === 'preload' ? 'dshDesktop' : 'dshWelcome')).toBe(true)
+ }
+ })
+})
diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts
new file mode 100644
index 0000000000..a127539ffe
--- /dev/null
+++ b/apps/desktop/tests/welcome-flow.e2e.ts
@@ -0,0 +1,92 @@
+/** Built Desktop Host acceptance; run after the repository build, without provider credentials. */
+
+import { createHash } from 'node:crypto'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { DesktopHostProcess } from '../src/host-process.ts'
+import { DesktopProjectManager } from '../src/project-manager.ts'
+import { resolveDesktopPaths } from '../src/paths.ts'
+import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts'
+import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
+import { prepareDevelopmentProject } from '../scripts/development-project.ts'
+
+const repository = fileURLToPath(new URL('../../../', import.meta.url))
+const builtHost = join(repository, 'apps/desktop-host/lib/index.js')
+afterEach(() => { vi.unstubAllEnvs() })
+
+function version(path: string): string {
+ return (JSON.parse(readFileSync(path, 'utf8')) as { version: string }).version
+}
+
+describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
+ it('keeps an unconfigured launch unconfigured and persists an explicitly saved key across restarts', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-welcome-'))
+ let host: DesktopHostProcess | undefined
+ try {
+ for (const name of Object.keys(process.env)) {
+ if (/KEY|TOKEN|SECRET|PASSWORD/u.test(name)) vi.stubEnv(name, undefined)
+ }
+ const home = join(root, 'home')
+ mkdirSync(home)
+ vi.stubEnv('DSH_HOME', home)
+ vi.stubEnv('DSH_TELEMETRY_MODE', 'DISABLED')
+ const project = prepareDevelopmentProject({
+ projectDir: join(root, 'project'),
+ cliDir: join(repository, 'apps/cli'),
+ hostDir: join(repository, 'apps/desktop-host'),
+ dependencyDir: join(repository, 'node_modules/.pnpm/node_modules'),
+ release: {
+ schemaVersion: 1,
+ version: version(join(repository, 'apps/desktop/package.json')),
+ pnpmVersion: version(join(repository, 'apps/desktop/node_modules/pnpm/package.json')),
+ nodeVersion: process.versions.node,
+ hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
+ },
+ })
+ const paths = resolveDesktopPaths(home)
+ const manager = new DesktopProjectManager(paths, {
+ node: process.execPath, pnpm: join(repository, 'apps/desktop/node_modules/pnpm/bin/pnpm.mjs'), dsh: project,
+ })
+ await manager.applyRelease()
+ writeFileSync(join(paths.profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n')
+ let backend: DesktopWelcomeBackend
+ const restart = async (): Promise => {
+ await host?.stop()
+ host = new DesktopHostProcess(process.execPath, project, paths.profile)
+ const { url } = await host.start()
+ let cookie = ''
+ const send: typeof fetch = async (input, init) => {
+ const headers = new Headers(init?.headers)
+ if (cookie !== '') headers.set('cookie', cookie)
+ const response = await fetch(input, { ...init, headers, redirect: 'manual' })
+ if (response.status !== 303) return response
+ cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
+ await response.body?.cancel()
+ return fetch(new URL(response.headers.get('location')!, url), { headers: { cookie } })
+ }
+ backend = await connectDesktopWelcome(url, send)
+ }
+ const status = async () => backend.read()
+ const fingerprint = (): string => createHash('sha256')
+ .update(readFileSync(join(home, '.credentials.yaml'))).digest('hex')
+ await restart()
+ expect(await status()).toMatchObject({ hasApiKey: false, localePreference: null })
+ const before = fingerprint()
+ await host!.stop()
+ writeFileSync(join(home, 'settings.yaml'), 'locale:\n preference: zh\n')
+ await restart()
+ expect(await status()).toMatchObject({ hasApiKey: false, localePreference: 'zh' })
+ expect(fingerprint()).toBe(before)
+ expect(await backend!.save('sk-local-onboarding-test')).toEqual({ ok: true })
+ expect(await status()).toMatchObject({ hasApiKey: true, localePreference: 'zh' })
+ await restart()
+ expect(await status()).toMatchObject({ hasApiKey: true })
+ } finally {
+ await host?.stop()
+ rmSync(root, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/apps/desktop/tests/welcome-host.spec.ts b/apps/desktop/tests/welcome-host.spec.ts
new file mode 100644
index 0000000000..6ff6da1577
--- /dev/null
+++ b/apps/desktop/tests/welcome-host.spec.ts
@@ -0,0 +1,76 @@
+/** Native onboarding reuses authenticated Web RPC and never reads credential values. */
+import { describe, expect, it, vi } from 'vitest'
+import { connectDesktopWelcome } from '../src/welcome-backend.ts'
+
+function transport(preference?: string) {
+ const keys = new Map()
+ const namespaces = [
+ { ns: 'llm-deepseek', value: { apiKeyEnv: 'CUSTOM_DEEPSEEK_KEY' } },
+ { ns: 'llm-pi-ai', value: { profiles: { example: { apiKeyEnv: 'EXAMPLE_API_KEY' } } } },
+ { ns: 'locale', value: preference === undefined ? {} : { preference } },
+ ]
+ const send = vi.fn[1]>(async (_input, init) => {
+ if (init?.method !== 'POST') return new Response('index')
+ const { rpcId, method, payload } = JSON.parse(init.body as string) as {
+ rpcId: string
+ method: string
+ payload: { args: { ref: string; value: string; refs: string[] } }
+ }
+ let value: unknown
+ if (method === 'settings/describe') value = { namespaces }
+ else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }]
+ else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value)
+ else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }]))
+ return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } })
+ })
+ return { send, keys }
+}
+
+const url = 'http://127.0.0.1:19387/?token=fixture'
+
+describe('desktop welcome Web operations', () => {
+ it('authenticates through Web and stores only through the configured credential reference', async () => {
+ const host = transport()
+ const backend = await connectDesktopWelcome(url, host.send)
+ expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
+ expect(await backend.save('sk-example')).toEqual({ ok: true })
+ expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
+ expect(await backend.read()).toEqual({ hasApiKey: true, writable: true, localePreference: null })
+ for (const [input, init] of host.send.mock.calls.slice(1)) {
+ expect(input).toMatch(/^http:\/\/127\.0\.0\.1:19387\/api\//u)
+ expect(init).toMatchObject({ credentials: 'include', redirect: 'error' })
+ }
+ })
+
+ it('reads the explicit language preference and recognizes another provider key', async () => {
+ const host = transport('zh')
+ host.keys.set('EXAMPLE_API_KEY', 'sk-other')
+ const backend = await connectDesktopWelcome(url, host.send)
+ expect(await backend.read()).toMatchObject({ localePreference: 'zh', hasApiKey: true })
+ host.keys.clear()
+ expect(await backend.read()).toMatchObject({ hasApiKey: false })
+ expect(host.send.mock.calls.every(([, init]) => !(init?.body as string | undefined)?.includes('credentials/set'))).toBe(true)
+ })
+
+ it.each(['', 'bad key', 'key\n'])('rejects malformed keys without contacting a provider: %s', async (value) => {
+ const host = transport()
+ const backend = await connectDesktopWelcome(url, host.send)
+ host.send.mockClear()
+ expect(await backend.save(value)).toEqual({ ok: false })
+ expect(host.send).not.toHaveBeenCalled()
+ })
+
+ it('keeps provider diagnostics out of failures and rejects unmatched RPC envelopes', async () => {
+ const host = transport()
+ const backend = await connectDesktopWelcome(url, host.send)
+ host.send.mockRejectedValueOnce(new Error('private credential sk-must-not-leak'))
+ expect(await backend.save('sk-example')).toEqual({ ok: false })
+ host.send.mockResolvedValueOnce(Response.json({ type: 'server-response', rpcId: 'other', result: { ok: true } }))
+ await expect(backend.read()).rejects.toThrow('Web RPC failed')
+ })
+
+ it('refuses an unauthenticated Web launch', async () => {
+ const send = vi.fn[1]>(async () => new Response(null, { status: 401 }))
+ await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed')
+ })
+})
diff --git a/apps/desktop/tests/welcome-renderer.spec.ts b/apps/desktop/tests/welcome-renderer.spec.ts
new file mode 100644
index 0000000000..0171d42e51
--- /dev/null
+++ b/apps/desktop/tests/welcome-renderer.spec.ts
@@ -0,0 +1,150 @@
+import { readFileSync } from 'node:fs'
+import { JSDOM } from 'jsdom'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { resolveDesktopLocale } from '../src/locale.ts'
+import type { WelcomeSaveResult } from '../src/welcome-api.ts'
+
+const html = readFileSync(new URL('../renderer/welcome.html', import.meta.url), 'utf8')
+const script = readFileSync(new URL('../renderer/welcome.js', import.meta.url), 'utf8')
+const opened: JSDOM[] = []
+afterEach(() => { for (const dom of opened.splice(0)) dom.window.close() })
+
+function mount(language = 'zh-CN') {
+ const dom = new JSDOM(html, { runScripts: 'outside-only' })
+ opened.push(dom)
+ const api = {
+ ...resolveDesktopLocale(language),
+ saveApiKey: vi.fn<(value: string) => Promise>().mockResolvedValue({ ok: true }),
+ skip: vi.fn<() => Promise>().mockResolvedValue(undefined),
+ }
+ Object.defineProperty(dom.window, 'dshWelcome', { value: api })
+ dom.window.eval(script)
+ const document = dom.window.document
+ const input = document.querySelector('input')!
+ const button = (id: string) => document.querySelector(id)!
+ const enterKey = (value: string) => {
+ input.value = value
+ input.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
+ }
+ const submit = () => document.querySelector('form')!.dispatchEvent(new dom.window.Event('submit', { cancelable: true }))
+ const copy = () => {
+ const heading = document.querySelector('main')!.getAttribute('aria-labelledby')!
+ return [
+ document.title, document.querySelector('img')!.alt, document.getElementById(heading)!.textContent,
+ ...heading === 'key-title' ? [document.querySelector('#key-description')!.textContent, `${input.placeholder} [password]`] : [],
+ ...[...document.querySelectorAll('button')].filter(item => item.closest('[hidden]') === null)
+ .map(item => `${item.textContent}${item.disabled ? ' [disabled]' : ''}`),
+ '',
+ ].join('\n')
+ }
+ return { document, api, input, button, enterKey, submit, copy }
+}
+
+describe('desktop welcome presentation', () => {
+ it.each(['zh-CN', 'en'])('renders the %s entry and API-key step', async (language) => {
+ const view = mount(language)
+ expect(view.document.documentElement.lang).toBe(language)
+ expect(view.document.querySelector('img')!.getAttribute('src')).toBe('assets/welcome-brand.svg')
+ await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`)
+ view.button('#api-key').click()
+ expect(view.document.activeElement).toBe(view.input)
+ expect(view.input.type).toBe('password')
+ await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}-api-key.expected.txt`)
+ })
+
+ it('sends one trimmed key, prevents competing actions, and clears it after saving', async () => {
+ const view = mount()
+ const saved = Promise.withResolvers()
+ view.api.saveApiKey.mockReturnValue(saved.promise)
+ view.button('#api-key').click()
+ view.enterKey(' sk-desktop-example ')
+ view.submit()
+ view.submit()
+ view.button('#skip-key').click()
+ view.button('#back-to-login').click()
+ expect(view.api.saveApiKey).toHaveBeenCalledExactlyOnceWith('sk-desktop-example')
+ expect(view.api.skip).not.toHaveBeenCalled()
+ expect(view.button('#save-key').disabled).toBe(true)
+ expect(view.button('#save-key').textContent).toBe(view.api.messages.welcomeKeySave)
+ expect(view.button('#back-to-login').disabled).toBe(true)
+ expect(view.document.querySelector('#key-form')!.hidden).toBe(false)
+ saved.resolve({ ok: true })
+ await vi.waitFor(() => { expect(view.input.value).toBe('') })
+ expect(view.document.body.textContent).not.toContain('sk-desktop-example')
+ })
+
+ it.each(['', 'bad key', '密钥', 'DEEPSEEK_API_KEY=sk-example', '"sk-example"', '`sk-example`'])(
+ 'rejects invalid input before sending it: %s', (value) => {
+ const view = mount()
+ view.button('#api-key').click()
+ view.enterKey(value)
+ view.submit()
+ expect(view.api.saveApiKey).not.toHaveBeenCalled()
+ expect(view.document.querySelector('#key-error')!.hidden).toBe(false)
+ expect(view.input.getAttribute('aria-invalid')).toBe('true')
+ },
+ )
+
+ it('retains an unsaved draft and allows retry after a refused save', async () => {
+ const view = mount()
+ view.api.saveApiKey.mockResolvedValue({ ok: false })
+ view.button('#api-key').click()
+ view.enterKey('sk-retry')
+ view.submit()
+ await vi.waitFor(() => { expect(view.button('#save-key').disabled).toBe(false) })
+ expect(view.input.value).toBe('sk-retry')
+ expect(view.document.querySelector('#key-error')!.textContent).toBe(view.api.messages.welcomeKeyFailed)
+ view.api.saveApiKey.mockResolvedValue({ ok: true })
+ view.submit()
+ await vi.waitFor(() => { expect(view.input.value).toBe('') })
+ })
+
+ it('skips without saving and starts a fresh renderer at the entry again', async () => {
+ const view = mount()
+ const skipped = Promise.withResolvers()
+ view.api.skip.mockReturnValue(skipped.promise)
+ view.button('#api-key').click()
+ view.enterKey('sk-not-saved')
+ view.button('#skip-key').click()
+ try {
+ expect(view.button('#save-key').textContent).toBe(view.api.messages.welcomeKeySave)
+ expect(view.button('#skip-key').textContent).toBe(view.api.messages.welcomeKeyLater)
+ expect(view.button('#save-key').disabled).toBe(true)
+ expect(view.button('#skip-key').disabled).toBe(true)
+ expect(view.button('#back-to-login').disabled).toBe(true)
+ view.button('#skip-key').click()
+ view.submit()
+ expect(view.api.skip).toHaveBeenCalledOnce()
+ expect(view.api.saveApiKey).not.toHaveBeenCalled()
+ } finally {
+ skipped.resolve(undefined)
+ }
+ await vi.waitFor(() => { expect(view.input.value).toBe('') })
+ expect(view.api.skip).toHaveBeenCalledOnce()
+ expect(view.api.saveApiKey).not.toHaveBeenCalled()
+ expect(mount().document.querySelector('#key-form')!.hidden).toBe(true)
+ })
+
+ it('returns to the entry without saving and clears the draft and validation error', () => {
+ const view = mount()
+ view.button('#api-key').click()
+ view.enterKey('invalid key')
+ view.submit()
+ view.button('#back-to-login').click()
+ expect(view.document.querySelector('#key-form')!.hidden).toBe(true)
+ expect(view.document.activeElement).toBe(view.button('#api-key'))
+ expect(view.input.value).toBe('')
+ expect(view.api.saveApiKey).not.toHaveBeenCalled()
+ expect(view.api.skip).not.toHaveBeenCalled()
+ view.button('#api-key').click()
+ expect(view.input.value).toBe('')
+ expect(view.document.querySelector('#key-error')!.hidden).toBe(true)
+ expect(view.button('#save-key').disabled).toBe(true)
+ })
+
+ it('keeps visible copy in the shell dictionaries and denies network access', () => {
+ expect([...html.matchAll(/>([^<]*\p{L}[^<]*) ({
+ quit: vi.fn(),
+ startHost: vi.fn().mockResolvedValue({ url: 'http://127.0.0.1:3080/?token=test' }),
+ stopHost: vi.fn<() => Promise>().mockResolvedValue(undefined),
+ loadWorkspace: vi.fn<(url: string) => Promise>().mockResolvedValue(undefined),
+ showWorkspace: vi.fn(),
+ closeWelcome: vi.fn(),
+ welcomeLocale: undefined as unknown,
+ preference: 'zh',
+ handlers: new Map unknown>(),
+ listeners: new Map void>(),
+ contents: undefined as unknown,
+ windowOptions: undefined as unknown,
+ menu: vi.fn(),
+ operations: undefined as WelcomeOperations | undefined,
+}))
+
+vi.mock('electron', () => ({
+ app: {
+ isPackaged: false,
+ name: 'Harness',
+ requestSingleInstanceLock: () => true,
+ whenReady: () => Promise.resolve(),
+ getLocale: () => 'en',
+ getAppPath: () => '/development-app',
+ getPreferredSystemLanguages: () => ['en-US'],
+ on: vi.fn(),
+ quit: state.quit,
+ exit: vi.fn(),
+ },
+ BrowserWindow: class {
+ constructor(options: unknown) { state.windowOptions = options }
+ private ready: (() => void) | undefined
+ webContents = { mainFrame: { url: 'http://127.0.0.1:3080/?token=test' }, setWindowOpenHandler: vi.fn(), on: vi.fn(), send: vi.fn(), openDevTools: vi.fn() }
+ static getAllWindows() { return [] }
+ once(name: string, callback: () => void) { if (name === 'ready-to-show') this.ready = callback; return this }
+ on() { return this }
+ isDestroyed() { return false }
+ hide = vi.fn()
+ show = state.showWorkspace
+ async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
+ },
+ net: { fetch: vi.fn() },
+ protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
+ ipcMain: {
+ handle: (name: string, callback: (...args: unknown[]) => unknown) => { state.handlers.set(name, callback) },
+ on: (name: string, callback: (...args: unknown[]) => void) => { state.listeners.set(name, callback) },
+ },
+ dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() },
+ Menu: { buildFromTemplate: state.menu, setApplicationMenu: vi.fn() },
+}))
+
+vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) }))
+vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class {
+ applyRelease = vi.fn(async () => {})
+ canRecoverProfile = vi.fn(() => true)
+} }))
+vi.mock('../src/host-process.ts', () => ({
+ DesktopHostProcess: class {
+ start = state.startHost
+ stop = state.stopHost
+ fetch() { return Promise.resolve(Response.json({ hasApiKey: true, writable: true, localePreference: state.preference })) }
+ },
+}))
+vi.mock('../src/welcome-backend.ts', () => ({
+ connectDesktopWelcome: async () => ({
+ read: async () => ({ hasApiKey: true, writable: true, localePreference: state.preference }),
+ save: async () => ({ ok: true }),
+ }),
+}))
+vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() }))
+vi.mock('../src/welcome-window.ts', () => ({
+ openWelcomeWindow: (locale: unknown, operations: WelcomeOperations) => {
+ state.welcomeLocale = locale
+ state.operations = operations
+ return Promise.resolve({ once: vi.fn(), close: state.closeWelcome })
+ },
+}))
+
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ vi.unstubAllEnvs()
+ vi.restoreAllMocks()
+})
+
+it('starts the Host for a forced welcome preview and opens the workspace on skip without quitting', async () => {
+ vi.useFakeTimers()
+ vi.spyOn(process, 'argv', 'get').mockReturnValue(['electron', 'desktop', '--preview-welcome'])
+ vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
+ vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
+ vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
+ vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh')
+ vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
+ vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0')
+ await import('../src/main.ts')
+ await vi.waitFor(() => { expect(state.operations).toBeDefined() })
+ expect(state.startHost).toHaveBeenCalledOnce()
+ expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('dsh-app://shell/startup.html')
+ expect(state.showWorkspace).not.toHaveBeenCalled()
+ state.loadWorkspace.mockClear()
+ expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' })
+ await state.operations!.skip()
+ expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('http://127.0.0.1:3080/?token=test')
+ expect(state.showWorkspace).toHaveBeenCalledOnce()
+ expect(state.windowOptions).toMatchObject({
+ ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 }, vibrancy: 'sidebar' } : {}),
+ webPreferences: { contextIsolation: true, sandbox: true },
+ })
+ expect(state.closeWelcome).toHaveBeenCalledOnce()
+ expect(state.quit).not.toHaveBeenCalled()
+ expect(state.stopHost).not.toHaveBeenCalled()
+ const contents = state.contents as { mainFrame: { url: string } }
+ const event = { sender: contents, senderFrame: contents.mainFrame }
+ const bootstrap = state.handlers.get(DESKTOP_IPC.localeBootstrap)!
+ expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'zh' })
+ state.preference = 'en'
+ expect(await bootstrap(event)).toEqual({ languages: ['en-US'], preference: 'en' })
+ const changed = state.listeners.get(DESKTOP_IPC.localeChanged)!
+ const initialMenus = state.menu.mock.calls.length
+ changed({ ...event, senderFrame: {} }, 'en')
+ changed(event, 42)
+ expect(state.menu).toHaveBeenCalledTimes(initialMenus)
+ changed(event, 'en')
+ expect(state.menu).toHaveBeenCalledTimes(initialMenus + 1)
+ const shell = { senderFrame: { url: 'dsh-app://shell/plugin-manager.html' } }
+ expect(await state.handlers.get(DESKTOP_IPC.localeGet)!(shell)).toMatchObject({ id: 'en' })
+})
diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts
new file mode 100644
index 0000000000..7ff64bd364
--- /dev/null
+++ b/apps/desktop/tests/welcome-window.spec.ts
@@ -0,0 +1,130 @@
+import { join } from 'node:path'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { resolveDesktopLocale } from '../src/locale.ts'
+import { needsWelcome, WELCOME_IPC } from '../src/welcome-api.ts'
+
+const electron = vi.hoisted(() => ({
+ create: vi.fn<(options: unknown) => ReturnType>(),
+ root: '/desktop-app',
+ handlers: new Map Promise>(),
+}))
+vi.mock('electron', () => ({
+ app: { getAppPath: () => electron.root },
+ BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }),
+ ipcMain: {
+ handle: (name: string, handler: (event: unknown, value?: unknown) => Promise) => electron.handlers.set(name, handler),
+ removeHandler: (name: string) => electron.handlers.delete(name),
+ },
+}))
+
+const { openWelcomeWindow, welcomeWindowOptions } = await import('../src/welcome-window.ts')
+
+function createWindow() {
+ return {
+ webContents: {
+ mainFrame: {},
+ setWindowOpenHandler: vi.fn<(handler: () => { action: string }) => void>(),
+ on: vi.fn<(name: string, handler: (event: { preventDefault(): void }) => void) => void>(),
+ },
+ loadFile: vi.fn<(path: string) => Promise>().mockResolvedValue(undefined),
+ isDestroyed: vi.fn().mockReturnValue(false),
+ destroy: vi.fn(),
+ show: vi.fn(),
+ once: vi.fn<(name: string, callback: () => void) => void>(),
+ }
+}
+
+beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() })
+
+const operations = {
+ saveApiKey: () => Promise.resolve({ ok: true as const }),
+ skip: () => Promise.resolve(),
+}
+
+describe('desktop welcome window', () => {
+ it.each(['darwin', 'win32', 'linux'] as const)('keeps the %s preview fixed-size and sandboxed', (platform) => {
+ const options = welcomeWindowOptions(platform, resolveDesktopLocale('zh-CN'))
+ expect(options).toMatchObject({
+ width: 600, height: 700, useContentSize: true, center: true, show: false,
+ resizable: false, maximizable: false, fullscreenable: false,
+ webPreferences: {
+ nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true,
+ additionalArguments: ['--dsh-welcome-locale=zh-CN'],
+ },
+ })
+ expect(options.webPreferences?.preload).toMatch(/preload-welcome\.cjs$/u)
+ if (platform === 'darwin') {
+ expect(options.vibrancy).toBe('titlebar')
+ expect(options.visualEffectState).toBe('active')
+ expect(options.trafficLightPosition).toEqual({ x: 16, y: 21 })
+ } else if (platform === 'win32') {
+ expect(options.backgroundMaterial).toBe('acrylic')
+ expect(options.titleBarOverlay).toMatchObject({ height: 42 })
+ } else {
+ expect(options.backgroundColor).toBe('#FFFFFF')
+ expect(options.vibrancy).toBeUndefined()
+ expect(options.backgroundMaterial).toBeUndefined()
+ }
+ })
+
+ it('waits for its local document and blocks renderer navigation', async () => {
+ const window = createWindow()
+ const loaded = Promise.withResolvers()
+ window.loadFile.mockReturnValue(loaded.promise)
+ electron.create.mockReturnValue(window)
+ const opening = openWelcomeWindow(resolveDesktopLocale('en'), operations)
+ expect(window.show).not.toHaveBeenCalled()
+ expect(window.loadFile).toHaveBeenCalledWith(join(electron.root, 'renderer', 'welcome.html'))
+ expect(window.webContents.setWindowOpenHandler.mock.calls[0]![0]()).toEqual({ action: 'deny' })
+ const event = { preventDefault: vi.fn() }
+ window.webContents.on.mock.calls[0]
+ expect(event.preventDefault).toHaveBeenCalledOnce()
+ loaded.resolve(undefined)
+ expect(await opening).toBe(window)
+ expect(window.show).toHaveBeenCalledOnce()
+ })
+
+ it('destroys a window whose document fails to load', async () => {
+ const window = createWindow()
+ window.loadFile.mockRejectedValue(new Error('missing welcome document'))
+ electron.create.mockReturnValue(window)
+ await expect(openWelcomeWindow(resolveDesktopLocale('en'), operations)).rejects.toThrow('missing welcome document')
+ expect(window.destroy).toHaveBeenCalledOnce()
+ expect(window.show).not.toHaveBeenCalled()
+ })
+
+ it('does not show a window closed while its document was loading', async () => {
+ const window = createWindow()
+ window.isDestroyed.mockReturnValue(true)
+ electron.create.mockReturnValue(window)
+ await openWelcomeWindow(resolveDesktopLocale('en'), operations)
+ expect(window.show).not.toHaveBeenCalled()
+ expect(window.destroy).not.toHaveBeenCalled()
+ })
+
+ it('accepts actions only from its own top frame and removes handlers on close', async () => {
+ const window = createWindow()
+ electron.create.mockReturnValue(window)
+ const saveApiKey = vi.fn(operations.saveApiKey)
+ const skip = vi.fn(operations.skip)
+ await openWelcomeWindow(resolveDesktopLocale('en'), { saveApiKey, skip })
+ const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+ const save = electron.handlers.get(WELCOME_IPC.saveApiKey)!
+ await expect(save({ sender: {}, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame')
+ await expect(save({ ...own, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame')
+ expect(await save(own, 'bad key')).toEqual({ ok: false })
+ expect(saveApiKey).not.toHaveBeenCalled()
+ expect(await save(own, 'sk-test')).toEqual({ ok: true })
+ await electron.handlers.get(WELCOME_IPC.skip)!(own)
+ expect(skip).toHaveBeenCalledOnce()
+ window.once.mock.calls[0]![1]()
+ expect(electron.handlers.size).toBe(0)
+ })
+
+ it('shows the entry after logout only without a separately configured API key', () => {
+ expect(needsWelcome({ loggedIn: true, hasApiKey: false })).toBe(false)
+ expect(needsWelcome({ loggedIn: false, hasApiKey: false })).toBe(true)
+ expect(needsWelcome({ loggedIn: false, hasApiKey: true })).toBe(false)
+ expect(needsWelcome({ loggedIn: true, hasApiKey: true })).toBe(false)
+ })
+})
diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts
index aed896b427..88daa327ec 100644
--- a/apps/desktop/tsdown.config.ts
+++ b/apps/desktop/tsdown.config.ts
@@ -12,11 +12,12 @@ export default defineConfig([
clean: false,
deps: { neverBundle: ['electron'] },
},
- ...(['preload-app', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({
+ ...(['preload-app', 'preload-welcome', 'preload-platform-account', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({
// Sandboxed Electron preloads run as CommonJS even though the application package is ESM.
entry: { [name]: `lib/types/${name}.js` },
outDir: 'lib',
- format: ['cjs'] as const,
+ format: 'cjs' as const,
+ codeSplitting: false,
platform: 'node' as const,
target: 'es2024',
fixedExtension: false,
diff --git a/apps/web/tests/desktop-locale.e2e.ts b/apps/web/tests/desktop-locale.e2e.ts
new file mode 100644
index 0000000000..f3486111a0
--- /dev/null
+++ b/apps/web/tests/desktop-locale.e2e.ts
@@ -0,0 +1,83 @@
+// Native initialization supplies system languages; the existing Host preference remains the only durable choice.
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/desktop-locale', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe.skipIf(MODE === 'record')('web e2e: native and Client locale preferences', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const reported: string[] = []
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold()
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: 'en-US' })
+ tripwire = watchConsole(page)
+ await page.exposeFunction('nativeLocaleRead', () => {
+ const section = scaffold.ctx.settings.get('locale') as { preference?: string }
+ return { languages: ['ja-JP', 'zh-CN', 'en-US'], preference: section.preference ?? null }
+ })
+ await page.exposeFunction('nativeLocaleChanged', (locale: string) => { reported.push(locale) })
+ await page.addInitScript(() => {
+ const bridge = globalThis as typeof globalThis & {
+ nativeLocaleRead(): Promise
+ nativeLocaleChanged(locale: string): Promise
+ __DSH_LOCALE__: { read(): Promise; onChange(locale: string): void }
+ }
+ bridge.__DSH_LOCALE__ = {
+ read: () => bridge.nativeLocaleRead(),
+ onChange: (locale) => { void bridge.nativeLocaleChanged(locale) },
+ }
+ })
+ })
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('uses OS languages without saving them, then shares an explicit choice across reloads', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-desktop-locale'))
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const zhDialog = page.getByRole('dialog', { name: '设置', exact: true })
+ await zhDialog.getByRole('button', { name: '中文', exact: true }).waitFor()
+ await expect.poll(() => reported).toEqual(['zh'])
+ expect(scaffold.ctx.settings.get('locale')).toEqual({})
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'automatic.expected.md'),
+ await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
+ await zhDialog.getByRole('button', { name: '中文', exact: true }).click()
+ await page.getByRole('menuitem', { name: 'English', exact: true }).click()
+ const enDialog = page.getByRole('dialog', { name: 'Settings', exact: true })
+ await enDialog.getByRole('button', { name: 'English', exact: true }).waitFor()
+ await expect.poll(() => reported.at(-1)).toBe('en')
+ await expect.poll(() => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'))
+ .toMatch(/locale:\n\s+preference: en/u)
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'selected.expected.md'),
+ await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
+ const warningsBefore = tripwire.warnings.length
+ reported.length = 0
+ await page.reload({ waitUntil: 'load' })
+ acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
+ await page.getByRole('button', { name: 'Settings', exact: true }).click()
+ await enDialog.getByRole('button', { name: 'English', exact: true }).waitFor()
+ await expect.poll(() => reported).toEqual(['en'])
+ expect(await page.evaluate(() => document.documentElement.lang)).toBe('en')
+ expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
+ expect(tripwire.warnings).toEqual([])
+ expect(tripwire.pageErrors).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['automatic.expected.md', 'selected.expected.md'])
+ })
+})
diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md
new file mode 100644
index 0000000000..f80893bfb2
--- /dev/null
+++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md
@@ -0,0 +1,50 @@
+- dialog "设置":
+ - navigation:
+ - text: 设置
+ - button "通用设置":
+ - img
+ - text: 通用设置
+ - button "模型":
+ - img
+ - text: 模型
+ - button "插件":
+ - img
+ - text: 插件
+ - button "Agent 预设":
+ - img
+ - text: Agent 预设
+ - button "打开配置文件"
+ - button "关闭":
+ - img
+ - text: 关闭
+ - text: 权限 选择新会话的默认权限模式
+ - button "工作区内修改":
+ - text: 工作区内修改
+ - img
+ - text: 语言
+ - button "中文":
+ - text: 中文
+ - img
+ - text: 外观
+ - button "浅色":
+ - img
+ - text: 浅色
+ - button "深色":
+ - img
+ - text: 深色
+ - button "跟随系统" [pressed]:
+ - img
+ - text: 跟随系统
+ - text: 字号大小 仅影响会话内容的字号 14
+ - button "增大字号":
+ - img
+ - button "减小字号":
+ - img
+ - text: px 对话显示 控制已完成轮次的过程内容
+ - button "紧凑":
+ - text: 紧凑
+ - img
+ - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
+ - button "排队发送":
+ - text: 排队发送
+ - img
diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md
new file mode 100644
index 0000000000..3121238a25
--- /dev/null
+++ b/apps/web/tests/expected/desktop-locale/selected.expected.md
@@ -0,0 +1,50 @@
+- dialog "Settings":
+ - navigation:
+ - text: Settings
+ - button "General":
+ - img
+ - text: General
+ - button "Models":
+ - img
+ - text: Models
+ - button "Plugins":
+ - img
+ - text: Plugins
+ - button "Agent presets":
+ - img
+ - text: Agent presets
+ - button "Open configuration file"
+ - button "Close":
+ - img
+ - text: Close
+ - text: Permission Choose the default permission mode for new sessions
+ - button "Workspace Write":
+ - text: Workspace Write
+ - img
+ - text: Language
+ - button "English":
+ - text: English
+ - img
+ - text: Appearance
+ - button "Light":
+ - img
+ - text: Light
+ - button "Dark":
+ - img
+ - text: Dark
+ - button "System" [pressed]:
+ - img
+ - text: System
+ - text: Font size Only affects conversation content 14
+ - button "Increase font size":
+ - img
+ - button "Decrease font size":
+ - img
+ - text: px Conversation display Controls process content in completed turns
+ - button "Compact":
+ - text: Compact
+ - img
+ - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
+ - button "Queue":
+ - text: Queue
+ - img
diff --git a/apps/web/tests/expected/onboarding-native/models.expected.md b/apps/web/tests/expected/onboarding-native/models.expected.md
new file mode 100644
index 0000000000..74bf74bb3a
--- /dev/null
+++ b/apps/web/tests/expected/onboarding-native/models.expected.md
@@ -0,0 +1,35 @@
+- dialog "设置":
+ - navigation:
+ - text: 设置
+ - button "通用设置":
+ - img
+ - text: 通用设置
+ - button "模型":
+ - img
+ - text: 模型
+ - button "插件":
+ - img
+ - text: 插件
+ - button "Agent 预设":
+ - img
+ - text: Agent 预设
+ - button "打开配置文件"
+ - button "关闭":
+ - img
+ - text: 关闭
+ - heading "模型" [level=2]
+ - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+ - list:
+ - listitem:
+ - text: DeepSeek deepseek-official API 密钥
+ - textbox "API 密钥":
+ - /placeholder: 输入 API 密钥
+ - group: 自定义设置
+ - button "取消"
+ - button "保存"
+ - button "添加提供方":
+ - img
+ - text: 添加提供方
+ - button "添加自定义提供方":
+ - img
+ - text: 添加自定义提供方
diff --git a/apps/web/tests/fixtures/onboarding-native/cordis.patch.yml b/apps/web/tests/fixtures/onboarding-native/cordis.patch.yml
new file mode 100644
index 0000000000..3fa7f00794
--- /dev/null
+++ b/apps/web/tests/fixtures/onboarding-native/cordis.patch.yml
@@ -0,0 +1,3 @@
+- id: ui-settings-models
+ config:
+ credentialOnboarding: false
diff --git a/apps/web/tests/onboarding-native.e2e.ts b/apps/web/tests/onboarding-native.e2e.ts
new file mode 100644
index 0000000000..0b7e269ac3
--- /dev/null
+++ b/apps/web/tests/onboarding-native.e2e.ts
@@ -0,0 +1,70 @@
+// The Host's native-onboarding setting must reach the assembled browser before its dialogs register.
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, WELCOME_NOTICE_COPY, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-native', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credential onboarding (desktop marker: %s)', (desktop) => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({
+ deepSeekMissingCredential: true,
+ welcomeNoticePending: true,
+ ...desktop ? {} : { extraOverlayPath: fileURLToPath(new URL('./fixtures/onboarding-native/cordis.patch.yml', import.meta.url)) },
+ })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
+ if (desktop) await page.addInitScript(() => { Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } }) })
+ tripwire = watchConsole(page)
+ })
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('keeps the notice and Models settings without another credential dialog or credential write', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-native'))
+ const credentialPath = join(scaffold.harnessHome, '.credentials.yaml')
+ const credentials = await readFile(credentialPath, 'utf8')
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
+ await welcome.waitFor()
+ await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
+ await welcome.waitFor({ state: 'detached' })
+
+ for (const reload of [false, true]) {
+ if (reload) {
+ const warningsBefore = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
+ }
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const settings = page.getByRole('dialog', { name: '设置', exact: true })
+ await settings.getByRole('button', { name: '模型', exact: true }).click()
+ await settings.getByLabel('API 密钥', { exact: true }).waitFor()
+ expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
+ expect(await welcome.count()).toBe(0)
+ expect(await readFile(credentialPath, 'utf8')).toBe(credentials)
+ const aria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'models.expected.md'), aria, MODE)
+ await page.keyboard.press('Escape')
+ }
+ expect(tripwire.warnings).toEqual([])
+ expect(tripwire.pageErrors).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['models.expected.md'])
+ })
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 12cc32a904..237608f4d4 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -54,6 +54,7 @@
"tests/plugin-install-approve.e2e.ts",
"tests/client-plugin-live.e2e.ts",
"tests/settings-chrome.e2e.ts",
+ "tests/desktop-locale.e2e.ts",
"tests/desktop-updates.e2e.ts",
"tests/developer-tools-settings.e2e.ts",
"tests/models-settings.e2e.ts",
@@ -65,6 +66,7 @@
"tests/streaming-fence-highlight.e2e.ts",
"tests/declared-reasoning.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
+ "tests/onboarding-native.e2e.ts",
"tests/onboarding-usable-provider.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-new-session-folding.e2e.ts",
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 9abb7e2f19..7acbc376d5 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -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 docs/config-catalog.md
-config-catalog.md: fd7ddf0b0ea3eef331283531a860a2079d02cf81
-config-catalog.zh.md: 7f58a328ec0650ee3032950d7afadd8a7fdd6a5b
+config-catalog.md: 99ef3f4080f5e23ccfa9ebea0d4b89daf443bcfb
+config-catalog.zh.md: 0e2cb6eb3f6936b7ad190333a83a701ecbbb133d
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index fd7ddf0b0e..99ef3f4080 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -446,6 +446,20 @@ export interface Config {
Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
+
+
+## `@deepseek-ai/dsh-client-ui-settings-models`
+
+```ts config-catalog
+/** Onboarding options after schema defaults are applied. */
+export interface Config {
+ /** Offer the browser API-key step when no native shell owns credential onboarding. */
+ credentialOnboarding: boolean
+}
+```
+
+Source: [`packages/client/ui-settings-models/src/onboarding-config.ts:6`](../packages/client/ui-settings-models/src/onboarding-config.ts)
+
## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`
@@ -3807,7 +3821,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-session` ([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 7f58a328ec..0e2cb6eb3f 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -448,6 +448,20 @@ export interface Config {
来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
+
+
+## `@deepseek-ai/dsh-client-ui-settings-models`
+
+```ts config-catalog
+/** Onboarding options after schema defaults are applied. */
+export interface Config {
+ /** Offer the browser API-key step when no native shell owns credential onboarding. */
+ credentialOnboarding: boolean
+}
+```
+
+来源: [`packages/client/ui-settings-models/src/onboarding-config.ts:6`](../packages/client/ui-settings-models/src/onboarding-config.ts)
+
## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`
@@ -3809,7 +3823,6 @@ export interface Config {
- `@deepseek-ai/dsh-client-ui-session`([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml
index 14b3339b26..75d42010f7 100644
--- a/packages/client/locale/README.i18n.yaml
+++ b/packages/client/locale/README.i18n.yaml
@@ -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/locale/README.md
-README.md: 56a9cff9c3ec18dcc395378fcd1691207c022284
-README.zh.md: 1b7a7b7de48843961f3cbac3f4ee99cff641d212
+README.md: 39cff9dbeb29d32e2da611512e0849c007d96853
+README.zh.md: 4f4cdacfb7a5b0a65fb0065e0186ee765488a17e
diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md
index 56a9cff9c3..39cff9dbeb 100644
--- a/packages/client/locale/README.md
+++ b/packages/client/locale/README.md
@@ -31,6 +31,8 @@ Use it wherever the web GUI needs a language switch or translated copy: the ship
Open Settings → General and select a registered language. The active locale is applied immediately: the UI copy switches, `` points at the external id or built-in document tag, and the choice is written to the durable settings section. A browser without an explicit Host preference selects the first registered language that matches `navigator` by full tag and then primary subtag, falling back to English. A stored external locale waits for its definition to register instead of becoming active while unavailable.
+Native shells may provide `__DSH_LOCALE__` with an asynchronous `read()` and an `onChange(locale)` callback. Initialization supplies the current Host preference and ordered OS languages before the Client mounts. Automatic selection stays provisional; only Settings selections write `locale.preference`. A fresh read on each page load prevents a stale preload preference after reload. Ordinary browsers keep navigator-based detection and their existing settings-scope policy.
+
### Registering a dictionary
Call `ctx.locale.register(ns, { zh, en })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both shipped locales. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount.
diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md
index 1b7a7b7de4..4f4cdacfb7 100644
--- a/packages/client/locale/README.zh.md
+++ b/packages/client/locale/README.zh.md
@@ -31,6 +31,8 @@ kind: "package-reference"
打开“设置 → 常规”并选择一种已注册语言。生效中的 locale 会立即应用:UI 文案切换、`` 指向外部 id 或内置语言的文档标签,选择写入持久设置分区。没有显式 Host 偏好的浏览器会按完整标签、再按主语言子标签选择 `navigator` 请求的第一个已注册语言,无法匹配时回退到英文。已存储的外部 locale 会等待其定义注册,不会在不可用时生效。
+原生壳可以提供包含异步 `read()` 和 `onChange(locale)` 回调的 `__DSH_LOCALE__`。初始化在 Client 挂载前提供当前 Host 偏好和有序的系统语言列表。自动选择保持临时状态;只有设置中的选择会写入 `locale.preference`。每次加载页面都重新读取,避免重载后沿用过期的 preload 偏好。普通浏览器继续使用 navigator 检测和原有的设置作用域策略。
+
### 注册字典
用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个内置 locale 齐全。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。
diff --git a/packages/client/locale/src/client/bootstrap.ts b/packages/client/locale/src/client/bootstrap.ts
new file mode 100644
index 0000000000..34a89b2716
--- /dev/null
+++ b/packages/client/locale/src/client/bootstrap.ts
@@ -0,0 +1,31 @@
+/** Locale initialization supplied by a native shell before its Client tree mounts. */
+
+/** System languages and the existing Host preference for one page load. */
+export interface LocaleBootstrap {
+ /** Operating-system language tags in preference order. */
+ readonly languages: readonly string[]
+ /** Stored locale.preference; null preserves automatic language selection. */
+ readonly preference: string | null
+}
+
+/** Optional page-global bridge named __DSH_LOCALE__; ordinary browsers omit it. */
+export interface LocaleBridge {
+ /** Read current initialization data through the shell's isolated preload. @returns unvalidated IPC data. */
+ read(): Promise
+ /** Publish the resolved Client locale without writing another preference. @param locale - active registered locale id. */
+ onChange(locale: string): void
+}
+
+/**
+ * Validate initialization data returned over the preload IPC bridge.
+ * @param value - untrusted IPC response.
+ * @returns language initialization with no persistence side effects.
+ */
+export function parseLocaleBootstrap(value: unknown): LocaleBootstrap {
+ if (typeof value !== 'object' || value === null || !('languages' in value) || !Array.isArray(value.languages)
+ || !value.languages.every((language: unknown) => typeof language === 'string')
+ || !('preference' in value) || (value.preference !== null && typeof value.preference !== 'string')) {
+ throw new TypeError('locale: invalid native initialization data')
+ }
+ return { languages: value.languages, preference: value.preference }
+}
diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts
index 8370696886..7523455af5 100644
--- a/packages/client/locale/src/client/index.ts
+++ b/packages/client/locale/src/client/index.ts
@@ -12,6 +12,7 @@ import {
// Cross-plugin collaboration goes through the service, never a value import
// (client bundle purity gate).
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
+import { parseLocaleBootstrap, type LocaleBootstrap, type LocaleBridge } from './bootstrap.ts'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import {
@@ -178,14 +179,16 @@ export class LocaleRuntime {
* listener is released through ctx.effect on dispose).
* @param host - durable preference scope owned by the providing plugin;
* absent compositions (standalone dictionary registries) stay process-local.
+ * @param bootstrap - native initialization; absent in ordinary browsers.
*/
- constructor(ctx: ClientContext, host?: SettingsScope) {
+ constructor(ctx: ClientContext, host?: SettingsScope, private readonly bootstrap?: LocaleBootstrap) {
this.ctx = ctx
this.host = host
for (const locale of BUILT_IN_LOCALES) this.catalog.set(localeKey(locale.id), locale)
const locales = this.localeList()
- this.provisional = resolveInitialLocale(locales)
- this.snapshot = Object.freeze({ active: this.provisional, locales, revision: 0 })
+ this.provisional = resolveInitialLocale(locales, bootstrap?.languages)
+ this.preference = bootstrap?.preference ?? undefined
+ this.snapshot = Object.freeze({ active: this.resolveActive(), locales, revision: 0 })
if (host !== undefined) {
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
this.adopt(host)
@@ -295,7 +298,7 @@ export class LocaleRuntime {
private publishCatalog(): void {
this.fallbackChains.clear()
const locales = this.localeList()
- this.provisional = resolveInitialLocale(locales)
+ this.provisional = resolveInitialLocale(locales, this.bootstrap?.languages)
const active = this.resolveActive()
this.publish(active, active !== this.snapshot.active, locales)
}
@@ -497,8 +500,8 @@ export class LocaleRuntime {
* The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit
* Host preference may replace this provisional value after plugin activation.
*/
-function resolveInitialLocale(locales: readonly LocaleDefinition[]): LocaleId {
- return detectBrowserLocale(locales) ?? FALLBACK_LOCALE
+function resolveInitialLocale(locales: readonly LocaleDefinition[], languages?: readonly string[]): LocaleId {
+ return detectBrowserLocale(locales, languages) ?? FALLBACK_LOCALE
}
/**
@@ -510,13 +513,17 @@ function resolveInitialLocale(locales: readonly LocaleDefinition[]): LocaleId {
* locale for non-browser runs. `navigator.language` trails the ordered
* `languages` list and covers hosts exposing only the single tag.
* @param locales - definitions currently available to the browser.
+ * @param languages - native system language order, when supplied by a shell.
* @returns the first matching locale id, or undefined.
*/
-function detectBrowserLocale(locales: readonly LocaleDefinition[]): LocaleId | undefined {
- if (typeof window === 'undefined') return undefined
- // Embedders and older WebViews may omit the DOM-typed `languages` property.
- const languages = (navigator as { readonly languages?: readonly string[] }).languages
- for (const tag of [...(languages ?? []), navigator.language]) {
+function detectBrowserLocale(locales: readonly LocaleDefinition[], languages?: readonly string[]): LocaleId | undefined {
+ if (languages === undefined) {
+ if (typeof window === 'undefined') return undefined
+ // Embedders and older WebViews may omit the DOM-typed `languages` property.
+ const browserLanguages = (navigator as { readonly languages?: readonly string[] }).languages
+ languages = [...(browserLanguages ?? []), navigator.language]
+ }
+ for (const tag of languages) {
const requested = localeKey(tag)
const exact = locales.find(locale => localeKey(locale.id) === requested)
if (exact !== undefined) return exact.id
@@ -535,13 +542,31 @@ export const inject = ['slots', 'remote', 'settingsScope']
* register the feature-owned Language preference row into the General
* section's item slot (a feature owns its settings surface).
* @param ctx - client cordis context.
+ * @returns resolves after native language initialization and plugin registration.
*/
-export function apply(ctx: ClientContext): void {
+export async function apply(ctx: ClientContext): Promise {
+ const bridge = (globalThis as { __DSH_LOCALE__?: LocaleBridge }).__DSH_LOCALE__
+ let bootstrap: LocaleBootstrap | undefined
+ if (bridge !== undefined) {
+ let value: unknown
+ try {
+ value = await bridge.read()
+ } catch (error) {
+ if (ctx.fiber.uid === null) return
+ throw error
+ }
+ if (ctx.fiber.uid === null) return
+ bootstrap = parseLocaleBootstrap(value)
+ }
const host = ctx.settingsScope.bind({ namespace: LOCALE_SETTINGS_NAMESPACE })
- const locale = new LocaleRuntime(ctx, host)
+ const locale = new LocaleRuntime(ctx, host, bootstrap)
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
ctx.provide('locale', locale)
+ if (bridge !== undefined) {
+ ctx.on('locale/change', (snapshot) => { bridge.onChange(snapshot.active) })
+ bridge.onChange(locale.getSnapshot().active)
+ }
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
// so the render machinery can synthesize the `t` standard seat.
ctx.slots.installLocale(locale)
diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts
index c4a5b11284..a373ecefc2 100644
--- a/packages/client/locale/tests/apply.client.spec.ts
+++ b/packages/client/locale/tests/apply.client.spec.ts
@@ -2,7 +2,7 @@
* Language row registration, snapshot projection into the row store, and
* recovery after an HMR collapse of the declaring entry. */
import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
@@ -15,11 +15,11 @@ import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
const SLOT = 'settings.general.item'
+afterEach(() => { vi.unstubAllGlobals() })
-async function bench() {
+async function bench(preference?: string) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
- let preference: string | undefined
let revision = 0
const namespace = () => ({
ns: LOCALE_SETTINGS_NAMESPACE,
@@ -65,6 +65,52 @@ function faceOf(slots: SlotRegistry) {
}
describe('locale apply', () => {
+ it.each(['resolve', 'reject'] as const)('contains a late native initialization %s after unloading', async (outcome) => {
+ const b = await bench()
+ const ready = Promise.withResolvers()
+ const read = vi.fn(() => ready.promise)
+ const onChange = vi.fn()
+ vi.stubGlobal('__DSH_LOCALE__', { read, onChange })
+ const fiber = b.ctx.plugin({ inject: [...inject], apply })
+ try {
+ await vi.waitFor(() => { expect(read).toHaveBeenCalledOnce() })
+ const disposal = fiber.dispose()
+ if (outcome === 'resolve') ready.resolve({ languages: ['zh-CN'], preference: null })
+ else ready.reject(new Error('native window closed'))
+ await disposal
+ expect(b.ctx.get('locale')).toBeUndefined()
+ expect(onChange).not.toHaveBeenCalled()
+ } finally {
+ ready.resolve({ languages: ['zh-CN'], preference: null })
+ await b.ctx.fiber.dispose()
+ }
+ })
+
+ it('awaits native initialization and reports later changes only while mounted', async () => {
+ const b = await bench('zh')
+ const ready = Promise.withResolvers()
+ const onChange = vi.fn()
+ vi.stubGlobal('__DSH_LOCALE__', { read: () => ready.promise, onChange })
+ const fiber = b.ctx.plugin({ inject: [...inject], apply })
+ try {
+ expect(b.ctx.get('locale')).toBeUndefined()
+ ready.resolve({ languages: ['en-US'], preference: 'zh' })
+ await fiber.await()
+ const locale = b.ctx.get('locale') as LocaleRuntime
+ expect(locale.getSnapshot().active).toBe('zh')
+ expect(onChange).toHaveBeenLastCalledWith('zh')
+ locale.setLocale('en')
+ expect(onChange).toHaveBeenLastCalledWith('en')
+ await fiber.dispose()
+ const calls = onChange.mock.calls.length
+ b.ctx.emit('locale/change', { active: 'zh', locales: [], revision: 99 })
+ expect(onChange).toHaveBeenCalledTimes(calls)
+ } finally {
+ ready.resolve({ languages: ['en-US'], preference: 'zh' })
+ await b.ctx.fiber.dispose()
+ }
+ })
+
// These are wiring specs, not default-language specs. A fresh LocaleRuntime
// with no jsdom `window` skips browser detection and opens on FALLBACK_LOCALE
// (en); each test that reads localized copy stages its locale explicitly via
diff --git a/packages/client/locale/tests/bootstrap.client.spec.ts b/packages/client/locale/tests/bootstrap.client.spec.ts
new file mode 100644
index 0000000000..ea3966d90c
--- /dev/null
+++ b/packages/client/locale/tests/bootstrap.client.spec.ts
@@ -0,0 +1,75 @@
+/** Native initialization precedes Client mounting and does not persist automatic locale choices. */
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import { apply, LocaleRuntime } from '../src/client/index.ts'
+import { parseLocaleBootstrap } from '../src/client/bootstrap.ts'
+import type { LocaleSettings } from '../src/locale-settings.ts'
+
+afterEach(() => { vi.unstubAllGlobals() })
+
+describe('native locale initialization', () => {
+ it('reports a failed native read instead of silently choosing a language', async () => {
+ const ctx = new Context()
+ const error = new Error('native settings unavailable')
+ vi.stubGlobal('__DSH_LOCALE__', { read: () => Promise.reject(error), onChange: vi.fn() })
+ try {
+ await expect(apply(ctx)).rejects.toBe(error)
+ expect(ctx.get('locale')).toBeUndefined()
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('uses native language order without persisting a provisional choice', async () => {
+ vi.stubGlobal('window', {})
+ vi.stubGlobal('navigator', { languages: ['en-US'], language: 'en-US' })
+ const ctx = new Context()
+ const host = stubSettingsScope()
+ try {
+ const locale = new LocaleRuntime(ctx, host.scope, { languages: ['ja-JP', 'zh-Hant', 'en-US'], preference: null })
+ expect(locale.getSnapshot().active).toBe('zh')
+ expect(host.set).not.toHaveBeenCalled()
+ locale.setLocale('en')
+ expect(host.set).toHaveBeenCalledExactlyOnceWith('preference', 'en')
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('keeps the stored choice while dictionaries and languages register', async () => {
+ const ctx = new Context()
+ try {
+ const locale = new LocaleRuntime(ctx, undefined, { languages: ['zh-CN'], preference: 'en' })
+ expect(locale.getSnapshot().active).toBe('en')
+ locale.register('native-test', 'en', {})
+ expect(locale.getSnapshot().active).toBe('en')
+ locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })
+ expect(locale.getSnapshot().active).toBe('en')
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('keeps an external preference pending until its language registers', async () => {
+ const ctx = new Context()
+ try {
+ const locale = new LocaleRuntime(ctx, undefined, { languages: ['zh-CN'], preference: 'ja' })
+ expect(locale.getSnapshot().active).toBe('zh')
+ const remove = locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })
+ expect(locale.getSnapshot().active).toBe('ja')
+ remove()
+ expect(locale.getSnapshot().active).toBe('zh')
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('validates the preload response before reading its fields', () => {
+ expect(parseLocaleBootstrap({ languages: ['en-US'], preference: null })).toEqual({ languages: ['en-US'], preference: null })
+ expect(parseLocaleBootstrap({ languages: [], preference: 'zh' }).preference).toBe('zh')
+ for (const value of [null, {}, { languages: 'en', preference: null }, { languages: [42], preference: null }, { languages: [], preference: 42 }]) {
+ expect(() => parseLocaleBootstrap(value)).toThrow('invalid native initialization data')
+ }
+ })
+})
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index c3c33c6f90..0365518cc1 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -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-models/README.md
-README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842
-README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27
+README.md: d89bc11c45ecce69f70f685c657c662faebd2382
+README.zh.md: 61e00aed9b54713469ed4f5c5395e125ab2a9f18
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index c1567814b5..d89bc11c45 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -29,6 +29,8 @@ Open the Models page from the Settings navigation to see every configured provid
A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic.
+Host configuration `credentialOnboarding` defaults to `true`. Electron’s preload marker suppresses the credential step automatically; other native shells can set it to `false` in the plugin row; the Models settings page and welcome notice remain available. The Host publishes this public boolean through `webserver/index-inject`, and the Client validates it before registering its dialogs. It is page initialization data, not a persisted completion flag.
+
### API keys
The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material.
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index baa0f02e82..61e00aed9b 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -29,6 +29,8 @@ kind: "package-reference"
存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。
+Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会自动抑制凭证步骤;其他原生壳可以在插件行中把它设为 `false`;模型设置页和欢迎须知仍然可用。Host 通过 `webserver/index-inject` 发布这个公开的布尔值,Client 在注册弹窗前校验它。它是页面初始化数据,不是持久化的完成标记。
+
### API 密钥
编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。
diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json
index 74cc7e2132..fe1dcc15b1 100644
--- a/packages/client/ui-settings-models/package.json
+++ b/packages/client/ui-settings-models/package.json
@@ -44,7 +44,7 @@
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
- "@deepseek-ai/dsh-remote-mock": "workspace:^",
+ "@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
@@ -56,7 +56,11 @@
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
- "@deepseek-ai/dsh-util-values": "workspace:^"
+ "@deepseek-ai/dsh-util-values": "workspace:^",
+ "@deepseek-ai/dsh-remote-mock": "workspace:^"
+ },
+ "dependencies": {
+ "@deepseek-ai/schemastery": "workspace:^"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts
index 721f807092..5c45ca70f9 100644
--- a/packages/client/ui-settings-models/src/client/index.ts
+++ b/packages/client/ui-settings-models/src/client/index.ts
@@ -27,6 +27,7 @@ import { createModelsOperations } from './operations.ts'
import { createSettingsSchemaOperations } from './schema-operations.ts'
import { en, zh, type ModelsKey } from './locales.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
+import { Config, ONBOARDING_CONFIG_GLOBAL } from '../onboarding-config.ts'
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
export type { ModelsFooterOwnerProps, ProviderCardExtrasOwnerProps } from './slot-contract.ts'
@@ -41,6 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.models'
+
export type {
ModelsSettingsState, ProviderDirectoryEntry, ProviderRow,
} from './store.ts'
@@ -73,6 +75,10 @@ export const inject = [
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
+ const page = globalThis as Partial>
+ const payload = page[ONBOARDING_CONFIG_GLOBAL]
+ const configured = Config(payload === undefined ? {} : payload)
+ const credentialOnboarding = configured.credentialOnboarding && !('dshDesktop' in globalThis)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries')
const schema = createSettingsSchemaOperations(ctx.settingsSchema)
@@ -145,10 +151,12 @@ export function apply(ctx: ClientContext): void {
order: -100,
inject: welcomeInjected,
}, WelcomeNotice))
- ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
- name: 'settings.onboarding',
- id: 'deepseek-official',
- order: 0,
- inject: deepSeekOnboardingInjected,
- }, DeepSeekOnboardingDialog))
+ if (credentialOnboarding) {
+ ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
+ name: 'settings.onboarding',
+ id: 'deepseek-official',
+ order: 0,
+ inject: deepSeekOnboardingInjected,
+ }, DeepSeekOnboardingDialog))
+ }
}
diff --git a/packages/client/ui-settings-models/src/index.ts b/packages/client/ui-settings-models/src/index.ts
index da3060fe59..d73e39dc5e 100644
--- a/packages/client/ui-settings-models/src/index.ts
+++ b/packages/client/ui-settings-models/src/index.ts
@@ -1,4 +1,22 @@
-/** Host loader entry for the browser implementation exported from `./client`. */
+/** Host configuration and page bootstrap for Models credential onboarding. */
-/** Host plugin body — no host-side behavior for the models settings plugin. */
-export function apply(): void {}
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-host-webserver'
+import { type Config, ONBOARDING_CONFIG_GLOBAL } from './onboarding-config.ts'
+
+export { Config } from './onboarding-config.ts'
+
+/**
+ * Publish the credential-onboarding choice before browser plugins activate.
+ * @param ctx - Host context collecting the page's initialization data.
+ * @param config - plugin options with schema defaults applied by the Loader.
+ */
+export function apply(ctx: Context, config: Config): void {
+ ctx.on('webserver/index-inject', (table) => {
+ table.push({
+ kind: 'global',
+ name: ONBOARDING_CONFIG_GLOBAL,
+ value: { credentialOnboarding: config.credentialOnboarding },
+ })
+ })
+}
diff --git a/packages/client/ui-settings-models/src/onboarding-config.ts b/packages/client/ui-settings-models/src/onboarding-config.ts
new file mode 100644
index 0000000000..2213ea3a93
--- /dev/null
+++ b/packages/client/ui-settings-models/src/onboarding-config.ts
@@ -0,0 +1,17 @@
+/** Public page-bootstrap options shared by the Host and Client halves. */
+
+import z from '@deepseek-ai/schemastery'
+
+/** Onboarding options after schema defaults are applied. */
+export interface Config {
+ /** Offer the browser API-key step when no native shell owns credential onboarding. */
+ credentialOnboarding: boolean
+}
+
+/** Validate Host configuration and its public page-bootstrap payload. */
+export const Config: z, Config> = z.object({
+ credentialOnboarding: z.boolean().default(true),
+})
+
+/** Page-global key carrying only the public onboarding options. */
+export const ONBOARDING_CONFIG_GLOBAL = '__DSH_MODELS_ONBOARDING__'
diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts
index da1dd00f50..f079e00151 100644
--- a/packages/client/ui-settings-models/tests/apply.client.spec.ts
+++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts
@@ -1,6 +1,6 @@
/** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */
import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -15,7 +15,11 @@ import {
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
-import { apply as hostApply } from '../src/index.ts'
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+import * as hostPlugin from '../src/index.ts'
+import { ONBOARDING_CONFIG_GLOBAL } from '../src/onboarding-config.ts'
+
+afterEach(() => { vi.unstubAllGlobals() })
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
// so browser-language detection never runs and a fresh LocaleRuntime opens on
@@ -62,8 +66,41 @@ function declare(slots: SlotRegistry): () => void {
}
describe('ui-settings-models apply', () => {
- it('keeps the host Loader entry inert', () => {
- expect(hostApply).not.toThrow()
+ it('lets a native shell own credential onboarding while retaining settings and the notice', async () => {
+ const { ctx, slots } = await bench()
+ declare(slots)
+ try {
+ const host = ctx.plugin(hostPlugin, { credentialOnboarding: false })
+ await host.await()
+ const rows: IndexInjection[] = []
+ ctx.emit('webserver/index-inject', rows)
+ expect(rows).toEqual([{ kind: 'global', name: ONBOARDING_CONFIG_GLOBAL, value: { credentialOnboarding: false } }])
+ for (const row of rows) if (row.kind === 'global') vi.stubGlobal(row.name, row.value)
+ const plugin = ctx.plugin({ inject: [...inject], apply })
+ await plugin.await()
+ expect(slots.entries('settings.onboarding').map(entry => entry.options.id)).toEqual(['welcome-notice'])
+ expect(slots.entries('settings.section').map(entry => entry.options.id)).toEqual(['models'])
+ await plugin.dispose()
+ expect(slots.entries('settings.onboarding')).toEqual([])
+ await host.dispose()
+ const after: IndexInjection[] = []
+ ctx.emit('webserver/index-inject', after)
+ expect(after).toEqual([])
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('defaults to browser onboarding and rejects malformed bootstrap options', async () => {
+ expect(hostPlugin.Config({})).toEqual({ credentialOnboarding: true })
+ expect(hostPlugin.Config['~standard'].validate({ credentialOnboarding: 'false' })).toHaveProperty('issues')
+ const { ctx } = await bench()
+ try {
+ vi.stubGlobal(ONBOARDING_CONFIG_GLOBAL, { credentialOnboarding: 'false' })
+ expect(() => { apply(ctx) }).toThrow()
+ } finally {
+ await ctx.fiber.dispose()
+ }
})
it('declares the services it uses', () => {
diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json
index 3c276ddd5f..27e35f4500 100644
--- a/packages/client/ui-settings-models/tsconfig.json
+++ b/packages/client/ui-settings-models/tsconfig.json
@@ -8,6 +8,8 @@
"src"
],
"references": [
+ { "path": "../../host/webserver" },
+ { "path": "../../../vendor/schemastery" },
{
"path": "../../../vendor/cordis"
},
diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx
index a7e0830c66..ef6ca6adde 100644
--- a/packages/client/ui-trajectory/tests/views.client.spec.tsx
+++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx
@@ -39,7 +39,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createConversationStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
-import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
+import * as localePlugin from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
@@ -293,7 +293,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
// inject); its settings scope needs a connection handle.
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
- await runtime.mount({ inject: [...localeInject], apply: localeApply })
+ await runtime.mount(localePlugin)
const provide = vi.spyOn(ctx.uiSession, 'provide')
const feature = await runtime.mount({ inject: [...inject], apply })
const sourceDescriptor = provide.mock.calls[0]?.[0]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d28edf0ab4..d4374d8a7c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4110,6 +4110,10 @@ importers:
version: 18.3.1
packages/client/ui-settings-models:
+ dependencies:
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -4138,6 +4142,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
+ '@deepseek-ai/dsh-host-webserver':
+ specifier: workspace:^
+ version: link:../../host/webserver
'@deepseek-ai/dsh-remote-mock':
specifier: workspace:^
version: link:../../test-support/remote-mock
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 82b9fde2c3..aafd4ece8f 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -51,11 +51,14 @@
"apps/web/tests/plugin-install-approve.e2e.ts",
"apps/web/tests/client-plugin-live.e2e.ts",
"apps/web/tests/settings-chrome.e2e.ts",
+ "apps/web/tests/desktop-locale.e2e.ts",
+ "apps/web/tests/window-controls.e2e.ts",
"apps/web/tests/models-settings.e2e.ts",
"apps/web/tests/deepseek-messages-settings.e2e.ts",
"apps/web/tests/deepseek-messages-chat.e2e.ts",
"apps/web/tests/models-settings-recovery.e2e.ts",
"apps/web/tests/onboarding-deepseek-config.e2e.ts",
+ "apps/web/tests/onboarding-native.e2e.ts",
"apps/web/tests/onboarding-usable-provider.e2e.ts",
"apps/web/tests/remote-welcome.e2e.ts",
"apps/web/tests/workspace-new-session-folding.e2e.ts",
diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts
index de04f77bed..882357bb69 100644
--- a/vitest.e2e.config.ts
+++ b/vitest.e2e.config.ts
@@ -40,9 +40,9 @@ export default defineConfig({
test: {
execArgv: vitestExecArgv,
setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'],
- // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built
+ // Explicit Node app owners only: apps/web/tests/*.e2e.ts needs the built
// frontend dist and runs under vitest.web.config.ts (the test:web job).
- include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts'],
+ include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts', 'apps/desktop/tests/**/*.e2e.ts'],
exclude: [
'**/*.expected.e2e.ts',
'packages/experimental/inspector/tests/client-browser.e2e.ts',
From 048297321ad1364557308d360b8e535978ec8876 Mon Sep 17 00:00:00 2001
From: lsdsjy <1356263+lsdsjy@users.noreply.github.com>
Date: Wed, 16 Sep 2026 17:06:16 +0800
Subject: [PATCH 002/288] feat(desktop,credentials,client): integrate DeepSeek
account sign-in and Platform pages
---
...026-09-14-deepseek-account-login.i18n.yaml | 6 +
.../2026-09-14-deepseek-account-login.md | 55 ++
.../2026-09-14-deepseek-account-login.zh.md | 55 ++
THIRD_PARTY_NOTICES.md | 2 +
apps/cli/composition.md | 6 +
apps/desktop-host/package.json | 3 +-
apps/desktop-host/src/index.ts | 21 +
apps/desktop-host/tsconfig.json | 3 +
apps/desktop/electron-builder.config.d.mts | 1 +
apps/desktop/package.json | 8 +-
apps/desktop/renderer/welcome.css | 6 +
apps/desktop/renderer/welcome.html | 12 +-
apps/desktop/renderer/welcome.js | 85 ++-
apps/desktop/scripts/dev.ts | 2 +-
apps/desktop/src/account-backend.ts | 112 ++++
apps/desktop/src/host-process.ts | 31 +-
apps/desktop/src/platform-ipc.ts | 8 +
apps/desktop/src/platform-view.ts | 130 +++++
apps/desktop/src/preload-platform-account.ts | 24 +
apps/desktop/src/preload-welcome.ts | 9 +
apps/desktop/src/welcome-api.ts | 19 +-
apps/desktop/src/welcome-backend.ts | 7 +
apps/desktop/src/welcome-window.ts | 17 +-
apps/desktop/tests/account-backend.spec.ts | 26 +
.../expected/welcome/en-timeout.expected.txt | 6 +
.../tests/expected/welcome/en.expected.txt | 2 +-
.../welcome/zh-CN-timeout.expected.txt | 6 +
.../tests/expected/welcome/zh-CN.expected.txt | 2 +-
apps/desktop/tests/host-process.spec.ts | 11 +
apps/desktop/tests/macos-signature.spec.ts | 1 +
apps/desktop/tests/platform-preload.e2e.ts | 50 ++
apps/desktop/tests/platform-view.spec.ts | 106 ++++
apps/desktop/tests/welcome-flow.e2e.ts | 84 ++-
apps/desktop/tests/welcome-host.spec.ts | 5 +-
apps/desktop/tests/welcome-renderer.spec.ts | 18 +
apps/desktop/tests/welcome-startup.spec.ts | 13 +-
apps/desktop/tests/welcome-window.spec.ts | 7 +-
apps/desktop/tsconfig.json | 18 +-
docs/capability-seams.i18n.yaml | 4 +-
docs/capability-seams.md | 9 +
docs/capability-seams.zh.md | 9 +
docs/config-catalog.i18n.yaml | 4 +-
docs/config-catalog.md | 48 ++
docs/config-catalog.zh.md | 48 ++
docs/event-producer-consumer.i18n.yaml | 4 +-
docs/event-producer-consumer.md | 2 +-
docs/event-producer-consumer.zh.md | 2 +-
docs/sign-in-readiness.i18n.yaml | 6 +
docs/sign-in-readiness.md | 39 ++
docs/sign-in-readiness.zh.md | 39 ++
docs/subsystems/credentials.i18n.yaml | 4 +-
docs/subsystems/credentials.md | 76 +++
docs/subsystems/credentials.zh.md | 76 +++
.../api/account-controller/README.i18n.yaml | 6 +
packages/api/account-controller/README.md | 53 ++
packages/api/account-controller/README.zh.md | 53 ++
packages/api/account-controller/package.json | 56 ++
packages/api/account-controller/src/index.ts | 61 ++
packages/api/account-controller/src/types.ts | 2 +
packages/api/account-controller/tsconfig.json | 24 +
packages/api/gateway/README.i18n.yaml | 4 +-
packages/api/gateway/README.md | 2 +
packages/api/gateway/README.zh.md | 2 +
packages/api/gateway/package.json | 6 +-
packages/api/remotes/README.i18n.yaml | 4 +-
packages/api/remotes/README.md | 2 +
packages/api/remotes/README.zh.md | 2 +
packages/api/remotes/package.json | 3 +-
packages/api/remotes/src/client/index.ts | 4 +-
packages/api/remotes/tsconfig.client.json | 3 +
packages/api/remotes/tsconfig.host.json | 3 +
packages/bundle/base/README.i18n.yaml | 4 +-
packages/bundle/base/README.md | 2 +
packages/bundle/base/README.zh.md | 2 +
packages/bundle/base/cordis.patch.yml | 6 +
packages/bundle/base/package.json | 4 +-
packages/bundle/web-app/README.i18n.yaml | 4 +-
packages/bundle/web-app/README.md | 2 +
packages/bundle/web-app/README.zh.md | 2 +
packages/bundle/web-app/cordis.patch.yml | 6 +
packages/bundle/web-app/package.json | 4 +-
.../client/ui-primitives/README.i18n.yaml | 4 +-
packages/client/ui-primitives/README.md | 2 +
packages/client/ui-primitives/README.zh.md | 2 +
packages/client/ui-primitives/src/Menu.tsx | 6 +-
.../ui-settings-account/README.i18n.yaml | 6 +
packages/client/ui-settings-account/README.md | 61 ++
.../client/ui-settings-account/README.zh.md | 61 ++
.../client/ui-settings-account/package.json | 74 +++
.../src/client/AccountMenu.module.css | 11 +
.../src/client/AccountMenu.tsx | 58 ++
.../src/client/AccountOnboarding.tsx | 17 +
.../src/client/AccountSection.module.css | 181 ++++++
.../src/client/AccountSection.tsx | 134 +++++
.../src/client/LogoutIcon.tsx | 10 +
.../src/client/PlatformOverlay.module.css | 62 ++
.../src/client/PlatformOverlay.tsx | 70 +++
.../src/client/SignInDialog.module.css | 17 +
.../src/client/SignInDialog.tsx | 69 +++
.../src/client/browser-login.ts | 71 +++
.../src/client/contact-url.ts | 34 ++
.../src/client/formatBalance.ts | 21 +
.../ui-settings-account/src/client/index.ts | 135 +++++
.../ui-settings-account/src/client/locales.ts | 46 ++
.../ui-settings-account/src/contact-config.ts | 17 +
.../ui-settings-account/src/css-modules.d.ts | 6 +
.../client/ui-settings-account/src/index.ts | 16 +
.../tests/account.client.spec.tsx | 137 +++++
.../tests/browser-login.client.spec.ts | 71 +++
.../tests/contact-url.client.spec.ts | 31 +
.../tests/expected/account-en.txt | 1 +
.../tests/expected/account-signed-out-en.txt | 1 +
.../tests/expected/account-signed-out-zh.txt | 1 +
.../tests/expected/account-zh.txt | 1 +
.../tests/expected/details-en.txt | 1 +
.../tests/expected/details-zh.txt | 1 +
.../tests/expected/login-en.txt | 1 +
.../tests/expected/login-timeout-en.txt | 1 +
.../tests/expected/login-timeout-zh.txt | 1 +
.../tests/expected/login-waiting-en.txt | 1 +
.../tests/expected/login-waiting-zh.txt | 1 +
.../tests/expected/login-zh.txt | 1 +
.../tests/expected/menu-en.txt | 1 +
.../tests/expected/menu-zh.txt | 1 +
.../tests/expected/platform-header-en.txt | 1 +
.../tests/expected/platform-header-zh.txt | 1 +
.../tests/expected/profile-before-balance.txt | 1 +
.../tests/formatBalance.client.spec.ts | 10 +
.../tests/sign-in-dialog.client.spec.tsx | 49 ++
.../client/ui-settings-account/tsconfig.json | 45 ++
.../ui-settings-account/tsdown.config.ts | 3 +
.../ui-settings-general/README.i18n.yaml | 4 +-
packages/client/ui-settings-general/README.md | 4 +
.../client/ui-settings-general/README.zh.md | 4 +
.../src/client/SettingsRoot.module.css | 14 +-
.../src/client/SettingsRoot.tsx | 25 +-
.../ui-settings-general/src/client/index.ts | 1 +
.../src/client/shell-contract.ts | 1 +
.../tests/settings-root.client.spec.tsx | 15 +-
.../tests/shell.client.spec.ts | 3 +-
.../ui-settings-models/README.i18n.yaml | 4 +-
packages/client/ui-settings-models/README.md | 2 +
.../client/ui-settings-models/README.zh.md | 2 +
.../src/client/DeepSeekOnboardingDialog.tsx | 32 +-
.../ui-settings-models/src/client/index.ts | 16 +-
.../src/client/slot-contract.ts | 2 +
.../tests/apply.client.spec.ts | 6 +-
.../tests/onboarding-dialog.client.spec.tsx | 31 +
packages/client/ui-settings/README.i18n.yaml | 4 +-
packages/client/ui-settings/README.md | 2 +
packages/client/ui-settings/README.zh.md | 2 +
.../ui-settings/src/client/contract/slots.ts | 15 +
.../client/ui-settings/src/client/index.ts | 2 +-
.../authorization/README.i18n.yaml | 8 +-
packages/credentials/authorization/README.md | 2 +
.../credentials/authorization/README.zh.md | 2 +
.../credentials/authorization/src/index.ts | 27 +-
.../authorization/tests/authorization.spec.ts | 42 ++
.../README.i18n.yaml | 6 +
.../deepseek-account-platform/README.md | 80 +++
.../deepseek-account-platform/README.zh.md | 80 +++
.../deepseek-account-platform/package.json | 52 ++
.../deepseek-account-platform/src/details.ts | 51 ++
.../deepseek-account-platform/src/index.ts | 412 ++++++++++++++
.../deepseek-account-platform/src/logout.ts | 37 ++
.../deepseek-account-platform/src/protocol.ts | 178 ++++++
.../deepseek-account-platform/src/types.ts | 2 +
.../tests/account.spec.ts | 537 ++++++++++++++++++
.../tests/diagnostics.spec.ts | 55 ++
.../tests/expected/web-exchange-failure.txt | 1 +
.../tests/logout.spec.ts | 75 +++
.../deepseek-account-platform/tsconfig.json | 33 ++
.../deepseek-account/README.i18n.yaml | 6 +
.../credentials/deepseek-account/README.md | 55 ++
.../credentials/deepseek-account/README.zh.md | 55 ++
.../credentials/deepseek-account/package.json | 42 ++
.../credentials/deepseek-account/src/index.ts | 74 +++
.../credentials/deepseek-account/src/types.ts | 45 ++
.../deepseek-account/tsconfig.json | 21 +
.../src/client/slot-catalog.ts | 79 ++-
.../extensions/tool-cordis/src/api-catalog.ts | 103 +++-
packages/llm/llm-deepseek/README.i18n.yaml | 4 +-
packages/llm/llm-deepseek/README.md | 4 +
packages/llm/llm-deepseek/README.zh.md | 4 +
packages/llm/llm-deepseek/package.json | 11 +-
packages/llm/llm-deepseek/src/adapter.ts | 1 +
.../llm/llm-deepseek/src/common/file-store.ts | 3 +
.../llm/llm-deepseek/src/common/files-api.ts | 9 +-
packages/llm/llm-deepseek/src/common/types.ts | 2 +
packages/llm/llm-deepseek/src/index.ts | 2 +
.../src/protocols/chat-completions/adapter.ts | 10 +-
.../src/protocols/messages/adapter.ts | 14 +-
.../llm/llm-deepseek/tests/adapter.spec.ts | 30 +
.../llm/llm-deepseek/tests/files-api.spec.ts | 15 +
packages/llm/llm-deepseek/tsconfig.json | 3 +
.../client-runtime/README.i18n.yaml | 4 +-
.../test-support/client-runtime/README.md | 2 +
.../test-support/client-runtime/README.zh.md | 2 +
.../src/assembly/bundle-roster.ts | 15 +-
.../src/assembly/remote-default-responses.ts | 5 +
.../assembly-bundle-roster.client.spec.ts | 19 +-
pnpm-lock.yaml | 150 +++++
scripts/gen-cordis-catalog.ts | 5 +
scripts/gen-doc-graphs.ts | 9 +
.../verify-package-readme-model-experience.ts | 6 +
tsconfig.base.json | 9 +
tsconfig.client.json | 1 +
tsconfig.host.json | 4 +
208 files changed, 5773 insertions(+), 148 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
create mode 100644 .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md
create mode 100644 apps/desktop/src/account-backend.ts
create mode 100644 apps/desktop/src/platform-ipc.ts
create mode 100644 apps/desktop/src/platform-view.ts
create mode 100644 apps/desktop/src/preload-platform-account.ts
create mode 100644 apps/desktop/tests/account-backend.spec.ts
create mode 100644 apps/desktop/tests/expected/welcome/en-timeout.expected.txt
create mode 100644 apps/desktop/tests/expected/welcome/zh-CN-timeout.expected.txt
create mode 100644 apps/desktop/tests/platform-preload.e2e.ts
create mode 100644 apps/desktop/tests/platform-view.spec.ts
create mode 100644 docs/sign-in-readiness.i18n.yaml
create mode 100644 docs/sign-in-readiness.md
create mode 100644 docs/sign-in-readiness.zh.md
create mode 100644 packages/api/account-controller/README.i18n.yaml
create mode 100644 packages/api/account-controller/README.md
create mode 100644 packages/api/account-controller/README.zh.md
create mode 100644 packages/api/account-controller/package.json
create mode 100644 packages/api/account-controller/src/index.ts
create mode 100644 packages/api/account-controller/src/types.ts
create mode 100644 packages/api/account-controller/tsconfig.json
create mode 100644 packages/client/ui-settings-account/README.i18n.yaml
create mode 100644 packages/client/ui-settings-account/README.md
create mode 100644 packages/client/ui-settings-account/README.zh.md
create mode 100644 packages/client/ui-settings-account/package.json
create mode 100644 packages/client/ui-settings-account/src/client/AccountMenu.module.css
create mode 100644 packages/client/ui-settings-account/src/client/AccountMenu.tsx
create mode 100644 packages/client/ui-settings-account/src/client/AccountOnboarding.tsx
create mode 100644 packages/client/ui-settings-account/src/client/AccountSection.module.css
create mode 100644 packages/client/ui-settings-account/src/client/AccountSection.tsx
create mode 100644 packages/client/ui-settings-account/src/client/LogoutIcon.tsx
create mode 100644 packages/client/ui-settings-account/src/client/PlatformOverlay.module.css
create mode 100644 packages/client/ui-settings-account/src/client/PlatformOverlay.tsx
create mode 100644 packages/client/ui-settings-account/src/client/SignInDialog.module.css
create mode 100644 packages/client/ui-settings-account/src/client/SignInDialog.tsx
create mode 100644 packages/client/ui-settings-account/src/client/browser-login.ts
create mode 100644 packages/client/ui-settings-account/src/client/contact-url.ts
create mode 100644 packages/client/ui-settings-account/src/client/formatBalance.ts
create mode 100644 packages/client/ui-settings-account/src/client/index.ts
create mode 100644 packages/client/ui-settings-account/src/client/locales.ts
create mode 100644 packages/client/ui-settings-account/src/contact-config.ts
create mode 100644 packages/client/ui-settings-account/src/css-modules.d.ts
create mode 100644 packages/client/ui-settings-account/src/index.ts
create mode 100644 packages/client/ui-settings-account/tests/account.client.spec.tsx
create mode 100644 packages/client/ui-settings-account/tests/browser-login.client.spec.ts
create mode 100644 packages/client/ui-settings-account/tests/contact-url.client.spec.ts
create mode 100644 packages/client/ui-settings-account/tests/expected/account-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/account-signed-out-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/account-signed-out-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/account-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/details-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/details-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/login-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/login-timeout-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/login-timeout-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/login-waiting-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/login-waiting-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/login-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/menu-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/menu-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/platform-header-en.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/platform-header-zh.txt
create mode 100644 packages/client/ui-settings-account/tests/expected/profile-before-balance.txt
create mode 100644 packages/client/ui-settings-account/tests/formatBalance.client.spec.ts
create mode 100644 packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx
create mode 100644 packages/client/ui-settings-account/tsconfig.json
create mode 100644 packages/client/ui-settings-account/tsdown.config.ts
create mode 100644 packages/credentials/deepseek-account-platform/README.i18n.yaml
create mode 100644 packages/credentials/deepseek-account-platform/README.md
create mode 100644 packages/credentials/deepseek-account-platform/README.zh.md
create mode 100644 packages/credentials/deepseek-account-platform/package.json
create mode 100644 packages/credentials/deepseek-account-platform/src/details.ts
create mode 100644 packages/credentials/deepseek-account-platform/src/index.ts
create mode 100644 packages/credentials/deepseek-account-platform/src/logout.ts
create mode 100644 packages/credentials/deepseek-account-platform/src/protocol.ts
create mode 100644 packages/credentials/deepseek-account-platform/src/types.ts
create mode 100644 packages/credentials/deepseek-account-platform/tests/account.spec.ts
create mode 100644 packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts
create mode 100644 packages/credentials/deepseek-account-platform/tests/expected/web-exchange-failure.txt
create mode 100644 packages/credentials/deepseek-account-platform/tests/logout.spec.ts
create mode 100644 packages/credentials/deepseek-account-platform/tsconfig.json
create mode 100644 packages/credentials/deepseek-account/README.i18n.yaml
create mode 100644 packages/credentials/deepseek-account/README.md
create mode 100644 packages/credentials/deepseek-account/README.zh.md
create mode 100644 packages/credentials/deepseek-account/package.json
create mode 100644 packages/credentials/deepseek-account/src/index.ts
create mode 100644 packages/credentials/deepseek-account/src/types.ts
create mode 100644 packages/credentials/deepseek-account/tsconfig.json
diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml
new file mode 100644
index 0000000000..e2fb6b15cf
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
+2026-09-14-deepseek-account-login.md: d570966b4758786195d5b3e43fff404d3d005bba
+2026-09-14-deepseek-account-login.zh.md: 4b79bee13c855729088463d8b3a43cc785776a01
diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
new file mode 100644
index 0000000000..d570966b47
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
@@ -0,0 +1,55 @@
+# Agent Note: DeepSeek account browser login
+
+Status: implemented
+
+English | [中文](2026-09-14-deepseek-account-login.zh.md)
+
+Sign-in captures the initiating UI language per attempt so browser authorization follows the Desktop or Settings locale without depending on the Platform default.
+
+## Problem
+
+Desktop login must preserve local cancellation across the browser, UI, and Host and report success only after credentials are saved.
+
+## Decision
+
+The account Service Definition separates UI and model consumers from the platform protocol implementation. The platform provider registers an AuthorizationFlow and persists an owner-scoped GrantRecord. Account presence is derived from storage. Login attempts stay in memory and expose safe snapshots through the account Remote controller.
+
+Local cancellation is authoritative. The provider stops callbacks and ignores late exchange results; auth_cancel invalidates the remote application and unexchanged code using request_id and the original PKCE verifier. Its background request does not delay local cancellation, and failure never restores login. session.commit admits persistence synchronously before its first await and rejects an already-cancelled flow. Cancellation after admission waits for the commit result. The callback redirects to auth_exchange.biz_data.authorized_url only after authorization settlement confirms storage.
+
+Authorization registers a temporary /oauth/callback route on the Host webServer. The authenticated initiating client supplies its browser-accessible loopback HTTP origin, including any local SSH forwarding port. Non-loopback reverse proxies are unsupported. Cleanup removes only the route and preserves shared connections. Exchange failure returns Web to its login UI or focuses Desktop through account state; retry is explicit. PKCE secrets never cross UI transports. Model and file request tokens resolve only for https://api.deepseek.com; redirects are refused. Development loopback grants cannot authenticate production requests. API-key references remain separately stored.
+
+Platform navigation and authorization share one validated platformOrigin from Cordis configuration. Private profile patches or environment expressions supply deployment-specific origins without committing development addresses.
+
+## Alternatives considered
+
+Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. These alternatives are rejected.
+
+## Consequences
+
+Sign out removes the local grant before starting Platform POST /auth-api/v0/users/logout with the captured token. Remote failure never restores login. The provider retries at most five times after the initial request, with configurable exponential backoff starting at one second by default. Retry jobs retain only their old token, cannot change later login state, and end at provider shutdown without durable storage. Local credential deletion must succeed before publishing signed-out state. Sign out belongs to the sidebar account menu; Account settings owns profile, balance and sign-in. Both read one plugin-owned Host stream through framework hooks.
+
+API keys and account grants remain separately stored. Account tokens have no expiry or refresh flow; exhausted logout retries can leave the removed token valid remotely. Profile and balance query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances without adding bonus wallets.
+
+The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs.
+
+Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
+
+Support questionnaires receive only the Platform UID and available environment metadata through explicit prefill fields. Account tokens and masked contact details stay out of questionnaire URLs. The account provider projects the current-user UID for this purpose; the UI uses the latest account snapshot so sign-out cannot reuse a previous UID.
+
+Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs.
+
+DSH grants authenticate Platform, inference and Files requests with x-dsh-auth-token, without a Bearer prefix. API-key authentication remains protocol-specific.
+
+Profile and recharge-wallet queries use independent getProfile and getBalance operations. The client publishes each result as it arrives, so a slow or failed balance request cannot delay the sidebar username. Account changes invalidate both pending results.
+
+## Verification
+
+Provider tests exercise real loopback callbacks, invalid state, delayed exchange cancellation, credential persistence, sign-out, and official-origin restrictions. Desktop tests cover the native action bridge and localized entry. Manual development integration uses the platform dev middleware Mock and the real Electron Host, including cancellation before browser approval. Production backend credentials and installer scheme registration require release-environment validation.
+
+## Related
+
+[Credential records and flows](2026-08-13-credential-records-and-authorization-flows.md) remains the generic credential authority. [Desktop wrapper](2026-09-10-desktop-web-wrapper.md) owns the transport composition.
+
+Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider.
+
+The Host sends client_type (desktop or web) in auth_init so Platform can choose the completion experience. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md
new file mode 100644
index 0000000000..4b79bee13c
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md
@@ -0,0 +1,55 @@
+# Agent Note: DeepSeek 账号浏览器登录
+
+Status: implemented
+
+[English](2026-09-14-deepseek-account-login.md) | 中文
+
+登录申请保存发起界面的语言,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。
+
+## 问题
+
+桌面登录必须在浏览器、UI 和 Host 之间保留本地取消意图,并只在凭证保存后宣告成功。
+
+## 决策
+
+账号服务定义将 UI 和模型使用方与平台协议实现分开。平台提供者注册 AuthorizationFlow,并保存按所有者寻址的 GrantRecord。账号是否存在从存储派生。登录尝试保留在内存中,通过账号 Remote 控制器提供安全快照。
+
+本地取消具有决定权。提供者停止回调并忽略迟到的兑换结果;auth_cancel 使用 request_id 和原始 PKCE verifier 作废远程申请及未兑换的 code。后台请求不延迟本地取消,失败也不会恢复登录。session.commit 在第一次 await 前同步准入持久化,并拒绝已取消的流程。准入后的取消等待提交结果。回调只有在授权结束确认存储后才跳转 auth_exchange.biz_data.authorized_url。
+
+授权在 Host webServer 上注册临时 /oauth/callback 路由。已鉴权的发起客户端提供浏览器可访问的本机 HTTP 来源,包含 SSH 本地转发端口。不支持非本机域名反向代理。清理仅移除路由,保留共享连接。兑换失败返回 Web 登录界面,或通过账号状态聚焦 Desktop;重试由用户明确发起。PKCE 私密数据不经过 UI 传输。模型和文件请求的 token 仅为 https://api.deepseek.com 解析;请求拒绝重定向。本机开发授权记录不能认证生产请求。API Key 引用独立保存。
+
+平台导航与授权共享 Cordis 配置中同一个经过校验的 platformOrigin。私有 profile patch 或环境表达式提供部署来源,开发地址不进入仓库。
+
+## 替代方案
+
+把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。这些方案均被拒绝。
+
+## 影响
+
+退出登录先删除本地授权,再使用捕获的 token 调用 Platform POST /auth-api/v0/users/logout。远程失败不会恢复登录态。首次请求失败后,提供者最多重试五次,使用可配置的指数退避,默认从一秒开始。重试任务只保留旧 token,不改变后续登录态,并在提供者关闭时结束,不持久化。本地凭证删除成功后才发布已退出状态。退出登录位于侧边栏账号菜单;账号设置负责资料、余额和登录。两者通过框架 hook 读取插件持有的同一条 Host 状态流。
+
+API Key 与账号记录独立保存。账号 token 没有过期或刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。资料和余额查询失败保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 显示 normal_wallets 充值余额,不合并赠送钱包。
+
+账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。
+
+Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
+
+支持问卷仅通过显式预填字段接收 Platform UID 和已有的环境信息,URL 不包含账号 token 或脱敏联系方式。账号提供者为此投影 current-user 的 UID;UI 使用最新账号快照,避免退出登录后复用旧 UID。
+
+私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。
+
+DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请求,不加 Bearer 前缀。API Key 继续使用各协议的认证方式。
+
+资料和充值钱包余额分别通过 getProfile 与 getBalance 查询。客户端在各自返回时立即更新,余额请求慢或失败不会延迟侧边栏用户名显示。账号变化使两种进行中的查询结果失效。
+
+## 验证
+
+提供者测试覆盖真实本机回调、错误 state、延迟兑换取消、凭证持久化、退出和官方来源限制。桌面测试覆盖原生操作桥接与本地化入口。手动开发联调使用平台 dev middleware Mock 和真实 Electron Host,包括在浏览器批准前取消。生产后端凭证及安装器 scheme 注册仍需发布环境验证。
+
+## 相关记录
+
+[凭证记录与流程](2026-08-13-credential-records-and-authorization-flows.zh.md)仍是通用凭证依据。[桌面外壳](2026-09-10-desktop-web-wrapper.zh.md)负责传输组合。
+
+开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。
+
+Host 在 auth_init 中发送 client_type(desktop 或 web),供 Platform 选择完成页交互。Web 失败时关闭授权标签页,原标签页接收 Host 状态,不使用 Web UI 返回地址。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index ec698dca33..ccd764f0db 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -69,6 +69,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
| [`acorn`](https://github.com/acornjs/acorn) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
+| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
| [`buffer`](https://github.com/feross/buffer) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
| [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 |
@@ -174,6 +175,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index fb7a946d7c..f6c99c4d79 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -48,6 +48,10 @@ flowchart LR
cfg --> plugin_dsh_base_llm_retry
plugin_dsh_base_settings["settings @deepseek-ai/dsh-settings-file"]
cfg --> plugin_dsh_base_settings
+ plugin_dsh_base_authorization["authorization @deepseek-ai/dsh-authorization"]
+ cfg --> plugin_dsh_base_authorization
+ plugin_dsh_base_deepseek_account["deepseek-account @deepseek-ai/dsh-deepseek-account-platform"]
+ cfg --> plugin_dsh_base_deepseek_account
plugin_dsh_base_credentials["credentials @deepseek-ai/dsh-credentials-local"]
cfg --> plugin_dsh_base_credentials
plugin_dsh_base_llm_pi_ai["llm-pi-ai @deepseek-ai/dsh-llm-pi-ai"]
@@ -210,6 +214,8 @@ flowchart LR
| `jobs` | `@deepseek-ai/dsh-jobs-local` |
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
| `settings` | `@deepseek-ai/dsh-settings-file` |
+| `authorization` | `@deepseek-ai/dsh-authorization` |
+| `deepseek-account` | `@deepseek-ai/dsh-deepseek-account-platform` |
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json
index 092e3c2813..fba5fda302 100644
--- a/apps/desktop-host/package.json
+++ b/apps/desktop-host/package.json
@@ -19,6 +19,7 @@
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-skill-office": "workspace:^"
+ "@deepseek-ai/dsh-skill-office": "workspace:^",
+ "@deepseek-ai/dsh-deepseek-account": "workspace:^"
}
}
diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts
index 573ef11541..9e8ecfc71c 100644
--- a/apps/desktop-host/src/index.ts
+++ b/apps/desktop-host/src/index.ts
@@ -5,6 +5,7 @@ import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
import { runProfile } from '@deepseek-ai/dsh/profile-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-host-webserver'
+import type {} from '@deepseek-ai/dsh-deepseek-account'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import * as desktopOffice from './office.ts'
@@ -70,6 +71,26 @@ async function main(): Promise {
source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
})
+ const account = ctx.get('deepseekAccount')
+ if (account !== undefined) {
+ const publish = async (): Promise => {
+ const session = await account.getPlatformSession()
+ if (process.connected) process.send?.({ type: 'platform-session', session })
+ }
+ await publish()
+ ctx.effect(() => {
+ const lifetime = new AbortController()
+ const updates = (async () => {
+ for await (const _state of account.watch(lifetime.signal)) {
+ if (lifetime.signal.aborted) break
+ await publish()
+ }
+ })().catch(() => {
+ if (process.connected) process.send?.({ type: 'platform-session', session: null })
+ })
+ return async () => { lifetime.abort(); await updates }
+ })
+ }
const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`)
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
}
diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json
index 89780f7b94..b077063d9b 100644
--- a/apps/desktop-host/tsconfig.json
+++ b/apps/desktop-host/tsconfig.json
@@ -25,6 +25,9 @@
},
{
"path": "../../packages/host/webserver"
+ },
+ {
+ "path": "../../packages/credentials/deepseek-account"
}
]
}
diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts
index f7cfd63f42..84d4a259ef 100644
--- a/apps/desktop/electron-builder.config.d.mts
+++ b/apps/desktop/electron-builder.config.d.mts
@@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib'
/** Electron-builder fields asserted by the Desktop release tests. */
export interface DesktopElectronBuilderConfig {
readonly appId: string
+ readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }]
readonly directories: {
readonly output: string
}
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index c060f1c673..0d7e652bad 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -35,7 +35,9 @@
},
"dependencies": {
"electron-updater": "^6.8.9",
- "semver": "^7.8.5"
+ "semver": "^7.8.5",
+ "ws": "^8.21.0",
+ "@deepseek-ai/dsh-api-gateway": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:^",
@@ -54,6 +56,8 @@
"js-yaml": "^4.2.0",
"pnpm": "11.7.0",
"tar": "^7.5.0",
- "typescript": "^6.0.3"
+ "typescript": "^6.0.3",
+ "@deepseek-ai/dsh-deepseek-account": "workspace:^",
+ "@types/ws": "^8.18.1"
}
}
diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css
index 2bfd791bb1..f3ae24cb45 100644
--- a/apps/desktop/renderer/welcome.css
+++ b/apps/desktop/renderer/welcome.css
@@ -282,3 +282,9 @@ body {
clip-path: inset(50%);
white-space: nowrap;
}
+
+.auth-expired { gap: 12px; padding-top: 28px; }
+.auth-expired h1 { font-size: 20px; line-height: 24px; font-weight: 400; letter-spacing: normal; }
+.auth-expired p { line-height: 16px; letter-spacing: normal; }
+
+.welcome.expired-page { padding-bottom: 60px; }
diff --git a/apps/desktop/renderer/welcome.html b/apps/desktop/renderer/welcome.html
index 83248438c4..886b478e3f 100644
--- a/apps/desktop/renderer/welcome.html
+++ b/apps/desktop/renderer/welcome.html
@@ -24,8 +24,18 @@
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/apps/desktop/renderer/welcome.js b/apps/desktop/renderer/welcome.js
index 13b9daeaf6..54c89adb4f 100644
--- a/apps/desktop/renderer/welcome.js
+++ b/apps/desktop/renderer/welcome.js
@@ -41,15 +41,21 @@ function renderBusy() {
form.setAttribute('aria-busy', String(busy))
}
-document.querySelector('#api-key').addEventListener('click', () => {
+function openApiKey() {
+ authPageVisible = false
+ document.querySelector('#auth-page').hidden = true
+ document.querySelector('#auth-actions').hidden = true
document.querySelector('#tagline').hidden = true
document.querySelector('#entry-actions').hidden = true
form.hidden = false
document.querySelector('#key-actions').hidden = false
welcome.setAttribute('aria-labelledby', 'key-title')
+ welcome.classList.remove('expired-page')
welcome.classList.add('key-page')
input.focus()
-})
+}
+document.querySelector('#api-key').addEventListener('click', openApiKey)
+document.querySelector('#auth-api-key').addEventListener('click', openApiKey)
back.addEventListener('click', () => {
if (busy) return
@@ -111,3 +117,78 @@ skip.addEventListener('click', async () => {
})
renderBusy()
+
+
+let authAttempt
+let authPageVisible = false
+const authPage = document.querySelector('#auth-page')
+const authActions = document.querySelector('#auth-actions')
+const authStatus = document.querySelector('#auth-status')
+const authReopen = document.querySelector('#auth-reopen')
+const authRetry = document.querySelector('#auth-retry')
+const authCancel = document.querySelector('#auth-cancel')
+authReopen.textContent = messages.welcomeAuthReopen
+authRetry.textContent = messages.welcomeAuthRetry
+document.querySelector('#auth-api-key').textContent = messages.welcomeApiKey
+authCancel.textContent = messages.welcomeAuthCancel
+
+function showAccount(state) {
+ const attempt = state.attempt
+ if (!authPageVisible && !form.hidden) return
+ if (attempt === null && !authPageVisible) return
+ authAttempt = attempt
+ if (attempt?.phase === 'cancelled') {
+ welcome.setAttribute('aria-labelledby', 'tagline')
+ authPageVisible = false
+ authPage.hidden = true
+ authActions.hidden = true
+ document.querySelector('#tagline').hidden = false
+ document.querySelector('#entry-actions').hidden = false
+ return
+ }
+ authPageVisible = true
+ welcome.setAttribute('aria-labelledby', 'auth-status')
+ authPage.hidden = false
+ authActions.hidden = false
+ document.querySelector('#tagline').hidden = true
+ document.querySelector('#entry-actions').hidden = true
+ const phase = attempt?.phase ?? 'failed'
+ const failed = ['expired', 'failed'].includes(phase)
+ authStatus.textContent = phase === 'initializing' ? messages.welcomeAuthStarting
+ : phase === 'waiting-browser' ? messages.welcomeAuthWaiting
+ : phase === 'expired' ? messages.welcomeAuthExpired
+ : phase === 'failed' ? messages.welcomeAuthFailed : messages.welcomeAuthExchanging
+ document.querySelector('#auth-description').hidden = phase !== 'expired'
+ document.querySelector('#auth-description').textContent = messages.welcomeAuthExpiredDescription
+ authPage.classList.toggle('auth-expired', phase === 'expired')
+ welcome.classList.toggle('expired-page', phase === 'expired')
+ document.querySelector('#auth-api-key').hidden = !failed
+ authCancel.hidden = failed
+ authReopen.hidden = failed
+ authReopen.disabled = phase !== 'waiting-browser'
+ authRetry.hidden = !failed
+ authCancel.disabled = phase === 'committing' || phase === 'succeeded' || (phase === 'initializing' && !attempt?.id)
+}
+
+async function startSignIn() {
+ authPageVisible = true
+ showAccount({ attempt: { phase: 'initializing' } })
+ try { showAccount(await api.startSignIn()) }
+ catch { showAccount({ attempt: null }) }
+}
+document.querySelector('#sign-in').addEventListener('click', startSignIn)
+authRetry.addEventListener('click', startSignIn)
+authCancel.addEventListener('click', async () => {
+ authCancel.disabled = true
+ try {
+ if (authAttempt?.id) showAccount(await api.cancelSignIn(authAttempt.id))
+ else showAccount({ attempt: { phase: 'cancelled' } })
+ } catch { authCancel.disabled = false }
+})
+authReopen.addEventListener('click', async () => {
+ if (!authAttempt?.id) return
+ try { await api.reopenSignIn(authAttempt.id) }
+ catch { authStatus.textContent = messages.welcomeAuthFailed }
+})
+const stopAccount = api.onAccountState(showAccount)
+window.addEventListener('pagehide', stopAccount, { once: true })
diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts
index 1ce40ff758..c54600354e 100644
--- a/apps/desktop/scripts/dev.ts
+++ b/apps/desktop/scripts/dev.ts
@@ -82,7 +82,7 @@ async function launchElectron(previewWelcome: boolean): Promise {
}
async function main(): Promise {
- const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } })
+ const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false }, 'preview-welcome': { type: 'boolean', default: false } } })
if (!values['skip-build']) {
await runPackageScript('build', REPOSITORY_ROOT)
await runPackageScript('build', APP_ROOT)
diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts
new file mode 100644
index 0000000000..964675e654
--- /dev/null
+++ b/apps/desktop/src/account-backend.ts
@@ -0,0 +1,112 @@
+/** Native account commands and Gateway state stream; no renderer receives credentials. */
+import { randomUUID } from 'node:crypto'
+import WebSocket from 'ws'
+import { parseRemoteStreamServerMessage, REMOTE_STREAM_MUX_PATH } from '@deepseek-ai/dsh-api-gateway/stream-protocol'
+import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
+
+/** Authenticated unary caller shared with native onboarding. */
+export type AccountInvoke = (request: { namespace: string; method: string; args: Record }) => Promise
+
+/** Decode the UI-safe state received across HTTP or WebSocket. @param value - wire value. @returns account projection. */
+export function accountView(value: unknown): AccountView {
+ if (typeof value !== 'object' || value === null || !('status' in value)
+ || !['signed-out', 'credential-stored'].includes(String(value.status)) || !('attempt' in value)) {
+ throw new Error('desktop account: invalid state')
+ }
+ if (!('links' in value) || typeof value.links !== 'object' || value.links === null
+ || !('usageUrl' in value.links) || typeof value.links.usageUrl !== 'string'
+ || !('topUpUrl' in value.links) || typeof value.links.topUpUrl !== 'string') {
+ throw new Error('desktop account: invalid platform links')
+ }
+ validateBrowserDestination(value.links.usageUrl)
+ validateBrowserDestination(value.links.topUpUrl)
+ const attempt = value.attempt
+ if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string'
+ || !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase))
+ || ('authorizeUrl' in attempt && typeof attempt.authorizeUrl !== 'string')
+ || ('expiresAt' in attempt && (typeof attempt.expiresAt !== 'number' || !Number.isFinite(attempt.expiresAt)))
+ || ('errorCode' in attempt && !['network', 'protocol', 'expired', 'storage'].includes(String(attempt.errorCode))))) {
+ throw new Error('desktop account: invalid attempt')
+ }
+ if (attempt !== null && 'authorizeUrl' in attempt) {
+ validateBrowserDestination(String(attempt.authorizeUrl))
+ }
+ const parsed = value as AccountView
+ return {
+ status: parsed.status,
+ links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl },
+ attempt: parsed.attempt === null ? null : {
+ id: parsed.attempt.id, phase: parsed.attempt.phase,
+ ...parsed.attempt.authorizeUrl === undefined ? {} : { authorizeUrl: parsed.attempt.authorizeUrl },
+ ...parsed.attempt.expiresAt === undefined ? {} : { expiresAt: parsed.attempt.expiresAt },
+ ...parsed.attempt.errorCode === undefined ? {} : { errorCode: parsed.attempt.errorCode },
+ },
+ }
+}
+
+/** Only HTTP loopback or HTTPS destinations can leave the native app. */
+function validateBrowserDestination(value: string): void {
+ const url = new URL(value)
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
+ if (url.username || url.password || !(url.protocol === 'https:' || (loopback && url.protocol === 'http:'))) {
+ throw new Error('desktop account: invalid browser destination')
+ }
+}
+
+/** Native account operations and explicitly owned stream lifetime. */
+export interface DesktopAccountBackend {
+ /** @returns current account snapshot. */
+ state(): Promise
+ /** @param locale - active desktop UI language. @returns new or already-running login attempt. */
+ start(locale: string): Promise
+ /** @param id - attempt to cancel. @returns cancellation or completed commit state. */
+ cancel(id: SignInAttemptId): Promise
+ /** @returns state after local sign-out. */
+ signOut(): Promise
+ /** @param listener - state recipient. @param failed - stream failure recipient. @returns stream disposer. */
+ watch(listener: (state: AccountView) => void, failed: () => void): () => void
+}
+
+/**
+ * Connect native account operations to the standard authenticated Web backend.
+ * @param origin - Host Web origin.
+ * @param invoke - validated unary RPC caller.
+ * @param cookies - Electron session cookie reader.
+ * @returns account operations; watch callers own their subscriptions.
+ */
+export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise): DesktopAccountBackend {
+ const call = async (method: string, args: Record = {}): Promise =>
+ accountView(await invoke({ namespace: 'account', method, args }))
+ return {
+ state: () => call('getState'), start: locale => call('startSignIn', { locale, callbackOrigin: new URL(origin).origin, client: 'desktop' }),
+ cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: () => call('signOut'),
+ watch(listener, failed) {
+ let closed = false
+ let socket: WebSocket | undefined
+ let retry: ReturnType | undefined
+ const connect = (): void => {
+ const streamId = randomUUID()
+ void cookies().then((cookie) => {
+ if (closed) return
+ const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+ socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
+ socket.on('open', () => socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })))
+ socket.on('message', (data) => {
+ try {
+ const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
+ const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
+ if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
+ if (frame.type === 'item') listener(accountView(frame.value))
+ else socket?.close()
+ } catch { socket?.close() }
+ })
+ socket.on('error', () => { socket?.close() })
+ socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
+ }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
+ }
+ connect()
+ return () => { closed = true; clearTimeout(retry); socket?.close() }
+ },
+ }
+}
diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts
index e4be343c99..fd8b9d5e73 100644
--- a/apps/desktop/src/host-process.ts
+++ b/apps/desktop/src/host-process.ts
@@ -2,6 +2,7 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
+import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
import { desktopNodeEnvironment } from './node-environment.ts'
interface ReadyEvent {
@@ -15,7 +16,12 @@ interface FatalEvent {
readonly message: string
}
-type DesktopHostEvent = ReadyEvent | FatalEvent | { readonly type: 'shutdown-complete' } | {
+interface PlatformSessionEvent {
+ readonly type: 'platform-session'
+ readonly session: PlatformSession | null
+}
+
+type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {
readonly type: 'update-tasks'
readonly requestId: number
readonly active: boolean
@@ -32,6 +38,23 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
return true
case 'ready':
return typeof candidate.url === 'string'
+ case 'platform-session': {
+ const session = candidate.session
+ if (session === null) return true
+ if (typeof session !== 'object' || !('origin' in session) || !('token' in session)
+ || typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false
+ if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false
+ if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null
+ || Array.isArray(session.requestHeaders)
+ || Object.entries(session.requestHeaders).some(([name, value]) => typeof value !== 'string'
+ || name !== name.toLowerCase() || /[\r\n]/.test(value)
+ || ['authorization', 'x-dsh-auth-token', 'host', 'content-length', 'transfer-encoding', 'connection', 'content-type'].includes(name)))) return false
+ try {
+ const url = new URL(session.origin)
+ return url.origin === session.origin && !url.username && !url.password
+ && (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
+ } catch { return false }
+ }
case 'fatal':
return typeof candidate.message === 'string'
case 'update-tasks':
@@ -92,6 +115,7 @@ export class DesktopHostProcess {
* `office-skills` resources fail Host startup.
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
* @param profileResolution - Package resolution mode for the application-owned profile.
+ * @param onPlatformSession - Private credential updates for embedded Platform views.
*/
constructor(
private readonly node: string,
@@ -103,6 +127,8 @@ export class DesktopHostProcess {
private readonly primaryRuntime?: string,
private readonly profileResolution: 'link' | 'runtime' = 'link',
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
+
+ private readonly onPlatformSession?: (session: PlatformSession | null) => void,
) {}
/**
@@ -137,6 +163,7 @@ export class DesktopHostProcess {
return
}
if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })
+ else if (message.type === 'platform-session') this.onPlatformSession?.(message.session)
else if (message.type === 'shutdown-complete') {
if (this.stopping) this.shutdownCompleted = true
else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown'))
@@ -195,6 +222,7 @@ export class DesktopHostProcess {
const child = this.child
if (child === undefined) return
this.stopping = true
+ this.onPlatformSession?.(null)
if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
const exited = this.exitPromise ?? Promise.resolve()
const graceful = await exitsWithin(exited, 10_000)
@@ -213,6 +241,7 @@ export class DesktopHostProcess {
}
private fail(error: Error): void {
+ this.onPlatformSession?.(null)
this.readyReject(error)
for (const query of this.taskQueries.values()) query.reject(error)
this.taskQueries.clear()
diff --git a/apps/desktop/src/platform-ipc.ts b/apps/desktop/src/platform-ipc.ts
new file mode 100644
index 0000000000..3745e5c605
--- /dev/null
+++ b/apps/desktop/src/platform-ipc.ts
@@ -0,0 +1,8 @@
+/** Shared names for the desktop Platform bridge. */
+/** Private desktop channels; the Platform renderer receives only bootstrap. */
+export const PLATFORM_IPC = {
+ bootstrap: 'dsh-platform:bootstrap',
+ open: 'dsh-platform:open',
+ bounds: 'dsh-platform:bounds',
+ close: 'dsh-platform:close',
+} as const
diff --git a/apps/desktop/src/platform-view.ts b/apps/desktop/src/platform-view.ts
new file mode 100644
index 0000000000..6c901196af
--- /dev/null
+++ b/apps/desktop/src/platform-view.ts
@@ -0,0 +1,130 @@
+/** Isolated Platform documents owned by the desktop account lifetime. */
+import { randomUUID } from 'node:crypto'
+import { WebContentsView, session, shell, type BrowserWindow, type IpcMainEvent } from 'electron'
+import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
+
+export { PLATFORM_IPC } from './platform-ipc.ts'
+
+/** Bounds in desktop content coordinates, supplied by the owned application renderer. */
+export interface PlatformBounds { x: number; y: number; width: number; height: number }
+
+/**
+ * Decode the renderer rectangle before allocating a native view.
+ * @param value - IPC payload.
+ * @returns finite, nonnegative integer coordinates.
+ */
+export function platformBounds(value: unknown): PlatformBounds {
+ if (typeof value !== 'object' || value === null) throw new Error('Invalid Platform bounds')
+ const row = value as Record
+ const result: Record = {}
+ for (const key of ['x', 'y', 'width', 'height'] as const) {
+ const n = row[key]
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0 || n > 100_000) throw new Error('Invalid Platform bounds')
+ result[key] = Math.round(n)
+ }
+ return result as unknown as PlatformBounds
+}
+
+/** Native view and its credential snapshot are discarded together. */
+export class DesktopPlatformView {
+ private account: PlatformSession | null = null
+ private view: WebContentsView | undefined
+ private owner: BrowserWindow | undefined
+ private generation = 0
+
+ /** @param preload - bundled sandboxed Platform preload path. */
+ constructor(private readonly preload: string) {}
+
+ /** @param next - private Host credential snapshot; replacement invalidates the current document. */
+ setSession(next: PlatformSession | null): void {
+ if (next?.token === this.account?.token && next?.origin === this.account?.origin) return
+ this.close()
+ this.account = next
+ }
+
+ /**
+ * Create an in-memory browser session after account preparation has completed.
+ * @param owner - application window containing the view.
+ * @param page - explicit supported Platform page.
+ * @param bounds - owned renderer rectangle.
+ * @returns when the document finishes loading.
+ */
+ async open(owner: BrowserWindow, page: 'usage' | 'top-up', bounds: PlatformBounds): Promise {
+ this.close()
+ const account = this.account
+ if (account === null) throw new Error('Platform account unavailable')
+ const generation = this.generation
+ const browserSession = session.fromPartition(`dsh-platform-${randomUUID()}`)
+ browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
+ browserSession.setPermissionCheckHandler(() => false)
+ const view = new WebContentsView({ webPreferences: {
+ session: browserSession, preload: this.preload, sandbox: true, contextIsolation: true,
+ additionalArguments: [`--dsh-platform-origin=${account.origin}`],
+ nodeIntegration: false, webSecurity: true,
+ } })
+ this.view = view
+ this.owner = owner
+ // External payment and documentation pages open without the embedded session or token.
+ view.webContents.setWindowOpenHandler(({ url }) => {
+ const destination = new URL(url)
+ if (destination.protocol === 'https:' && !destination.username && !destination.password) {
+ void shell.openExternal(url).catch(() => {
+ // An OS browser-launch failure leaves the embedded page available for retry.
+ })
+ }
+ return { action: 'deny' }
+ })
+ const allowNavigation = (url: string): boolean => {
+ try {
+ const parsed = new URL(url)
+ return parsed.origin === account.origin && !parsed.username && !parsed.password
+ } catch { return false }
+ }
+ view.webContents.on('will-navigate', (event, url) => { if (!allowNavigation(url)) event.preventDefault() })
+ view.webContents.on('will-redirect', (event, url) => { if (!allowNavigation(url)) event.preventDefault() })
+ view.webContents.on('will-attach-webview', (event) => { event.preventDefault() })
+ view.webContents.on('preload-error', () => { if (this.view === view) this.close() })
+ view.webContents.on('render-process-gone', () => { if (this.view === view) this.close() })
+ owner.contentView.addChildView(view)
+ view.setBounds(bounds)
+ try {
+ await view.webContents.loadURL(new URL(page === 'usage' ? '/usage' : '/top_up', account.origin).href)
+ } catch (error) {
+ if (generation === this.generation) this.close()
+ throw error
+ }
+ }
+
+ /** @param bounds - current application viewport rectangle. */
+ setBounds(bounds: PlatformBounds): void { this.view?.setBounds(bounds) }
+
+ /**
+ * Return prepared credentials only to the current Platform main frame.
+ * @param event - Electron-provided sender identity.
+ * @returns credentials copied once into the isolated preload.
+ */
+ bootstrap(event: IpcMainEvent): PlatformSession {
+ const view = this.view
+ const account = this.account
+ if (view === undefined || account === null || event.sender !== view.webContents
+ || event.senderFrame !== view.webContents.mainFrame || new URL(event.senderFrame.url).origin !== account.origin) {
+ throw new Error('Rejected Platform bootstrap')
+ }
+ return { ...account }
+ }
+
+ /** Destroy the document before releasing its temporary browser storage. */
+ close(): void {
+ this.generation++
+ const view = this.view
+ this.view = undefined
+ if (view === undefined) return
+ if (this.owner !== undefined && !this.owner.isDestroyed()) this.owner.contentView.removeChildView(view)
+ this.owner = undefined
+ const browserSession = view.webContents.session
+ if (!view.webContents.isDestroyed()) view.webContents.close()
+ void browserSession.clearStorageData().catch(() => {
+ // The non-persistent partition is unreachable after its only view is closed.
+ })
+ }
+}
diff --git a/apps/desktop/src/preload-platform-account.ts b/apps/desktop/src/preload-platform-account.ts
new file mode 100644
index 0000000000..c8813e1c08
--- /dev/null
+++ b/apps/desktop/src/preload-platform-account.ts
@@ -0,0 +1,24 @@
+/** Platform initialization copies credentials once; token getters never perform IPC. */
+import { contextBridge, ipcRenderer } from 'electron'
+import { PLATFORM_IPC } from './platform-ipc.ts'
+
+const originArgument = '--dsh-platform-origin='
+const allowedOrigin = process.argv.find(argument => argument.startsWith(originArgument))?.slice(originArgument.length)
+if (process.isMainFrame && location.origin === allowedOrigin) {
+ let token: string | undefined
+ try {
+ const value: unknown = ipcRenderer.sendSync(PLATFORM_IPC.bootstrap)
+ if (typeof value === 'object' && value !== null && 'token' in value && 'origin' in value
+ && typeof value.token === 'string' && value.token.length > 0 && value.origin === location.origin) token = value.token
+ } catch {
+ // Initialization failure retains embedded mode so Platform cannot use browser credentials.
+ }
+ contextBridge.exposeInMainWorld('dsh', {
+ protocolVersion: 1,
+ displayMode: 'embedded',
+ getAuthToken: (): string => {
+ if (token === undefined) throw new Error('Platform initialization failed')
+ return token
+ },
+ })
+}
diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts
index a0ad499dc6..e973c5d6ab 100644
--- a/apps/desktop/src/preload-welcome.ts
+++ b/apps/desktop/src/preload-welcome.ts
@@ -1,3 +1,4 @@
+import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Localized welcome copy and write-only credential actions. */
import { contextBridge, ipcRenderer } from 'electron'
@@ -9,6 +10,14 @@ const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice
if (locale === undefined) throw new Error('desktop welcome: missing window locale')
const api: WelcomeApi = {
...resolveDesktopLocale(locale),
+ startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise,
+ cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise,
+ reopenSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.reopen, id) as Promise,
+ onAccountState: (listener) => {
+ const receive = (_event: Electron.IpcRendererEvent, state: AccountView): void =>{ listener(state) }
+ ipcRenderer.on(WELCOME_IPC.state, receive)
+ return () => { ipcRenderer.removeListener(WELCOME_IPC.state, receive) }
+ },
saveApiKey: (value: string) => ipcRenderer.invoke(WELCOME_IPC.saveApiKey, value) as Promise,
skip: () => ipcRenderer.invoke(WELCOME_IPC.skip) as Promise,
}
diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts
index 85e494ea2c..f9a5520fe3 100644
--- a/apps/desktop/src/welcome-api.ts
+++ b/apps/desktop/src/welcome-api.ts
@@ -1,11 +1,16 @@
/** Operations available to the isolated native welcome renderer. */
+import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { DesktopLocale } from './locale.ts'
/** Private native welcome channels, installed only while its window exists. */
export const WELCOME_IPC = {
saveApiKey: 'dsh-welcome:save-api-key',
skip: 'dsh-welcome:skip',
+ start: 'dsh-welcome:start',
+ cancel: 'dsh-welcome:cancel',
+ reopen: 'dsh-welcome:reopen',
+ state: 'dsh-welcome:state',
} as const
/** Credential writes return a safe outcome without exposing Host diagnostics. */
@@ -13,6 +18,13 @@ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
/** Host-owned operations used by the welcome window. */
export interface WelcomeOperations {
+ /** @returns account state after starting a login attempt. */
+ startSignIn(): Promise
+ /** @param id - attempt to cancel. @returns the settled state. */
+ cancelSignIn(id: SignInAttemptId): Promise
+ /** @param id - current attempt whose browser page should reopen. */
+ reopenSignIn(id: SignInAttemptId): Promise
+
/**
* Store the official provider's key before entering the workspace.
* @param value - validated, trimmed API key.
@@ -26,8 +38,11 @@ export interface WelcomeOperations {
skip(): Promise
}
-/** The renderer receives localized copy and two narrowly scoped operations. */
-export type WelcomeApi = DesktopLocale & WelcomeOperations
+/** The renderer receives localized copy, login operations, and safe account snapshots. */
+export type WelcomeApi = DesktopLocale & WelcomeOperations & {
+ /** @param listener - safe account snapshot recipient. @returns subscription disposer. */
+ onAccountState(listener: (state: AccountView) => void): () => void
+}
/** Authentication facts supplied at cold start or after a completed sign-out. */
export interface WelcomeAuthentication {
diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts
index 2af24c19ae..73b4486737 100644
--- a/apps/desktop/src/welcome-backend.ts
+++ b/apps/desktop/src/welcome-backend.ts
@@ -1,9 +1,11 @@
/** Native welcome operations using the shared Web authentication and RPC APIs. */
import { randomUUID } from 'node:crypto'
+import { desktopAccountBackend, type DesktopAccountBackend } from './account-backend.ts'
/** Metadata needed before the native entry or workspace becomes visible. */
export interface WelcomeState {
+ readonly loggedIn: boolean
readonly hasApiKey: boolean
readonly writable: boolean
readonly localePreference: string | null
@@ -11,6 +13,7 @@ export interface WelcomeState {
/** Narrow operations available to the native welcome flow. */
export interface DesktopWelcomeBackend {
+ readonly account: DesktopAccountBackend
/** @returns Configured-key presence and the shared language preference, without credential values. */
read(): Promise
/**
@@ -33,6 +36,7 @@ function record(value: unknown): value is Record {
export async function connectDesktopWelcome(
authenticatedUrl: string,
send: (input: string, init?: RequestInit) => Promise,
+ cookies: () => Promise = () => Promise.resolve(''),
): Promise {
const origin = new URL(authenticatedUrl).origin
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
@@ -54,6 +58,7 @@ export async function connectDesktopWelcome(
}
return envelope.result.value
}
+ const account = desktopAccountBackend(origin, invoke, cookies)
const settingsAndReference = async () => {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
@@ -95,12 +100,14 @@ export async function connectDesktopWelcome(
throw new Error('desktop welcome: invalid locale preference')
}
return {
+ loggedIn: (await account.state()).status === 'credential-stored',
hasApiKey: Object.values(states).some(value => record(value) && value.configured === true),
writable: states[ref].writable === true,
localePreference: locale.value.preference ?? null,
}
}
return {
+ account,
read,
async save(apiKey) {
if (!/^[\x21-\x7e]+$/.test(apiKey)) return { ok: false }
diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts
index bf8596b490..6d5f704e85 100644
--- a/apps/desktop/src/welcome-window.ts
+++ b/apps/desktop/src/welcome-window.ts
@@ -1,3 +1,4 @@
+import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Native welcome window and its presentation-only renderer. */
import { join } from 'node:path'
@@ -26,7 +27,7 @@ export function welcomeWindowOptions(platform: NodeJS.Platform, locale: DesktopL
backgroundColor: platform === 'darwin' || platform === 'win32' ? '#00000000' : '#FFFFFF',
...(platform === 'darwin' ? {
titleBarStyle: 'hidden',
- trafficLightPosition: { x: 16, y: 21 },
+ trafficLightPosition: { x: 21, y: 21 },
vibrancy: 'titlebar',
visualEffectState: 'active',
} as const : {}),
@@ -69,9 +70,23 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
assertSender(event)
await operations.skip()
})
+ ipcMain.handle(WELCOME_IPC.start, async (event) => { assertSender(event); return operations.startSignIn() })
+ ipcMain.handle(WELCOME_IPC.cancel, async (event, id: unknown) => {
+ assertSender(event)
+ if (typeof id !== 'string') throw new Error('desktop welcome: invalid attempt')
+ return operations.cancelSignIn(id as SignInAttemptId)
+ })
+ ipcMain.handle(WELCOME_IPC.reopen, async (event, id: unknown) => {
+ assertSender(event)
+ if (typeof id !== 'string') throw new Error('desktop welcome: invalid attempt')
+ return operations.reopenSignIn(id as SignInAttemptId)
+ })
window.once('closed', () => {
ipcMain.removeHandler(WELCOME_IPC.saveApiKey)
ipcMain.removeHandler(WELCOME_IPC.skip)
+ ipcMain.removeHandler(WELCOME_IPC.start)
+ ipcMain.removeHandler(WELCOME_IPC.cancel)
+ ipcMain.removeHandler(WELCOME_IPC.reopen)
})
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
window.webContents.on('will-navigate', (event) => { event.preventDefault() })
diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts
new file mode 100644
index 0000000000..f0078fac26
--- /dev/null
+++ b/apps/desktop/tests/account-backend.spec.ts
@@ -0,0 +1,26 @@
+import { expect, it } from 'vitest'
+import { accountView, desktopAccountBackend } from '../src/account-backend.ts'
+
+it('projects only safe account fields and refuses non-browser authorization schemes', () => {
+ const state = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', token: 'not-for-the-renderer',
+ attempt: { id: 'test', phase: 'waiting-browser', authorizeUrl: 'https://platform.deepseek.com/dsh/authorize', codeVerifier: 'private' } }
+ expect(accountView(state)).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: {
+ id: 'test', phase: 'waiting-browser', authorizeUrl: 'https://platform.deepseek.com/dsh/authorize',
+ } })
+ for (const authorizeUrl of ['file:///tmp/example', 'javascript:alert(1)', 'http://example.com/login']) {
+ expect(() => accountView({ ...state, attempt: { ...state.attempt, authorizeUrl } })).toThrow()
+ }
+ expect(() => accountView({ ...state, links: { usageUrl: 'file:///tmp/example', topUpUrl: 'https://example.com/top_up' } })).toThrow()
+ expect(() => accountView({ ...state, attempt: { ...state.attempt, expiresAt: 'tomorrow' } })).toThrow()
+ expect(() => accountView({ ...state, attempt: { ...state.attempt, errorCode: 'raw-server-message' } })).toThrow()
+})
+
+it('uses account Remote commands without returning additional wire fields', async () => {
+ const requests: unknown[] = []
+ const backend = desktopAccountBackend('http://127.0.0.1:1234', (request) => {
+ requests.push(request)
+ return Promise.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null, token: 'private' })
+ }, () => Promise.resolve(''))
+ expect(await backend.start('en')).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })
+ expect(requests).toEqual([{ namespace: 'account', method: 'startSignIn', args: { locale: 'en', callbackOrigin: 'http://127.0.0.1:1234', client: 'desktop' } }])
+})
diff --git a/apps/desktop/tests/expected/welcome/en-timeout.expected.txt b/apps/desktop/tests/expected/welcome/en-timeout.expected.txt
new file mode 100644
index 0000000000..2fa1f3a300
--- /dev/null
+++ b/apps/desktop/tests/expected/welcome/en-timeout.expected.txt
@@ -0,0 +1,6 @@
+DeepSeek Harness
+DeepSeek Harness
+Sign in timed out
+Sign in again
+Add API Key
+Sign in again to continue
diff --git a/apps/desktop/tests/expected/welcome/en.expected.txt b/apps/desktop/tests/expected/welcome/en.expected.txt
index b23e770617..0b9fdd6c2c 100644
--- a/apps/desktop/tests/expected/welcome/en.expected.txt
+++ b/apps/desktop/tests/expected/welcome/en.expected.txt
@@ -1,5 +1,5 @@
DeepSeek Harness
DeepSeek Harness
Go deeper with DeepSeek
-Sign in [disabled]
+Sign in
Add API Key
diff --git a/apps/desktop/tests/expected/welcome/zh-CN-timeout.expected.txt b/apps/desktop/tests/expected/welcome/zh-CN-timeout.expected.txt
new file mode 100644
index 0000000000..f44aa1dadd
--- /dev/null
+++ b/apps/desktop/tests/expected/welcome/zh-CN-timeout.expected.txt
@@ -0,0 +1,6 @@
+DeepSeek Harness
+DeepSeek Harness
+登录已超时
+重新登录
+添加 API Key
+请重新登录后继续操作
diff --git a/apps/desktop/tests/expected/welcome/zh-CN.expected.txt b/apps/desktop/tests/expected/welcome/zh-CN.expected.txt
index 5c82dce711..6d258ab293 100644
--- a/apps/desktop/tests/expected/welcome/zh-CN.expected.txt
+++ b/apps/desktop/tests/expected/welcome/zh-CN.expected.txt
@@ -1,5 +1,5 @@
DeepSeek Harness
DeepSeek Harness
与 DeepSeek 深度对话
-登录 [disabled]
+登录
添加 API Key
diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts
index 45f5d4d9c5..fe95945964 100644
--- a/apps/desktop/tests/host-process.spec.ts
+++ b/apps/desktop/tests/host-process.spec.ts
@@ -182,3 +182,14 @@ describe('desktop host process', () => {
await expect(host.start()).rejects.toThrow(message)
})
})
+
+it('carries Platform credentials over private IPC and clears them on shutdown', async () => {
+ const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', token: 'fixture-secret' } }); process.send({ type: 'ready'"))
+ const changed = vi.fn()
+ const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, undefined, undefined, 'link', undefined, changed)
+ hosts.push(host)
+ await host.start()
+ expect(changed).toHaveBeenCalledWith({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
+ await host.stop()
+ expect(changed).toHaveBeenLastCalledWith(null)
+})
diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts
index eaf5f03920..f0fc7d8e63 100644
--- a/apps/desktop/tests/macos-signature.spec.ts
+++ b/apps/desktop/tests/macos-signature.spec.ts
@@ -40,6 +40,7 @@ describe('desktop macOS release signature', () => {
it('loads release identifiers from the environment and requires code signing', async () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
+ expect(config.protocols).toEqual([{ name: 'DeepSeek Harness', schemes: ['dsh'] }])
expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
expect(config.extraResources).toHaveLength(2)
expect(config.extraResources[0]?.to).toBe('runtime')
diff --git a/apps/desktop/tests/platform-preload.e2e.ts b/apps/desktop/tests/platform-preload.e2e.ts
new file mode 100644
index 0000000000..f7639e1398
--- /dev/null
+++ b/apps/desktop/tests/platform-preload.e2e.ts
@@ -0,0 +1,50 @@
+/** The built sandbox preload provides a synchronous getter after one synchronous initialization. */
+import { readFileSync } from 'node:fs'
+import { runInNewContext } from 'node:vm'
+import { expect, it, vi } from 'vitest'
+
+interface Bridge { getAuthToken(): string; displayMode: string }
+function load(mainFrame: boolean, response: unknown, origin = 'https://platform.deepseek.com') {
+ const bridges = new Map()
+ const invoke = vi.fn(() => response)
+ runInNewContext(readFileSync(new URL('../lib/preload-platform-account.cjs', import.meta.url), 'utf8'), {
+ require: (id: string) => {
+ if (id !== 'electron') throw new Error('Sandbox module unavailable')
+ return {
+ contextBridge: { exposeInMainWorld: (key: string, value: Bridge) => bridges.set(key, value) },
+ ipcRenderer: { sendSync: invoke },
+ }
+ },
+ process: { isMainFrame: mainFrame, argv: ['--dsh-platform-origin=https://platform.deepseek.com'] }, location: new URL('/usage', origin), exports: {},
+ })
+ return { bridge: bridges.get('dsh'), invoke }
+}
+it('provides a synchronous getter immediately after exactly one bootstrap IPC', () => {
+ const { bridge, invoke } = load(true, { origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
+ expect(bridge!.displayMode).toBe('embedded')
+ expect(bridge!.getAuthToken()).toBe('fixture-secret')
+ expect(bridge!.getAuthToken()).toBe('fixture-secret')
+ expect(bridge).not.toHaveProperty('ready')
+ expect(invoke).toHaveBeenCalledTimes(1)
+})
+it('does not expose the credential bridge to subframes', () => {
+ const { bridge, invoke } = load(false, null)
+ expect(bridge).toBeUndefined()
+ expect(invoke).not.toHaveBeenCalled()
+})
+it('rejects a mismatched issuer before exposing credentials', () => {
+ const { bridge } = load(true, { origin: 'https://other.example', token: 'fixture-secret' })
+ expect(bridge!.displayMode).toBe('embedded')
+ expect(() => bridge!.getAuthToken()).toThrow()
+})
+it('fails closed when the main process rejects bootstrap', () => {
+ const { bridge } = load(true, null)
+ expect(bridge!.displayMode).toBe('embedded')
+ expect(() => bridge!.getAuthToken()).toThrow()
+})
+
+it('exposes no bridge when the document leaves the allowed origin', () => {
+ const { bridge, invoke } = load(true, null, 'https://other.example')
+ expect(bridge).toBeUndefined()
+ expect(invoke).not.toHaveBeenCalled()
+})
diff --git a/apps/desktop/tests/platform-view.spec.ts b/apps/desktop/tests/platform-view.spec.ts
new file mode 100644
index 0000000000..4d701de382
--- /dev/null
+++ b/apps/desktop/tests/platform-view.spec.ts
@@ -0,0 +1,106 @@
+import { EventEmitter } from 'node:events'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { BrowserWindow, IpcMainEvent } from 'electron'
+import { DesktopPlatformView, platformBounds } from '../src/platform-view.ts'
+
+const state = vi.hoisted(() => ({ views: [] as unknown[], sessions: [] as unknown[], openExternal: vi.fn(async () => {}) }))
+vi.mock('electron', () => ({
+ shell: { openExternal: state.openExternal },
+ session: { fromPartition: vi.fn((partition: string) => {
+ const value = {
+ partition, setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(),
+ clearStorageData: vi.fn(async () => {}),
+ }
+ state.sessions.push(value)
+ return value
+ }) },
+ WebContentsView: class {
+ webContents = Object.assign(new EventEmitter(), {
+ mainFrame: { url: 'https://platform.deepseek.com/usage' },
+ session: { clearStorageData: vi.fn(async () => {}) },
+ setWindowOpenHandler: vi.fn(), loadURL: vi.fn(async (_url: string) => {}),
+ isDestroyed: () => false, close: vi.fn(),
+ })
+ setBounds = vi.fn()
+ constructor() { state.views.push(this) }
+ },
+}))
+
+afterEach(() => { state.views.length = 0; state.sessions.length = 0; vi.clearAllMocks() })
+function setup() {
+ const owner = { contentView: { addChildView: vi.fn(), removeChildView: vi.fn() }, isDestroyed: () => false } as unknown as BrowserWindow
+ const manager = new DesktopPlatformView('/bundled/preload.cjs')
+ manager.setSession({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
+ return { manager, owner }
+}
+function view() {
+ return state.views.at(-1) as { webContents: EventEmitter & {
+ mainFrame: { url: string }
+ loadURL: ReturnType
+ setWindowOpenHandler: ReturnType
+ close: ReturnType
+ } }
+}
+const bounds = { x: 10, y: 20, width: 800, height: 600 }
+
+it('bootstraps only the owned main frame and never puts the token in a URL', async () => {
+ const { manager, owner } = setup()
+ await manager.open(owner, 'usage', bounds)
+ const sender = view().webContents
+ const event = { sender, senderFrame: sender.mainFrame } as unknown as IpcMainEvent
+ expect(manager.bootstrap(event)).toEqual({ origin: 'https://platform.deepseek.com', token: 'fixture-secret' })
+ expect(sender.loadURL).toHaveBeenCalledWith('https://platform.deepseek.com/usage')
+ expect(() => manager.bootstrap({ ...event, senderFrame: { url: sender.mainFrame.url } } as IpcMainEvent)).toThrow()
+ expect(() => manager.bootstrap({ ...event, sender: {} } as IpcMainEvent)).toThrow()
+ sender.mainFrame.url = 'https://other.example/usage'
+ expect(() => manager.bootstrap(event)).toThrow()
+ manager.close()
+})
+
+it('destroys old documents on sign-out or credential replacement', async () => {
+ const { manager, owner } = setup()
+ await manager.open(owner, 'usage', bounds)
+ const first = view().webContents
+ manager.setSession({ origin: 'https://platform.deepseek.com', token: 'replacement' })
+ expect(first.close).toHaveBeenCalledOnce()
+ expect(() => manager.bootstrap({ sender: first, senderFrame: first.mainFrame } as unknown as IpcMainEvent)).toThrow()
+ await manager.open(owner, 'top-up', bounds)
+ expect(view().webContents.loadURL).toHaveBeenCalledWith('https://platform.deepseek.com/top_up')
+ const second = view().webContents
+ manager.setSession(null)
+ expect(second.close).toHaveBeenCalledOnce()
+ await expect(manager.open(owner, 'usage', bounds)).rejects.toThrow()
+})
+
+it('blocks cross-origin navigation and redirects', async () => {
+ const { manager, owner } = setup()
+ await manager.open(owner, 'usage', bounds)
+ for (const name of ['will-navigate', 'will-redirect']) {
+ const event = { preventDefault: vi.fn() }
+ view().webContents.emit(name, event, 'https://platform.deepseek.com.evil/usage')
+ expect(event.preventDefault).toHaveBeenCalledOnce()
+ event.preventDefault.mockClear()
+ view().webContents.emit(name, event, 'https://platform.deepseek.com/top_up')
+ expect(event.preventDefault).not.toHaveBeenCalled()
+ }
+ manager.close()
+})
+
+it.each([null, {}, { ...bounds, width: NaN }, { ...bounds, x: -1 }, { ...bounds, y: Infinity }])('rejects malformed IPC rectangles', (value) => {
+ expect(() => platformBounds(value)).toThrow()
+})
+
+
+it('opens HTTPS payment links in the system browser without an embedded child window', async () => {
+ const { manager, owner } = setup()
+ await manager.open(owner, 'top-up', bounds)
+ const handler = view().webContents.setWindowOpenHandler.mock.calls[0]![0] as (details: { url: string }) => { action: string }
+ expect(handler({ url: 'https://payment.example/order' })).toEqual({ action: 'deny' })
+ expect(state.openExternal).toHaveBeenCalledWith('https://payment.example/order')
+ vi.mocked(state.openExternal).mockClear()
+ for (const url of ['file:///tmp/test', 'javascript:alert(1)', 'https://user:pass@payment.example/order']) {
+ expect(handler({ url })).toEqual({ action: 'deny' })
+ }
+ expect(state.openExternal).not.toHaveBeenCalled()
+ manager.close()
+})
diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts
index a127539ffe..7b17375e77 100644
--- a/apps/desktop/tests/welcome-flow.e2e.ts
+++ b/apps/desktop/tests/welcome-flow.e2e.ts
@@ -1,6 +1,7 @@
/** Built Desktop Host acceptance; run after the repository build, without provider credentials. */
import { createHash } from 'node:crypto'
+import { createServer } from 'node:http'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -21,10 +22,57 @@ function version(path: string): string {
return (JSON.parse(readFileSync(path, 'utf8')) as { version: string }).version
}
+async function mockPlatform() {
+ let origin = ''
+ let init: Record = {}
+ let failExchange = false
+ const server = createServer((req, res) => {
+ if (req.headers.cookie !== 'test_gate=synthetic') { res.writeHead(403).end(); return }
+ if (req.url === '/auth-api/v0/users/logout' && req.method === 'POST') {
+ if (req.headers.authorization !== 'Bearer dsh_mock_composition_test') { res.writeHead(401).end(); return }
+ res.writeHead(503).end()
+ return
+ }
+ req.setEncoding('utf8')
+ let body = ''
+ req.on('data', (chunk: string) => { body += chunk })
+ req.on('end', () => {
+ const input = JSON.parse(body) as Record
+ let value: unknown
+ if (req.url?.endsWith('auth_init')) {
+ init = input
+ if (input.locale !== 'en_US') { res.writeHead(400).end(); return }
+ value = { authorize_url: `${origin}/dsh/authorize?request_id=test`, expires_in: 600 }
+ } else {
+ if (failExchange) { res.writeHead(503).end(); return }
+ const challenge = createHash('sha256').update(input.code_verifier ?? '').digest('base64url')
+ if (challenge !== init.code_challenge || input.redirect_uri !== init.redirect_uri) {
+ res.writeHead(400).end()
+ return
+ }
+ value = { token: 'dsh_mock_composition_test', authorized_url: `${origin}/dsh/authorized?result=test&locale=zh_CN` }
+ }
+ res.setHeader('content-type', 'application/json')
+ res.end(JSON.stringify({ code: 0, data: { biz_code: 0, biz_data: value } }))
+ })
+ })
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('missing mock listener')
+ origin = `http://127.0.0.1:${String(address.port)}`
+ return {
+ origin,
+ failExchange: (value: boolean) => { failExchange = value },
+ callback: () => `${init.redirect_uri}?code=test&state=${init.state}`,
+ close: () => new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }),
+ }
+}
+
describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
- it('keeps an unconfigured launch unconfigured and persists an explicitly saved key across restarts', async () => {
+ it('persists explicit API keys and browser account login independently across Host restarts', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-welcome-'))
let host: DesktopHostProcess | undefined
+ const platform = await mockPlatform()
try {
for (const name of Object.keys(process.env)) {
if (/KEY|TOKEN|SECRET|PASSWORD/u.test(name)) vi.stubEnv(name, undefined)
@@ -51,12 +99,14 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
node: process.execPath, pnpm: join(repository, 'apps/desktop/node_modules/pnpm/bin/pnpm.mjs'), dsh: project,
})
await manager.applyRelease()
- writeFileSync(join(paths.profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n')
+ writeFileSync(join(paths.profile, 'cordis.patch.yml'), `- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n- id: deepseek-account\n config:\n platformOrigin: ${platform.origin}\n allowLoopbackHttp: true\n requestHeaders:\n Cookie: test_gate=synthetic\n`)
let backend: DesktopWelcomeBackend
+ let hostOrigin = ''
const restart = async (): Promise => {
await host?.stop()
host = new DesktopHostProcess(process.execPath, project, paths.profile)
const { url } = await host.start()
+ hostOrigin = new URL(url).origin
let cookie = ''
const send: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers)
@@ -67,7 +117,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
await response.body?.cancel()
return fetch(new URL(response.headers.get('location')!, url), { headers: { cookie } })
}
- backend = await connectDesktopWelcome(url, send)
+ backend = await connectDesktopWelcome(url, send, () => Promise.resolve(cookie))
}
const status = async () => backend.read()
const fingerprint = (): string => createHash('sha256')
@@ -84,8 +134,36 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
expect(await status()).toMatchObject({ hasApiKey: true, localePreference: 'zh' })
await restart()
expect(await status()).toMatchObject({ hasApiKey: true })
+ await backend!.account.start('en')
+ await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser')
+ expect(new URL(platform.callback()).origin).toBe(hostOrigin)
+ platform.failExchange(true)
+ const failed = await fetch(platform.callback(), { redirect: 'manual' })
+ expect(failed.status).toBe(204)
+ expect(await backend!.account.state()).toMatchObject({ status: 'signed-out', attempt: { phase: 'failed' } })
+ expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: false })
+ platform.failExchange(false)
+ await backend!.account.start('en')
+ await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser')
+ const response = await fetch(platform.callback(), { redirect: 'manual' })
+ expect(response.status).toBe(302)
+ expect(response.headers.get('location')).toBe(`${platform.origin}/dsh/authorized?result=test&locale=zh_CN`)
+ expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: true })
+ await restart()
+ expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: true })
+ // A failed remote logout must not block local sign-out through the real Host composition.
+ await backend!.account.signOut()
+ expect(await status()).toMatchObject({ hasApiKey: true, loggedIn: false })
+ await backend!.account.start('en')
+ await expect.poll(async () => (await backend!.account.state()).attempt?.phase).toBe('waiting-browser')
+ const waiting = await backend!.account.state()
+ const late = platform.callback()
+ await backend!.account.cancel(waiting.attempt!.id)
+ expect((await fetch(late, { redirect: 'manual' })).status).not.toBe(302)
+ expect(await status()).toMatchObject({ loggedIn: false })
} finally {
await host?.stop()
+ await platform.close()
rmSync(root, { recursive: true, force: true })
}
})
diff --git a/apps/desktop/tests/welcome-host.spec.ts b/apps/desktop/tests/welcome-host.spec.ts
index 6ff6da1577..ed8e74b0e7 100644
--- a/apps/desktop/tests/welcome-host.spec.ts
+++ b/apps/desktop/tests/welcome-host.spec.ts
@@ -17,7 +17,8 @@ function transport(preference?: string) {
payload: { args: { ref: string; value: string; refs: string[] } }
}
let value: unknown
- if (method === 'settings/describe') value = { namespaces }
+ if (method === 'account/getState') value = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }
+ else if (method === 'settings/describe') value = { namespaces }
else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }]
else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value)
else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }]))
@@ -35,7 +36,7 @@ describe('desktop welcome Web operations', () => {
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
expect(await backend.save('sk-example')).toEqual({ ok: true })
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
- expect(await backend.read()).toEqual({ hasApiKey: true, writable: true, localePreference: null })
+ expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null })
for (const [input, init] of host.send.mock.calls.slice(1)) {
expect(input).toMatch(/^http:\/\/127\.0\.0\.1:19387\/api\//u)
expect(init).toMatchObject({ credentials: 'include', redirect: 'error' })
diff --git a/apps/desktop/tests/welcome-renderer.spec.ts b/apps/desktop/tests/welcome-renderer.spec.ts
index 0171d42e51..d1b3f6bb08 100644
--- a/apps/desktop/tests/welcome-renderer.spec.ts
+++ b/apps/desktop/tests/welcome-renderer.spec.ts
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
import { JSDOM } from 'jsdom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveDesktopLocale } from '../src/locale.ts'
+import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import type { WelcomeSaveResult } from '../src/welcome-api.ts'
const html = readFileSync(new URL('../renderer/welcome.html', import.meta.url), 'utf8')
@@ -13,6 +14,10 @@ function mount(language = 'zh-CN') {
const dom = new JSDOM(html, { runScripts: 'outside-only' })
opened.push(dom)
const api = {
+ onAccountState: vi.fn((_listener: (state: AccountView) => void) => () => undefined),
+ startSignIn: vi.fn(async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
+ cancelSignIn: vi.fn(async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
+ reopenSignIn: vi.fn(async () => undefined),
...resolveDesktopLocale(language),
saveApiKey: vi.fn<(value: string) => Promise>().mockResolvedValue({ ok: true }),
skip: vi.fn<() => Promise>().mockResolvedValue(undefined),
@@ -148,3 +153,16 @@ describe('desktop welcome presentation', () => {
expect(html).toContain("form-action 'none'")
})
})
+
+it.each(['zh-CN', 'en'])('renders %s timeout with manual retry and API-key alternative', async (language) => {
+ const view = mount(language)
+ const receive = view.api.onAccountState.mock.calls[0]![0]
+ receive({ status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, attempt: { id: 'expired' as NonNullable['id'], phase: 'expired' } })
+ expect(view.button('#auth-retry').hidden).toBe(false)
+ expect(view.button('#auth-api-key').hidden).toBe(false)
+ expect(view.api.startSignIn).not.toHaveBeenCalled()
+ await expect(view.copy() + view.document.querySelector('#auth-description')!.textContent + '\n').toMatchFileSnapshot(`./expected/welcome/${language}-timeout.expected.txt`)
+ view.button('#auth-api-key').click()
+ expect(view.document.querySelector('#auth-page')!.hasAttribute('hidden')).toBe(true)
+ expect(view.document.querySelector('#key-form')!.hasAttribute('hidden')).toBe(false)
+})
diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts
index cc17064195..10d4367444 100644
--- a/apps/desktop/tests/welcome-startup.spec.ts
+++ b/apps/desktop/tests/welcome-startup.spec.ts
@@ -1,3 +1,4 @@
+vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
/** Preview startup must use the same Host and workspace transition as ordinary Desktop startup. */
import { afterEach, expect, it, vi } from 'vitest'
@@ -6,7 +7,7 @@ import { DESKTOP_IPC } from '../src/ipc.ts'
const state = vi.hoisted(() => ({
quit: vi.fn(),
- startHost: vi.fn().mockResolvedValue({ url: 'http://127.0.0.1:3080/?token=test' }),
+ startHost: vi.fn().mockResolvedValue({ url: 'http://127.0.0.1:3080/?token=test', injections: [] }),
stopHost: vi.fn<() => Promise>().mockResolvedValue(undefined),
loadWorkspace: vi.fn<(url: string) => Promise>().mockResolvedValue(undefined),
showWorkspace: vi.fn(),
@@ -26,6 +27,7 @@ vi.mock('electron', () => ({
isPackaged: false,
name: 'Harness',
requestSingleInstanceLock: () => true,
+ setAsDefaultProtocolClient: vi.fn(),
whenReady: () => Promise.resolve(),
getLocale: () => 'en',
getAppPath: () => '/development-app',
@@ -37,7 +39,7 @@ vi.mock('electron', () => ({
BrowserWindow: class {
constructor(options: unknown) { state.windowOptions = options }
private ready: (() => void) | undefined
- webContents = { mainFrame: { url: 'http://127.0.0.1:3080/?token=test' }, setWindowOpenHandler: vi.fn(), on: vi.fn(), send: vi.fn(), openDevTools: vi.fn() }
+ webContents = { mainFrame: { url: 'dsh-app://app/' }, setWindowOpenHandler: vi.fn(), on: vi.fn(), send: vi.fn(), openDevTools: vi.fn() }
static getAllWindows() { return [] }
once(name: string, callback: () => void) { if (name === 'ready-to-show') this.ready = callback; return this }
on() { return this }
@@ -47,6 +49,8 @@ vi.mock('electron', () => ({
async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
},
net: { fetch: vi.fn() },
+ nativeTheme: { themeSource: 'system' },
+ session: { defaultSession: { webRequest: { onBeforeSendHeaders: vi.fn() } } },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
ipcMain: {
handle: (name: string, callback: (...args: unknown[]) => unknown) => { state.handlers.set(name, callback) },
@@ -72,6 +76,7 @@ vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
read: async () => ({ hasApiKey: true, writable: true, localePreference: state.preference }),
save: async () => ({ ok: true }),
+ account: { watch: () => () => {}, state: async () => ({ status: 'signed-out', attempt: null }) },
}),
}))
vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() }))
@@ -102,12 +107,12 @@ it('starts the Host for a forced welcome preview and opens the workspace on skip
await import('../src/main.ts')
await vi.waitFor(() => { expect(state.operations).toBeDefined() })
expect(state.startHost).toHaveBeenCalledOnce()
- expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('dsh-app://shell/startup.html')
+ expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('dsh-app://app/')
expect(state.showWorkspace).not.toHaveBeenCalled()
state.loadWorkspace.mockClear()
expect(state.welcomeLocale).toMatchObject({ id: 'zh-CN' })
await state.operations!.skip()
- expect(state.loadWorkspace).toHaveBeenCalledExactlyOnceWith('http://127.0.0.1:3080/?token=test')
+ expect(state.loadWorkspace).not.toHaveBeenCalled()
expect(state.showWorkspace).toHaveBeenCalledOnce()
expect(state.windowOptions).toMatchObject({
...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 }, vibrancy: 'sidebar' } : {}),
diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts
index 7ff64bd364..874466faf6 100644
--- a/apps/desktop/tests/welcome-window.spec.ts
+++ b/apps/desktop/tests/welcome-window.spec.ts
@@ -37,6 +37,9 @@ function createWindow() {
beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() })
const operations = {
+ startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
+ cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
+ reopenSignIn: async () => undefined,
saveApiKey: () => Promise.resolve({ ok: true as const }),
skip: () => Promise.resolve(),
}
@@ -56,7 +59,7 @@ describe('desktop welcome window', () => {
if (platform === 'darwin') {
expect(options.vibrancy).toBe('titlebar')
expect(options.visualEffectState).toBe('active')
- expect(options.trafficLightPosition).toEqual({ x: 16, y: 21 })
+ expect(options.trafficLightPosition).toEqual({ x: 21, y: 21 })
} else if (platform === 'win32') {
expect(options.backgroundMaterial).toBe('acrylic')
expect(options.titleBarOverlay).toMatchObject({ height: 42 })
@@ -107,7 +110,7 @@ describe('desktop welcome window', () => {
electron.create.mockReturnValue(window)
const saveApiKey = vi.fn(operations.saveApiKey)
const skip = vi.fn(operations.skip)
- await openWelcomeWindow(resolveDesktopLocale('en'), { saveApiKey, skip })
+ await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, saveApiKey, skip })
const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
const save = electron.handlers.get(WELCOME_IPC.saveApiKey)!
await expect(save({ sender: {}, senderFrame: {} }, 'sk-test')).rejects.toThrow('unowned frame')
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
index 6811108afd..24f17d8bd0 100644
--- a/apps/desktop/tsconfig.json
+++ b/apps/desktop/tsconfig.json
@@ -4,9 +4,21 @@
"rootDir": "src",
"outDir": "lib/types"
},
- "include": ["src"],
+ "include": [
+ "src"
+ ],
"references": [
- { "path": "../../packages/boot/app-boot" },
- { "path": "../../packages/util/home-paths" }
+ {
+ "path": "../../packages/boot/app-boot"
+ },
+ {
+ "path": "../../packages/util/home-paths"
+ },
+ {
+ "path": "../../packages/credentials/deepseek-account"
+ },
+ {
+ "path": "../../packages/api/gateway/tsconfig.host.json"
+ }
]
}
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index 8c28ee4357..43f2ed19f4 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.i18n.yaml
@@ -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 docs/capability-seams.md
-capability-seams.md: edb8cd688768ec49ee3699c3775409a66975129d
-capability-seams.zh.md: b06d9378616669407b79ffa1da205924ff6ad757
+capability-seams.md: 6da4817d2825547a721cd4487b82d52b1d766e18
+capability-seams.zh.md: b8976406a7ccd18d6ff718b2eb3c0c1fb21e4ff6
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index edb8cd6887..6da4817d28 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -98,6 +98,10 @@ flowchart LR
pkg_credentials["credentials"]
svc_credentials["ctx.credentials Credential seam"]
pkg_credentials_local["credentials-local"]
+ pkg_deepseek_account["deepseek-account"]
+ svc_deepseekAccount["ctx.deepseekAccount DeepSeek account"]
+ pkg_deepseek_account_platform["deepseek-account-platform"]
+ pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization Authorization flow registry"]
pkg_session_telemetry["session-telemetry"]
@@ -288,6 +292,8 @@ flowchart LR
pkg_cordis_host_runner --> svc_dynamicCordisRunner
pkg_credentials --> svc_credentials
pkg_credentials_local --> svc_credentials
+ pkg_deepseek_account --> svc_deepseekAccount
+ pkg_deepseek_account_platform --> svc_deepseekAccount
pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions
pkg_experimental_agent_team --> svc_agentTeams
pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
@@ -421,6 +427,8 @@ flowchart LR
svc_credentials --> pkg_api_settings_controller
svc_credentials --> pkg_llm_deepseek
svc_credentials --> pkg_llm_pi_ai
+ svc_deepseekAccount --> pkg_api_account_controller
+ svc_deepseekAccount --> pkg_llm_deepseek
svc_deepseekLlmApiExtensions --> pkg_llm_deepseek
svc_directoryPicker --> pkg_api_workspace_controller
svc_dynamicCordisRunner --> pkg_tool_cordis
@@ -569,6 +577,7 @@ flowchart LR
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer. |
| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. |
+| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index b06d937861..b8976406a7 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -100,6 +100,10 @@ flowchart LR
pkg_credentials["credentials"]
svc_credentials["ctx.credentials Credential seam"]
pkg_credentials_local["credentials-local"]
+ pkg_deepseek_account["deepseek-account"]
+ svc_deepseekAccount["ctx.deepseekAccount DeepSeek account"]
+ pkg_deepseek_account_platform["deepseek-account-platform"]
+ pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization Authorization flow registry"]
pkg_session_telemetry["session-telemetry"]
@@ -290,6 +294,8 @@ flowchart LR
pkg_cordis_host_runner --> svc_dynamicCordisRunner
pkg_credentials --> svc_credentials
pkg_credentials_local --> svc_credentials
+ pkg_deepseek_account --> svc_deepseekAccount
+ pkg_deepseek_account_platform --> svc_deepseekAccount
pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions
pkg_experimental_agent_team --> svc_agentTeams
pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
@@ -423,6 +429,8 @@ flowchart LR
svc_credentials --> pkg_api_settings_controller
svc_credentials --> pkg_llm_deepseek
svc_credentials --> pkg_llm_pi_ai
+ svc_deepseekAccount --> pkg_api_account_controller
+ svc_deepseekAccount --> pkg_llm_deepseek
svc_deepseekLlmApiExtensions --> pkg_llm_deepseek
svc_directoryPicker --> pkg_api_workspace_controller
svc_dynamicCordisRunner --> pkg_tool_cordis
@@ -571,6 +579,7 @@ flowchart LR
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;settings controller 提供经过脱敏的分层描述符,并写入用户层。 |
| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 |
+| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 7acbc376d5..29d90bd4eb 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -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 docs/config-catalog.md
-config-catalog.md: 99ef3f4080f5e23ccfa9ebea0d4b89daf443bcfb
-config-catalog.zh.md: 0e2cb6eb3f6936b7ad190333a83a701ecbbb133d
+config-catalog.md: a1c73fab86356e8ee7786ba3c8db539c1201785a
+config-catalog.zh.md: 0917e168a59127f8b9b9e0afd99def9e105cdca5
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 99ef3f4080..a1c73fab86 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -446,6 +446,22 @@ export interface Config {
Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
+
+
+## `@deepseek-ai/dsh-client-ui-settings-account`
+
+```ts config-catalog
+/** Questionnaire destination and its supported source option. */
+export interface Config {
+ /** HTTPS questionnaire URL; override for a test form. */
+ contactFormUrl: string
+ /** Questionnaire source option; empty until Harness is supported by the form. */
+ contactSource: string
+}
+```
+
+Source: [`packages/client/ui-settings-account/src/contact-config.ts:5`](../packages/client/ui-settings-account/src/contact-config.ts)
+
## `@deepseek-ai/dsh-client-ui-settings-models`
@@ -585,6 +601,36 @@ export interface Config {
Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
+
+
+## `@deepseek-ai/dsh-deepseek-account-platform`
+
+Requires: `credentials` · `authorization`
+
+```ts config-catalog
+/** Deployment-specific platform and request deadlines. */
+export interface Config {
+ /** Platform origin serving auth-api and browser pages. */
+ platformOrigin?: string
+ /** Allow HTTP only on loopback for the development Mock. */
+ allowLoopbackHttp?: boolean
+ /** Map authorization and completion pages to platformOrigin for private development proxies. */
+ rewriteBrowserOrigin?: boolean
+ /** Host-only headers sent exclusively to platformOrigin; account authorization cannot be overridden. */
+ requestHeaders?: Record
+ /** Deadline for each platform HTTP request. */
+ requestTimeoutMs?: number
+ /** Additional logout attempts after the first request fails, at most five. */
+ logoutMaxRetries?: number
+ /** Delay before the first logout retry; each later delay doubles. */
+ logoutRetryDelayMs?: number
+ /** Upper bound for the entire local attempt, even if the server advertises a longer TTL. */
+ attemptTimeoutMs?: number
+}
+```
+
+Source: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts)
+
## `@deepseek-ai/dsh-experimental-agent-team`
@@ -3786,6 +3832,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
+- `@deepseek-ai/dsh-api-account-controller` — requires `deepseekAccount` ([`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts))
- `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
- `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
@@ -3884,6 +3931,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts))
- `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts))
- `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts))
+- `@deepseek-ai/dsh-deepseek-account` — abstract `DeepSeekAccount` ([`packages/credentials/deepseek-account/src/index.ts`](../packages/credentials/deepseek-account/src/index.ts))
- `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts))
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 0e2cb6eb3f..0917e168a5 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -448,6 +448,22 @@ export interface Config {
来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
+
+
+## `@deepseek-ai/dsh-client-ui-settings-account`
+
+```ts config-catalog
+/** Questionnaire destination and its supported source option. */
+export interface Config {
+ /** HTTPS questionnaire URL; override for a test form. */
+ contactFormUrl: string
+ /** Questionnaire source option; empty until Harness is supported by the form. */
+ contactSource: string
+}
+```
+
+来源: [`packages/client/ui-settings-account/src/contact-config.ts:5`](../packages/client/ui-settings-account/src/contact-config.ts)
+
## `@deepseek-ai/dsh-client-ui-settings-models`
@@ -587,6 +603,36 @@ export interface Config {
来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
+
+
+## `@deepseek-ai/dsh-deepseek-account-platform`
+
+需要: `credentials` · `authorization`
+
+```ts config-catalog
+/** Deployment-specific platform and request deadlines. */
+export interface Config {
+ /** Platform origin serving auth-api and browser pages. */
+ platformOrigin?: string
+ /** Allow HTTP only on loopback for the development Mock. */
+ allowLoopbackHttp?: boolean
+ /** Map authorization and completion pages to platformOrigin for private development proxies. */
+ rewriteBrowserOrigin?: boolean
+ /** Host-only headers sent exclusively to platformOrigin; account authorization cannot be overridden. */
+ requestHeaders?: Record
+ /** Deadline for each platform HTTP request. */
+ requestTimeoutMs?: number
+ /** Additional logout attempts after the first request fails, at most five. */
+ logoutMaxRetries?: number
+ /** Delay before the first logout retry; each later delay doubles. */
+ logoutRetryDelayMs?: number
+ /** Upper bound for the entire local attempt, even if the server advertises a longer TTL. */
+ attemptTimeoutMs?: number
+}
+```
+
+来源: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts)
+
## `@deepseek-ai/dsh-experimental-agent-team`
@@ -3788,6 +3834,7 @@ export interface Config {
- `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts))
- `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
+- `@deepseek-ai/dsh-api-account-controller` — 需要 `deepseekAccount` ([`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts))
- `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
- `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
@@ -3886,6 +3933,7 @@ export interface Config {
- `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts))
- `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts))
- `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts))
+- `@deepseek-ai/dsh-deepseek-account` — 抽象类 `DeepSeekAccount` ([`packages/credentials/deepseek-account/src/index.ts`](../packages/credentials/deepseek-account/src/index.ts))
- `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts))
- `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
- `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts))
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index 8bfe85ac05..0cff63511c 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -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 docs/event-producer-consumer.md
-event-producer-consumer.md: fcf95b4268cc41b84099af351f1d6fdb07c80fc4
-event-producer-consumer.zh.md: bd466a2e39f4a268e4a8b1567ae5a5f22d250977
+event-producer-consumer.md: 4b190f5aa2b705ee296d7d89aad1c58e1437b9de
+event-producer-consumer.zh.md: 3e5ae570d98b9ecb4713985541774bfff4391094
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index fcf95b4268..4b190f5aa2 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
-| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
+| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) |
| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index bd466a2e39..3e5ae570d9 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -39,7 +39,7 @@
| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
-| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
+| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization), [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) |
| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
diff --git a/docs/sign-in-readiness.i18n.yaml b/docs/sign-in-readiness.i18n.yaml
new file mode 100644
index 0000000000..2bf951929a
--- /dev/null
+++ b/docs/sign-in-readiness.i18n.yaml
@@ -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 docs/sign-in-readiness.md
+sign-in-readiness.md: d7b0b0094dcdbd28a2f7a1c0cf9b1d2eebed392e
+sign-in-readiness.zh.md: b589075b088702683abe7a945a2afe12ea8f4376
diff --git a/docs/sign-in-readiness.md b/docs/sign-in-readiness.md
new file mode 100644
index 0000000000..d7b0b0094d
--- /dev/null
+++ b/docs/sign-in-readiness.md
@@ -0,0 +1,39 @@
+# Sign-in pre-commit checklist
+
+English | [中文](sign-in-readiness.zh.md)
+
+This checklist covers DSH login on the welcome branch, Platform authorization pages, and their integration. It records unfinished or insufficiently verified work as of 2026-09-15.
+
+## Commit restriction
+
+On 2026-09-16, the user explicitly requested committing and pushing the current branch as a progress checkpoint. This authorizes that checkpoint despite the pending acceptance items below; those items remain unchecked and release acceptance is not complete.
+
+Before committing these changes, reread this checklist and confirm every item is complete with verifiable evidence. Do not commit while any item is unchecked. Deleting items, moving them to other TODOs, recording only “awaiting product confirmation,” or substituting mock tests for real integration does not clear the checklist. Removing a requirement requires an explicit user decision recorded before checking it off. This restriction also covers separate commits of this checklist and its accompanying documentation.
+
+Keep completed items and their evidence; do not delete the checklist. Before committing, inspect related DSH and Platform TODOs again and add any newly discovered unfinished work here.
+
+## Pending work and completion criteria
+
+- [x] **Loopback address policy (DSH / Platform / backend)**: User confirmed on 2026-09-15 that the backend supports localhost. Preserve the hostname and browser-visible port without conversion to an IP. DSH, Platform, and mock validation now accept exact localhost and reject lookalike domains. Evidence on 2026-09-15: 40 DSH account tests and lint passed; 72 Platform tests, two type checks, and lint passed, including successful exchange with the original URI and rejection after conversion to an IP. IPv6-literal support is not expanded by this decision.
+- [ ] **Real authorization API integration (backend / both applications)**: Verify auth_init client_type binding, authorize business codes, exchange token/authorized_url, auth_cancel verifier validation and idempotency, and invalidation of canceled requests and unexchanged codes against the real backend. Verify exchange cannot override desktop/web origin type. Mock implementation is not evidence of backend completion.
+- [ ] **Real DSH token usage and logout (backend / DSH)**: Verify current, balance, inference, and file endpoints with a real DSH token; account credentials must still accompany inference/file requests only at allowed api.deepseek.com destinations. Verify restart restoration, explicit Platform logout, local credential deletion, and remote revocation outcomes. Use a dedicated test account to avoid revoking a shared daily-use dev token.
+- [ ] **Latest Web tab integration (both applications)**: At localhost:8081, verify new-tab authorization, automatic closure on success and exchange failure, original-tab failure feedback, and explicit retry. Cover timeout, cancellation, blocked automatic closure, manually copied links, and localhost access through SSH with different local/remote ports and IPv4/IPv6 listener configurations. Confirm no second Web UI opens and Web completion has no desktop button.
+- [ ] **Native client and installer acceptance (DSH)**: With current artifacts, verify Host readiness before login, workspace loading after login, failure/timeout recovery, and window focus. Verify installed dsh://open registration, cold launch, and activation of an existing app on supported release platforms. Earlier manual checks do not replace acceptance of the latest changes.
+- [ ] **Business-error product decisions (product / both applications)**: Business codes already exist; do not request them again. Confirm English/Chinese copy and terminal versus retryable behavior for each business failure, then implement pages and snapshots. Other codes and HTTP errors retain the default fallback. Resolve corresponding product-error-ui TODOs.
+- [ ] **Platform production localization (Platform / product)**: Add temporary harnessCopy text to the official translation sheet, generate resources, and replace temporary lookup. Include cancellation, Web completion, and manual-close messages; confirm English absent from Figma and resolve corresponding i18n TODOs.
+- [ ] **Browser cancellation feedback requirement (product)**: Decide whether showing CANCELED only when the user next attempts approval is acceptable after DSH cancellation. If immediate feedback is required, implement and verify notification or querying. Neither choice may allow local acceptance of late authorization results.
+- [ ] **Login-help link decision (product / Platform)**: Confirm the original requirement. If needed, supply and connect its destination; otherwise remove related placeholders or TODOs. An unconfirmed link is not automatically a required feature.
+- [ ] **Final review and checks (both applications)**: Inspect final differences across related tasks and both workspaces, excluding unrelated files and private credentials. Complete code review, relevant behavior tests/snapshots, type/lint/build and documentation checks, record actual results, and resolve all blockers. Completing this item does not waive any item above.
+- [ ] **Development launcher early exit (DSH)**: Investigate start:desktop exiting successfully during primary-runtime preparation without launching Electron. Restore and verify the supported launcher; direct use of already prepared runtime artifacts is only a preview workaround.
+
+- [ ] **Embedded usage and top-up (DSH / Platform)**: Account buttons, the native 48px return bar, and Platform embedded layout from Figma node 2554:28786 are implemented. Verify the real Electron/Platform initialization, return navigation, reload, failure, and sign-out paths. TemPad succeeded after narrowing to child nodes on 2026-09-16. Verify payment navigation before enabling top-up; the native view blocks cross-origin document navigation and opens HTTPS popup links in the system browser without credentials. Supply the DSH key trackingId before implementing its default usage filter; token/authorized_url alone do not identify that record. The private IPC and synchronous getter after preload initialization have focused tests, not end-to-end acceptance.
+
+- [ ] **Shared device identity for embedded Platform (DSH / Platform / backend)**: Reuse the device ID persisted by DSH Host for authorization exchange, DSH requests and embedded Platform requests. Pass deviceId/deviceModel with the token in the existing Electron preload initialization snapshot and expose a synchronous window.dsh.getDeviceInfo() getter. Embedded Platform reads this information through its deviceProvider; ordinary browsers retain their localStorage UUID. Confirm whether auth_exchange already registers the device before adding auth_token/check_device reporting. Verify consistent x-device-id/x-device-model headers and no second browser device ID for the embedded page.
+
+## Implemented baseline
+
+Account details and balance, sidebar logout, private Platform URL configuration, shared Host callback port, PKCE, authoritative local cancellation, auth_cancel, Electron timeout, Web login dialogs, and signed-out Settings are implemented. Closing the new tab on Web failure and distinguishing desktop/web origins are also implemented. The pending items above cover final integration and release acceptance, not duplicate development requirements.
+
+## Acceptance evidence
+
+When completing an item, append the date, implementation location, and test commands/results or explicit product decision to that item. The address-policy item includes completion evidence; other items remain pending.
diff --git a/docs/sign-in-readiness.zh.md b/docs/sign-in-readiness.zh.md
new file mode 100644
index 0000000000..b589075b08
--- /dev/null
+++ b/docs/sign-in-readiness.zh.md
@@ -0,0 +1,39 @@
+# Sign in 提交前清单
+
+[English](sign-in-readiness.md) | 中文
+
+本清单覆盖 welcome 分支的 DSH 登录、Platform 授权页面及两端联调,记录截至 2026-09-15 尚未完成或尚未充分验证的工作。
+
+## 提交限制
+
+2026-09-16,用户明确要求提交并推送当前分支,保存现阶段进度。本次进度提交获准在下列验收项仍未完成时进行;未完成项继续保留,不标记为已验收。
+
+提交这批改动前,必须重新阅读本清单,确认所有待办均已完成并附有可核验的结果。存在任一未勾选项时不得提交;不能通过删除条目、搬到其他 TODO、仅记录“待产品确认”或把 mock 测试当成真实联调来清空清单。取消某项需求须有用户明确决定,并记录决定后才能勾选。此限制同样适用于本清单及其配套文档的单独提交。
+
+完成后保留已勾选条目和验收依据,不删除清单。提交前重新检查 DSH 与 Platform 的相关 TODO,新增未完成项也须纳入此清单。
+
+## 待办与完成条件
+
+- [x] **Loopback 地址策略(DSH/Platform/后端)**:用户于 2026-09-15 明确后端支持 localhost,保留主机名及浏览器实际访问端口,不转换 IP。DSH、Platform 与 mock 已对齐精确 localhost 校验并拒绝仿冒域名。验收:2026-09-15,DSH 40 项账号测试及 lint 通过;Platform 72 项测试、两个类型检查及 lint 通过,覆盖原 URI 兑换成功、转换 IP 后兑换失败。本次决定不扩展 IPv6 字面量支持。
+- [ ] **真实授权接口联调(后端/两端)**:在真实后端验证 auth_init 的 client_type 绑定、authorize 业务码、exchange 的 token/authorized_url、auth_cancel 的 verifier 校验和幂等、取消后申请及未兑换 code 失效。验证 desktop 与 web 来源不能在兑换时被覆盖;mock 实现不算后端完成证据。
+- [ ] **真实 DSH token 使用与退登(后端/DSH)**:验证 current、余额、推理和文件接口均可使用真实 DSH token;验证推理/文件请求仍仅在允许的 api.deepseek.com 地址附带账号凭证。验证重启恢复、主动退登调用 Platform logout、本地凭证删除与远端撤销结果。使用独立测试账号,避免撤销日常账号的共享 dev token。
+- [ ] **最新 Web 标签页流程联调(两端)**:使用 localhost:8081 跑通新标签页授权、成功自动关闭、exchange 失败关闭、原页失败弹窗和手动重试;覆盖超时、取消、自动关页受限、手动复制链接,以及 SSH 本地/远端端口不同和 IPv4/IPv6 监听配置下的 localhost 访问。确认不再打开第二个 Web UI,Web 完成页不显示桌面按钮。
+- [ ] **原生客户端与安装包验收(DSH)**:在最新产物验证登录前 Host ready、登录后主界面加载、失败/超时恢复和窗口聚焦;在支持的发布平台验证安装包 dsh://open 注册、冷启动及已运行时唤起。既有旧版本手测不能替代最新改动验收。
+- [ ] **业务错误产品定稿(产品/两端)**:已有 biz code 无需重新索要;确认每个业务错误的中英文文案、终态或可重试表现,并落实到页面及快照。其他 code/HTTP 错误沿用默认兜底。清理对应 product-error-ui TODO。
+- [ ] **Platform 正式国际化(Platform/产品)**:将临时 harnessCopy 文案纳入正式翻译表并生成资源,替换临时读取方式;覆盖取消、Web 完成和手动关页文案,确认设计稿未提供的英文,清理对应 i18n TODO。
+- [ ] **浏览器取消反馈要求(产品)**:明确 DSH 取消后,Platform 页面等用户再次点击授权才显示 CANCELED 是否可接受;若要求即时反馈,实现并验证通知/查询机制。无论采用哪种表现,本地都不能接受迟到授权结果。
+- [ ] **“登录问题”链接去留(产品/Platform)**:核实原始需求;需要则提供并接入目标地址,不需要则移除相关占位或待办。不能把尚未确认的链接默认为必做功能。
+- [ ] **最终变更审查与检查(两端)**:核对相关任务及两仓工作区的最终差异,排除无关文件和私有凭证;完成代码审查、相关行为测试/快照、类型/lint/构建及文档一致性检查,记录真实执行结果,并解决所有阻塞问题。完成本项不代表允许略过上方任一待办。
+- [ ] **开发启动脚本提前退出(DSH)**:排查 start:desktop 在准备 primary runtime 时以成功状态退出、未启动 Electron 的问题;修复并验证标准启动入口,复用已有运行环境直接启动仅作为预览临时方案。
+
+- [ ] **内嵌用量与充值(DSH / Platform)**:已按 Figma 节点 2554:28786 接入 Account 按钮、原生 48px 返回栏和 Platform 内嵌布局。验证真实 Electron/Platform 初始化、返回、刷新、失败及退登路径。2026-09-16 缩小到子节点后 TemPad 读取成功。启用充值前验证支付导航;原生视图阻止跨来源文档导航,并在系统浏览器中打开 HTTPS 弹出链接,不传递凭证。实现用量默认筛选前须提供 DSH key 的 trackingId;仅凭 token/authorized_url 无法识别该记录。私有 IPC 和 preload 初始化后的同步 getter 已有定向测试,但尚未通过端到端验收。
+
+- [ ] **内嵌 Platform 复用设备身份(DSH / Platform / 后端)**:授权兑换、DSH 请求和内嵌 Platform 请求复用 DSH Host 已持久化的设备 ID。通过现有 Electron preload 初始化快照,将 deviceId/deviceModel 与 token 一起传入,并提供同步 window.dsh.getDeviceInfo() getter。内嵌 Platform 的 deviceProvider 从桥接读取,普通浏览器保留 localStorage UUID。接入 auth_token/check_device 补报前,先确认 auth_exchange 是否已完成设备登记。验证各处 x-device-id/x-device-model 一致,内嵌页不另生成浏览器设备 ID。
+
+## 已实现的基线
+
+账号资料与余额、左下角退登、私有 Platform URL 配置、共享 Host 回调端口、PKCE、本地取消决定权、auth_cancel、Electron 超时页、Web 登录弹窗和未登录 Settings 空态均已有实现。Web 失败关闭新标签页以及 desktop/web 来源区分也已实现;上方保留的是最终联调和发布验收,不是重复开发需求。
+
+## 验收记录
+
+每项完成时在该条目后记录日期、实现位置以及测试命令/结果或产品明确决定。地址策略条目已附完成依据;其他待办仍未完成。
diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml
index 626f298b8b..fc87f79a82 100644
--- a/docs/subsystems/credentials.i18n.yaml
+++ b/docs/subsystems/credentials.i18n.yaml
@@ -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 docs/subsystems/credentials.md
-credentials.md: 5ae53a19140f423e1c52334c40f25fca7d3ed754
-credentials.zh.md: b6d5d8eb3960edd68a96a32a41c591e61f2c8b5e
+credentials.md: aaafc3577d8748ff0eeffaec6ebed99db24c9e14
+credentials.zh.md: 8f9b77cd30573f42645d14155e450bf765b85fa6
diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md
index 5ae53a1914..aaafc3577d 100644
--- a/docs/subsystems/credentials.md
+++ b/docs/subsystems/credentials.md
@@ -53,6 +53,10 @@ interface CredentialInfo {
`credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
+## Embedded Platform credentials
+
+PlatformSession is a Host-only snapshot from getPlatformSession: origin names the configured Platform issuer and token contains its stored account credential. Signed-out accounts return null; a mismatched issuer fails. Native consumers own document invalidation when credentials change. This snapshot is excluded from account-controller RPC, AccountView, and AccountDetails.
+
@@ -253,6 +257,76 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie
Source: [`packages/api/settings-controller/src/credentials.ts`](../../packages/api/settings-controller/src/credentials.ts)
+
+
+### `ctx.deepseekAccount` — `DeepSeekAccount` (abstract seam)
+
+Account operations; only Host consumers can obtain a request credential.
+
+```ts cordis-catalog
+/**
+ * Read stored-account presence and the latest login attempt.
+ * @returns a snapshot without credentials or PKCE secrets.
+ */
+abstract getState(): Promise
+
+/**
+ * Query Platform profile independently of wallet balances.
+ * @returns profile outcome, or null if signed out or the grant changed during the query.
+ */
+abstract getProfile(): Promise
+
+/**
+ * Query Platform recharge-wallet balances independently of profile data.
+ * @returns balance outcome, or null if signed out or the grant changed during the query.
+ */
+abstract getBalance(): Promise
+
+/**
+ * Join an active attempt or start browser authorization.
+ * @param locale - active UI language for a new attempt; joining retains its original language.
+ * @param callbackOrigin - browser-accessible loopback HTTP origin, including any SSH local port.
+ * @param client - initiating UI, used to return from a failed exchange.
+ * @returns the initial snapshot without waiting for browser approval.
+ */
+abstract startSignIn(locale: string, callbackOrigin: string, client: 'web' | 'desktop'): Promise
+
+/**
+ * Cancel only the named attempt; committing attempts settle before returning.
+ * @param id - attempt identity from this Host.
+ * @returns state after cancellation or an already-started commit.
+ */
+abstract cancelSignIn(id: SignInAttemptId): Promise
+
+/**
+ * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background.
+ * @returns the signed-out state after local removal; remote failures never restore the grant.
+ */
+abstract signOut(): Promise
+
+/**
+ * Subscribe to snapshots including a complete initial state.
+ * @param signal - subscription lifetime; ending it never cancels login.
+ * @returns complete snapshots as account state changes.
+ */
+abstract watch(signal: AbortSignal): AsyncIterable
+
+/**
+ * Resolve a credential only for the official HTTPS API origin.
+ * @param url - actual request destination or API base URL.
+ * @returns stored token, or undefined for other origins or a signed-out account.
+ */
+abstract resolveToken(url: string): Promise
+
+/**
+ * Read credentials for the configured Platform origin, bound to their issuing environment.
+ * @returns a Host-only snapshot, or null while signed out.
+ */
+abstract getPlatformSession(): Promise
+```
+
+Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
+
### `authorization/*` events
@@ -327,3 +401,5 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
Source: [`packages/credentials/credentials/src/types.ts`](../../packages/credentials/credentials/src/types.ts)
+
+The account Service Definition exposes getState, getDetails, startSignIn, cancelSignIn, signOut, watch, and Host-only resolveToken. The platform provider implements it with an AuthorizationFlow and a private GrantRecord. AccountView distinguishes stored presence from server validation; attempt IDs bind cancellation to one local flow. See [the account package](../../packages/credentials/deepseek-account/README.md).
diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md
index b6d5d8eb39..8f9b77cd30 100644
--- a/docs/subsystems/credentials.zh.md
+++ b/docs/subsystems/credentials.zh.md
@@ -53,6 +53,10 @@ interface CredentialInfo {
`credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
+## 内嵌 Platform 凭证
+
+PlatformSession 是 getPlatformSession 返回的仅限 Host 快照:origin 指定所配置的 Platform 签发来源,token 包含其已存账号凭证。退登账号返回 null;签发来源不匹配时失败。原生使用方负责在凭证变化时使文档失效。账号控制器 RPC、AccountView 和 AccountDetails 均不包含此快照。
+
@@ -253,6 +257,76 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie
Source: [`packages/api/settings-controller/src/credentials.ts`](../../packages/api/settings-controller/src/credentials.ts)
+
+
+### `ctx.deepseekAccount` — `DeepSeekAccount` (abstract seam)
+
+Account operations; only Host consumers can obtain a request credential.
+
+```ts cordis-catalog
+/**
+ * Read stored-account presence and the latest login attempt.
+ * @returns a snapshot without credentials or PKCE secrets.
+ */
+abstract getState(): Promise
+
+/**
+ * Query Platform profile independently of wallet balances.
+ * @returns profile outcome, or null if signed out or the grant changed during the query.
+ */
+abstract getProfile(): Promise
+
+/**
+ * Query Platform recharge-wallet balances independently of profile data.
+ * @returns balance outcome, or null if signed out or the grant changed during the query.
+ */
+abstract getBalance(): Promise
+
+/**
+ * Join an active attempt or start browser authorization.
+ * @param locale - active UI language for a new attempt; joining retains its original language.
+ * @param callbackOrigin - browser-accessible loopback HTTP origin, including any SSH local port.
+ * @param client - initiating UI, used to return from a failed exchange.
+ * @returns the initial snapshot without waiting for browser approval.
+ */
+abstract startSignIn(locale: string, callbackOrigin: string, client: 'web' | 'desktop'): Promise
+
+/**
+ * Cancel only the named attempt; committing attempts settle before returning.
+ * @param id - attempt identity from this Host.
+ * @returns state after cancellation or an already-started commit.
+ */
+abstract cancelSignIn(id: SignInAttemptId): Promise
+
+/**
+ * Remove the local grant while retaining API keys and tasks; the provider revokes it in the background.
+ * @returns the signed-out state after local removal; remote failures never restore the grant.
+ */
+abstract signOut(): Promise
+
+/**
+ * Subscribe to snapshots including a complete initial state.
+ * @param signal - subscription lifetime; ending it never cancels login.
+ * @returns complete snapshots as account state changes.
+ */
+abstract watch(signal: AbortSignal): AsyncIterable
+
+/**
+ * Resolve a credential only for the official HTTPS API origin.
+ * @param url - actual request destination or API base URL.
+ * @returns stored token, or undefined for other origins or a signed-out account.
+ */
+abstract resolveToken(url: string): Promise
+
+/**
+ * Read credentials for the configured Platform origin, bound to their issuing environment.
+ * @returns a Host-only snapshot, or null while signed out.
+ */
+abstract getPlatformSession(): Promise
+```
+
+Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
+
### `authorization/*` events
@@ -327,3 +401,5 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
Source: [`packages/credentials/credentials/src/types.ts`](../../packages/credentials/credentials/src/types.ts)
+
+账号服务定义提供 getState、getDetails、startSignIn、cancelSignIn、signOut、watch 及仅限 Host 的 resolveToken。平台提供者使用 AuthorizationFlow 和私有 GrantRecord 实现这些操作。AccountView 区分本地存在与服务器验证;尝试 ID 将取消绑定到单次本地流程。参见[账号包](../../packages/credentials/deepseek-account/README.zh.md)。
diff --git a/packages/api/account-controller/README.i18n.yaml b/packages/api/account-controller/README.i18n.yaml
new file mode 100644
index 0000000000..6a1ad9e167
--- /dev/null
+++ b/packages/api/account-controller/README.i18n.yaml
@@ -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 packages/api/account-controller/README.md
+README.md: eb6ef47f91d372a5a7ff895f343ff4a3dcfceb8d
+README.zh.md: 3ab666d576e8c1d10b7f439f818a45d8a4ae23b6
diff --git a/packages/api/account-controller/README.md b/packages/api/account-controller/README.md
new file mode 100644
index 0000000000..eb6ef47f91
--- /dev/null
+++ b/packages/api/account-controller/README.md
@@ -0,0 +1,53 @@
+---
+description: "Account screens use authenticated Remote commands and a snapshot stream. The controller exposes login state without returning tokens or PKCE secrets."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-api-account-controller
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Account screens use authenticated Remote commands and a snapshot stream. The controller exposes login state without returning tokens or PKCE secrets.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+
+
+## Use this package
+
+The account namespace exposes getState, getProfile / getBalance, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login.
+
+
+## Understand the implementation
+
+The controller forwards operations to the account service and maintains no independent account state; no invariant companion is published.
+
+
+## Further Exploration
+
+The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
+
+
+## Model Experience
+
+None, as account credentials affect HTTP authentication and never enter model prompts, Session logs, or tool results.
+
+#### KV Cache effect
+
+No model request prefix changes.
+
+## Known Limitations and Deferred Work
+
+
+
+- A disconnected UI can recover account state through watch, but cannot resume an attempt after the Host exits. Credentials and request-token resolution are not Remote operations.
+
+
+### Dev Note
+
+The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership.
diff --git a/packages/api/account-controller/README.zh.md b/packages/api/account-controller/README.zh.md
new file mode 100644
index 0000000000..3ab666d576
--- /dev/null
+++ b/packages/api/account-controller/README.zh.md
@@ -0,0 +1,53 @@
+---
+description: "账号页面使用经过认证的 Remote 操作和快照流。控制器提供登录状态,不返回 token 或 PKCE 私密数据。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-api-account-controller
+
+[English](README.md) | 中文
+
+## 概述
+
+账号页面使用经过认证的 Remote 操作和快照流。控制器提供登录状态,不返回 token 或 PKCE 私密数据。
+
+## 目录
+
+- [使用此包](#use-this-package)
+- [模型体验](#model-experience)
+- [已知限制与后续工作](#known-limitations-and-deferred-work)
+
+
+## 使用此包
+
+account 命名空间提供 getState、getProfile / getBalance、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。
+
+
+## 理解实现
+
+控制器向账号服务转发操作,不维护独立的账号状态,因此不发布 invariant。
+
+
+## 深入探索
+
+[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
+
+
+## 模型体验
+
+无,因为账号凭证只影响 HTTP 认证,不进入模型提示、Session 日志或工具结果。
+
+#### KV Cache effect
+
+不改变模型请求前缀。
+
+## 已知限制与后续工作
+
+
+
+- UI 断线后可通过 watch 恢复状态,但不能在 Host 退出后恢复登录尝试。凭证和请求 token 解析不对 Remote 暴露。
+
+
+### 开发备注
+
+[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。
diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json
new file mode 100644
index 0000000000..58ff848555
--- /dev/null
+++ b/packages/api/account-controller/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@deepseek-ai/dsh-api-account-controller",
+ "description": "Expose safe account operations over authenticated Remote",
+ "version": "0.1.6-alpha.1",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/api/account-controller"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./types": {
+ "types": "./lib/types/types.d.ts",
+ "default": "./lib/types/types.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json",
+ "./typert": {
+ "types": "./lib/typert.host.d.ts",
+ "default": "./lib/typert.host.js"
+ },
+ "./remote": {
+ "types": "./lib/typert.remote-client.d.ts",
+ "default": "./lib/typert.remote-client.js"
+ }
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.js",
+ "lib/types/**/*.d.ts",
+ "lib/typert.host.js",
+ "lib/typert.host.d.ts",
+ "lib/typert.remote-client.js",
+ "lib/typert.remote-client.d.ts"
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-deepseek-account": "workspace:^",
+ "@deepseek-ai/dsh-typert-protocol": "workspace:^"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-deepseek-account": "workspace:^",
+ "@deepseek-ai/dsh-typert-protocol": "workspace:^"
+ }
+}
diff --git a/packages/api/account-controller/src/index.ts b/packages/api/account-controller/src/index.ts
new file mode 100644
index 0000000000..17535ef5ca
--- /dev/null
+++ b/packages/api/account-controller/src/index.ts
@@ -0,0 +1,61 @@
+/** Authenticated Remote operations for account UI consumers. */
+import { Context } from '@deepseek-ai/cordis'
+import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-deepseek-account'
+import type { AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
+import type { AccountView, SignInAttemptId } from './types.ts'
+
+/** Account commands and reconnect-safe state stream. */
+export class AccountController extends TypertRemoteService {
+ static inject = ['deepseekAccount']
+ /** @param ctx - Host with the account provider mounted. */
+ constructor(ctx: Context) { super(ctx, 'accountController', { namespace: 'account' }) }
+ /**
+ * Read the safe account projection.
+ * @returns current account and attempt state.
+ */
+ @Remote
+ getState(): Promise { return this.ctx.deepseekAccount.getState() }
+ /**
+ * Query display-safe Platform profile data.
+ * @returns profile outcome, or null when the account grant is absent or changed.
+ */
+ @Remote
+ getProfile(): Promise { return this.ctx.deepseekAccount.getProfile() }
+ /**
+ * Query Platform recharge-wallet balances.
+ * @returns balance outcome, or null when the account grant is absent or changed.
+ */
+ @Remote
+ getBalance(): Promise { return this.ctx.deepseekAccount.getBalance() }
+ /**
+ * Begin browser sign-in.
+ * @param locale - active UI language for a new attempt.
+ * @param callbackOrigin - browser-accessible loopback HTTP origin.
+ * @param client - initiating UI, used to return from a failed exchange.
+ * @returns a new or already-active login attempt.
+ */
+ @Remote
+ startSignIn(locale: string, callbackOrigin: string, client: 'web' | 'desktop'): Promise { return this.ctx.deepseekAccount.startSignIn(locale, callbackOrigin, client) }
+ /**
+ * Cancel the named local attempt.
+ * @param attemptId - attempt to cancel.
+ * @returns settled cancellation or commit state.
+ */
+ @Remote
+ cancelSignIn(attemptId: SignInAttemptId): Promise { return this.ctx.deepseekAccount.cancelSignIn(attemptId) }
+ /**
+ * Remove the local account grant and revoke it through Platform in the background, without deleting API keys.
+ * @returns state after removing the local account grant.
+ */
+ @Remote
+ signOut(): Promise { return this.ctx.deepseekAccount.signOut() }
+ /**
+ * Stream the safe account projection.
+ * @param signal - stream lifetime.
+ * @returns initial snapshot and subsequent changes.
+ */
+ @Remote({ mode: 'stream' })
+ watch(signal: AbortSignal): AsyncIterable { return this.ctx.deepseekAccount.watch(signal) }
+}
+export default AccountController
diff --git a/packages/api/account-controller/src/types.ts b/packages/api/account-controller/src/types.ts
new file mode 100644
index 0000000000..3c0455849e
--- /dev/null
+++ b/packages/api/account-controller/src/types.ts
@@ -0,0 +1,2 @@
+/** Account Remote values contain no credential payloads. */
+export type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
diff --git a/packages/api/account-controller/tsconfig.json b/packages/api/account-controller/tsconfig.json
new file mode 100644
index 0000000000..82f75a2d8d
--- /dev/null
+++ b/packages/api/account-controller/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../credentials/deepseek-account"
+ },
+ {
+ "path": "../../typert/protocol"
+ }
+ ]
+}
diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml
index 366e9297fe..a4e999fbbd 100644
--- a/packages/api/gateway/README.i18n.yaml
+++ b/packages/api/gateway/README.i18n.yaml
@@ -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/api/gateway/README.md
-README.md: 1460f5a27062ffcac09c68358dc7b186241a1baa
-README.zh.md: 79ed379faad8498cfdf249a0accb437f6d5f98e3
+README.md: 27d055d96d0d863951b780e6392485e2a2bca06d
+README.zh.md: 6c7bdbc858a07de3a66a3d8e8d3445c977476f74
diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md
index 1460f5a270..27d055d96d 100644
--- a/packages/api/gateway/README.md
+++ b/packages/api/gateway/README.md
@@ -92,3 +92,5 @@ None.
**Runtime invariant:** No companion is published. Host calls re-read authoritative Cordis and Typert state, while Client methods, descriptors, and `$on` subscriptions mutate in one owned effect.
+
+The ./stream-protocol export supplies the shared Remote stream framing and parser to native Desktop callers. These callers use the same authenticated WebSocket endpoint as the browser client.
diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md
index 79ed379faa..6c7bdbc858 100644
--- a/packages/api/gateway/README.zh.md
+++ b/packages/api/gateway/README.zh.md
@@ -92,3 +92,5 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的
**运行时不变式:** 不发布伴生入口。Host 调用会重新读取权威的 Cordis 与 Typert 状态,Client 方法、描述符与 `$on` 订阅的变更则统一归属同一个 effect。
+
+./stream-protocol 导出为原生桌面调用方提供共享的 Remote 流帧格式和解析器。它们与浏览器客户端使用同一个经过认证的 WebSocket 端点。
diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json
index 4680b06f39..9a7408ee1d 100644
--- a/packages/api/gateway/package.json
+++ b/packages/api/gateway/package.json
@@ -27,7 +27,11 @@
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
- "./package.json": "./package.json"
+ "./package.json": "./package.json",
+ "./stream-protocol": {
+ "types": "./lib/types/stream-protocol.d.ts",
+ "default": "./lib/types/stream-protocol.js"
+ }
},
"dsh": {
"client": {
diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml
index 342461b966..0456102db4 100644
--- a/packages/api/remotes/README.i18n.yaml
+++ b/packages/api/remotes/README.i18n.yaml
@@ -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/api/remotes/README.md
-README.md: 4e365165f0b1c406e3d5693c772dbcc21f125589
-README.zh.md: 913e08ca7e35e5a6d2756347bf24a840660f85e0
+README.md: 74e1595bfbe852605e5d5a7c6930c113e63a92bd
+README.zh.md: b8974b0987d5be4ebee2a2886d3cbce321638d4d
diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md
index 4e365165f0..74e1595bfb 100644
--- a/packages/api/remotes/README.md
+++ b/packages/api/remotes/README.md
@@ -86,3 +86,5 @@ None.
**Runtime invariant:** No companion is published. Typert and the Agent/Session registries own the observed relationships.
+
+The Client assembly includes the account namespace for login commands and reconnectable account snapshots.
diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md
index 913e08ca7e..b8974b0987 100644
--- a/packages/api/remotes/README.zh.md
+++ b/packages/api/remotes/README.zh.md
@@ -86,3 +86,5 @@ Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个
**运行时不变式:** 不发布伴生入口。被观察的关系由 Typert、agent 注册表和会话注册表负责。
+
+客户端组装包含 account 命名空间,用于登录操作和可重连的账号快照。
diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json
index 8a20b05e13..2b1ff20851 100644
--- a/packages/api/remotes/package.json
+++ b/packages/api/remotes/package.json
@@ -89,6 +89,7 @@
"@deepseek-ai/dsh-command-feedback": "workspace:^",
"@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
- "@deepseek-ai/dsh-office-to-pdf": "workspace:^"
+ "@deepseek-ai/dsh-office-to-pdf": "workspace:^",
+ "@deepseek-ai/dsh-api-account-controller": "workspace:^"
}
}
diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts
index 26d7999810..b532493d6e 100644
--- a/packages/api/remotes/src/client/index.ts
+++ b/packages/api/remotes/src/client/index.ts
@@ -3,6 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
+import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote'
import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote'
import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
@@ -33,6 +34,7 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven
export type {} from '@deepseek-ai/dsh-agent-presets/remote'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
+export type {} from '@deepseek-ai/dsh-api-account-controller/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
export type {} from '@deepseek-ai/dsh-llm/remote'
@@ -166,7 +168,7 @@ export async function apply(ctx: Context): Promise<() => Promise> {
const disposers: Array<() => Promise> = []
try {
for (const contribution of [
- agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
+ agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote,
pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, officeToPdfRemote,
]) {
diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json
index 301798c890..5b04760715 100644
--- a/packages/api/remotes/tsconfig.client.json
+++ b/packages/api/remotes/tsconfig.client.json
@@ -94,6 +94,9 @@
},
{
"path": "../../document/office-to-pdf"
+ },
+ {
+ "path": "../account-controller"
}
]
}
diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json
index 958a70d828..ad02bcf469 100644
--- a/packages/api/remotes/tsconfig.host.json
+++ b/packages/api/remotes/tsconfig.host.json
@@ -79,6 +79,9 @@
},
{
"path": "../../host/plugin-inventory"
+ },
+ {
+ "path": "../account-controller"
}
]
}
diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml
index 292f948500..cad307b9e5 100644
--- a/packages/bundle/base/README.i18n.yaml
+++ b/packages/bundle/base/README.i18n.yaml
@@ -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/bundle/base/README.md
-README.md: f9cc3e4d03a0d71d1f598d97633c0f89f7700b42
-README.zh.md: 2e98bb710502b62ad87f04ae768833487b4b5787
+README.md: 98f4b148bf0f0304c412b6964be53c4b2e76287c
+README.zh.md: 2ed43b780bdee0056e17dcad8d720b003f70b3f1
diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md
index f9cc3e4d03..98f4b148bf 100644
--- a/packages/bundle/base/README.md
+++ b/packages/bundle/base/README.md
@@ -147,3 +147,5 @@ These limits tell you when the core needs extra care or where an override must g
None.
+
+The base composition mounts authorization and the platform account provider alongside credentials. The provider opens a callback listener only during an explicit login attempt.
diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md
index 2e98bb7105..2ed43b780b 100644
--- a/packages/bundle/base/README.zh.md
+++ b/packages/bundle/base/README.zh.md
@@ -147,3 +147,5 @@ patch 在自身上按平台门控两个 shell 栈:`bash-sandbox` 与 `tool-bas
无。
+
+基础组合在凭证服务旁挂载授权服务和平台账号提供者。提供者只在显式登录尝试期间打开回调监听器。
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml
index 1aaf9da6b4..8f32bb462f 100644
--- a/packages/bundle/base/cordis.patch.yml
+++ b/packages/bundle/base/cordis.patch.yml
@@ -101,6 +101,12 @@
# `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks.
# Adapters resolve references per request; the Models page writes only the
# managed document, which is never materialized into the process environment.
+ - id: authorization
+ name: '@deepseek-ai/dsh-authorization'
+
+ - id: deepseek-account
+ name: '@deepseek-ai/dsh-deepseek-account-platform'
+
- id: credentials
name: '@deepseek-ai/dsh-credentials-local'
diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json
index 8ef9704211..4e88dfe9b2 100644
--- a/packages/bundle/base/package.json
+++ b/packages/bundle/base/package.json
@@ -121,7 +121,9 @@
"@deepseek-ai/dsh-workflow-ptc": "workspace:^",
"@deepseek-ai/dsh-agent-instructions": "workspace:^",
"@deepseek-ai/dsh-ptc-runtime-node": "workspace:^",
- "@deepseek-ai/dsh-plugin-manager": "workspace:^"
+ "@deepseek-ai/dsh-plugin-manager": "workspace:^",
+ "@deepseek-ai/dsh-authorization": "workspace:^",
+ "@deepseek-ai/dsh-deepseek-account-platform": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml
index 7229246e9a..f54e31b8df 100644
--- a/packages/bundle/web-app/README.i18n.yaml
+++ b/packages/bundle/web-app/README.i18n.yaml
@@ -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/bundle/web-app/README.md
-README.md: d74489dd294bbf0f8c09df02ffc42984517267cd
-README.zh.md: 9d80190bcf332e1271f1f00bceee34c72416cce4
+README.md: eae16e6921cff5c613366e4355fc12dddca19421
+README.zh.md: 342500f9e833c1a1e0863ef541fa4cdc45ace339
diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md
index d74489dd29..eae16e6921 100644
--- a/packages/bundle/web-app/README.md
+++ b/packages/bundle/web-app/README.md
@@ -161,3 +161,5 @@ These limits tell you what to expect in unusual setups — a source checkout, SS
None.
+
+The Web composition includes the account Remote controller and Account settings section.
diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md
index 9d80190bcf..342500f9e8 100644
--- a/packages/bundle/web-app/README.zh.md
+++ b/packages/bundle/web-app/README.zh.md
@@ -161,3 +161,5 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
无。
+
+Web 组合包含账号 Remote 控制器和账号设置页面。
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index 9fd0effbc5..50d152c51c 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -111,6 +111,12 @@
# Configuration-surface reads and writes over Typert Remote. Each method
# reports an actionable error when its settings-domain provider is absent.
+ - id: ui-settings-account
+ name: '@deepseek-ai/dsh-client-ui-settings-account'
+
+ - id: account-controller
+ name: '@deepseek-ai/dsh-api-account-controller'
+
- id: settings-controller
name: '@deepseek-ai/dsh-api-settings-controller'
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 6a322911e9..fadfca90fe 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -124,7 +124,9 @@
"open": "^11.0.0",
"@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^",
- "@deepseek-ai/dsh-office-to-pdf": "workspace:^"
+ "@deepseek-ai/dsh-office-to-pdf": "workspace:^",
+ "@deepseek-ai/dsh-api-account-controller": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index f12df6c122..972881090d 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -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-primitives/README.md
-README.md: 62f3346050ac106b9324f19666560347d5b37928
-README.zh.md: d30726da1881affb589709d9bf667c78875cf16d
+README.md: e7344fa1f62ff6d384b74df5b1207e6e7bdfcaf6
+README.zh.md: af03ce14baea7a50270fc74828e819f3824a4550
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 62f3346050..e7344fa1f6 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -92,6 +92,8 @@ The atoms cannot read the application locale, so every piece of user-facing copy
## Understand the implementation
+Menu.listClassName styles the menu card independently of the anchor wrapper, including in portal mode.
+
Implementation internals — click to expand
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index d30726da18..af03ce14ba 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -92,6 +92,8 @@ kind: "package-library"
## 理解实现
+Menu.listClassName 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。
+
实现细节——点击展开
diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx
index bd56ee1309..04f748d5f7 100644
--- a/packages/client/ui-primitives/src/Menu.tsx
+++ b/packages/client/ui-primitives/src/Menu.tsx
@@ -54,6 +54,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* with the list. Only a keyboard on the trigger or inside the list is
* intercepted; Tab presses elsewhere on the page stay the browser's.
* @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way.
+ * @param props.listClassName - feature-owned menu card styling.
* @param props.open - whether the list is showing (owner-controlled).
* @param props.anchor - the trigger element (rendered in place).
* @param props.items - selectable rows and optional separators.
@@ -89,7 +90,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* crowds the cell).
* @returns anchor wrapper with the conditional list.
*/
-export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: {
+export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className, listClassName }: {
open: boolean
autoFocus?: boolean
anchor: ReactNode
@@ -107,6 +108,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
compact?: boolean
selection?: 'check' | 'fill'
getAnchorRect?: () => DOMRect | null
+ listClassName?: string | undefined
className?: string | undefined
}) {
const rootRef = useRef(null)
@@ -404,7 +406,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
const list = open && (
+## Use this package
+
+The section registers through settings.section and uses the account Remote namespace. The stream survives carrier reconnects through the shared Remote supervisor. The page renders feature-owned English and Chinese copy and keeps API keys separate from account state.
+
+Balances use Platform Web currency formatting: two decimal places and digit grouping, positive amounts truncated to cents, positive sub-cent amounts shown as <0.01, and negative amounts rounded with a minimum displayed magnitude of 0.01. Raw Host balance strings remain unchanged.
+
+Desktop usage and top-up actions open an isolated native Platform view below a 48px return bar. Back destroys the view and preserves the Account settings page. Browser clients retain external links. Loading failures retain the return action and show the existing localized fallback; renderer commands never receive the account token.
+
+Contact us opens the Feishu questionnaire in the system browser on Desktop or a new browser tab on Web. It supplies the available Platform UID, build version, UI locale and physical screen resolution as prefill_* parameters, with hide_*=1 for every context field; tokens and contact details are excluded. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form. contactSource defaults to empty until the questionnaire supports a Harness source option; OS and device fields remain unfilled, matching the Web implementation.
+
+
+## Understand the implementation
+
+The plugin owns one Host snapshot stream shared through framework hooks by settings.section and settings.launcher. The launcher opens Settings and offers Sign out only while an account credential is stored. Platform failures leave the menu available for retry. It maintains no independent credential state, so no invariant companion is published.
+
+
+## Further Exploration
+
+The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
+
+Signed-out Settings shows a centered sign-in prompt without account cards, balances, or Platform links. Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
+
+
+## Model Experience
+
+None, as account credentials affect HTTP authentication and never enter model prompts, Session logs, or tool results.
+
+#### KV Cache effect
+
+No model request prefix changes.
+
+## Known Limitations and Deferred Work
+
+
+
+- Profile and recharge-wallet balances use the existing Platform Web endpoints through Host getProfile / getBalance. The page refreshes when opened and after login or reconnect, preserves server-masked contact data, and shows query failures independently without manufacturing a zero balance. Usage and top-up use Host-provided links derived from platformOrigin and the browser’s own login; the links never carry a DSH token.
+
+
+### Dev Note
+
+The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership.
diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md
new file mode 100644
index 0000000000..e3405a4888
--- /dev/null
+++ b/packages/client/ui-settings-account/README.zh.md
@@ -0,0 +1,61 @@
+---
+description: "设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器登录和取消;侧边栏账号菜单提供 Platform 退出登录。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-settings-account
+
+[English](README.md) | 中文
+
+## 概述
+
+设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器登录和取消;侧边栏账号菜单提供 Platform 退出登录。
+
+## 目录
+
+- [使用此包](#use-this-package)
+- [模型体验](#model-experience)
+- [已知限制与后续工作](#known-limitations-and-deferred-work)
+
+
+## 使用此包
+
+该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。
+
+余额沿用 Platform Web 的金额格式:两位小数和千分位分组,正金额截断至分,小于一分的正金额显示为 <0.01,负金额按舍入规则处理且显示绝对值至少为 0.01。Host 返回的原始余额字符串保持不变。
+
+Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Platform 视图。返回操作销毁视图并保留 Account 设置页。浏览器客户端继续使用外链。加载失败时保留返回操作并显示现有本地化兜底提示;渲染进程命令不接收账号 token。
+
+“联系我们”在 Desktop 的系统浏览器或 Web 的新标签页打开飞书问卷。链接通过 prefill_* 参数填写已有的 Platform UID、构建版本、界面语言和屏幕物理分辨率,并为所有上下文字段设置 hide_*=1;不传 token 或联系方式。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷。问卷支持 Harness 来源选项前,contactSource 默认为空;OS 和设备字段沿用 Web 实现,保持未填写。
+
+
+## 理解实现
+
+插件持有一条 Host 快照流,通过框架 hook 供 settings.section 和 settings.launcher 共享。入口菜单可打开设置,并仅在已存储账号凭证时提供退出登录。Platform 请求失败时保留菜单,以供重试。插件不维护独立凭证状态,因此不发布 invariant。
+
+
+## 深入探索
+
+[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
+
+未登录的设置页居中显示登录提示,不展示账号卡片、余额或开放平台链接。账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
+
+
+## 模型体验
+
+无,因为账号凭证只影响 HTTP 认证,不进入模型提示、Session 日志或工具结果。
+
+#### KV Cache effect
+
+不改变模型请求前缀。
+
+## 已知限制与后续工作
+
+
+
+- 资料和充值钱包余额通过 Host getProfile / getBalance 复用已有 Platform Web 接口。页面在打开、登录及重连后刷新,原样显示服务端脱敏后的联系方式,并分别处理查询失败,不将失败显示为零余额。用量和充值使用 Host 按 platformOrigin 派生的链接,并沿用浏览器自身登录态,不携带 DSH token。
+
+
+### 开发备注
+
+[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。
diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json
new file mode 100644
index 0000000000..0d4ad9f6e5
--- /dev/null
+++ b/packages/client/ui-settings-account/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@deepseek-ai/dsh-client-ui-settings-account",
+ "description": "Manage DeepSeek login and open Platform billing pages",
+ "version": "0.1.6-alpha.1",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/client/ui-settings-account"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./client": {
+ "types": "./lib/types/client/index.d.ts",
+ "default": "./lib/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-api-remotes",
+ "@deepseek-ai/dsh-client-ui-settings",
+ "@deepseek-ai/dsh-client-locale"
+ ],
+ "platform": "web"
+ }
+ },
+ "scripts": {
+ "bundle": "tsdown",
+ "watch": "tsdown --watch"
+ },
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-api-remotes": "workspace:^",
+ "@deepseek-ai/dsh-client-locale": "workspace:^",
+ "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+ "@deepseek-ai/dsh-deepseek-account": "workspace:^",
+ "@testing-library/react": "^16.1.0",
+ "@types/big.js": "^6.2.2",
+ "@types/react": "~18.3.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
+ "@types/react-dom": "~18.3.0",
+ "@deepseek-ai/dsh-host-webserver": "workspace:^",
+ "big.js": "^7.0.1"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/client.js",
+ "lib/types/**/*.d.ts"
+ ],
+ "dependencies": {
+ "@deepseek-ai/schemastery": "workspace:^"
+ }
+}
diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.module.css b/packages/client/ui-settings-account/src/client/AccountMenu.module.css
new file mode 100644
index 0000000000..f2c21bb84e
--- /dev/null
+++ b/packages/client/ui-settings-account/src/client/AccountMenu.module.css
@@ -0,0 +1,11 @@
+.root { flex: 1; min-width: 0; }
+.anchor { width: 100%; }
+.trigger { display: flex; align-items: center; width: 100%; gap: 8px; padding: 6px; border: 0; border-radius: 12px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; }
+.trigger:hover { background: var(--dsw-alias-interactive-bg-hover); }
+.avatar { display: flex; align-items: center; justify-content: center; flex: none; width: 32px; height: 32px; border-radius: 16px; background: var(--dsw-alias-bg-skeleton); color: var(--dsw-alias-label-tertiary); }
+.label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.menu { min-width: 124px; max-width: 280px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-inverted); }
+.menu [role='menuitem'] { min-height: 40px; padding: 8px 10px; border-radius: 10px; gap: 8px; font-size: 14px; line-height: 22px; }
+.error { color: var(--dsw-alias-state-error-primary); font-size: 12px; }
+
+.menu [role='menuitem'] svg { color: var(--dsw-alias-label-primary); }
diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx
new file mode 100644
index 0000000000..ffc23d82f1
--- /dev/null
+++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx
@@ -0,0 +1,58 @@
+/** Sidebar account launcher and locally authoritative sign-out action. */
+import { useState } from 'react'
+import { Menu, IconPaperPlaneOutline14, IconSettingsOutline16, IconUserOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { AccountSectionInjected } from './AccountSection.tsx'
+import { SignInDialog } from './SignInDialog.tsx'
+import { LogoutIcon } from './LogoutIcon.tsx'
+import css from './AccountMenu.module.css'
+
+/** Account launcher composed by the settings shell. */
+export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'settings.account'> & InjectFace
+
+/** The signed-in label stays empty while the profile loads.
+ * @param props - sidebar geometry, settings navigation and account operations.
+ * @returns account menu launcher.
+ */
+export function AccountMenu({
+ wide, openSettings, openOnboarding, useAccount, signOut, contactUs, showLogin, start, cancel, t,
+}: AccountMenuProps) {
+ const account = useAccount(state => state)
+ const signedIn = account.view?.status === 'credential-stored'
+ const profile = account.details?.profile
+ const label = profile === undefined ? null : profile.status === 'ready' ? profile.value.name ?? t('signedIn') : t('signedIn')
+ const [open, setOpen] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [failed, setFailed] = useState(false)
+ const logout = async () => {
+ setBusy(true)
+ setFailed(false)
+ try { await signOut(); setOpen(false) }
+ catch { setFailed(true) }
+ finally { setBusy(false) }
+ }
+ return