Skip to content

Commit 7d91cbc

Browse files
committed
Sync desktop window theme with web theme changes
- Add desktop IPC channel and preload bridge method to set native window theme - Propagate theme updates from `useTheme` on init, user changes, system changes, and storage sync - Add shared `DesktopWindowTheme` contract type and optional `setWindowTheme` bridge API
1 parent 4088b7a commit 7d91cbc

4 files changed

Lines changed: 63 additions & 6 deletions

File tree

‎apps/desktop/src/main.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,23 @@ import * as FS from "node:fs";
44
import * as OS from "node:os";
55
import * as Path from "node:path";
66

7-
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
7+
import {
8+
app,
9+
BrowserWindow,
10+
dialog,
11+
ipcMain,
12+
Menu,
13+
nativeImage,
14+
nativeTheme,
15+
protocol,
16+
shell,
17+
} from "electron";
818
import type { MenuItemConstructorOptions } from "electron";
919
import * as Effect from "effect/Effect";
10-
import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
20+
import type {
21+
DesktopUpdateActionResult,
22+
DesktopUpdateState,
23+
} from "@t3tools/contracts";
1124
import { autoUpdater } from "electron-updater";
1225

1326
import type { ContextMenuItem } from "@t3tools/contracts";
@@ -38,6 +51,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
3851
const CONFIRM_CHANNEL = "desktop:confirm";
3952
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
4053
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
54+
const SET_WINDOW_THEME_CHANNEL = "desktop:set-window-theme";
4155
const MENU_ACTION_CHANNEL = "desktop:menu-action";
4256
const UPDATE_STATE_CHANNEL = "desktop:update-state";
4357
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
@@ -1095,6 +1109,19 @@ function registerIpcHandlers(): void {
10951109
}
10961110
});
10971111

1112+
ipcMain.removeHandler(SET_WINDOW_THEME_CHANNEL);
1113+
ipcMain.handle(SET_WINDOW_THEME_CHANNEL, async (_event, rawTheme: unknown) => {
1114+
if (process.platform !== "win32") {
1115+
return;
1116+
}
1117+
1118+
if (rawTheme !== "light" && rawTheme !== "dark" && rawTheme !== "system") {
1119+
return;
1120+
}
1121+
1122+
nativeTheme.themeSource = rawTheme;
1123+
});
1124+
10981125
ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
10991126
ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);
11001127

@@ -1193,6 +1220,7 @@ function createWindow(): BrowserWindow {
11931220
event.preventDefault();
11941221
window.setTitle(APP_DISPLAY_NAME);
11951222
});
1223+
11961224
window.webContents.on("did-finish-load", () => {
11971225
window.setTitle(APP_DISPLAY_NAME);
11981226
emitUpdateState();

‎apps/desktop/src/preload.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
55
const CONFIRM_CHANNEL = "desktop:confirm";
66
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
77
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
8+
const SET_WINDOW_THEME_CHANNEL = "desktop:set-window-theme";
89
const MENU_ACTION_CHANNEL = "desktop:menu-action";
910
const UPDATE_STATE_CHANNEL = "desktop:update-state";
1011
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
@@ -18,6 +19,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
1819
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
1920
showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
2021
openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
22+
setWindowTheme: (theme) => {
23+
void ipcRenderer.invoke(SET_WINDOW_THEME_CHANNEL, theme);
24+
},
2125
onMenuAction: (listener) => {
2226
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
2327
if (typeof action !== "string") return;

‎apps/web/src/hooks/useTheme.ts‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useCallback, useEffect, useSyncExternalStore } from "react";
22

3+
import type { DesktopWindowTheme } from "@t3tools/contracts";
4+
35
type Theme = "light" | "dark" | "system";
46
type ThemeSnapshot = {
57
theme: Theme;
@@ -25,11 +27,23 @@ function getStored(): Theme {
2527
return "system";
2628
}
2729

30+
function resolveTheme(theme: Theme): DesktopWindowTheme {
31+
return theme === "system" ? (getSystemDark() ? "dark" : "light") : theme;
32+
}
33+
34+
function syncDesktopWindowTheme(theme: Theme) {
35+
if (typeof window === "undefined") {
36+
return;
37+
}
38+
39+
window.desktopBridge?.setWindowTheme?.(theme);
40+
}
41+
2842
function applyTheme(theme: Theme, suppressTransitions = false) {
2943
if (suppressTransitions) {
3044
document.documentElement.classList.add("no-transitions");
3145
}
32-
const isDark = theme === "dark" || (theme === "system" && getSystemDark());
46+
const isDark = resolveTheme(theme) === "dark";
3347
document.documentElement.classList.toggle("dark", isDark);
3448
if (suppressTransitions) {
3549
// Force a reflow so the no-transitions class takes effect before removal
@@ -43,6 +57,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
4357

4458
// Apply immediately on module load to prevent flash
4559
applyTheme(getStored());
60+
syncDesktopWindowTheme(getStored());
4661

4762
function getSnapshot(): ThemeSnapshot {
4863
const theme = getStored();
@@ -62,15 +77,20 @@ function subscribe(listener: () => void): () => void {
6277
// Listen for system preference changes
6378
const mq = window.matchMedia(MEDIA_QUERY);
6479
const handleChange = () => {
65-
if (getStored() === "system") applyTheme("system", true);
80+
if (getStored() === "system") {
81+
applyTheme("system", true);
82+
syncDesktopWindowTheme("system");
83+
}
6684
emitChange();
6785
};
6886
mq.addEventListener("change", handleChange);
6987

7088
// Listen for storage changes from other tabs
7189
const handleStorage = (e: StorageEvent) => {
7290
if (e.key === STORAGE_KEY) {
73-
applyTheme(getStored(), true);
91+
const nextTheme = getStored();
92+
applyTheme(nextTheme, true);
93+
syncDesktopWindowTheme(nextTheme);
7494
emitChange();
7595
}
7696
};
@@ -93,13 +113,15 @@ export function useTheme() {
93113
const setTheme = useCallback((next: Theme) => {
94114
localStorage.setItem(STORAGE_KEY, next);
95115
applyTheme(next, true);
116+
syncDesktopWindowTheme(next);
96117
emitChange();
97118
}, []);
98119

99120
// Keep DOM in sync on mount/change
100121
useEffect(() => {
101122
applyTheme(theme);
102-
}, [theme]);
123+
syncDesktopWindowTheme(theme);
124+
}, [resolvedTheme, theme]);
103125

104126
return { theme, setTheme, resolvedTheme } as const;
105127
}

‎packages/contracts/src/ipc.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export interface DesktopUpdateActionResult {
7777
state: DesktopUpdateState;
7878
}
7979

80+
export type DesktopWindowTheme = "light" | "dark" | "system";
81+
8082
export interface DesktopBridge {
8183
getWsUrl: () => string | null;
8284
pickFolder: () => Promise<string | null>;
@@ -86,6 +88,7 @@ export interface DesktopBridge {
8688
position?: { x: number; y: number },
8789
) => Promise<T | null>;
8890
openExternal: (url: string) => Promise<boolean>;
91+
setWindowTheme?: (theme: DesktopWindowTheme) => void;
8992
onMenuAction: (listener: (action: string) => void) => () => void;
9093
getUpdateState: () => Promise<DesktopUpdateState>;
9194
downloadUpdate: () => Promise<DesktopUpdateActionResult>;

0 commit comments

Comments
 (0)