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.
157 lines
5.9 KiB
TypeScript
157 lines
5.9 KiB
TypeScript
/**
|
|
* Keeps the native window background in step with the theme the renderer
|
|
* settled on.
|
|
*
|
|
* A BrowserWindow paints `backgroundColor` before the renderer has produced a
|
|
* frame, so a dark-theme user gets a white flash on every launch unless we
|
|
* know the theme up front. The renderer's choice lives in its own
|
|
* localStorage, which the main process cannot read, so the last applied
|
|
* appearance is cached in a small file next to window-state and used to seed
|
|
* the next launch.
|
|
*
|
|
* ── Why this deliberately does NOT touch `nativeTheme.themeSource` ──
|
|
*
|
|
* Pinning it to the user's fixed theme is tempting: it would make context
|
|
* menus, devtools and the macOS window frame agree with the app. It was tried
|
|
* and reverted, because `themeSource` is a process-wide override with two
|
|
* consequences that outweigh that polish:
|
|
*
|
|
* 1. It overrides `prefers-color-scheme` in every renderer — which is exactly
|
|
* the signal "follow the system" is built on. Pinned to `'light'`, the
|
|
* moment a user switches the toggle back on, the renderer resolves against
|
|
* the *pinned* value rather than the real OS setting and lands on the wrong
|
|
* theme until an async IPC round-trip unpins it.
|
|
* 2. It leaks out of the app: the preview WebContentsView loads arbitrary
|
|
* third-party sites, and a user on a fixed light theme would force every
|
|
* previewed page light too.
|
|
*
|
|
* Leaving it at Electron's `'system'` default keeps `prefers-color-scheme`
|
|
* truthful everywhere and matches the behaviour that shipped before this
|
|
* feature. Do not reintroduce the pin without solving both points above.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import type { App, BrowserWindow } from 'electron'
|
|
|
|
export const APPEARANCE_STATE_FILE = 'appearance-state.json'
|
|
|
|
/**
|
|
* Grounds used before the renderer has reported anything — the defaults of
|
|
* each half. Pinned to THEME_BACKGROUNDS by nativeAppearance.test.ts.
|
|
*/
|
|
export const LIGHT_WINDOW_BACKGROUND = '#FFFFFF'
|
|
export const DARK_WINDOW_BACKGROUND = '#201D17'
|
|
|
|
export type AppliedAppearance = {
|
|
isDark: boolean
|
|
background: string
|
|
/**
|
|
* Background of the light theme the user picked, so a launch that follows
|
|
* the system into its light half repaints the theme they actually use
|
|
* rather than defaulting to pure white.
|
|
*/
|
|
lightBackground: string
|
|
followSystem: boolean
|
|
}
|
|
|
|
type StoredAppearance = AppliedAppearance
|
|
|
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/
|
|
|
|
// Matches windows.ts / petWindow.ts, the other two writers into ~/.claude: on a
|
|
// read-only or full disk this is written on every theme change, and an
|
|
// undeduplicated error would repeat forever.
|
|
const failedAppearanceWritePaths = new Set<string>()
|
|
|
|
export function appearanceStatePath(app: App, env: NodeJS.ProcessEnv = process.env): string {
|
|
return path.join(
|
|
env.CLAUDE_CONFIG_DIR || path.join(app.getPath('home'), '.claude'),
|
|
APPEARANCE_STATE_FILE,
|
|
)
|
|
}
|
|
|
|
export function isAppliedAppearance(value: unknown): value is AppliedAppearance {
|
|
if (typeof value !== 'object' || value === null) return false
|
|
const candidate = value as Record<string, unknown>
|
|
return typeof candidate.isDark === 'boolean'
|
|
&& typeof candidate.followSystem === 'boolean'
|
|
&& typeof candidate.background === 'string'
|
|
&& HEX_COLOR.test(candidate.background)
|
|
&& typeof candidate.lightBackground === 'string'
|
|
&& HEX_COLOR.test(candidate.lightBackground)
|
|
}
|
|
|
|
export function readAppearanceState(app: App, env: NodeJS.ProcessEnv = process.env): StoredAppearance | null {
|
|
const statePath = appearanceStatePath(app, env)
|
|
if (!existsSync(statePath)) return null
|
|
try {
|
|
const parsed: unknown = JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
return isAppliedAppearance(parsed) ? parsed : null
|
|
} catch (error) {
|
|
console.error(`[desktop] failed to read appearance state ${statePath}:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function writeAppearanceState(
|
|
app: App,
|
|
state: AppliedAppearance,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): void {
|
|
if (!isAppliedAppearance(state)) return
|
|
const statePath = appearanceStatePath(app, env)
|
|
try {
|
|
mkdirSync(path.dirname(statePath), { recursive: true })
|
|
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`)
|
|
failedAppearanceWritePaths.delete(statePath)
|
|
} catch (error) {
|
|
if (!failedAppearanceWritePaths.has(statePath)) {
|
|
failedAppearanceWritePaths.add(statePath)
|
|
console.error(`[desktop] failed to write appearance state ${statePath}:`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Background to paint a window with before the renderer has drawn anything.
|
|
*
|
|
* A cached state that follows the system is re-resolved against the OS as it
|
|
* is *now* — the user may have flipped appearance while the app was closed —
|
|
* while a fixed pick is replayed verbatim.
|
|
*/
|
|
export function startupWindowBackground(
|
|
cached: StoredAppearance | null,
|
|
systemPrefersDark: boolean,
|
|
): string {
|
|
if (!cached) return systemPrefersDark ? DARK_WINDOW_BACKGROUND : LIGHT_WINDOW_BACKGROUND
|
|
if (!cached.followSystem) return cached.background
|
|
if (systemPrefersDark) return DARK_WINDOW_BACKGROUND
|
|
// Light half of a follow-the-system setup. `background` is whatever was on
|
|
// screen when the cache was written — dark, if the app was closed at night —
|
|
// so the light theme is carried separately rather than falling back to white.
|
|
return cached.lightBackground
|
|
}
|
|
|
|
export type AppearanceSyncDeps = {
|
|
app: App
|
|
windows: () => BrowserWindow[]
|
|
env?: NodeJS.ProcessEnv
|
|
}
|
|
|
|
/**
|
|
* Apply a renderer-reported appearance to the native windows and cache it for
|
|
* the next launch. Deliberately does not touch `nativeTheme.themeSource` —
|
|
* see the note at the top of this file.
|
|
*/
|
|
export function applyAppliedAppearance(
|
|
state: AppliedAppearance,
|
|
{ app, windows, env = process.env }: AppearanceSyncDeps,
|
|
): void {
|
|
for (const window of windows()) {
|
|
if (window.isDestroyed()) continue
|
|
window.setBackgroundColor(state.background)
|
|
}
|
|
writeAppearanceState(app, state, env)
|
|
}
|