mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
An Auto-dark-mode user reported being flashed by a white window every evening, then switching to a dark palette by hand. That is two defects, and only one of them is the missing feature. The flash fired even for someone who had already saved a dark palette. `index.html` hardcoded `data-theme="white"` while the code that reads the stored theme, `initializeTheme()`, only runs after the app bundle's dynamic imports resolve. So every launch painted white first. A synchronous inline script now resolves the theme before any stylesheet is parsed, and Electron seeds `backgroundColor` from a cached appearance so the window is not white before the renderer's first frame either. Following the system is a switch in Settings -> General rather than a seventh palette. The OS only reports dark/light while the app ships six palettes, so each ground carries its own preference: the picker splits into "use in light mode" (the four paper grounds) and "use in dark mode" (the two ink ones), and a pick lands in the preference for its own ground. Choosing ink-blue at noon is therefore remembered for that night rather than fighting the OS. Detection goes through `prefers-color-scheme` because the same renderer runs under Electron, the Tauri shell and the browser entry, and the media query is the only signal all three share. New installs follow the system; existing ones keep their fixed palette until they opt in, so an update never silently repaints someone's app. `nativeTheme.themeSource` is deliberately left alone, which is the part most likely to be "fixed" later. Pinning it to the user's palette would make context menus and the macOS frame agree with the app, but it is a process-wide override of `prefers-color-scheme` — the very signal this feature reads. Re-enabling the switch would then resolve against the pinned value instead of the real OS setting, and the override also leaks into the preview WebContentsView, forcing third-party pages to the app's theme. A test fails on any assignment to it. The OS-flip listener reads the preferences from storage rather than from its own store. The pet and trace windows run the same bootstrap with their own store instance over one shared localStorage, so after the main window turns the switch off their in-memory copy still says "on" — acting on it wrote the user's choice straight back out. A `storage` listener catches the other windows up. `settingsStore.theme` is gone. It was a copy that only refreshed on an explicit `setTheme`, so an OS flip left the Settings picker highlighting a palette that was no longer on screen. uiStore owns the theme; the copy had no remaining readers. The 「纸·墨·印」 rename reaches the new keys too: `light` -> `warm-classic` now migrates for the per-ground preferences, not just the applied theme, so the palette daytime returns to is not silently reset. Guards, each verified by breaking what it protects: the inline script is extracted from `index.html` and run verbatim against `resolveAppliedTheme` over every stored combination — including dirty values, which are reachable because it runs before the persistence migrations, and cross-ground values like a dark palette stored as the light preference; the three copies of the palette grounds (CSS `--cc-bg`, `index.html`, main process) are pinned to each other and to `THEME_MODES`, so a seventh palette cannot ship without a pre-paint color; the two grounds are proven to cover every palette at compile time; and the IPC payload is held to a literal 6-digit hex because `setBackgroundColor` also accepts `#AARRGGBB`, where a translucent window means click-through and overlay spoofing.
196 lines
8.8 KiB
HTML
196 lines
8.8 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<!--
|
|
Conservative CSP: hardens default/object/base-uri without breaking the app.
|
|
script/style keep 'unsafe-inline' + 'unsafe-eval' on purpose - Emotion CSS-in-JS
|
|
injects runtime <style> tags, the startup watchdog below is inline, and shiki/
|
|
mermaid/Vite rely on eval. connect-src stays permissive (localhost sidecar + ws +
|
|
https) so renderer fetches and dev HMR keep working. Tighten only after verifying
|
|
on a packaged build.
|
|
-->
|
|
<meta
|
|
http-equiv="Content-Security-Policy"
|
|
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; connect-src 'self' https: http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
|
/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Claude Code Haha</title>
|
|
<!-- Fonts are self-hosted in /fonts/ and declared via @font-face in globals.css -->
|
|
<script>
|
|
if (new URLSearchParams(window.location.search).get('petWindow') === '1') {
|
|
document.documentElement.dataset.windowKind = 'pet'
|
|
}
|
|
</script>
|
|
<script>
|
|
/*
|
|
Resolve the theme synchronously, before any stylesheet is parsed.
|
|
The app bundle loads as a module and only reaches initializeTheme()
|
|
after its dynamic imports resolve, which is long enough for a dark
|
|
mode user to get flashed by a white window on every launch.
|
|
|
|
Mirrors resolveAppliedTheme() in src/theme/systemAppearance.ts;
|
|
src/theme/bootstrapScript.test.ts checks the two agree.
|
|
*/
|
|
(function () {
|
|
try {
|
|
var read = function (key) {
|
|
try { return window.localStorage.getItem(key) } catch (_) { return null }
|
|
}
|
|
// The six palettes, split by ground. Keep in step with
|
|
// LIGHT_THEME_MODES / DARK_THEME_MODES in src/types/settings.ts.
|
|
var LIGHT = ['white', 'paper', 'warm-classic', 'celadon']
|
|
var DARK = ['dark', 'ink-blue']
|
|
var has = function (list, value) { return list.indexOf(value) !== -1 }
|
|
var isDark = function (value) { return has(DARK, value) }
|
|
|
|
var stored = read('cc-haha-theme')
|
|
var theme = (has(LIGHT, stored) || isDark(stored)) ? stored : 'white'
|
|
|
|
var followRaw = read('cc-haha-follow-system-theme')
|
|
// Unset means: new install follows the system, existing install
|
|
// (recognised by having a stored theme) keeps its fixed pick.
|
|
var follow = followRaw === '1' || (followRaw !== '0' && stored === null)
|
|
|
|
if (follow) {
|
|
var storedLight = read('cc-haha-light-theme')
|
|
var lightTheme = has(LIGHT, storedLight)
|
|
? storedLight
|
|
: (has(LIGHT, theme) ? theme : 'white')
|
|
var storedDark = read('cc-haha-dark-theme')
|
|
var darkTheme = isDark(storedDark) ? storedDark : (isDark(theme) ? theme : 'dark')
|
|
var prefersDark = false
|
|
try {
|
|
// Some WebViews reject the query outright. Treating that as
|
|
// "light" matches getSystemAppearance() in the app bundle.
|
|
prefersDark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
} catch (_) { /* no color-scheme signal available */ }
|
|
theme = prefersDark ? darkTheme : lightTheme
|
|
}
|
|
|
|
document.documentElement.setAttribute('data-theme', theme)
|
|
document.documentElement.style.colorScheme = isDark(theme) ? 'dark' : 'light'
|
|
} catch (_) {
|
|
// `theme` is function-scoped, so a failure after it was resolved
|
|
// still applies the user's stored choice rather than flashing white.
|
|
document.documentElement.setAttribute('data-theme', theme || 'white')
|
|
document.documentElement.style.colorScheme =
|
|
(theme === 'dark' || theme === 'ink-blue') ? 'dark' : 'light'
|
|
}
|
|
})()
|
|
</script>
|
|
<style>
|
|
/* Paint the resolved theme's ground before globals.css arrives, so the
|
|
window never shows a palette it is about to replace. Values track
|
|
--cc-bg per theme in src/theme/globals.css.
|
|
The bare `html` rule only applies if the script above never ran (a CSP
|
|
that strips it would remove the element entirely, so its try/catch is
|
|
no help there) — it matches the default palette in globals.css rather
|
|
than a fixed white, so that degraded case stays self-consistent. */
|
|
html { background: #FFFFFF; }
|
|
html[data-theme="white"] { background: #FFFFFF; }
|
|
html[data-theme="paper"] { background: #FBF9F4; }
|
|
html[data-theme="warm-classic"] { background: #F6F0E1; }
|
|
html[data-theme="celadon"] { background: #F5F8F5; }
|
|
html[data-theme="dark"] { background: #201D17; }
|
|
html[data-theme="ink-blue"] { background: #1A1D24; }
|
|
html[data-window-kind="pet"],
|
|
html[data-window-kind="pet"] body,
|
|
html[data-window-kind="pet"] #root { background: transparent !important; }
|
|
html, body, #root { min-height: 100%; margin: 0; }
|
|
</style>
|
|
<script>
|
|
(function () {
|
|
var bootDeadlineMs = 8000
|
|
|
|
function summarize(value) {
|
|
if (!value) return 'Unknown startup error'
|
|
if (typeof value === 'string') return value
|
|
if (value.message) return value.message
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch (_) {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
function renderStartupError(reason) {
|
|
if (window.__CC_HAHA_BOOTSTRAPPED__) return
|
|
var root = document.getElementById('root')
|
|
if (!root) return
|
|
|
|
var summary = summarize(reason)
|
|
var details = [
|
|
summary,
|
|
'',
|
|
'URL: ' + window.location.href,
|
|
'User Agent: ' + navigator.userAgent,
|
|
'Time: ' + new Date().toISOString()
|
|
].join('\n')
|
|
|
|
root.innerHTML = ''
|
|
var shell = document.createElement('main')
|
|
shell.style.cssText = [
|
|
'box-sizing:border-box',
|
|
'min-height:100vh',
|
|
'padding:32px',
|
|
'background:#fff',
|
|
'color:#151515',
|
|
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'justify-content:center'
|
|
].join(';')
|
|
|
|
var panel = document.createElement('section')
|
|
panel.style.cssText = 'width:min(760px,100%);border:1px solid #d8d8d8;border-radius:8px;padding:24px;background:#fff'
|
|
|
|
var title = document.createElement('h1')
|
|
title.textContent = 'Desktop startup failed'
|
|
title.style.cssText = 'margin:0 0 8px;font-size:20px;line-height:1.3;font-weight:700;color:#111'
|
|
|
|
var body = document.createElement('p')
|
|
body.textContent = 'The app could not finish booting. Keep this text when reporting the issue.'
|
|
body.style.cssText = 'margin:0 0 16px;font-size:14px;line-height:1.6;color:#555'
|
|
|
|
var pre = document.createElement('pre')
|
|
pre.textContent = details
|
|
pre.style.cssText = 'box-sizing:border-box;max-height:360px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:0;padding:14px;border-radius:6px;background:#f6f6f6;color:#222;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace'
|
|
|
|
panel.appendChild(title)
|
|
panel.appendChild(body)
|
|
panel.appendChild(pre)
|
|
shell.appendChild(panel)
|
|
root.appendChild(shell)
|
|
}
|
|
|
|
window.__CC_HAHA_SHOW_STARTUP_ERROR__ = renderStartupError
|
|
|
|
window.addEventListener('error', function (event) {
|
|
var target = event.target
|
|
if (target && target !== window) {
|
|
var tagName = target.tagName
|
|
if (tagName === 'SCRIPT' || tagName === 'LINK') {
|
|
renderStartupError('Startup resource failed to load: ' + (target.src || target.href || tagName))
|
|
}
|
|
return
|
|
}
|
|
renderStartupError(event.message || event.error || 'Startup script error')
|
|
}, true)
|
|
|
|
window.addEventListener('unhandledrejection', function (event) {
|
|
renderStartupError(event.reason || 'Unhandled startup promise rejection')
|
|
})
|
|
|
|
window.setTimeout(function () {
|
|
renderStartupError('Desktop app did not finish bootstrapping within ' + bootDeadlineMs + 'ms. This usually means the WebView could not execute the app bundle or startup code stopped before React mounted.')
|
|
}, bootDeadlineMs)
|
|
})()
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/main.tsx"></script>
|
|
</body>
|
|
</html>
|