Merge pull request #4903 from deepseek-harness/fix/platform-login-theme

fix(desktop): match Platform login theme
This commit is contained in:
lsdsjy authored and GitHub committed 2026-09-22 16:00:44 +08:00
commit e76a5eba21
27 files changed
+327 -65

No files matched your search

+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 711e65fb5feaea601800218801d138a0df9b3f97
README.zh.md: 183d195a9af485c9ffe5a3e5bf65e3a459582b93
README.md: 59f18ea269f303e267d5e9597d8748e2a8fed134
README.zh.md: eb1594e032fea2da38f778e6b2217bd52587af91
+1 -1
View File
@@ -405,7 +405,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
+1 -1
View File
@@ -405,7 +405,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
+16 -2
View File
@@ -127,6 +127,20 @@ function chromeFallbackFill(): string {
return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb'
}
/**
* Add the effective Desktop palette to a Platform authorization URL so the
* login page opens in the application's theme. `system` resolves through
* `nativeTheme.shouldUseDarkColors`, which follows the theme source the
* application preload publishes.
* @param authorizeUrl - validated Platform authorization URL.
* @returns the authorization URL carrying `theme=light` or `theme=dark`.
*/
function platformLoginUrl(authorizeUrl: string): string {
const url = new URL(authorizeUrl)
url.searchParams.set('theme', nativeTheme.shouldUseDarkColors ? 'dark' : 'light')
return url.href
}
function createWindow(preload: string, show = false, primary = false): BrowserWindow {
const window = new BrowserWindow({
width: 1280,
@@ -340,7 +354,7 @@ async function main(): Promise<void> {
const attempt = state.attempt
if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) {
openedAttempt = attempt.id
void shell.openExternal(attempt.authorizeUrl).catch(() => undefined)
void shell.openExternal(platformLoginUrl(attempt.authorizeUrl)).catch(() => undefined)
}
if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) {
returnedAttempt = attempt.id
@@ -904,7 +918,7 @@ async function main(): Promise<void> {
if (state?.attempt?.id !== id || state.attempt.phase !== 'waiting-browser' || state.attempt.authorizeUrl === undefined) {
throw new Error('desktop welcome: login link is unavailable')
}
await clipboard.writeText(state.attempt.authorizeUrl)
await clipboard.writeText(platformLoginUrl(state.attempt.authorizeUrl))
},
saveApiKey: async (apiKey) => {
if (backend.host === undefined || welcomeBackend === undefined) return { ok: false }
+9 -8
View File
@@ -1,4 +1,4 @@
/** Mirrors the Web UI's theme source into the macOS native theme so window vibrancy follows the app palette. */
/** Mirrors the Web UI's theme source into Electron's native theme so native chrome and Platform login pages follow the app palette. */
import { ipcRenderer } from 'electron'
import { DESKTOP_IPC } from './ipc.ts'
@@ -7,14 +7,15 @@ import { DESKTOP_IPC } from './ipc.ts'
const THEME_SOURCE_ATTRIBUTE = 'data-ds-theme-source'
/**
* On macOS, watches `html[data-ds-theme-source]` and forwards each value to
* the main process, which sets `nativeTheme.themeSource` — the sidebar
* vibrancy material then follows the app's theme preference instead of the
* OS appearance, while `system` keeps following the OS. Other platforms
* never send.
* Watches `html[data-ds-theme-source]` and forwards each value to the main
* process, which sets `nativeTheme.themeSource`. Native chrome and renderer
* `prefers-color-scheme` queries on every platform then follow the app's
* theme preference instead of the OS appearance while `system` keeps
* following the OS; the macOS sidebar vibrancy material is one such consumer.
* The main process reads the same value back as `shouldUseDarkColors` when a
* Platform login link needs the resolved palette.
*/
export function syncNativeTheme(): void {
if (process.platform !== 'darwin') return
let sent: string | undefined
const send = (): void => {
const value = document.documentElement.getAttribute(THEME_SOURCE_ATTRIBUTE)
@@ -28,6 +29,6 @@ export function syncNativeTheme(): void {
}
// The preload runs before the document root exists; the theme bootstrap
// script writes the attribute before DOMContentLoaded.
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe)
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe, { once: true })
else observe()
}
+19 -1
View File
@@ -165,7 +165,7 @@ const harness = await vi.hoisted(async () => {
get publishUpdate() { return publishUpdate! },
set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value },
dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
openExternal: vi.fn(),
openExternal: vi.fn(async () => {}),
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise<Response>) => void>(),
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
disableAllPlugins: vi.fn(async () => {
@@ -1634,3 +1634,21 @@ it.each(['failed', 'expired'] as const)('focuses DSH once when browser authoriza
harness.publishAccount(state)
expect(window.focus).toHaveBeenCalledTimes(1)
})
it.each([['light', false], ['dark', true]] as const)('opens Platform authorization in the effective %s palette', async (theme, shouldUseDarkColors) => {
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
harness.nativeTheme.shouldUseDarkColors = shouldUseDarkColors
const state: AccountView = {
status: 'signed-out', links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
attempt: { id: 'test-theme-attempt' as NonNullable<AccountView['attempt']>['id'], phase: 'waiting-browser',
authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=state-1' },
}
harness.publishAccount(state)
harness.publishAccount(state)
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
})
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { DESKTOP_IPC } from '../src/ipc.ts'
import { syncNativeTheme } from '../src/preload-theme.ts'
const send = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({ ipcRenderer: { send } }))
/** Observers created by the module under test; jsdom keeps one document per file. */
const observers = new Set<MutationObserver>()
const NativeMutationObserver = MutationObserver
/** Records every observer so teardown detaches it from the shared document. */
class TrackedMutationObserver extends NativeMutationObserver {
constructor(callback: MutationCallback) {
super(callback)
observers.add(this)
}
}
afterEach(() => {
for (const observer of observers) observer.disconnect()
observers.clear()
document.documentElement.removeAttribute('data-ds-theme-source')
vi.unstubAllGlobals()
vi.restoreAllMocks()
send.mockClear()
})
it.each(['darwin', 'win32'] as const)('mirrors the application theme source on %s', async (platform) => {
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
document.documentElement.setAttribute('data-ds-theme-source', 'dark')
syncNativeTheme()
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'dark')
document.documentElement.setAttribute('data-ds-theme-source', 'system')
await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.nativeThemeSet, 'system') })
expect(send).toHaveBeenCalledTimes(2)
})
it('defers observation until the document root exists', () => {
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
syncNativeTheme()
expect(send).not.toHaveBeenCalled()
document.documentElement.setAttribute('data-ds-theme-source', 'light')
window.dispatchEvent(new Event('DOMContentLoaded'))
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'light')
})
+48 -1
View File
@@ -6,26 +6,73 @@ import { describe, expect, it, vi } from 'vitest'
const preload = (name: string): string => fileURLToPath(new URL(`../lib/${name}.cjs`, import.meta.url))
/**
* Browser globals a sandboxed Electron preload sees. A preload runs while the
* document is still loading, so the theme preload defers its first read to
* `DOMContentLoaded` and observes later `html[data-ds-theme-source]` changes.
* @returns context globals plus the root attribute transitions the Web UI performs.
*/
function browserEnvironment() {
const attributes = new Map<string, string>()
const listeners = new Map<string, Array<() => void>>()
const observers: Array<() => void> = []
const document = {
readyState: 'loading',
documentElement: {
dataset: {} as Record<string, string>,
getAttribute: (name: string): string | null => attributes.get(name) ?? null,
setAttribute: (name: string, value: string): void => { attributes.set(name, value) },
},
}
const window = {
addEventListener: (type: string, listener: () => void): void => {
listeners.set(type, [...listeners.get(type) ?? [], listener])
},
}
class SandboxMutationObserver {
constructor(callback: () => void) { observers.push(callback) }
observe(): void {}
}
return {
globals: { document, window, MutationObserver: SandboxMutationObserver },
/** Writes the theme root attribute and settles the document, as the theme bootstrap does before DOMContentLoaded. */
loadTheme(value: string): void {
attributes.set('data-ds-theme-source', value)
for (const listener of listeners.get('DOMContentLoaded') ?? []) listener()
},
/** Applies a later theme preference change through the observer the preload registered. */
changeTheme(value: string): void {
attributes.set('data-ds-theme-source', value)
for (const observer of observers) observer()
},
}
}
describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => {
it.each(['preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => {
const exposed = new Map<string, Record<string, unknown>>()
const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' }))
const send = vi.fn()
const browser = browserEnvironment()
const electron = {
contextBridge: { exposeInMainWorld: (key: string, value: Record<string, unknown>) => { exposed.set(key, value) } },
ipcRenderer: { invoke, send, on: vi.fn(), off: vi.fn() },
}
runInNewContext(readFileSync(preload(name), 'utf8'), {
...browser.globals,
require: (id: string) => {
if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`)
return electron
},
process: { argv: ['electron', '--dsh-welcome-locale=en'] },
location: new URL('dsh-app://app/'),
document: { documentElement: { dataset: {} } },
exports: {},
})
if (name === 'preload-app') {
browser.loadTheme('dark')
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'dark')
browser.changeTheme('light')
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'light')
const bridge = exposed.get('__DSH_LOCALE__') as { read(): unknown; onChange(locale: string): void }
bridge.read()
expect(invoke).toHaveBeenCalledWith('dsh-desktop:locale-bootstrap')
+8 -3
View File
@@ -31,6 +31,7 @@ const state = vi.hoisted(() => ({
windowOptions: undefined as BrowserWindowConstructorOptions | undefined,
menu: vi.fn(),
operations: undefined as WelcomeOperations | undefined,
nativeTheme: { themeSource: 'system', shouldUseDarkColors: false },
}))
vi.mock('electron', () => ({
@@ -67,7 +68,7 @@ vi.mock('electron', () => ({
async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
},
net: { fetch: vi.fn() },
nativeTheme: { themeSource: 'system' },
nativeTheme: state.nativeTheme,
session: { defaultSession: {
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() },
} },
@@ -177,11 +178,15 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
attempt: { id: attemptId, phase: 'waiting-browser', authorizeUrl: 'https://example.test/login' } }
state.accountState.mockResolvedValue(account)
await state.operations!.copySignInLink(attemptId)
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login')
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login?theme=light')
state.nativeTheme.shouldUseDarkColors = true
await state.operations!.copySignInLink(attemptId)
expect(state.copy).toHaveBeenLastCalledWith('https://example.test/login?theme=dark')
state.nativeTheme.shouldUseDarkColors = false
await expect(state.operations!.copySignInLink('stale' as typeof attemptId)).rejects.toThrow('login link is unavailable')
state.accountState.mockResolvedValue({ ...account, attempt: { id: attemptId, phase: 'expired' } })
await expect(state.operations!.copySignInLink(attemptId)).rejects.toThrow('login link is unavailable')
expect(state.copy).toHaveBeenCalledOnce()
expect(state.copy).toHaveBeenCalledTimes(2)
await state.operations!.skip()
expect(state.loadWorkspace).not.toHaveBeenCalled()
expect(state.showWorkspace).toHaveBeenCalledOnce()
@@ -16,10 +16,12 @@ export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
/**
* Root attribute publishing the theme source (`light`, `dark`, or `system`)
* for host shells that mirror it into native window chrome (the Electron
* preload forwards it to `nativeTheme.themeSource` so macOS vibrancy follows
* the app theme). `system` only when the preference is `system`; a fixed
* preference (including registered theme ids) publishes its resolved scheme.
* for host shells that mirror it into the native theme (the Electron preload
* forwards it to `nativeTheme.themeSource`, so native chrome, renderer
* `prefers-color-scheme` queries, and Platform login links follow the app
* palette on every platform). `system` only when the preference is `system`;
* a fixed preference (including registered theme ids) publishes its resolved
* scheme.
*/
export const THEME_SOURCE_ATTRIBUTE = 'data-ds-theme-source'
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
README.md: f024f7ef6506c1c9317d80660aaa0c8221e6a51e
README.zh.md: 2868d46a11db60cfec57f1eee6b146316b989288
README.md: 27682d9fa145723019573d492bbc1627ed43d1be
README.zh.md: 0fd64077f980482a31774d91fa62d349b0a6616f
@@ -46,7 +46,7 @@ The Account section appears first in Settings only while signed in; it is hidden
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
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. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Sign-in failures render in that dialog; the launcher's inline failure line reports only a failed sign-out. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator.
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. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. The dialog's Copy sign-in link and the Settings Open link append the resolved Desktop palette as `theme=light` or `theme=dark` from the live theme snapshot, so both routes reach the Platform page in the current application theme without changing the theme preference Platform has saved. Sign-in failures render in that dialog; the launcher's inline failure line reports only a failed sign-out. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator.
A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports.
@@ -46,7 +46,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。登录失败在该弹窗内呈现;侧边栏入口的内联失败提示仅用于退出登录失败。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。弹窗的“复制登录链接”和设置页的“打开”链接会从当前主题快照读取 Desktop 生效配色,追加 `theme=light` 或 `theme=dark`,使两条路径都以当前应用主题打开 Platform 页面,且不改变 Platform 已保存的主题偏好。登录失败在该弹窗内呈现;侧边栏入口的内联失败提示仅用于退出登录失败。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。
账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。
@@ -30,7 +30,8 @@
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
}
@@ -52,6 +53,7 @@
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-deepseek-account": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/big.js": "^6.2.2",
@@ -16,9 +16,10 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'
* @returns account menu launcher.
*/
export function AccountMenu({
wide, openSettings, openOnboarding, useAccount, signOut, contactUs, showLogin, start, cancel, t,
wide, openSettings, openOnboarding, useAccount, useTheme, signOut, contactUs, showLogin, start, cancel, t,
}: AccountMenuProps) {
const account = useAccount(state => state)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
const signedIn = account.view?.status === 'credential-stored'
const profile = account.details?.profile
const label = profile === undefined ? null : profile.status === 'ready'
@@ -55,7 +56,8 @@ export function AccountMenu({
else if (id === 'signin') beginSignIn()
else void logout()
}} />
{account.loginVisible && !account.onboarding && <SignInDialog account={account} start={start} cancel={cancel} t={t}
{account.loginVisible && !account.onboarding && <SignInDialog account={account} colorScheme={colorScheme}
start={start} cancel={cancel} t={t}
close={() => { showLogin(false) }} useApiKey={() => { showLogin(false); openOnboarding('deepseek-official') }} />}
{logoutFailed && <span className={css.error} role="alert">{t('failed')}</span>}
</div>
@@ -6,12 +6,13 @@ 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 }:
export function AccountOnboarding({ complete, useApiKey, useAccount, useTheme, setOnboarding, showLogin, start, cancel, t }:
PropsRuntime<'settings.models.sign-in'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>) {
const account = useAccount(value => value)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
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}
return <SignInDialog account={account} colorScheme={colorScheme} start={start} cancel={cancel} t={t}
close={() => { showLogin(false); complete() }} useApiKey={() => { showLogin(false); useApiKey() }} />
}
@@ -4,9 +4,11 @@ import { useEffect, useState } from 'react'
import { Button, IconRightUpOutlineRegular } 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 type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx'
import { formatBalance } from './formatBalance.ts'
import { AccountAvatar } from './AccountAvatar.tsx'
import { authorizeUrlWithTheme } from './authorize-url.ts'
import css from './AccountSection.module.css'
/** Safe account snapshot shared by the settings page and launcher. */
@@ -30,8 +32,12 @@ export interface AccountSectionInjected {
/** Desktop-only commands; absent in ordinary browsers. */
platform?: PlatformBridge
/** Host-owned account stream observed through framework hooks. */
hooks: { account: HostObservable<AccountSnapshot> }
/** Account stream owned by the Host and theme snapshots published by the renderer, observed through framework hooks. */
hooks: {
account: HostObservable<AccountSnapshot>
/** Palette the Platform login pages follow. */
theme: HostObservable<ThemeSnapshot>
}
/** @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. */
@@ -51,8 +57,9 @@ export interface AccountSectionInjected {
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) {
export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh, platform }: AccountSectionProps) {
const { view: state, details, failed: streamFailed } = useAccount(value => value)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
const [platformPage, setPlatformPage] = useState<'usage' | 'top-up'>()
const [failed, setFailed] = useState(false)
const [busy, setBusy] = useState(false)
@@ -107,7 +114,8 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform
</a>}
</div>
{active && <div className={css.actions}>
{attempt.authorizeUrl && <a className={css.linkButton} href={attempt.authorizeUrl} target="_blank" rel="noreferrer">
{attempt.authorizeUrl && <a className={css.linkButton} href={authorizeUrlWithTheme(attempt.authorizeUrl, colorScheme)}
target="_blank" rel="noreferrer">
{t('open')}
</a>}
<Button variant="outline" className={css.button} disabled={busy || attempt.phase === 'committing'}
@@ -4,11 +4,14 @@ import { Button, IconCloseOutlineRegular, IconLoadingOutlineRegular, Modal } fro
import type { AccountSnapshot } from './AccountSection.tsx'
import type { SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { AccountKey } from './locales.ts'
import { authorizeUrlWithTheme } from './authorize-url.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 }: {
export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: {
account: AccountSnapshot
/** Resolved scheme of the active Desktop theme; the copied link carries it. */
colorScheme: 'light' | 'dark'
start: () => Promise<void>
cancel: (id: SignInAttemptId) => Promise<void>
close: () => void
@@ -49,7 +52,7 @@ export function SignInDialog({ account, start, cancel, close, useApiKey, t }: {
}
const copyLink = async (authorizeUrl: string) => {
try {
await navigator.clipboard.writeText(authorizeUrl)
await navigator.clipboard.writeText(authorizeUrlWithTheme(authorizeUrl, colorScheme))
setCopyResult({ messageKey: 'copiedLink' })
} catch { setCopyResult({ messageKey: 'copyFailed' }) }
}
@@ -0,0 +1,14 @@
/** Platform authorization links that carry the Desktop palette into the login page. */
/**
* Add the resolved Desktop palette to a Platform authorization link without
* dropping any parameter the Host already put on it.
* @param authorizeUrl - authorization URL of the current account attempt.
* @param colorScheme - resolved scheme of the active Desktop theme.
* @returns the authorization URL carrying `theme=light` or `theme=dark`.
*/
export function authorizeUrlWithTheme(authorizeUrl: string, colorScheme: 'light' | 'dark'): string {
const url = new URL(authorizeUrl)
url.searchParams.set('theme', colorScheme)
return url.href
}
@@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
import type { PlatformBridge } from './PlatformOverlay.tsx'
@@ -21,7 +22,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { 'settings.account': AccountKey }
}
/** Services required by account settings. */
export const inject = ['slots', 'locale', 'remote', 'remote.account']
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'theme']
/** Register account UI only in the Desktop renderer. @param ctx - client plugin context. */
export function apply(ctx: Context): void {
if (!('dshDesktop' in globalThis)) return
@@ -92,10 +93,16 @@ export function apply(ctx: Context): void {
},
showLogin(visible) { publish({ ...snapshot, loginVisible: visible }) },
setOnboarding(active) { publish({ ...snapshot, onboarding: active }) },
hooks: { account: {
getSnapshot: () => snapshot,
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
} },
hooks: {
account: {
getSnapshot: () => snapshot,
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } },
},
theme: {
getSnapshot: () => ctx.theme.getTheme(),
subscribe: listener => ctx.on('theme/change', listener),
},
},
async start() {
publish({ ...snapshot, loginVisible: true, loginFailed: false })
const transport = (globalThis as typeof globalThis & {
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { PlatformBridge } from '../src/client/PlatformOverlay.tsx'
import { AccountSection, type AccountSectionInjected, type AccountSnapshot } from '../src/client/AccountSection.tsx'
import type {} from '../src/client/index.ts'
@@ -10,21 +11,35 @@ import { en, zh, type AccountKey } from '../src/client/locales.ts'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>, platform?: PlatformBridge) {
const operations: AccountSectionInjected = {
/** One resolved theme snapshot per scheme; the slot's theme hook serves these in the application. */
const themeOf = (colorScheme: 'light' | 'dark'): ThemeSnapshot => ({
preference: colorScheme, fontSize: 14,
active: { id: colorScheme, colorScheme, tokens: {} }, themes: [], revision: 0,
})
function operationsOf(state: Omit<AccountView, 'links'>, details?: Partial<AccountDetails>, platform?: PlatformBridge): AccountSectionInjected {
return {
...platform === undefined ? {} : { platform },
hooks: { account: {
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
subscribe: () => () => {},
} },
hooks: {
account: {
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
subscribe: () => () => {},
},
theme: { getSnapshot: () => themeOf('light'), subscribe: () => () => {} },
},
contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(),
refresh: vi.fn(() => Promise.resolve()),
start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()),
}
}
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>, platform?: PlatformBridge) {
const operations = operationsOf(state, details, platform)
// AccountSection consumes no global hooks; the slot supplies them in the application.
const globals = {} as GlobalStandardProps
render(<AccountSection {...globals} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
close={() => {}} t={key => key in copy ? copy[key as AccountKey] : key} />)
return operations
}
@@ -57,7 +72,8 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu',
cleanup()
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} signOut={signOut}
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
t={key => key in copy ? copy[key as AccountKey] : key} />)
fireEvent.click(screen.getByRole('button', { name: copy.menu }))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([copy.settings, copy.contactUs, copy.signOut])
@@ -86,14 +102,16 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async (
return Promise.reject(new Error('account start failed'))
})
const view = render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
useAccount={selector => selector(snapshot)} wide openOnboarding={() => {}} openSettings={() => {}}
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openOnboarding={() => {}} openSettings={() => {}}
t={key => key in en ? en[key as AccountKey] : key} />)
fireEvent.click(screen.getByRole('button', { name: en.menu }))
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signIn })) })
expect(start).toHaveBeenCalledOnce()
expect(screen.queryByRole('alert')).toBeNull()
view.rerender(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
useAccount={selector => selector(snapshot)} wide openOnboarding={() => {}} openSettings={() => {}}
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openOnboarding={() => {}} openSettings={() => {}}
t={key => key in en ? en[key as AccountKey] : key} />)
expect(screen.getByRole('dialog').textContent).toContain(en.failed)
expect(screen.queryByRole('alert')).toBeNull()
@@ -108,7 +126,8 @@ it('keeps the sidebar alert for a failed sign-out', async () => {
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
signOut={() => Promise.reject(new Error('account sign-out failed'))}
useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide
openOnboarding={() => {}} openSettings={() => {}} t={key => key in en ? en[key as AccountKey] : key} />)
fireEvent.click(screen.getByRole('button', { name: en.menu }))
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signOut })) })
@@ -253,6 +272,7 @@ it.each([
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe(expected)
})
@@ -264,6 +284,7 @@ it('shows the profile image in settings and the sidebar, with independent load-e
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
const images = document.querySelectorAll('img')
expect(images).toHaveLength(2)
@@ -324,12 +345,26 @@ it('opens more account information externally without invoking the embedded Plat
it.each(['initializing', 'waiting-browser', 'exchanging'] as const)('shows %s and lets the user cancel the active attempt', async (phase) => {
const operations = mount({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase, authorizeUrl: 'https://example.test/authorize' } })
expect(screen.getByRole('link', { name: en.open }).getAttribute('href')).toBe('https://example.test/authorize')
expect(screen.getByRole('link', { name: en.open }).getAttribute('href')).toBe('https://example.test/authorize?theme=light')
expect(screen.getByRole('status').textContent).toBe(phase === 'initializing' ? en.initializing : phase === 'waiting-browser' ? en.waiting : en.completing)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.cancel })) })
expect(operations.cancel).toHaveBeenCalledExactlyOnceWith('attempt')
})
it('opens the authorization link with the active Desktop palette and follows later switches', () => {
const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example'
const operations = operationsOf({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase: 'waiting-browser', authorizeUrl } })
const element = (theme: ThemeSnapshot) => <AccountSection {...({} as GlobalStandardProps)} {...operations}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
useTheme={selector => selector(theme)} close={() => {}} t={key => key in en ? en[key as AccountKey] : key} />
const view = render(element(themeOf('dark')))
expect(screen.getByRole('link', { name: en.open }).getAttribute('href'))
.toBe('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
view.rerender(element(themeOf('light')))
expect(screen.getByRole('link', { name: en.open }).getAttribute('href'))
.toBe('https://platform.deepseek.com/dsh/authorize?state=example&theme=light')
})
it.each(['failed', 'expired'] as const)('allows a new sign-in after the attempt is %s', (phase) => {
mount({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase } })
expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false)
@@ -354,7 +389,8 @@ it('reports a rejected settings login and disables login while initial state is
let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: true }
const props = { ...({} as GlobalStandardProps), ...operations,
start: vi.fn(async () => { throw new Error('unavailable') }),
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot), close: () => {},
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
useTheme: <T,>(select: (value: ThemeSnapshot) => T) => select(operations.hooks.theme.getSnapshot()), close: () => {},
t: (key: string) => en[key as AccountKey],
}
const view = render(<AccountSection {...props} />)
@@ -373,7 +409,9 @@ it('dismisses a collapsed menu and hands its login dialog to the API-key onboard
const openOnboarding = vi.fn()
let snapshot = operations.hooks.account.getSnapshot()
const props = { ...({} as GlobalStandardProps), ...operations, wide: false, openSettings: vi.fn(), openOnboarding,
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot), t: (key: string) => en[key as AccountKey] }
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
useTheme: <T,>(select: (value: ThemeSnapshot) => T) => select(operations.hooks.theme.getSnapshot()),
t: (key: string) => en[key as AccountKey] }
const view = render(<AccountMenu {...props} />)
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe('')
fireEvent.click(screen.getByRole('button', { name: en.menu }))
@@ -5,6 +5,7 @@ import { ok } from '@deepseek-ai/dsh-remote-mock'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import type { AccountDetails, AccountView, AccountUserId, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type { AccountSectionInjected } from '../src/client/AccountSection.tsx'
import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts'
@@ -40,6 +41,16 @@ it('shares account actions across seats, publishes dialog ownership, and opens c
const c = await start()
const actions = operations(c)
expect(c.ctx.slots.entries('settings.models.sign-in')[0]!.inject!()).toBe(actions)
// The account UI follows the live theme service through the framework hook channel.
const theme = c.ctx.get('theme') as ThemeRuntime
const onTheme = vi.fn()
const offTheme = actions.hooks.theme.subscribe(onTheme)
expect(actions.hooks.theme.getSnapshot()).toBe(theme.getTheme())
const probe = theme.register({ id: 'probe', colorScheme: 'dark', tokens: {} })
expect(onTheme).toHaveBeenCalledOnce()
probe()
offTheme()
expect(theme.getTheme().themes.map(candidate => candidate.id)).toEqual(['light', 'dark'])
await actions.refresh()
expect(c.mock.remote.account.getProfile).not.toHaveBeenCalled()
const listener = vi.fn()
@@ -3,6 +3,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { AccountOnboarding } from '../src/client/AccountOnboarding.tsx'
import type { AccountSnapshot } from '../src/client/AccountSection.tsx'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -10,6 +11,11 @@ import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const theme: ThemeSnapshot = {
preference: 'light', fontSize: 14,
active: { id: 'light', colorScheme: 'light', tokens: {} }, themes: [], revision: 0,
}
it('claims onboarding while loading, offers API key fallback, and completes after login', () => {
let account: AccountSnapshot = { view: undefined, details: undefined, failed: false }
const unused = (): never => { throw new Error('AccountOnboarding does not consume global hooks') }
@@ -21,6 +27,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte
...globals,
complete: vi.fn(), useApiKey: vi.fn(), setOnboarding: vi.fn(), showLogin: vi.fn(),
useAccount: <T,>(select: (snapshot: AccountSnapshot) => T) => select(account),
useTheme: <T,>(select: (snapshot: ThemeSnapshot) => T) => select(theme),
start: vi.fn(async () => {}), cancel: vi.fn(async () => {}), signOut: vi.fn(async () => {}),
refresh: vi.fn(async () => {}), contactUs: vi.fn(), t: makeTranslate(en),
}
@@ -8,13 +8,16 @@ import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const id = 'login-attempt' as SignInAttemptId
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en) {
function dialogProps(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
const start = vi.fn(async () => {})
const cancel = vi.fn(async () => {})
const close = vi.fn()
const useApiKey = vi.fn()
const props = { start, cancel, close, useApiKey, t: (key: keyof typeof en) => copy[key],
return { start, cancel, close, useApiKey, colorScheme, t: (key: keyof typeof en) => copy[key],
account: { view: { status: 'signed-out' as const, attempt, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }, details: undefined, failed: false } }
}
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
const props = dialogProps(attempt, copy, colorScheme)
render(<SignInDialog {...props} />)
return props
}
@@ -57,7 +60,7 @@ it.each([en, zh])('restores the copy label two seconds after the latest successf
const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example'
mount({ id, phase: 'waiting-browser', authorizeUrl }, copy)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copyLink })) })
expect(writeText).toHaveBeenLastCalledWith(authorizeUrl)
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
act(() => { vi.advanceTimersByTime(1000) })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.copiedLink })) })
expect(writeText).toHaveBeenCalledTimes(2)
@@ -74,6 +77,25 @@ it.each([en, zh])('restores the copy label two seconds after the latest successf
}
})
it('copies the authorization link with the palette of the current render', async () => {
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const writeText = vi.fn(async () => {})
try {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const attempt = { id, phase: 'waiting-browser' as const, authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=example' }
const props = dialogProps(attempt, en, 'dark')
const view = render(<SignInDialog {...props} />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.copyLink })) })
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=dark')
view.rerender(<SignInDialog {...props} colorScheme="light" />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.copiedLink })) })
expect(writeText).toHaveBeenLastCalledWith('https://platform.deepseek.com/dsh/authorize?state=example&theme=light')
} finally {
if (clipboard) Object.defineProperty(navigator, 'clipboard', clipboard)
else Reflect.deleteProperty(navigator, 'clipboard')
}
})
it.each([en, zh])('shows a temporary copy failure without interrupting sign-in', async (copy) => {
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
vi.useFakeTimers()
@@ -29,6 +29,9 @@
{
"path": "../ui-slots"
},
{
"path": "../ui-theme/tsconfig.client.json"
},
{
"path": "../../credentials/deepseek-account"
},
@@ -484,12 +484,16 @@ it('adds private deployment cookies to every Platform request without exposing t
expect(JSON.stringify(await f.account.getState())).not.toContain('test_gate')
await f.account.signOut()
await expect.poll(f.logoutCount).toBe(1)
expect(f.receivedHeaders.map(item => item.path)).toEqual([
'/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_exchange',
'/auth-api/v0/users/current', '/api/v0/users/get_user_summary', '/auth-api/v0/users/logout',
])
expect(f.receivedHeaders.every(item => item.cookie === 'test_gate=synthetic')).toBe(true)
expect(f.receivedHeaders.slice(2).every(item => item.authorization === 'dsh_mock_test')).toBe(true)
const headers = f.receivedHeaders
const paths = headers.map(item => item.path)
// The profile and wallet queries are independent requests, so their arrival order is scheduler-dependent.
expect(paths.slice(0, 2)).toEqual(['/auth-api/v0/dsh/auth_init', '/auth-api/v0/dsh/auth_exchange'])
expect(paths.slice(2, -1).sort()).toEqual(['/api/v0/users/get_user_summary', '/auth-api/v0/users/current'])
expect(paths.at(-1)).toBe('/auth-api/v0/users/logout')
expect(headers.every(item => item.cookie === 'test_gate=synthetic')).toBe(true)
const grantedPaths = new Set(['/auth-api/v0/users/current', '/api/v0/users/get_user_summary', '/auth-api/v0/users/logout'])
expect(headers.filter(item => grantedPaths.has(item.path ?? '')).every(item => item.authorization === 'dsh_mock_test')).toBe(true)
expect(headers.filter(item => !grantedPaths.has(item.path ?? '')).every(item => item.authorization === undefined)).toBe(true)
})
it('rejects reserved, duplicate and malformed deployment headers without disclosing values', () => {
+3
View File
@@ -4474,6 +4474,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-client-ui-theme':
specifier: workspace:^
version: link:../ui-theme
'@deepseek-ai/dsh-deepseek-account':
specifier: workspace:^
version: link:../../credentials/deepseek-account