fix(desktop): match Platform login theme

Opened and copied authorization links carried no palette, so the Platform
login page rendered its own stored preference. Append the effective Desktop
palette as theme=light or theme=dark, resolving system through the theme
source the preload publishes.

The theme source now reaches nativeTheme on Windows as well: the preload
drops its macOS-only guard, since shouldUseDarkColors is read for every
platform, and gates the DOMContentLoaded listener with once.
This commit is contained in:
lsdsjy committed 2026-09-22 12:02:54 +08:00
1 parent 1c96299173
commit 817623750a
8 files changed
+112 -18

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,
@@ -333,7 +347,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
@@ -892,7 +906,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 }
+7 -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,13 @@ 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`. The macOS sidebar vibrancy
* material then follows the app's theme preference instead of the OS
* appearance while `system` keeps following the OS, and the resolved
* `shouldUseDarkColors` palette travels with the Platform login link.
*/
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 +27,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 () => {
@@ -1617,3 +1617,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,58 @@
// @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)
for (const observer of observers) observer.disconnect()
send.mockClear()
document.documentElement.setAttribute('data-ds-theme-source', 'light')
await new Promise<void>((resolve) => { queueMicrotask(resolve) })
expect(send).not.toHaveBeenCalled()
})
it('waits for the document root and then stops listening for it', () => {
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')
document.documentElement.setAttribute('data-ds-theme-source', 'dark')
window.dispatchEvent(new Event('DOMContentLoaded'))
expect(send).toHaveBeenCalledOnce()
})
+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()