Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/theme-follow-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@juejin-opensource/jusage-desktop": patch
"@juejin-opensource/jusage-dashboard": patch
---

主题切换收敛为单个按钮,在「跟随系统 → 亮色 → 暗色」间循环,图标显示当前模式。支持跟随系统并实时响应外观变化,手动选择的亮/暗主题会持久化(桌面重启后保持,web 刷新后保持)。
22 changes: 22 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Domain Context

## Terms

### Theme Mode(主题模式)
- **Definition**: 用户在应用内选择的主题偏好,三态:`system`(跟随系统)、`light`(亮色)、`dark`(暗色)。
- **Rules/Invariants**:
- 是用户的**选择**,可持久化,重启后保持。
- 选择 `light` / `dark` 即退出跟随系统,直到用户重新选择 `system`。
- 默认值为 `system`(首次安装 / 升级后未手动选择时)。

### Theme(生效主题)
- **Definition**: 应用实际渲染使用的主题,二态:`light` / `dark`。
- **Rules/Invariants**:
- 由 Theme Mode 与操作系统深浅色共同解析得出(`system` 模式下跟随系统实时变化)。
- 同一时刻只有一个生效主题,所有窗口(主窗口、托盘弹窗)保持一致。

### 跟随系统(Follow System)
- **Definition**: Theme Mode 为 `system` 时,应用自动检测操作系统的深浅色偏好(Windows / macOS / Linux 的系统外观设置),并在系统切换时实时跟随。
- **Rules/Invariants**:
- 检测以系统当前实际外观为准(如 Windows 的「深色」应用模式、macOS 的「外观」设置)。
- 系统主题在应用运行期间变化时,应用应实时响应,无需重启。
2 changes: 1 addition & 1 deletion apps/desktop/src/main/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface DesktopWindowOptions {

export type DesktopWindowTheme = 'dark' | 'light';

const WINDOW_BACKGROUND_COLORS: Record<DesktopWindowTheme, string> = {
export const WINDOW_BACKGROUND_COLORS: Record<DesktopWindowTheme, string> = {
dark: '#050607',
light: '#f5f5f5',
};
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/main/TrayPopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from 'electron';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { defaultPreloadPath, resolveAppIconPath } from './DesktopWindow';
import { defaultPreloadPath, resolveAppIconPath, WINDOW_BACKGROUND_COLORS, type DesktopWindowTheme } from './DesktopWindow';
import { pokeSyncOnForeground } from './local-runtime';

/**
Expand Down Expand Up @@ -45,11 +45,14 @@ export interface TrayPopoverOptions {
openSettings: () => void;
/** Same as in-app「同步数据」→ POST tud-trigger-sync. */
triggerSync: () => void;
/** Initial popover window background, kept in sync with the app theme. */
theme: DesktopWindowTheme;
}

let tray: Tray | null = null;
let popover: BrowserWindow | null = null;
let isQuitting = false;
let popoverTheme: DesktopWindowTheme = 'light';
/** Latest content height reported by the renderer, used when re-anchoring. */
let popoverHeight = POPOVER_INITIAL_HEIGHT;

Expand Down Expand Up @@ -111,6 +114,7 @@ function ensurePopover(): BrowserWindow {
show: false,
frame: false,
fullscreenable: false,
backgroundColor: WINDOW_BACKGROUND_COLORS[popoverTheme],
// Keep native resizing enabled for programmatic auto-height updates. The
// active height is locked with equal min/max bounds after every update.
resizable: true,
Expand Down Expand Up @@ -250,8 +254,17 @@ export function hideTrayPopover(): void {
popover.hide();
}

/** Keep the popover's native background in sync with the app theme. */
export function setPopoverTheme(theme: DesktopWindowTheme): void {
popoverTheme = theme;
if (popover && !popover.isDestroyed()) {
popover.setBackgroundColor(WINDOW_BACKGROUND_COLORS[theme]);
}
}

export function createTrayPopover(options: TrayPopoverOptions): void {
if (tray) return;
popoverTheme = options.theme;

tray = new Tray(loadTrayIcon());
tray.setToolTip('Juejin Usage');
Expand Down
54 changes: 43 additions & 11 deletions apps/desktop/src/main/autostart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
isDashboardRange,
type DashboardRange,
} from '../shared/dashboard-range';
import { isThemeMode, type ThemeMode } from '../shared/theme';

const AUTOSTART_GET_CHANNEL = 'autostart:get';
const AUTOSTART_SET_CHANNEL = 'autostart:set';
Expand Down Expand Up @@ -48,6 +49,8 @@ interface DesktopPrefs {
/** 开机自启时是否静默启动(仅托盘,不显示主窗口)。默认开启。 */
launchHidden: boolean;
desktopPet?: DesktopPetPref;
/** 主题模式(system / light / dark)。缺省跟随系统。 */
themeMode?: ThemeMode;
/**
* Last dashboard time range. Stored here so the pet window can follow it;
* pet.html does not share the dashboard renderer's localStorage origin.
Expand Down Expand Up @@ -81,6 +84,7 @@ async function readPrefsFile(): Promise<DesktopPrefs | null> {
launchHidden: typeof parsed.launchHidden === 'boolean'
? parsed.launchHidden
: true,
themeMode: isThemeMode(parsed.themeMode) ? parsed.themeMode : 'system',
dashboardRange: isDashboardRange(parsed.dashboardRange)
? parsed.dashboardRange
: undefined,
Expand Down Expand Up @@ -115,18 +119,32 @@ async function writePrefs(prefs: DesktopPrefs): Promise<void> {
await writeFile(prefsPath(), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8');
}

/** Serialize prefs read-modify-write so consecutive updates cannot clobber
* each other (theme switches are user-paced, but a quick flip could otherwise
* interleave reads against the same file). */
let prefsQueue: Promise<unknown> = Promise.resolve();

function withPrefsLock<T>(task: () => Promise<T>): Promise<T> {
const run = prefsQueue.then(task, task);
prefsQueue = run.then(() => undefined, () => undefined);
return run;
}

async function patchPrefs(patch: Partial<DesktopPrefs>): Promise<DesktopPrefs> {
const existing = await readPrefsFile();
const next: DesktopPrefs = {
openAtLogin: patch.openAtLogin ?? existing?.openAtLogin ?? true,
launchHidden: patch.launchHidden ?? existing?.launchHidden ?? true,
desktopPet: patch.desktopPet !== undefined ? patch.desktopPet : existing?.desktopPet,
dashboardRange: patch.dashboardRange !== undefined
? patch.dashboardRange
: existing?.dashboardRange,
};
await writePrefs(next);
return next;
return withPrefsLock(async () => {
const existing = await readPrefsFile();
const next: DesktopPrefs = {
openAtLogin: patch.openAtLogin ?? existing?.openAtLogin ?? true,
launchHidden: patch.launchHidden ?? existing?.launchHidden ?? true,
desktopPet: patch.desktopPet !== undefined ? patch.desktopPet : existing?.desktopPet,
themeMode: patch.themeMode !== undefined ? patch.themeMode : existing?.themeMode,
dashboardRange: patch.dashboardRange !== undefined
? patch.dashboardRange
: existing?.dashboardRange,
};
await writePrefs(next);
return next;
});
}

function broadcastDashboardRange(range: DashboardRange): void {
Expand All @@ -137,6 +155,20 @@ function broadcastDashboardRange(range: DashboardRange): void {
}
}

/** Persisted theme mode; defaults to following the OS. */
export function loadThemeMode(): Promise<ThemeMode> {
// Read under the same lock as writes so a concurrent writeFile truncation
// cannot surface a half-written prefs file.
return withPrefsLock(async () => {
const prefs = await readPrefsFile();
return prefs?.themeMode ?? 'system';
});
}

export function saveThemeMode(mode: ThemeMode): Promise<void> {
return patchPrefs({ themeMode: mode }).then(() => undefined);
}

/** Frozen at init: was *this* process started as a silent login launch? */
let silentThisLaunch = false;

Expand Down
93 changes: 76 additions & 17 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { app, BrowserWindow, clipboard, ipcMain, nativeImage } from 'electron';
import { app, BrowserWindow, clipboard, ipcMain, nativeImage, nativeTheme } from 'electron';
import {
initAutostartOnLaunch,
loadThemeMode,
registerAutostartIpc,
saveThemeMode,
shouldStartHidden,
unregisterAutostartIpc,
} from './autostart';
Expand All @@ -16,7 +18,7 @@ import {
type DesktopWindowTheme,
unregisterOpenExternalIpc,
} from './DesktopWindow';
import { createTrayPopover, disposeTrayPopover, hideTrayPopover } from './TrayPopover';
import { createTrayPopover, disposeTrayPopover, hideTrayPopover, setPopoverTheme } from './TrayPopover';
import { registerLocalApiIpc } from './local-api-ipc';
import {
localApiRequest,
Expand All @@ -40,6 +42,7 @@ import {
type OpenSettingsPayload,
} from './deep-link';
import { disposeAutoUpdate, initializeAutoUpdate } from './auto-update';
import { isThemeMode, resolveTheme, type ThemeMode } from '../shared/theme';
import {
DEFAULT_DATA_DIR,
evictRuntimeKind,
Expand Down Expand Up @@ -67,6 +70,7 @@ const SHARE_CARD_COPY_IMAGE_CHANNEL = 'share-card:copy-image';

const windows = new Set<DesktopWindow>();
let disposeLocalApiIpc: (() => void) | null = null;
let currentThemeMode: ThemeMode = 'system';
let currentTheme: Theme = 'light';
let pendingDeepLinkUrl: string | null = null;
let runtimeReady = false;
Expand Down Expand Up @@ -103,26 +107,62 @@ function broadcastConfigResetNotice(): void {
}
}

function isTheme(value: unknown): value is Theme {
return value === 'light' || value === 'dark';
function onNativeThemeUpdated(): void {
// `system` mode re-resolves on OS appearance changes; fixed modes keep the
// resolved theme stable (shouldUseDarkColors follows themeSource).
const next = resolveTheme(currentThemeMode, nativeTheme.shouldUseDarkColors);
if (next === currentTheme) return;
applyResolvedTheme(next);
broadcastThemeState();
}

/** Apply the rendered theme to window chrome; no-op when unchanged. */
function applyResolvedTheme(next: Theme): void {
if (next === currentTheme) return;
currentTheme = next;
for (const desktopWindow of windows) {
if (desktopWindow.window.isDestroyed()) continue;
desktopWindow.setThemeBackground(currentTheme);
}
setPopoverTheme(currentTheme);
}

/** Push the full theme state to every window: mode drives the selector
* highlight, resolved drives the rendered theme. */
function broadcastThemeState(): void {
const payload = { mode: currentThemeMode, resolved: currentTheme };
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(THEME_CHANGED_CHANNEL, payload);
}
}

function registerThemeIpc(): void {
ipcMain.removeHandler(THEME_GET_CHANNEL);
ipcMain.handle(THEME_GET_CHANNEL, () => currentTheme);
ipcMain.handle(THEME_GET_CHANNEL, () => ({
mode: currentThemeMode,
resolved: currentTheme,
}));

ipcMain.removeAllListeners(THEME_SET_CHANNEL);
ipcMain.on(THEME_SET_CHANNEL, (_event, nextTheme: unknown) => {
if (!isTheme(nextTheme) || nextTheme === currentTheme) return;
currentTheme = nextTheme;
for (const desktopWindow of windows) {
if (desktopWindow.window.isDestroyed()) continue;
desktopWindow.setThemeBackground(currentTheme);
}
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(THEME_CHANGED_CHANNEL, currentTheme);
}
ipcMain.on(THEME_SET_CHANNEL, (_event, nextMode: unknown) => {
if (!isThemeMode(nextMode) || nextMode === currentThemeMode) return;
currentThemeMode = nextMode;
nativeTheme.themeSource = nextMode;
// Persist the user's choice; failure must not block the in-memory switch.
saveThemeMode(nextMode).catch((err) => {
console.error(
'[tud-desktop] failed to persist theme mode:',
err instanceof Error ? err.message : err,
);
});
// Always broadcast the full state: the selector highlight must follow the
// new mode even when the resolved theme does not change.
applyResolvedTheme(resolveTheme(nextMode, nativeTheme.shouldUseDarkColors));
broadcastThemeState();
});

nativeTheme.removeListener('updated', onNativeThemeUpdated);
nativeTheme.on('updated', onNativeThemeUpdated);
}

function registerShareCardIpc(): void {
Expand Down Expand Up @@ -168,7 +208,7 @@ async function showMainWindowAsync(): Promise<void> {
// Window was destroyed on close (tray-resident app). Rebuild it. On
// macOS the dock was hidden at close; await the accessory→regular
// transform so the freshly built window is not hidden by macOS mid-flight.
if (process.platform === 'darwin' && !app.dock.isVisible()) {
if (process.platform === 'darwin' && app.dock && !app.dock.isVisible()) {
try {
await app.dock.show();
} catch {
Expand Down Expand Up @@ -362,6 +402,23 @@ void acquireDesktopInstanceLock().then((gotLock) => {

app.whenReady().then(async () => {
applyDevDockIcon();

// Restore the persisted theme mode before any window / IPC is registered
// so the first window and theme:get already resolve correctly.
try {
currentThemeMode = await loadThemeMode();
} catch (err) {
console.error(
'[tud-desktop] failed to load theme mode:',
err instanceof Error ? err.message : err,
);
}
nativeTheme.themeSource = currentThemeMode;
currentTheme = resolveTheme(
currentThemeMode,
nativeTheme.shouldUseDarkColors,
);

ipcMain.removeAllListeners('app:quit');
ipcMain.on('app:quit', () => app.quit());

Expand Down Expand Up @@ -426,7 +483,7 @@ void acquireDesktopInstanceLock().then((gotLock) => {
// to destroy it on ready-to-show still pays for a full dashboard load and
// drops IPC (config-reset / deep-link) aimed at that doomed window.
if (shouldStartHidden()) {
if (process.platform === 'darwin') {
if (process.platform === 'darwin' && app.dock) {
app.dock.hide();
}
} else {
Expand All @@ -438,6 +495,7 @@ void acquireDesktopInstanceLock().then((gotLock) => {
showMainWindow,
openSettings: () => openSettings(),
triggerSync,
theme: currentTheme,
});

if (pendingDeepLinkUrl) {
Expand Down Expand Up @@ -478,6 +536,7 @@ void acquireDesktopInstanceLock().then((gotLock) => {
ipcMain.removeHandler(THEME_GET_CHANNEL);
ipcMain.removeHandler(SHARE_CARD_COPY_IMAGE_CHANNEL);
ipcMain.removeAllListeners(THEME_SET_CHANNEL);
nativeTheme.removeListener('updated', onNativeThemeUpdated);
unregisterOpenExternalIpc();
unregisterAutostartIpc();
unregisterDesktopPetIpc();
Expand Down
20 changes: 14 additions & 6 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isDashboardRange,
type DashboardRange,
} from '../shared/dashboard-range';
import { isThemeMode, type Theme, type ThemeMode } from '../shared/theme';

const API_REQUEST_CHANNEL = 'tud:api-request';
const DATA_SYNCED_CHANNEL = 'tud:data-synced';
Expand Down Expand Up @@ -105,15 +106,22 @@ const tudApi = {
return () => ipcRenderer.removeListener(DASHBOARD_RANGE_CHANGED_CHANNEL, listener);
},

getTheme: (): Promise<'light' | 'dark'> =>
getTheme: (): Promise<{ mode: ThemeMode; resolved: Theme }> =>
ipcRenderer.invoke(THEME_GET_CHANNEL),

setTheme: (theme: 'light' | 'dark') =>
ipcRenderer.send(THEME_SET_CHANNEL, theme),
setThemeMode: (mode: ThemeMode) =>
ipcRenderer.send(THEME_SET_CHANNEL, mode),

onThemeChanged: (callback: (theme: 'light' | 'dark') => void) => {
const listener = (_event: unknown, theme: 'light' | 'dark') => {
if (theme === 'light' || theme === 'dark') callback(theme);
onThemeChanged: (
callback: (state: { mode: ThemeMode; resolved: Theme }) => void,
) => {
const listener = (
_event: unknown,
state: { mode: ThemeMode; resolved: Theme },
) => {
if (isThemeMode(state?.mode) && (state.resolved === 'light' || state.resolved === 'dark')) {
callback(state);
}
};
ipcRenderer.on(THEME_CHANGED_CHANNEL, listener);
return () => ipcRenderer.removeListener(THEME_CHANGED_CHANNEL, listener);
Expand Down
Loading