feat(desktop,credentials,client): integrate DeepSeek account sign-in and Platform pages

This commit is contained in:
lsdsjy committed 2026-09-20 11:31:40 +08:00
1 parent 753eff67b1
commit 048297321a
208 files changed
+5773 -148

No files matched your search

@@ -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
@@ -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.
@@ -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 字面量转换。
+2
View File
@@ -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 |
+6
View File
@@ -48,6 +48,10 @@ flowchart LR
cfg --> plugin_dsh_base_llm_retry
plugin_dsh_base_settings["settings<br/>@deepseek-ai/dsh-settings-file"]
cfg --> plugin_dsh_base_settings
plugin_dsh_base_authorization["authorization<br/>@deepseek-ai/dsh-authorization"]
cfg --> plugin_dsh_base_authorization
plugin_dsh_base_deepseek_account["deepseek-account<br/>@deepseek-ai/dsh-deepseek-account-platform"]
cfg --> plugin_dsh_base_deepseek_account
plugin_dsh_base_credentials["credentials<br/>@deepseek-ai/dsh-credentials-local"]
cfg --> plugin_dsh_base_credentials
plugin_dsh_base_llm_pi_ai["llm-pi-ai<br/>@deepseek-ai/dsh-llm-pi-ai"]
@@ -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` |
+2 -1
View File
@@ -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:^"
}
}
+21
View File
@@ -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<void> {
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<void> => {
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) })
}
+3
View File
@@ -25,6 +25,9 @@
},
{
"path": "../../packages/host/webserver"
},
{
"path": "../../packages/credentials/deepseek-account"
}
]
}
@@ -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
}
+6 -2
View File
@@ -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"
}
}
+6
View File
@@ -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; }
+11 -1
View File
@@ -24,8 +24,18 @@
<p id="key-error" class="key-error" role="alert" hidden></p>
</div>
</form>
<section id="auth-page" class="key-heading" hidden aria-live="polite">
<h1 id="auth-status"></h1>
<p id="auth-description" hidden></p>
</section>
<div id="auth-actions" class="actions" hidden>
<button id="auth-reopen" class="primary" type="button"></button>
<button id="auth-retry" class="primary" type="button" hidden></button>
<button id="auth-api-key" class="secondary" type="button" hidden></button>
<button id="auth-cancel" class="secondary" type="button"></button>
</div>
<div id="entry-actions" class="actions">
<button id="sign-in" class="primary" type="button" disabled></button>
<button id="sign-in" class="primary" type="button"></button>
<button id="api-key" class="secondary" type="button"></button>
</div>
<div id="key-actions" class="actions" hidden>
+83 -2
View File
@@ -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 })
+1 -1
View File
@@ -82,7 +82,7 @@ async function launchElectron(previewWelcome: boolean): Promise<void> {
}
async function main(): Promise<void> {
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)
+112
View File
@@ -0,0 +1,112 @@
/** Native account commands and Gateway state stream; no renderer receives credentials. */
import { randomUUID } from 'node:crypto'
import WebSocket from 'ws'
import { parseRemoteStreamServerMessage, REMOTE_STREAM_MUX_PATH } from '@deepseek-ai/dsh-api-gateway/stream-protocol'
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Authenticated unary caller shared with native onboarding. */
export type AccountInvoke = (request: { namespace: string; method: string; args: Record<string, unknown> }) => Promise<unknown>
/** Decode the UI-safe state received across HTTP or WebSocket. @param value - wire value. @returns account projection. */
export function accountView(value: unknown): AccountView {
if (typeof value !== 'object' || value === null || !('status' in value)
|| !['signed-out', 'credential-stored'].includes(String(value.status)) || !('attempt' in value)) {
throw new Error('desktop account: invalid state')
}
if (!('links' in value) || typeof value.links !== 'object' || value.links === null
|| !('usageUrl' in value.links) || typeof value.links.usageUrl !== 'string'
|| !('topUpUrl' in value.links) || typeof value.links.topUpUrl !== 'string') {
throw new Error('desktop account: invalid platform links')
}
validateBrowserDestination(value.links.usageUrl)
validateBrowserDestination(value.links.topUpUrl)
const attempt = value.attempt
if (attempt !== null && (typeof attempt !== 'object' || !('id' in attempt) || typeof attempt.id !== 'string'
|| !('phase' in attempt) || !['initializing', 'waiting-browser', 'exchanging', 'committing', 'succeeded', 'cancelled', 'expired', 'failed'].includes(String(attempt.phase))
|| ('authorizeUrl' in attempt && typeof attempt.authorizeUrl !== 'string')
|| ('expiresAt' in attempt && (typeof attempt.expiresAt !== 'number' || !Number.isFinite(attempt.expiresAt)))
|| ('errorCode' in attempt && !['network', 'protocol', 'expired', 'storage'].includes(String(attempt.errorCode))))) {
throw new Error('desktop account: invalid attempt')
}
if (attempt !== null && 'authorizeUrl' in attempt) {
validateBrowserDestination(String(attempt.authorizeUrl))
}
const parsed = value as AccountView
return {
status: parsed.status,
links: { usageUrl: parsed.links.usageUrl, topUpUrl: parsed.links.topUpUrl },
attempt: parsed.attempt === null ? null : {
id: parsed.attempt.id, phase: parsed.attempt.phase,
...parsed.attempt.authorizeUrl === undefined ? {} : { authorizeUrl: parsed.attempt.authorizeUrl },
...parsed.attempt.expiresAt === undefined ? {} : { expiresAt: parsed.attempt.expiresAt },
...parsed.attempt.errorCode === undefined ? {} : { errorCode: parsed.attempt.errorCode },
},
}
}
/** Only HTTP loopback or HTTPS destinations can leave the native app. */
function validateBrowserDestination(value: string): void {
const url = new URL(value)
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
if (url.username || url.password || !(url.protocol === 'https:' || (loopback && url.protocol === 'http:'))) {
throw new Error('desktop account: invalid browser destination')
}
}
/** Native account operations and explicitly owned stream lifetime. */
export interface DesktopAccountBackend {
/** @returns current account snapshot. */
state(): Promise<AccountView>
/** @param locale - active desktop UI language. @returns new or already-running login attempt. */
start(locale: string): Promise<AccountView>
/** @param id - attempt to cancel. @returns cancellation or completed commit state. */
cancel(id: SignInAttemptId): Promise<AccountView>
/** @returns state after local sign-out. */
signOut(): Promise<AccountView>
/** @param listener - state recipient. @param failed - stream failure recipient. @returns stream disposer. */
watch(listener: (state: AccountView) => void, failed: () => void): () => void
}
/**
* Connect native account operations to the standard authenticated Web backend.
* @param origin - Host Web origin.
* @param invoke - validated unary RPC caller.
* @param cookies - Electron session cookie reader.
* @returns account operations; watch callers own their subscriptions.
*/
export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise<string>): DesktopAccountBackend {
const call = async (method: string, args: Record<string, unknown> = {}): Promise<AccountView> =>
accountView(await invoke({ namespace: 'account', method, args }))
return {
state: () => call('getState'), start: locale => call('startSignIn', { locale, callbackOrigin: new URL(origin).origin, client: 'desktop' }),
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: () => call('signOut'),
watch(listener, failed) {
let closed = false
let socket: WebSocket | undefined
let retry: ReturnType<typeof setTimeout> | undefined
const connect = (): void => {
const streamId = randomUUID()
void cookies().then((cookie) => {
if (closed) return
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
socket.on('open', () => socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })))
socket.on('message', (data) => {
try {
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
if (frame.type === 'item') listener(accountView(frame.value))
else socket?.close()
} catch { socket?.close() }
})
socket.on('error', () => { socket?.close() })
socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
}).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
}
connect()
return () => { closed = true; clearTimeout(retry); socket?.close() }
},
}
}
+30 -1
View File
@@ -2,6 +2,7 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
import { desktopNodeEnvironment } from './node-environment.ts'
interface ReadyEvent {
@@ -15,7 +16,12 @@ interface FatalEvent {
readonly message: string
}
type DesktopHostEvent = ReadyEvent | FatalEvent | { readonly type: 'shutdown-complete' } | {
interface PlatformSessionEvent {
readonly type: 'platform-session'
readonly session: PlatformSession | null
}
type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {
readonly type: 'update-tasks'
readonly requestId: number
readonly active: boolean
@@ -32,6 +38,23 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
return true
case 'ready':
return typeof candidate.url === 'string'
case 'platform-session': {
const session = candidate.session
if (session === null) return true
if (typeof session !== 'object' || !('origin' in session) || !('token' in session)
|| typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false
if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false
if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null
|| Array.isArray(session.requestHeaders)
|| Object.entries(session.requestHeaders).some(([name, value]) => typeof value !== 'string'
|| name !== name.toLowerCase() || /[\r\n]/.test(value)
|| ['authorization', 'x-dsh-auth-token', 'host', 'content-length', 'transfer-encoding', 'connection', 'content-type'].includes(name)))) return false
try {
const url = new URL(session.origin)
return url.origin === session.origin && !url.username && !url.password
&& (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
} catch { return false }
}
case 'fatal':
return typeof candidate.message === 'string'
case 'update-tasks':
@@ -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()
+8
View File
@@ -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
+130
View File
@@ -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<string, unknown>
const result: Record<string, number> = {}
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<void> {
this.close()
const account = this.account
if (account === null) throw new Error('Platform account unavailable')
const generation = this.generation
const browserSession = session.fromPartition(`dsh-platform-${randomUUID()}`)
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
browserSession.setPermissionCheckHandler(() => false)
const 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.
})
}
}
@@ -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
},
})
}
+9
View File
@@ -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<AccountView>,
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>,
reopenSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.reopen, id) as Promise<void>,
onAccountState: (listener) => {
const receive = (_event: Electron.IpcRendererEvent, state: AccountView): void =>{ listener(state) }
ipcRenderer.on(WELCOME_IPC.state, receive)
return () => { ipcRenderer.removeListener(WELCOME_IPC.state, receive) }
},
saveApiKey: (value: string) => ipcRenderer.invoke(WELCOME_IPC.saveApiKey, value) as Promise<WelcomeSaveResult>,
skip: () => ipcRenderer.invoke(WELCOME_IPC.skip) as Promise<void>,
}
+17 -2
View File
@@ -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<AccountView>
/** @param id - attempt to cancel. @returns the settled state. */
cancelSignIn(id: SignInAttemptId): Promise<AccountView>
/** @param id - current attempt whose browser page should reopen. */
reopenSignIn(id: SignInAttemptId): Promise<void>
/**
* 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<void>
}
/** 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 {
+7
View File
@@ -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<WelcomeState>
/**
@@ -33,6 +36,7 @@ function record(value: unknown): value is Record<string, unknown> {
export async function connectDesktopWelcome(
authenticatedUrl: string,
send: (input: string, init?: RequestInit) => Promise<Response>,
cookies: () => Promise<string> = () => Promise.resolve(''),
): Promise<DesktopWelcomeBackend> {
const origin = new URL(authenticatedUrl).origin
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
@@ -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 }
+16 -1
View File
@@ -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() })
@@ -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' } }])
})
@@ -0,0 +1,6 @@
DeepSeek Harness
DeepSeek Harness
Sign in timed out
Sign in again
Add API Key
Sign in again to continue
@@ -1,5 +1,5 @@
DeepSeek Harness
DeepSeek Harness
Go deeper with DeepSeek
Sign in [disabled]
Sign in
Add API Key
@@ -0,0 +1,6 @@
DeepSeek Harness
DeepSeek Harness
登录已超时
重新登录
添加 API Key
请重新登录后继续操作
@@ -1,5 +1,5 @@
DeepSeek Harness
DeepSeek Harness
与 DeepSeek 深度对话
登录 [disabled]
登录
添加 API Key
+11
View File
@@ -182,3 +182,14 @@ describe('desktop host process', () => {
await expect(host.start()).rejects.toThrow(message)
})
})
it('carries Platform credentials over private IPC and clears them on shutdown', async () => {
const runtime = projectWithHost(HTTP_HOST.replace("process.send({ type: 'ready'", "process.send({ type: 'platform-session', session: { origin: 'https://platform.deepseek.com', token: 'fixture-secret' } }); 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)
})
@@ -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')
@@ -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<string, Bridge>()
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()
})
+106
View File
@@ -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<typeof vi.fn>
setWindowOpenHandler: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
} }
}
const bounds = { x: 10, y: 20, width: 800, height: 600 }
it('bootstraps only the owned main frame and never puts the token in a URL', async () => {
const { manager, owner } = setup()
await manager.open(owner, 'usage', bounds)
const sender = view().webContents
const event = { sender, senderFrame: sender.mainFrame } 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:[email protected]/order']) {
expect(handler({ url })).toEqual({ action: 'deny' })
}
expect(state.openExternal).not.toHaveBeenCalled()
manager.close()
})
+81 -3
View File
@@ -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<string, string> = {}
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<string, string>
let value: unknown
if (req.url?.endsWith('auth_init')) {
init = input
if (input.locale !== 'en_US') { res.writeHead(400).end(); return }
value = { authorize_url: `${origin}/dsh/authorize?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<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('missing mock listener')
origin = `http://127.0.0.1:${String(address.port)}`
return {
origin,
failExchange: (value: boolean) => { failExchange = value },
callback: () => `${init.redirect_uri}?code=test&state=${init.state}`,
close: () => new Promise<void>((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }),
}
}
describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
it('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<void> => {
await host?.stop()
host = new DesktopHostProcess(process.execPath, project, paths.profile)
const { url } = await host.start()
hostOrigin = new URL(url).origin
let cookie = ''
const send: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers)
@@ -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 })
}
})
+3 -2
View File
@@ -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' })
@@ -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<WelcomeSaveResult>>().mockResolvedValue({ ok: true }),
skip: vi.fn<() => Promise<void>>().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<AccountView['attempt']>['id'], phase: 'expired' } })
expect(view.button('#auth-retry').hidden).toBe(false)
expect(view.button('#auth-api-key').hidden).toBe(false)
expect(view.api.startSignIn).not.toHaveBeenCalled()
await expect(view.copy() + view.document.querySelector('#auth-description')!.textContent + '\n').toMatchFileSnapshot(`./expected/welcome/${language}-timeout.expected.txt`)
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)
})
+9 -4
View File
@@ -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<void>>().mockResolvedValue(undefined),
loadWorkspace: vi.fn<(url: string) => Promise<void>>().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' } : {}),
+5 -2
View File
@@ -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')
+15 -3
View File
@@ -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"
}
]
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/capability-seams.md
capability-seams.md: edb8cd688768ec49ee3699c3775409a66975129d
capability-seams.zh.md: b06d9378616669407b79ffa1da205924ff6ad757
capability-seams.md: 6da4817d2825547a721cd4487b82d52b1d766e18
capability-seams.zh.md: b8976406a7ccd18d6ff718b2eb3c0c1fb21e4ff6
+9
View File
@@ -98,6 +98,10 @@ flowchart LR
pkg_credentials["credentials"]
svc_credentials["ctx.credentials<br/>Credential seam"]
pkg_credentials_local["credentials-local"]
pkg_deepseek_account["deepseek-account"]
svc_deepseekAccount["ctx.deepseekAccount<br/>DeepSeek account"]
pkg_deepseek_account_platform["deepseek-account-platform"]
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization<br/>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. |
+9
View File
@@ -100,6 +100,10 @@ flowchart LR
pkg_credentials["credentials"]
svc_credentials["ctx.credentials<br/>Credential seam"]
pkg_credentials_local["credentials-local"]
pkg_deepseek_account["deepseek-account"]
svc_deepseekAccount["ctx.deepseekAccount<br/>DeepSeek account"]
pkg_deepseek_account_platform["deepseek-account-platform"]
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization<br/>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 单元原语。 |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 99ef3f4080f5e23ccfa9ebea0d4b89daf443bcfb
config-catalog.zh.md: 0e2cb6eb3f6936b7ad190333a83a701ecbbb133d
config-catalog.md: a1c73fab86356e8ee7786ba3c8db539c1201785a
config-catalog.zh.md: 0917e168a59127f8b9b9e0afd99def9e105cdca5
+48
View File
@@ -446,6 +446,22 @@ export interface Config {
Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
<a id="deepseek-aidsh-client-ui-settings-account"></a>
## `@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)
<a id="deepseek-aidsh-client-ui-settings-models"></a>
## `@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)
<a id="deepseek-aidsh-deepseek-account-platform"></a>
## `@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<string, string>
/** 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)
<a id="deepseek-aidsh-experimental-agent-team"></a>
## `@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))
+48
View File
@@ -448,6 +448,22 @@ export interface Config {
来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
<a id="deepseek-aidsh-client-ui-settings-account"></a>
## `@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)
<a id="deepseek-aidsh-client-ui-settings-models"></a>
## `@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)
<a id="deepseek-aidsh-deepseek-account-platform"></a>
## `@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<string, string>
/** 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)
<a id="deepseek-aidsh-experimental-agent-team"></a>
## `@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))
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write 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
+1 -1
View File
@@ -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) |
+1 -1
View File
@@ -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) |
+6
View File
@@ -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
+39
View File
@@ -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.
+39
View File
@@ -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 来源区分也已实现;上方保留的是最终联调和发布验收,不是重复开发需求。
## 验收记录
每项完成时在该条目后记录日期、实现位置以及测试命令/结果或产品明确决定。地址策略条目已附完成依据;其他待办仍未完成。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/credentials.md
credentials.md: 5ae53a19140f423e1c52334c40f25fca7d3ed754
credentials.zh.md: b6d5d8eb3960edd68a96a32a41c591e61f2c8b5e
credentials.md: aaafc3577d8748ff0eeffaec6ebed99db24c9e14
credentials.zh.md: 8f9b77cd30573f42645d14155e450bf765b85fa6
+76
View File
@@ -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.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -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)
<a id="ctxdeepseekaccount--deepseekaccount-abstract-seam"></a>
### `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<AccountView>
/**
* 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<AccountDetails['profile'] | null>
/**
* 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<AccountDetails['balance'] | null>
/**
* 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<AccountView>
/**
* 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<AccountView>
/**
* 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<AccountView>
/**
* 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<AccountView>
/**
* 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<string | undefined>
/**
* 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<PlatformSession | null>
```
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
<a id="authorization-events"></a>
### `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)
<!-- END GENERATED cordis-surface -->
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).
+76
View File
@@ -53,6 +53,10 @@ interface CredentialInfo {
`credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
## 内嵌 Platform 凭证
PlatformSession 是 getPlatformSession 返回的仅限 Host 快照:origin 指定所配置的 Platform 签发来源,token 包含其已存账号凭证。退登账号返回 null;签发来源不匹配时失败。原生使用方负责在凭证变化时使文档失效。账号控制器 RPC、AccountView 和 AccountDetails 均不包含此快照。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -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)
<a id="ctxdeepseekaccount--deepseekaccount-abstract-seam"></a>
### `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<AccountView>
/**
* 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<AccountDetails['profile'] | null>
/**
* 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<AccountDetails['balance'] | null>
/**
* 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<AccountView>
/**
* 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<AccountView>
/**
* 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<AccountView>
/**
* 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<AccountView>
/**
* 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<string | undefined>
/**
* 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<PlatformSession | null>
```
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
<a id="authorization-events"></a>
### `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)
<!-- END GENERATED cordis-surface -->
账号服务定义提供 getState、getDetails、startSignIn、cancelSignIn、signOut、watch 及仅限 Host 的 resolveToken。平台提供者使用 AuthorizationFlow 和私有 GrantRecord 实现这些操作。AccountView 区分本地存在与服务器验证;尝试 ID 将取消绑定到单次本地流程。参见[账号包](../../packages/credentials/deepseek-account/README.zh.md)。
@@ -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
+53
View File
@@ -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)
<a id="use-this-package"></a>
## 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.
<a id="understand-the-implementation"></a>
## Understand the implementation
The controller forwards operations to the account service and maintains no independent account state; no invariant companion is published.
<a id="further-exploration"></a>
## Further Exploration
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
<a id="model-experience"></a>
## 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 id="known-limitations-and-deferred-work"></a>
- 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.
<a id="dev-note"></a>
### Dev Note
The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership.
@@ -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)
<a id="use-this-package"></a>
## 使用此包
account 命名空间提供 getState、getProfile / getBalance、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。
<a id="understand-the-implementation"></a>
## 理解实现
控制器向账号服务转发操作,不维护独立的账号状态,因此不发布 invariant。
<a id="further-exploration"></a>
## 深入探索
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
<a id="model-experience"></a>
## 模型体验
无,因为账号凭证只影响 HTTP 认证,不进入模型提示、Session 日志或工具结果。
#### KV Cache effect
不改变模型请求前缀。
## 已知限制与后续工作
<a id="known-limitations-and-deferred-work"></a>
- UI 断线后可通过 watch 恢复状态,但不能在 Host 退出后恢复登录尝试。凭证和请求 token 解析不对 Remote 暴露。
<a id="dev-note"></a>
### 开发备注
[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。
@@ -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:^"
}
}
@@ -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<AccountView> { 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<AccountDetails['profile'] | null> { 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<AccountDetails['balance'] | null> { 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<AccountView> { 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<AccountView> { 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<AccountView> { 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<AccountView> { return this.ctx.deepseekAccount.watch(signal) }
}
export default AccountController
@@ -0,0 +1,2 @@
/** Account Remote values contain no credential payloads. */
export type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
@@ -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"
}
]
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
README.md: 1460f5a27062ffcac09c68358dc7b186241a1baa
README.zh.md: 79ed379faad8498cfdf249a0accb437f6d5f98e3
README.md: 27d055d96d0d863951b780e6392485e2a2bca06d
README.zh.md: 6c7bdbc858a07de3a66a3d8e8d3445c977476f74
+2
View File
@@ -92,3 +92,5 @@ None.
</details>
**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.
+2
View File
@@ -92,3 +92,5 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的
</details>
**运行时不变式:** 不发布伴生入口。Host 调用会重新读取权威的 Cordis 与 Typert 状态,Client 方法、描述符与 `$on` 订阅的变更则统一归属同一个 effect。
./stream-protocol 导出为原生桌面调用方提供共享的 Remote 流帧格式和解析器。它们与浏览器客户端使用同一个经过认证的 WebSocket 端点。
+5 -1
View File
@@ -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": {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
README.md: 4e365165f0b1c406e3d5693c772dbcc21f125589
README.zh.md: 913e08ca7e35e5a6d2756347bf24a840660f85e0
README.md: 74e1595bfbe852605e5d5a7c6930c113e63a92bd
README.zh.md: b8974b0987d5be4ebee2a2886d3cbce321638d4d
+2
View File
@@ -86,3 +86,5 @@ None.
</details>
**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.
+2
View File
@@ -86,3 +86,5 @@ Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个
</details>
**运行时不变式:** 不发布伴生入口。被观察的关系由 Typert、agent 注册表和会话注册表负责。
客户端组装包含 account 命名空间,用于登录操作和可重连的账号快照。
+2 -1
View File
@@ -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:^"
}
}
+3 -1
View File
@@ -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<void>> {
const disposers: Array<() => Promise<void>> = []
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,
]) {
@@ -94,6 +94,9 @@
},
{
"path": "../../document/office-to-pdf"
},
{
"path": "../account-controller"
}
]
}
+3
View File
@@ -79,6 +79,9 @@
},
{
"path": "../../host/plugin-inventory"
},
{
"path": "../account-controller"
}
]
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/base/README.md
README.md: f9cc3e4d03a0d71d1f598d97633c0f89f7700b42
README.zh.md: 2e98bb710502b62ad87f04ae768833487b4b5787
README.md: 98f4b148bf0f0304c412b6964be53c4b2e76287c
README.zh.md: 2ed43b780bdee0056e17dcad8d720b003f70b3f1
+2
View File
@@ -147,3 +147,5 @@ These limits tell you when the core needs extra care or where an override must g
None.
</details>
The base composition mounts authorization and the platform account provider alongside credentials. The provider opens a callback listener only during an explicit login attempt.
+2
View File
@@ -147,3 +147,5 @@ patch 在自身上按平台门控两个 shell 栈:`bash-sandbox` 与 `tool-bas
无。
</details>
基础组合在凭证服务旁挂载授权服务和平台账号提供者。提供者只在显式登录尝试期间打开回调监听器。
+6
View File
@@ -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'
+3 -1
View File
@@ -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:^"
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
README.md: d74489dd294bbf0f8c09df02ffc42984517267cd
README.zh.md: 9d80190bcf332e1271f1f00bceee34c72416cce4
README.md: eae16e6921cff5c613366e4355fc12dddca19421
README.zh.md: 342500f9e833c1a1e0863ef541fa4cdc45ace339
+2
View File
@@ -161,3 +161,5 @@ These limits tell you what to expect in unusual setups — a source checkout, SS
None.
</details>
The Web composition includes the account Remote controller and Account settings section.
+2
View File
@@ -161,3 +161,5 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
无。
</details>
Web 组合包含账号 Remote 控制器和账号设置页面。
+6
View File
@@ -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'
+3 -1
View File
@@ -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:^",
@@ -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
+2
View File
@@ -92,6 +92,8 @@ The atoms cannot read the application locale, so every piece of user-facing copy
<a id="understand-the-implementation"></a>
## Understand the implementation
Menu.listClassName styles the menu card independently of the anchor wrapper, including in portal mode.
<details>
<summary>Implementation internals — click to expand</summary>
@@ -92,6 +92,8 @@ kind: "package-library"
<a id="understand-the-implementation"></a>
## 理解实现
Menu.listClassName 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。
<details>
<summary>实现细节——点击展开</summary>
+4 -2
View File
@@ -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<HTMLSpanElement>(null)
@@ -404,7 +406,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
const list = open && (
<div
ref={listRef}
className={clsx(css.list, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
className={clsx(css.list, listClassName, dense && css.denseList, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
// React portals bubble synthetic events through the REACT tree: without
@@ -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/client/ui-settings-account/README.md
README.md: 0851b27b3b6ba923e2e44ff38c04bf338b62cbd1
README.zh.md: e3405a488874598290ea0366d81d5234bec87d24
@@ -0,0 +1,61 @@
---
description: "The Account settings section displays DeepSeek login state and offers browser sign-in and cancellation; the sidebar account menu provides Platform sign-out."
kind: "package-reference"
---
# @deepseek-ai/dsh-client-ui-settings-account
English | [中文](README.zh.md)
## Summary
The Account settings section displays DeepSeek login state and offers browser sign-in and cancellation; the sidebar account menu provides Platform sign-out.
## Table of Contents
- [Use this package](#use-this-package)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
<a id="use-this-package"></a>
## 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.
<a id="understand-the-implementation"></a>
## 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.
<a id="further-exploration"></a>
## 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.
<a id="model-experience"></a>
## 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 id="known-limitations-and-deferred-work"></a>
- 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.
<a id="dev-note"></a>
### Dev Note
The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership.
@@ -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)
<a id="use-this-package"></a>
## 使用此包
该页面通过 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 实现,保持未填写。
<a id="understand-the-implementation"></a>
## 理解实现
插件持有一条 Host 快照流,通过框架 hook 供 settings.section 和 settings.launcher 共享。入口菜单可打开设置,并仅在已存储账号凭证时提供退出登录。Platform 请求失败时保留菜单,以供重试。插件不维护独立凭证状态,因此不发布 invariant。
<a id="further-exploration"></a>
## 深入探索
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
未登录的设置页居中显示登录提示,不展示账号卡片、余额或开放平台链接。账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
<a id="model-experience"></a>
## 模型体验
无,因为账号凭证只影响 HTTP 认证,不进入模型提示、Session 日志或工具结果。
#### KV Cache effect
不改变模型请求前缀。
## 已知限制与后续工作
<a id="known-limitations-and-deferred-work"></a>
- 资料和充值钱包余额通过 Host getProfile / getBalance 复用已有 Platform Web 接口。页面在打开、登录及重连后刷新,原样显示服务端脱敏后的联系方式,并分别处理查询失败,不将失败显示为零余额。用量和充值使用 Host 按 platformOrigin 派生的链接,并沿用浏览器自身登录态,不携带 DSH token。
<a id="dev-note"></a>
### 开发备注
[桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。
@@ -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:^"
}
}
@@ -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); }
@@ -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<AccountSectionInjected>
/** 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 <div className={css.root}>
<Menu open={open} side="top" portal autoFocus className={css.anchor} listClassName={css.menu}
anchor={<button type="button" className={css.trigger} aria-label={t('menu')}
aria-haspopup="menu" aria-expanded={open} onClick={() => { setOpen(value => !value) }}>
<span className={css.avatar}><IconUserOutline16 size={16} /></span>
{wide && <span className={css.label}>{signedIn ? label : t('signedOut')}</span>}
</button>}
items={[
{ id: 'settings', label: t('settings'), icon: <IconSettingsOutline16 size={16} /> },
{ id: 'contact', label: t('contactUs'), icon: <IconPaperPlaneOutline14 size={16} /> },
...(signedIn ? [{ id: 'signout', label: t('signOut'), icon: <LogoutIcon />, disabled: busy }]
: [{ id: 'signin', label: t('signIn'), icon: <IconUserOutline16 size={16} /> }]),
]}
onClose={() => { setOpen(false) }}
onSelect={(id) => {
if (id === 'settings') { setOpen(false); openSettings() }
else if (id === 'contact') { setOpen(false); contactUs() }
else if (id === 'signin') { setOpen(false); void start().catch(() => { setFailed(true) }) }
else if (id === 'signout') void logout()
}} />
{account.loginVisible && !account.onboarding && <SignInDialog account={account} start={start} cancel={cancel} t={t}
close={() => { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />}
{failed && <span className={css.error} role="alert">{t('failed')}</span>}
</div>
}
@@ -0,0 +1,17 @@
/** Account choice inside the model credential onboarding step. */
import { useEffect } from 'react'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-settings-models/client'
import type { AccountSectionInjected } from './AccountSection.tsx'
import { SignInDialog } from './SignInDialog.tsx'
/** @param props - onboarding completion, account state and actions. @returns account dialog while signed out. */
export function AccountOnboarding({ complete, useApiKey, useAccount, setOnboarding, showLogin, start, cancel, t }:
PropsRuntime<'settings.models.sign-in'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>) {
const account = useAccount(value => value)
useEffect(() => { setOnboarding(true); return () => { setOnboarding(false) } }, [setOnboarding])
useEffect(() => { if (account.view?.status === 'credential-stored') complete() }, [account.view?.status, complete])
if (!account.view || account.view.status === 'credential-stored') return null
return <SignInDialog account={account} start={start} cancel={cancel} t={t}
close={() => { showLogin(false); complete() }} useApiKey={() => { showLogin(false); useApiKey() }} />
}
@@ -0,0 +1,181 @@
.section {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 0;
font-size: 13px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.card,
.balanceCard {
padding: 12px 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-module-platform);
}
.card,
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.identity {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
min-width: 0;
}
.avatar {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 16px;
color: var(--dsw-alias-label-tertiary);
background: var(--dsw-alias-bg-skeleton);
}
.identityCopy {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
font-size: 14px;
font-weight: 500;
}
.status,
.secondary,
.unavailable {
color: var(--dsw-alias-label-tertiary);
}
.balanceCard {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
min-height: 40px;
padding: 6px 0;
box-sizing: border-box;
}
.divider {
border-top: 1px solid var(--dsw-alias-border-l2);
}
.links,
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.button.button,
.linkButton {
flex: none;
display: inline-flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
height: 32px;
min-width: 58px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
font-size: 13px;
line-height: 20px;
text-decoration: none;
white-space: nowrap;
}
.linkButton {
color: var(--dsw-alias-label-primary);
}
.linkButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.linkButton:focus-visible {
outline: 2px solid var(--dsw-alias-button-primary-fill);
outline-offset: 2px;
}
.primary {
color: var(--dsw-alias-label-primary-foreground);
background: var(--dsw-alias-button-primary-fill);
font-weight: 500;
border-color: transparent;
}
.primary:hover {
background: var(--dsw-alias-button-primary-hover);
}
.signedOut {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100% + 16px);
margin-bottom: -16px;
padding-bottom: 8px;
box-sizing: border-box;
color: var(--dsw-alias-label-primary);
}
.signedOutContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-height: 60px;
padding: 12px 0;
gap: 20px;
}
.signedOutCopy {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
text-align: center;
}
.signedOutTitle {
font-size: 14px;
line-height: 22px;
}
.signedOutDescription {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.signInButton.signInButton {
height: 36px;
min-width: 72px;
padding: 0 14px;
border-radius: 12px;
font-size: 14px;
line-height: 22px;
font-weight: 500;
}
@@ -0,0 +1,134 @@
/** Account settings renders safe Host state and explicit login actions. */
import { useEffect, useState } from 'react'
import { Button, IconUserOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { PropsRuntime, PropsLocale, InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx'
import { formatBalance } from './formatBalance.ts'
import css from './AccountSection.module.css'
/** Safe account snapshot shared by the settings page and launcher. */
export interface AccountSnapshot {
/** Latest Host state, absent until the stream responds. */
view: AccountView | undefined
/** Sanitized profile and balance query outcomes, absent while loading. */
details: Partial<AccountDetails> | undefined
/** Whether the state stream failed. */
failed: boolean
/** Explicitly opened account dialog outside onboarding. */
loginVisible?: boolean
/** Latest explicit start request failed before a Host state was available. */
loginFailed?: boolean
/** Mounted onboarding owns the dialog while active. */
onboarding?: boolean
}
/** Host operations injected into the Cordis-free account component. */
export interface AccountSectionInjected {
/** Desktop-only commands; absent in ordinary browsers. */
platform?: PlatformBridge
/** Host-owned account stream observed through framework hooks. */
hooks: { account: HostObservable<AccountSnapshot> }
/** @returns after account details are refreshed; concurrent refreshes share a request. */
refresh: () => Promise<void>
/** Open the external support questionnaire with the current account and environment. */
contactUs: () => void
/** Open or dismiss the login dialog. */
showLogin: (visible: boolean) => void
/** Claim dialog ownership for the onboarding step. */
setOnboarding: (active: boolean) => void
/** @returns after the login attempt is created. */
start: () => Promise<void>
/** @param id - attempt to cancel. @returns after cancellation or an already-admitted commit. */
cancel: (id: SignInAttemptId) => Promise<void>
/** @returns after local account credentials are removed. */
signOut: () => Promise<void>
}
/** Composed account section props. */
export type AccountSectionProps =
PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>
/** @param props - localized actions and account subscription. @returns account settings UI. */
export function AccountSection({ t, useAccount, start, cancel, refresh, platform }: AccountSectionProps) {
const { view: state, details, failed: streamFailed } = useAccount(value => value)
const [platformPage, setPlatformPage] = useState<'usage' | 'top-up'>()
const [failed, setFailed] = useState(false)
const [busy, setBusy] = useState(false)
useEffect(() => { void refresh() }, [refresh])
const profile = details?.profile?.status === 'ready' ? details.profile.value : undefined
const wallets = details?.balance?.status === 'ready' ? details.balance.value : undefined
const attempt = state?.attempt
const active = attempt !== null && attempt !== undefined
&& ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(attempt.phase)
const signedIn = state?.status === 'credential-stored'
useEffect(() => { if (!signedIn) setPlatformPage(undefined) }, [signedIn])
const run = async (action: () => Promise<void>) => {
setBusy(true)
setFailed(false)
try { await action() } catch { setFailed(true) } finally { setBusy(false) }
}
const status = failed || streamFailed || attempt?.phase === 'failed' ? t('failed')
: attempt?.phase === 'expired' ? t('expired')
: active ? t(attempt.phase === 'initializing' ? 'initializing' : attempt.phase === 'waiting-browser' ? 'waiting' : 'completing')
: signedIn ? profile?.contact ?? t(details?.profile === undefined ? 'loading' : 'profileUnavailable') : t('signInDescription')
if (!signedIn && !active) return (
<section className={css.signedOut} aria-label={t('nav')}>
<div className={css.signedOutContent}>
<div className={css.signedOutCopy}>
<span className={css.signedOutTitle}>{t('settingsSignedOutTitle')}</span>
<span className={css.signedOutDescription} role="status">
{failed || streamFailed ? t('failed') : t('settingsSignedOutDescription')}
</span>
</div>
<Button variant="primary" className={css.signInButton} disabled={busy || state === undefined}
onClick={() => { void run(start) }}>{t('signIn')}</Button>
</div>
</section>
)
return (
<section className={css.section} aria-label={t('nav')}>
{platformPage !== undefined && platform !== undefined && signedIn && <PlatformOverlay
bridge={platform} page={platformPage} backLabel={t('backToHarness')}
loadingLabel={t('loading')} failureLabel={t('failed')} onClose={() => { setPlatformPage(undefined) }} />}
<div className={css.card}>
<div className={css.identity}>
<span className={css.avatar}><IconUserOutline16 size={16} /></span>
<div className={css.identityCopy}>
<span className={css.name}>{signedIn ? profile?.name ?? t('signedIn') : t('signedOut')}</span>
<span className={css.status} role="status">{status}</span>
</div>
</div>
</div>
{active && <div className={css.actions}>
{attempt.authorizeUrl && <a className={css.linkButton} href={attempt.authorizeUrl} target="_blank" rel="noreferrer">
{t('open')}
</a>}
<Button variant="outline" className={css.button} disabled={busy || attempt.phase === 'committing'}
onClick={() => { void run(() => cancel(attempt.id)) }}>{t('cancel')}</Button>
</div>}
<div className={css.balanceCard}>
<div className={css.row}>
<span>{t('balance')}</span>
{signedIn && wallets !== undefined && wallets.length > 0
? <span className={css.links}>{wallets.map(wallet => <span key={wallet.currency}>
{formatBalance(wallet.balance, wallet.currency === 'CNY' ? '¥' : '$')}
</span>)}</span>
: <span className={css.unavailable}>{t(!signedIn ? 'balanceSignedOut'
: details?.balance === undefined ? 'loading' : 'balanceUnavailable')}</span>}
</div>
<div className={css.divider} />
<div className={css.row}>
<span className={css.secondary}>{t('more')}</span>
<div className={css.links}>
<a className={css.linkButton} href={state?.links.usageUrl} aria-disabled={state === undefined} target="_blank" rel="noreferrer"
onClick={(event) => { if (platform !== undefined && signedIn) { event.preventDefault(); setPlatformPage('usage') } }}>{t('usage')}</a>
<a className={`${css.linkButton} ${css.primary}`} href={state?.links.topUpUrl} aria-disabled={state === undefined}
target="_blank" rel="noreferrer"
onClick={(event) => { if (platform !== undefined && signedIn) { event.preventDefault(); setPlatformPage('top-up') } }}>{t('topUp')}</a>
</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,10 @@
/** Figma IcDsLogOutOutline16 glyph, stored inline for theme-aware rendering. */
/** @returns the account menu logout glyph. */
export function LogoutIcon() {
return <span style={{ width: 16, height: 16, position: 'relative' }} aria-hidden="true"><svg style={{ position: 'absolute', left: 1.168, top: 1.214 }} width="13.664" height="13.571" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.9961 1.40039C3.37842 1.40039 2.96028 1.40039 2.63867 1.42871C2.32631 1.45627 2.17171 1.50666 2.06641 1.56543C1.85606 1.68288 1.68189 1.85605 1.56446 2.06641C1.50569 2.17173 1.45625 2.32627 1.42871 2.63867C1.40036 2.9603 1.39942 3.37827 1.39942 3.9961L1.39942 9.5752C1.39942 10.193 1.40036 10.611 1.42871 10.9326C1.45625 11.245 1.50569 11.3996 1.56445 11.5049C1.68188 11.7153 1.85605 11.8884 2.06641 12.0059C2.17171 12.0646 2.32631 12.115 2.63867 12.1426C2.96028 12.1709 3.37842 12.1709 3.9961 12.1709V13.5713C3.4033 13.5713 2.91297 13.5721 2.51563 13.5371C2.10906 13.5013 1.73277 13.4233 1.38379 13.2285C0.946838 12.9845 0.585697 12.6235 0.341799 12.1865C0.14725 11.8377 0.0700118 11.4621 0.0341815 11.0557C-0.000847399 10.6583 1.58129e-06 10.1681 1.85029e-06 9.5752L2.09416e-06 3.9961C1.83027e-06 3.40325 -0.000846367 2.91297 0.0341818 2.51563C0.0700164 2.10924 0.147239 1.73362 0.341799 1.38477C0.585703 0.947758 0.946825 0.586744 1.38379 0.342775C1.73277 0.148001 2.10906 0.0700381 2.51563 0.0341813C2.91297 -0.000823893 3.40331 1.37805e-06 3.9961 1.71712e-06L3.9961 1.40039Z" fill="currentColor"/>
<path d="M13.6426 6.51953C13.6705 6.6957 13.6705 6.8756 13.6426 7.05176C13.5902 7.38188 13.4302 7.64112 13.2539 7.86035C13.0857 8.06937 12.8546 8.29971 12.6006 8.55371L9.3086 11.8447L8.31836 10.8545L11.6104 7.56348C11.637 7.53681 11.6628 7.51104 11.6875 7.48633H3.74317V6.08594H11.6885C11.6635 6.06088 11.6374 6.03487 11.6104 6.00781L8.31836 2.7168L9.3086 1.72656L12.6006 5.01758C12.8547 5.27169 13.0857 5.50185 13.2539 5.71094C13.4303 5.93018 13.5902 6.18939 13.6426 6.51953Z" fill="currentColor"/>
</svg>
</span>
}
@@ -0,0 +1,62 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1001;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.header {
box-sizing: border-box;
height: 48px;
flex: none;
padding: 6px 16px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
app-region: drag;
}
.controls {
display: flex;
align-items: center;
gap: 24px;
padding-top: 8px;
}
.trafficLights {
display: none;
width: 60.667px;
height: 14px;
flex: none;
}
:global(html[data-platform="darwin"]) .trafficLights {
display: block;
}
.back {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
box-sizing: border-box;
height: 28px;
padding: 9px 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 14px;
line-height: 22px;
white-space: nowrap;
cursor: pointer;
app-region: no-drag;
}
.viewport {
position: relative;
flex: 1;
min-height: 0;
color: var(--dsw-alias-label-secondary);
}
@@ -0,0 +1,70 @@
/** Desktop Platform viewport; the native child owns remote content and credentials. */
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { IconChevronLeftOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PlatformOverlay.module.css'
/** Non-secret commands supplied by the desktop application preload. */
export interface PlatformBridge {
/** @param page - supported Platform destination. @param bounds - viewport rectangle. @returns after document load. */
open(this: void, page: 'usage' | 'top-up', bounds: { x: number; y: number; width: number; height: number }): Promise<void>
/** @param bounds - viewport rectangle. @returns after native bounds update. */
setBounds(this: void, bounds: { x: number; y: number; width: number; height: number }): Promise<void>
/** @returns after the native document is destroyed. */
close(this: void): Promise<void>
}
/** @param props - native commands, localized copy, and return action. @returns full-window Platform container. */
export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failureLabel, onClose }: {
bridge: PlatformBridge
page: 'usage' | 'top-up'
backLabel: string
loadingLabel: string
failureLabel: string
onClose: () => void
}) {
const layer = useRef<HTMLDivElement>(null)
const back = useRef<HTMLButtonElement>(null)
const viewport = useRef<HTMLDivElement>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
const element = viewport.current
if (element === null) return
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const background = Array.from(document.body.children).filter((element): element is HTMLElement =>
element instanceof HTMLElement && element !== layer.current).map(element => ({ element, inert: element.inert }))
for (const { element } of background) element.inert = true
back.current?.focus()
let closed = false
const bounds = () => {
const rect = element.getBoundingClientRect()
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
}
const fail = () => { if (!closed) setFailed(true) }
void bridge.open(page, bounds()).catch(fail)
const observer = new ResizeObserver(() => { void bridge.setBounds(bounds()).catch(fail) })
observer.observe(element)
return () => {
closed = true
observer.disconnect()
for (const { element, inert } of background) element.inert = inert
if (previousFocus?.isConnected) previousFocus.focus()
void bridge.close().catch(() => {
// Window teardown may remove the desktop IPC receiver before React unmounts.
})
}
}, [bridge, page])
return createPortal(<div ref={layer} className={css.overlay} role="dialog" aria-modal="true" aria-label={backLabel}>
<header className={css.header}>
<div className={css.controls}>
<span className={css.trafficLights} aria-hidden="true" />
<button ref={back} className={css.back} onClick={onClose}>
<IconChevronLeftOutline14 size={16} />{backLabel}
</button>
</div>
</header>
<div ref={viewport} className={css.viewport}>
<span role="status">{failed ? failureLabel : loadingLabel}</span>
</div>
</div>, document.body)
}
@@ -0,0 +1,17 @@
.dialog { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-inverted); }
.content { display: flex; flex-direction: column; width: 100%; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 22px 24px 12px; }
.title { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); }
.close { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 6px; border: 0; border-radius: 28px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; }
.close:hover { background: var(--dsw-alias-interactive-bg-hover); }
.actions { box-sizing: border-box; display: flex; justify-content: flex-end; align-items: center; width: 100%; padding: 0 24px; gap: 8px; }
.primaryButton, .secondaryButton { box-sizing: border-box; min-width: 72px; height: 36px; border-radius: 12px; font-size: 14px; line-height: 22px; }
.primaryButton { padding: 0 16px; font-weight: 500; }
.secondaryButton { padding: 0 14px; border: 1px solid var(--dsw-alias-border-l2); }
.description { margin: 0; padding: 0 24px; font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); }
.browserDescription { text-decoration: underline; }
.link { border: 0; padding: 0; background: transparent; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; }
.spinner { flex: none; animation: spin 1s linear infinite; }
.primaryButton:has(.spinner) { opacity: 1; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
@@ -0,0 +1,69 @@
/** Account authorization dialog shared by onboarding and explicit login. */
import { useEffect, useState } from 'react'
import { Button, IconCloseOutline16, IconLoadingOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AccountSnapshot } from './AccountSection.tsx'
import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { AccountKey } from './locales.ts'
import css from './SignInDialog.module.css'
/** @param props - safe account state, localized copy, and user actions. @returns login dialog. */
export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
account: AccountSnapshot
start: () => Promise<void>
cancel: (id: SignInAttemptId) => Promise<void>
close: () => void
useApiKey: () => void
t: (key: AccountKey) => string
}) {
const [busy, setBusy] = useState(false)
const [failed, setFailed] = useState(false)
const [copied, setCopied] = useState(false)
const attempt = account.view?.attempt
useEffect(() => { setCopied(false) }, [attempt?.id, attempt?.authorizeUrl])
const phase = attempt?.phase
const active = busy || phase === 'initializing' || phase === 'waiting-browser' || phase === 'exchanging' || phase === 'committing'
const expired = phase === 'expired'
const error = failed || account.loginFailed || account.failed || phase === 'failed'
const committing = phase === 'committing'
useEffect(() => { if (account.view?.status === 'credential-stored') close() }, [account.view?.status, close])
const run = async (action: () => Promise<void>) => {
setBusy(true)
setFailed(false)
try { await action() } catch { setFailed(true) } finally { setBusy(false) }
}
const dismiss = () => {
if (committing || busy) return
if (active && attempt) void run(async () => { await cancel(attempt.id); close() })
else close()
}
const title = active ? t('browserTitle') : expired ? t('timeoutTitle') : error ? t('failureTitle') : t('loginTitle')
return <Modal open headless title={title} onClose={dismiss} className={css.dialog as string}>
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label={t('close')} onClick={dismiss}>
<IconCloseOutline16 size={14} />
</button>
</div>
{active ? <p className={`${css.description} ${css.browserDescription}`}>
{t('browserPrompt')}<button type="button" className={css.link} disabled={!attempt?.authorizeUrl}
onClick={() => {
if (attempt?.authorizeUrl) void navigator.clipboard.writeText(attempt.authorizeUrl)
.then(() => { setCopied(true) }, () => { setFailed(true) })
}}>
{t(copied ? 'copiedLink' : 'copyLink')}
</button>{t('browserDescription')}
</p> : <p className={css.description}>
{expired ? t('timeoutDescription') : error ? t('failed') : t('loginDescription')}
</p>}
</div>
<div className={css.actions}>
<Button variant="outline" className={css.secondaryButton} disabled={committing || busy}
onClick={active ? dismiss : useApiKey}>{t(active ? 'cancel' : 'addApiKey')}</Button>
<Button variant="primary" className={css.primaryButton} disabled={active || account.view === undefined}
aria-label={active ? t('waiting') : undefined} onClick={() => { void run(start) }}>
{active ? <IconLoadingOutline16 className={css.spinner} /> : t(expired || error ? 'retry' : 'signIn')}
</Button>
</div>
</Modal>
}
@@ -0,0 +1,71 @@
/** Browser tab ownership for one UI-initiated authorization attempt. */
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Keeps a preopened browser tab separate from Host authorization state. */
export class BrowserLogin {
private popup: Window | null = null
private navigated = false
private attempt: SignInAttemptId | undefined
/**
* Preopen a tab and bind it to the attempt returned by the Host.
* @param request - authenticated Host start request.
* @param latest - most recent streamed state, including frames received before the response.
* @returns after this UI is following the new attempt.
*/
async start(request: () => Promise<AccountView>, latest: () => AccountView | undefined): Promise<void> {
this.begin()
try {
this.follow(await request())
this.update(latest())
} catch (error) {
this.dispose()
throw error
}
}
/** Preopen a tab during the user's click; blocked popups need the copy-link route. */
begin(): void {
this.dispose()
this.popup = window.open('about:blank', '_blank')
if (this.popup) this.popup.opener = null
}
/**
* Bind the preopened tab to its Host attempt.
* @param view - initial RPC response identifying this UI's attempt.
*/
follow(view: AccountView): void {
this.attempt = view.attempt?.id
this.update(view)
}
/**
* Navigate or close the pending tab when its attempt changes.
* @param view - latest Host state.
*/
update(view: AccountView | undefined): void {
const attempt = view?.attempt
if (attempt?.id !== this.attempt) return
if (attempt && ['failed', 'expired', 'cancelled'].includes(attempt.phase)) {
this.dispose()
return
}
if (attempt?.phase === 'succeeded') {
this.popup = null
this.attempt = undefined
return
}
if (!attempt?.authorizeUrl || this.navigated) return
this.navigated = true
if (this.popup && !this.popup.closed) this.popup.location.replace(attempt.authorizeUrl)
}
/** Close the owned authorization tab and stop following its attempt. */
dispose(): void {
this.popup?.close()
this.popup = null
this.attempt = undefined
this.navigated = false
}
}
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more