From 291dd1f448e94cf7b39d92ea28c5f6d3eff66dff Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 21 Oct 2026 16:47:56 +0800 Subject: [PATCH] refactor(desktop): share shell theme initialization --- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/mandatory-update.html | 2 +- .../{update-theme.js => shell-theme.js} | 2 +- apps/desktop/renderer/update-dialog.html | 2 +- apps/desktop/renderer/welcome.html | 1 + apps/desktop/src/client/welcome.tsx | 6 ----- apps/desktop/tests/fixtures/local-updater.mjs | 2 +- .../desktop/tests/fixtures/update-dialogs.mjs | 2 +- apps/desktop/tests/fixtures/update-theme.mjs | 2 ++ apps/desktop/tests/main-startup.spec.ts | 2 +- apps/desktop/tests/shell-theme.spec.ts | 27 +++++++++++++++++++ apps/desktop/tests/update-theme.spec.ts | 25 ----------------- 14 files changed, 40 insertions(+), 41 deletions(-) rename apps/desktop/renderer/{update-theme.js => shell-theme.js} (92%) create mode 100644 apps/desktop/tests/shell-theme.spec.ts delete mode 100644 apps/desktop/tests/update-theme.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index db6580522c..4f822d630f 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: 9b90a2651a6d2b52 zh: 86d4d9c4358aad80 /deepseek-harness-desktop/updates: - en: 35b371bda39e941e - zh: 053c21b0eeccd578 + en: 72c04187e1eb73d9 + zh: 2226c816ff8f9653 /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7536b51d37..31a251d15e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -273,7 +273,7 @@ On macOS, installation preparation waits for the product window to leave fullscr Ordinary update dialogs use an isolated shell-origin WebContentsView inside the product window, so loading a result does not raise a separate native window. The document remains available before the workspace Host is ready. While attached, it intercepts product keyboard input; closing it releases its listeners and view. Windows caption controls remain above the view. -Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline. +Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline. Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e2da97122f..4a9ff3587a 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -273,7 +273,7 @@ macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启 普通更新弹窗使用产品窗口内、隔离的壳静态源 WebContentsView,检查结果加载不会抬升独立的原生窗口。工作区 Host 尚未就绪时,该文档仍可使用。视图挂载期间拦截产品键盘输入,关闭时释放监听和视图。Windows 标题栏控件位于视图上方。 -普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。 +普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。 打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。 diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html index 3fa4e0a148..d4d1747cf9 100644 --- a/apps/desktop/renderer/mandatory-update.html +++ b/apps/desktop/renderer/mandatory-update.html @@ -10,7 +10,7 @@ - +

diff --git a/apps/desktop/renderer/update-theme.js b/apps/desktop/renderer/shell-theme.js similarity index 92% rename from apps/desktop/renderer/update-theme.js rename to apps/desktop/renderer/shell-theme.js index fff03fd282..bbc12f381b 100644 --- a/apps/desktop/renderer/update-theme.js +++ b/apps/desktop/renderer/shell-theme.js @@ -1,4 +1,4 @@ -/** Shell documents follow Electron's app-resolved palette before rendering their cards. */ +/** Shell documents follow Electron's app-resolved palette before displaying their content. */ const palette = window.matchMedia('(prefers-color-scheme: dark)') const applyPalette = () => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) } applyPalette() diff --git a/apps/desktop/renderer/update-dialog.html b/apps/desktop/renderer/update-dialog.html index 32540954fa..c6a913347d 100644 --- a/apps/desktop/renderer/update-dialog.html +++ b/apps/desktop/renderer/update-dialog.html @@ -9,7 +9,7 @@ - +

diff --git a/apps/desktop/renderer/welcome.html b/apps/desktop/renderer/welcome.html index f8cbeb8e15..40178fd1b8 100644 --- a/apps/desktop/renderer/welcome.html +++ b/apps/desktop/renderer/welcome.html @@ -11,6 +11,7 @@ +
diff --git a/apps/desktop/src/client/welcome.tsx b/apps/desktop/src/client/welcome.tsx index c72eec3887..f5c1ab5ba0 100644 --- a/apps/desktop/src/client/welcome.tsx +++ b/apps/desktop/src/client/welcome.tsx @@ -12,12 +12,6 @@ declare global { } } -const palette = window.matchMedia('(prefers-color-scheme: dark)') -const syncPalette = (): void => { document.body.toggleAttribute('data-ds-dark-theme', palette.matches) } -syncPalette() -palette.addEventListener('change', syncPalette) -window.addEventListener('pagehide', () => { palette.removeEventListener('change', syncPalette) }, { once: true }) - const container = document.getElementById('root') if (container === null) throw new Error('desktop welcome: missing React root') const root = createRoot(container) diff --git a/apps/desktop/tests/fixtures/local-updater.mjs b/apps/desktop/tests/fixtures/local-updater.mjs index d9519f13a6..fb01c13f04 100644 --- a/apps/desktop/tests/fixtures/local-updater.mjs +++ b/apps/desktop/tests/fixtures/local-updater.mjs @@ -231,7 +231,7 @@ async function main() { protocol.handle('dsh-app', async request => { if (new URL(request.url).hostname === 'app') return new Response('Updater qualification', { headers: { 'content-type': 'text/html' } }) const path = new URL(request.url).pathname.slice(1) - assert.ok(['design-platform.css', 'update-theme.js', 'mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path)) + assert.ok(['design-platform.css', 'shell-theme.js', 'mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path)) const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html' return new Response(await readFile(new URL(`${path === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${path}`, import.meta.url)), { headers: { 'content-type': mime } }) }) diff --git a/apps/desktop/tests/fixtures/update-dialogs.mjs b/apps/desktop/tests/fixtures/update-dialogs.mjs index a444cbc9f5..3ce147a8d4 100644 --- a/apps/desktop/tests/fixtures/update-dialogs.mjs +++ b/apps/desktop/tests/fixtures/update-dialogs.mjs @@ -27,7 +27,7 @@ export async function qualifyUpdateDialogs(root, fixture) { const screenshots = [] protocol.handle('dsh-app', async request => { const name = new URL(request.url).pathname.slice(1) - assert.ok(['design-platform.css', 'update-theme.js', 'update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name)) + assert.ok(['design-platform.css', 'shell-theme.js', 'update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg'].includes(name)) const mime = name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css' : name.endsWith('.svg') ? 'image/svg+xml' : 'text/html' return new Response(await readFile(new URL(`${name === 'design-platform.css' ? '../../lib/welcome' : '../../renderer'}/${name}`, import.meta.url)), { headers: { 'content-type': mime } }) diff --git a/apps/desktop/tests/fixtures/update-theme.mjs b/apps/desktop/tests/fixtures/update-theme.mjs index 4ed845e47a..021cbce517 100644 --- a/apps/desktop/tests/fixtures/update-theme.mjs +++ b/apps/desktop/tests/fixtures/update-theme.mjs @@ -45,6 +45,8 @@ async function main() { webPreferences: { preload: fileURLToPath(new URL(`../../lib/preload-${document === 'update-dialog' ? 'update-dialog' : 'mandatory'}.cjs`, import.meta.url)), contextIsolation: true, sandbox: true, nodeIntegration: false } }) windows.push(window) + // Palette checks exclude the user's pointer so hover cannot select an interaction color. + window.setIgnoreMouseEvents(true) await window.loadURL(`dsh-app://shell/${document}.html`) for (const source of [initial, initial === 'light' ? 'dark' : 'light', 'system']) { nativeTheme.themeSource = source diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 525408e727..6b1c05a934 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -501,7 +501,7 @@ describe('desktop main startup', () => { for (const [name, mime] of [ ['update-dialog.html', 'text/html'], ['update-dialog.css', 'text/css'], ['update-dialog.js', 'text/javascript'], ['mandatory-update.html', 'text/html'], ['mandatory-update.css', 'text/css'], ['mandatory-update.js', 'text/javascript'], - ['update-close.svg', 'image/svg+xml'], ['update-theme.js', 'text/javascript'], + ['update-close.svg', 'image/svg+xml'], ['shell-theme.js', 'text/javascript'], ] as const) { const response = await handler(new Request(`dsh-app://shell/${name}`)) expect(response.status).toBe(200) diff --git a/apps/desktop/tests/shell-theme.spec.ts b/apps/desktop/tests/shell-theme.spec.ts new file mode 100644 index 0000000000..e997699c65 --- /dev/null +++ b/apps/desktop/tests/shell-theme.spec.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { JSDOM } from 'jsdom' +import { expect, it, vi } from 'vitest' + +it.each(['welcome', 'update-dialog', 'mandatory-update'].flatMap(document => [false, true].map(dark => ({ document, dark }))))( + '$document applies the initial app palette and follows changes (dark=$dark)', ({ document, dark }) => { + const dom = new JSDOM(readFileSync(new URL(`../renderer/${document}.html`, import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + const palette = Object.assign(new dom.window.EventTarget(), { matches: dark, media: '(prefers-color-scheme: dark)', + onchange: null, addListener: vi.fn(), removeListener: vi.fn() }) + const remove = vi.spyOn(palette, 'removeEventListener') + dom.window.matchMedia = vi.fn(() => palette) + try { + expect(dom.window.document.querySelectorAll('script[src="shell-theme.js"]')).toHaveLength(1) + runInContext(readFileSync(new URL('../renderer/shell-theme.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) + expect(dom.window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)') + expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(dark) + palette.matches = !dark + palette.dispatchEvent(new dom.window.Event('change')) + expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark) + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + expect(remove).toHaveBeenCalledWith('change', expect.any(Function)) + palette.matches = dark + palette.dispatchEvent(new dom.window.Event('change')) + expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark) + } finally { dom.window.close() } + }) diff --git a/apps/desktop/tests/update-theme.spec.ts b/apps/desktop/tests/update-theme.spec.ts deleted file mode 100644 index 6cffdca32e..0000000000 --- a/apps/desktop/tests/update-theme.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readFileSync } from 'node:fs' -import { runInContext } from 'node:vm' -import { JSDOM } from 'jsdom' -import { expect, it, vi } from 'vitest' - -it.each([false, true])('applies the initial app palette before the card and follows changes (dark=%s)', (dark) => { - const dom = new JSDOM('', { runScripts: 'outside-only' }) - const palette = Object.assign(new dom.window.EventTarget(), { matches: dark, media: '(prefers-color-scheme: dark)', - onchange: null, addListener: vi.fn(), removeListener: vi.fn() }) - const remove = vi.spyOn(palette, 'removeEventListener') - dom.window.matchMedia = vi.fn(() => palette) - try { - runInContext(readFileSync(new URL('../renderer/update-theme.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) - expect(dom.window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)') - expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(dark) - palette.matches = !dark - palette.dispatchEvent(new dom.window.Event('change')) - expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark) - dom.window.dispatchEvent(new dom.window.Event('pagehide')) - expect(remove).toHaveBeenCalledWith('change', expect.any(Function)) - palette.matches = dark - palette.dispatchEvent(new dom.window.Event('change')) - expect(dom.window.document.body.hasAttribute('data-ds-dark-theme')).toBe(!dark) - } finally { dom.window.close() } -})