From 107863919c911de05ebc7b6f1597c4551e7c2eae Mon Sep 17 00:00:00 2001 From: jesieleo <90036937+jesieleo@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:16:29 +0800 Subject: [PATCH] fix(theme): synchronize dark mode across app and tray --- packages/core/src/config/config.ts | 37 ++++++++++++ packages/core/src/contracts/ipc-channels.ts | 2 + .../unit/config/theme-preference.test.mjs | 38 +++++++++++++ packages/electron/src/main/ipc.ts | 14 ++++- packages/electron/src/main/preload.ts | 6 ++ packages/electron/src/main/tray-controller.ts | 36 ++++++++---- .../electron/test/unit/native-theme.test.ts | 6 ++ packages/ui/src/components/ui/card.tsx | 2 +- packages/ui/src/components/ui/select.tsx | 4 +- packages/ui/src/pages/home/App.tsx | 56 +++++++++++++++---- .../ui/src/pages/tray/components/widgets.tsx | 4 +- packages/ui/src/pages/tray/shared.tsx | 6 +- packages/ui/src/styles/globals.css | 32 +++++++++++ packages/ui/src/types/electron.d.ts | 2 + .../ui/test/component/components.test.tsx | 15 +++++ .../test/component/tray-components.test.tsx | 2 + 16 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 packages/core/test/unit/config/theme-preference.test.mjs diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5bdc5438..77bb0006 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -323,19 +323,56 @@ export async function loadAppConfig(): Promise { } } +let appConfigWriteQueue: Promise = Promise.resolve(); +let appThemePreferenceOverride: AppConfig["theme"] | undefined; + export async function saveAppConfig(config: AppConfig): Promise { + return enqueueAppConfigWrite(() => saveAppConfigNow(config)); +} + +export async function saveAppThemePreference(theme: unknown): Promise { + const normalizedTheme = normalizeAppThemePreference(theme); + appThemePreferenceOverride = normalizedTheme; + return enqueueAppConfigWrite(async () => { + const currentConfig = await loadAppConfig(); + await writeSanitizedConfig({ + ...currentConfig, + theme: normalizedTheme + }); + return normalizedTheme; + }); +} + +async function saveAppConfigNow(config: AppConfig): Promise { const normalizedConfig = withSingleEnabledGlobalProfiles(config); assertProviderApiKeysAreSafe(normalizedConfig); const apiKeys = ensureGatewayApiKeys(normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); await replacePersistedApiKeys(apiKeys); await writeSanitizedConfig({ ...normalizedConfig, + theme: appThemePreferenceOverride ?? normalizedConfig.theme, APIKEY: apiKeys[0]?.key ?? "", APIKEYS: apiKeys }); return loadAppConfig(); } +function normalizeAppThemePreference(theme: unknown): AppConfig["theme"] { + if (theme === "system" || theme === "light" || theme === "dark") { + return theme; + } + throw new Error("Invalid theme preference."); +} + +function enqueueAppConfigWrite(operation: () => Promise): Promise { + const result = appConfigWriteQueue.then(operation, operation); + appConfigWriteQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} + function withSingleEnabledGlobalProfiles(config: AppConfig): AppConfig { return { ...config, diff --git a/packages/core/src/contracts/ipc-channels.ts b/packages/core/src/contracts/ipc-channels.ts index 6a89c087..64a89e14 100644 --- a/packages/core/src/contracts/ipc-channels.ts +++ b/packages/core/src/contracts/ipc-channels.ts @@ -63,6 +63,7 @@ export const IPC_CHANNELS = { appSaveConfig: "ccr:app:save-config", appSetOnboardingFinished: "ccr:app:set-onboarding-finished", appSetTrayDetailOpen: "ccr:app:set-tray-detail-open", + appSetThemePreference: "ccr:app:set-theme-preference", appSetProxyNetworkCaptureEnabled: "ccr:app:set-proxy-network-capture-enabled", appSelectPluginDirectory: "ccr:app:select-plugin-directory", appShowMainWindow: "ccr:app:show-main-window", @@ -71,6 +72,7 @@ export const IPC_CHANNELS = { appUpdateDownload: "ccr:app:update-download", appUpdateInstall: "ccr:app:update-install", appUpdateStatusChanged: "ccr:app:update-status-changed", + appThemePreferenceChanged: "ccr:app:theme-preference-changed", browserBack: "ccr:browser:back", browserCloseTab: "ccr:browser:close-tab", browserForward: "ccr:browser:forward", diff --git a/packages/core/test/unit/config/theme-preference.test.mjs b/packages/core/test/unit/config/theme-preference.test.mjs new file mode 100644 index 00000000..7da418e7 --- /dev/null +++ b/packages/core/test/unit/config/theme-preference.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/config/app-config-store.ts"; +import { loadAppConfig, saveAppConfig, saveAppThemePreference } from "@ccr/core/config/config.ts"; + +test("theme preference persistence changes only the theme field", async () => { + const current = await loadAppConfig(); + const markerHost = "theme-preference.test"; + await replacePersistedAppConfig({ + ...current, + HOST: markerHost, + theme: "system" + }); + + const savedTheme = await saveAppThemePreference("dark"); + const persisted = await loadPersistedAppConfig(); + + assert.equal(savedTheme, "dark"); + assert.equal(persisted.theme, "dark"); + assert.equal(persisted.HOST, markerHost); + assert.equal((await loadAppConfig()).theme, "dark"); + + const staleConfig = { + ...current, + HOST: "theme-preference-stale-save.test", + theme: "system" + }; + const savedConfig = await saveAppConfig(staleConfig); + assert.equal(savedConfig.theme, "dark"); + assert.equal(savedConfig.HOST, staleConfig.HOST); +}); + +test("theme preference persistence rejects unsupported values", async () => { + await assert.rejects( + saveAppThemePreference("sepia"), + /Invalid theme preference/ + ); +}); diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 36d4f48a..d93425e2 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -11,7 +11,7 @@ import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-q import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch"; -import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; +import { loadAppConfig, saveApiKeysConfig, saveAppConfig, saveAppThemePreference } from "@ccr/core/config/config"; import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; import { deepLinkService } from "./deep-link"; import { gatewayService } from "@ccr/core/gateway/service"; @@ -58,6 +58,11 @@ const pluginMarketplace: PluginMarketplaceEntry[] = [ const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; const imageExportTargets = new Map(); +function applyAppThemePreference(theme: AppConfig["theme"]): void { + applyNativeThemePreference(theme); + trayController.refreshTheme(theme); +} + ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { const chatgptAppPath = findInstalledCodexAppExecutable().executable; const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable; @@ -271,7 +276,7 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt } const launchAtLoginChanged = Boolean(config.launchAtLogin) !== Boolean(previousConfig.launchAtLogin); let savedConfig = await saveAppConfig(config); - applyNativeThemePreference(savedConfig.theme); + applyAppThemePreference(savedConfig.theme); if (launchAtLoginChanged) { try { syncLaunchAtLogin(savedConfig); @@ -299,6 +304,11 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt invalidateProviderAccountSnapshotCache(); return savedConfig; }); +ipcMain.handle(IPC_CHANNELS.appSetThemePreference, async (_event, theme: unknown) => { + const savedTheme = await saveAppThemePreference(theme); + applyAppThemePreference(savedTheme); + return savedTheme; +}); ipcMain.handle(IPC_CHANNELS.appSaveApiKeys, async (_event, apiKeys: ApiKeyConfig[]) => { const savedConfig = await saveApiKeysConfig(apiKeys); const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts index 9d2b3925..4bad6e9a 100644 --- a/packages/electron/src/main/preload.ts +++ b/packages/electron/src/main/preload.ts @@ -148,6 +148,7 @@ contextBridge.exposeInMainWorld("ccr", { selectPluginDirectory: () => invoke(IPC_CHANNELS.appSelectPluginDirectory) as Promise, setOnboardingFinished: () => invoke(IPC_CHANNELS.appSetOnboardingFinished) as Promise, setProxyNetworkCaptureEnabled: (enabled: boolean) => invoke(IPC_CHANNELS.appSetProxyNetworkCaptureEnabled, enabled) as Promise, + setThemePreference: (theme: AppConfig["theme"]) => invoke(IPC_CHANNELS.appSetThemePreference, theme) as Promise, setTrayDetailOpen: (open: boolean, provider?: string) => invoke(IPC_CHANNELS.appSetTrayDetailOpen, open, provider) as Promise, showMainWindow: () => invoke(IPC_CHANNELS.appShowMainWindow) as Promise, startGateway: () => invoke(IPC_CHANNELS.appStartGateway) as Promise, @@ -181,6 +182,11 @@ contextBridge.exposeInMainWorld("ccr", { ipcRenderer.on(IPC_CHANNELS.appOpenUpdate, handler); return () => ipcRenderer.removeListener(IPC_CHANNELS.appOpenUpdate, handler); }, + onThemePreferenceChanged: (callback: (theme: AppConfig["theme"]) => void) => { + const handler = (_event: Electron.IpcRendererEvent, theme: AppConfig["theme"]) => callback(theme); + ipcRenderer.on(IPC_CHANNELS.appThemePreferenceChanged, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.appThemePreferenceChanged, handler); + }, onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateStatus) => callback(status); ipcRenderer.on(IPC_CHANNELS.appUpdateStatusChanged, handler); diff --git a/packages/electron/src/main/tray-controller.ts b/packages/electron/src/main/tray-controller.ts index 6ddcc74a..bf012da6 100644 --- a/packages/electron/src/main/tray-controller.ts +++ b/packages/electron/src/main/tray-controller.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { deflateSync } from "node:zlib"; import { loadAppConfig } from "@ccr/core/config/config"; -import { APP_NAME } from "@ccr/core/config/constants"; +import { APP_NAME, IPC_CHANNELS } from "@ccr/core/config/constants"; import { getProviderAccountSnapshots } from "@ccr/core/providers/account-service"; import { getTodayUsageTotals, onUsageRecorded } from "@ccr/core/usage/store"; import windowsManager from "./windows"; @@ -144,6 +144,18 @@ class TrayController { this.applyTrayIcon(this.resolveTrayIconId(nextPreference)); } + refreshTheme(theme: AppConfig["theme"]): void { + for (const window of [this.popover, this.detailPopover]) { + if (!window || window.isDestroyed()) { + continue; + } + applyTrayWindowMaterial(window); + if (!window.webContents.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.appThemePreferenceChanged, theme); + } + } + } + setDetailOpen(open: boolean, _provider?: string): void { if (open) { this.detailOpen = false; @@ -566,15 +578,7 @@ function normalizeDetailProvider(provider?: string): string | undefined { function reinforceTrayWindowMaterial(window: BrowserWindow): void { const applyMaterial = () => { - if (window.isDestroyed()) { - return; - } - if (process.platform === "darwin") { - window.setBackgroundColor("#00000000"); - window.setVibrancy("under-window"); - return; - } - window.setBackgroundColor(trayWindowBackgroundColor()); + applyTrayWindowMaterial(window); }; applyMaterial(); @@ -585,6 +589,18 @@ function reinforceTrayWindowMaterial(window: BrowserWindow): void { } } +function applyTrayWindowMaterial(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + if (process.platform === "darwin") { + window.setBackgroundColor("#00000000"); + window.setVibrancy("under-window"); + return; + } + window.setBackgroundColor(trayWindowBackgroundColor()); +} + function trayWindowBackgroundColor(): string { return nativeTheme.shouldUseDarkColors ? trayWindowDarkBackgroundColor diff --git a/packages/electron/test/unit/native-theme.test.ts b/packages/electron/test/unit/native-theme.test.ts index 8299fbb9..a3b1d2b2 100644 --- a/packages/electron/test/unit/native-theme.test.ts +++ b/packages/electron/test/unit/native-theme.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels.ts"; import { nativeThemeSource } from "@ccr/electron/main/native-theme.ts"; test("native theme source maps explicit preferences and system fallback", () => { @@ -8,3 +9,8 @@ test("native theme source maps explicit preferences and system fallback", () => assert.equal(nativeThemeSource("system"), "system"); assert.equal(nativeThemeSource(undefined), "system"); }); + +test("theme preference IPC uses separate save and renderer notification channels", () => { + assert.equal(IPC_CHANNELS.appSetThemePreference, "ccr:app:set-theme-preference"); + assert.equal(IPC_CHANNELS.appThemePreferenceChanged, "ccr:app:theme-preference-changed"); +}); diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx index ac5eb861..f2b83ac8 100644 --- a/packages/ui/src/components/ui/card.tsx +++ b/packages/ui/src/components/ui/card.tsx @@ -7,7 +7,7 @@ const Card = React.forwardRef( ({ children, className, onChange, onValueChange, options, ...props }, ref) => ( + ); + + assert.match(html, /theme-aware-select/); + assert.equal((html.match(/theme-aware-select-option/g) ?? []).length, 2); +}); + test("Badge renders the selected visual variant", () => { const html = renderToStaticMarkup( @@ -68,6 +81,8 @@ test("Card primitives compose the expected document structure", () => { assert.match(html, /settings-card/); assert.match(html, /

Provider settings<\/h2>/); assert.match(html, /
Ready<\/div>/); + assert.match(html, /var\(--card-inset-highlight\)/); + assert.doesNotMatch(html, /rgba\(255,255,255,0\.5\)/); }); test("Switch renders accessible checked and disabled state", () => { diff --git a/packages/ui/test/component/tray-components.test.tsx b/packages/ui/test/component/tray-components.test.tsx index f4824842..b6eefa36 100644 --- a/packages/ui/test/component/tray-components.test.tsx +++ b/packages/ui/test/component/tray-components.test.tsx @@ -296,6 +296,8 @@ test("TokenActivityPanel renders summary, grid, and legend", () => { assert.match(html, /Activity/); assert.match(html, /Longest streak/); assert.match(html, /aria-label="Activity Tokens"/); + assert.match(html, /tray-activity-tooltip/); + assert.doesNotMatch(html, /bg-slate-950/); assert.match(html, /Less/); assert.match(html, /More/); });