refactor(desktop): share shell theme initialization

This commit is contained in:
winewill committed 2026-10-21 16:47:56 +08:00
1 parent 3f5dd8b931
commit 291dd1f448
14 files changed
+40 -41

No files matched your search

+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
+1 -1
View File
@@ -10,7 +10,7 @@
<link rel="stylesheet" href="mandatory-update.css">
</head>
<body class="update-dialog">
<script src="update-theme.js"></script>
<script src="shell-theme.js"></script>
<main role="alertdialog" aria-modal="true" aria-labelledby="title" aria-describedby="detail">
<header><h1 id="title"></h1></header>
<p id="detail"></p>
@@ -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()
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="update-dialog.css">
</head>
<body class="update-dialog">
<script src="update-theme.js"></script>
<script src="shell-theme.js"></script>
<main id="dialog" role="dialog" tabindex="-1" aria-modal="true" aria-labelledby="title" aria-describedby="detail" hidden>
<header><h1 id="title"></h1><button id="close" class="close" type="button"></button></header>
<p id="detail"></p>
+1
View File
@@ -11,6 +11,7 @@
<script src="../lib/welcome/welcome.js" defer></script>
</head>
<body>
<script src="shell-theme.js"></script>
<div id="root"></div>
</body>
</html>
-6
View File
@@ -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)
+1 -1
View File
@@ -231,7 +231,7 @@ async function main() {
protocol.handle('dsh-app', async request => {
if (new URL(request.url).hostname === 'app') return new Response('<!doctype html><title>Updater qualification</title>', { 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 } })
})
+1 -1
View File
@@ -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 } })
+2
View File
@@ -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
+1 -1
View File
@@ -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)
+27
View File
@@ -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() }
})
-25
View File
@@ -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('<body></body>', { 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() }
})