From cab2240f01f75d3c93dc1807aae9cd11fb9467bc Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Tue, 10 Mar 2026 23:17:21 +1300 Subject: [PATCH] Sync desktop native theme with web theme setting - add `desktop:set-theme` IPC channel and bridge method - validate theme values in Electron main process - propagate web theme changes to Electron `nativeTheme` with deduped sync --- apps/desktop/src/main.ts | 37 ++++++++++++++++++++++++++++++++-- apps/desktop/src/preload.ts | 2 ++ apps/web/src/hooks/useTheme.ts | 16 +++++++++++++++ packages/contracts/src/ipc.ts | 2 ++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4fec75804f..ce48a74311 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -4,10 +4,24 @@ import * as FS from "node:fs"; import * as OS from "node:os"; import * as Path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + nativeImage, + nativeTheme, + protocol, + shell, +} from "electron"; import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; -import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import type { + DesktopTheme, + DesktopUpdateActionResult, + DesktopUpdateState, +} from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; @@ -34,6 +48,7 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; +const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -137,6 +152,14 @@ function getSafeExternalUrl(rawUrl: unknown): string | null { return parsedUrl.toString(); } +function getSafeTheme(rawTheme: unknown): DesktopTheme | null { + if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") { + return rawTheme; + } + + return null; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -1037,6 +1060,16 @@ function registerIpcHandlers(): void { return showDesktopConfirmDialog(message, owner); }); + ipcMain.removeHandler(SET_THEME_CHANNEL); + ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { + const theme = getSafeTheme(rawTheme); + if (!theme) { + return; + } + + nativeTheme.themeSource = theme; + }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c941f2ed0f..1e1bb3bd8e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,6 +3,7 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; +const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -16,6 +17,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), + setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 1e6a7cc880..6afe83dfe3 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -11,6 +11,7 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)"; let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; +let lastDesktopTheme: Theme | null = null; function emitChange() { for (const listener of listeners) listener(); } @@ -31,6 +32,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } const isDark = theme === "dark" || (theme === "system" && getSystemDark()); document.documentElement.classList.toggle("dark", isDark); + syncDesktopTheme(theme); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal // oxlint-disable-next-line no-unused-expressions @@ -41,6 +43,20 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } } +function syncDesktopTheme(theme: Theme) { + const bridge = window.desktopBridge; + if (!bridge || lastDesktopTheme === theme) { + return; + } + + lastDesktopTheme = theme; + void bridge.setTheme(theme).catch(() => { + if (lastDesktopTheme === theme) { + lastDesktopTheme = null; + } + }); +} + // Apply immediately on module load to prevent flash applyTheme(getStored()); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4c93970fcc..d355546269 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -63,6 +63,7 @@ export type DesktopUpdateStatus = | "error"; export type DesktopRuntimeArch = "arm64" | "x64" | "other"; +export type DesktopTheme = "light" | "dark" | "system"; export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; @@ -96,6 +97,7 @@ export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; confirm: (message: string) => Promise; + setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number },