authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-03-11 06:23:52+13:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 10:23:52-07:00
log1e9bac7f6e783fe0498ddeb8392c5f4b50aad19d
tree40c9a2bbf74e92c9eb1480953117e0a52c0727d8
parent1c290767f22f9d8c7d3fa378d9450c8f48184679
signature Signed by PGP key B5690EEEBB952194

Sync desktop native theme with web theme setting (#800)


4 files changed, 55 insertions(+), 2 deletions(-)

apps/desktop/src/main.ts+35-2
...@@ -4,10 +4,24 @@ import * as FS from "node:fs";...@@ -4,10 +4,24 @@ import * as FS from "node:fs";
4import * as OS from "node:os";4import * as OS from "node:os";
5import * as Path from "node:path";5import * as Path from "node:path";
66
7import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";7import {
8 app,
9 BrowserWindow,
10 dialog,
11 ipcMain,
12 Menu,
13 nativeImage,
14 nativeTheme,
15 protocol,
16 shell,
17} from "electron";
8import type { MenuItemConstructorOptions } from "electron";18import type { MenuItemConstructorOptions } from "electron";
9import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
10import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";20import type {
21 DesktopTheme,
22 DesktopUpdateActionResult,
23 DesktopUpdateState,
24} from "@t3tools/contracts";
11import { autoUpdater } from "electron-updater";25import { autoUpdater } from "electron-updater";
1226
13import type { ContextMenuItem } from "@t3tools/contracts";27import type { ContextMenuItem } from "@t3tools/contracts";
...@@ -34,6 +48,7 @@ fixPath();...@@ -34,6 +48,7 @@ fixPath();
3448
35const PICK_FOLDER_CHANNEL = "desktop:pick-folder";49const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
36const CONFIRM_CHANNEL = "desktop:confirm";50const CONFIRM_CHANNEL = "desktop:confirm";
51const SET_THEME_CHANNEL = "desktop:set-theme";
37const CONTEXT_MENU_CHANNEL = "desktop:context-menu";52const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
38const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";53const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
39const MENU_ACTION_CHANNEL = "desktop:menu-action";54const MENU_ACTION_CHANNEL = "desktop:menu-action";
...@@ -137,6 +152,14 @@ function getSafeExternalUrl(rawUrl: unknown): string | null {...@@ -137,6 +152,14 @@ function getSafeExternalUrl(rawUrl: unknown): string | null {
137 return parsedUrl.toString();152 return parsedUrl.toString();
138}153}
139154
155function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
156 if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") {
157 return rawTheme;
158 }
159
160 return null;
161}
162
140function writeDesktopStreamChunk(163function writeDesktopStreamChunk(
141 streamName: "stdout" | "stderr",164 streamName: "stdout" | "stderr",
142 chunk: unknown,165 chunk: unknown,
...@@ -1037,6 +1060,16 @@ function registerIpcHandlers(): void {...@@ -1037,6 +1060,16 @@ function registerIpcHandlers(): void {
1037 return showDesktopConfirmDialog(message, owner);1060 return showDesktopConfirmDialog(message, owner);
1038 });1061 });
10391062
1063 ipcMain.removeHandler(SET_THEME_CHANNEL);
1064 ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => {
1065 const theme = getSafeTheme(rawTheme);
1066 if (!theme) {
1067 return;
1068 }
1069
1070 nativeTheme.themeSource = theme;
1071 });
1072
1040 ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);1073 ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
1041 ipcMain.handle(1074 ipcMain.handle(
1042 CONTEXT_MENU_CHANNEL,1075 CONTEXT_MENU_CHANNEL,
apps/desktop/src/preload.ts+2
...@@ -3,6 +3,7 @@ import type { DesktopBridge } from "@t3tools/contracts";...@@ -3,6 +3,7 @@ import type { DesktopBridge } from "@t3tools/contracts";
33
4const PICK_FOLDER_CHANNEL = "desktop:pick-folder";4const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
5const CONFIRM_CHANNEL = "desktop:confirm";5const CONFIRM_CHANNEL = "desktop:confirm";
6const SET_THEME_CHANNEL = "desktop:set-theme";
6const CONTEXT_MENU_CHANNEL = "desktop:context-menu";7const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
7const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";8const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
8const MENU_ACTION_CHANNEL = "desktop:menu-action";9const MENU_ACTION_CHANNEL = "desktop:menu-action";
...@@ -16,6 +17,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -16,6 +17,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
16 getWsUrl: () => wsUrl,17 getWsUrl: () => wsUrl,
17 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),18 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
18 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),19 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
20 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
19 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),21 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
20 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),22 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
21 onMenuAction: (listener) => {23 onMenuAction: (listener) => {
apps/web/src/hooks/useTheme.ts+16
...@@ -11,6 +11,7 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)";...@@ -11,6 +11,7 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)";
1111
12let listeners: Array<() => void> = [];12let listeners: Array<() => void> = [];
13let lastSnapshot: ThemeSnapshot | null = null;13let lastSnapshot: ThemeSnapshot | null = null;
14let lastDesktopTheme: Theme | null = null;
14function emitChange() {15function emitChange() {
15 for (const listener of listeners) listener();16 for (const listener of listeners) listener();
16}17}
...@@ -31,6 +32,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {...@@ -31,6 +32,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
31 }32 }
32 const isDark = theme === "dark" || (theme === "system" && getSystemDark());33 const isDark = theme === "dark" || (theme === "system" && getSystemDark());
33 document.documentElement.classList.toggle("dark", isDark);34 document.documentElement.classList.toggle("dark", isDark);
35 syncDesktopTheme(theme);
34 if (suppressTransitions) {36 if (suppressTransitions) {
35 // Force a reflow so the no-transitions class takes effect before removal37 // Force a reflow so the no-transitions class takes effect before removal
36 // oxlint-disable-next-line no-unused-expressions38 // oxlint-disable-next-line no-unused-expressions
...@@ -41,6 +43,20 @@ function applyTheme(theme: Theme, suppressTransitions = false) {...@@ -41,6 +43,20 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
41 }43 }
42}44}
4345
46function syncDesktopTheme(theme: Theme) {
47 const bridge = window.desktopBridge;
48 if (!bridge || lastDesktopTheme === theme) {
49 return;
50 }
51
52 lastDesktopTheme = theme;
53 void bridge.setTheme(theme).catch(() => {
54 if (lastDesktopTheme === theme) {
55 lastDesktopTheme = null;
56 }
57 });
58}
59
44// Apply immediately on module load to prevent flash60// Apply immediately on module load to prevent flash
45applyTheme(getStored());61applyTheme(getStored());
4662
packages/contracts/src/ipc.ts+2
...@@ -64,6 +64,7 @@ export type DesktopUpdateStatus =...@@ -64,6 +64,7 @@ export type DesktopUpdateStatus =
64 | "error";64 | "error";
6565
66export type DesktopRuntimeArch = "arm64" | "x64" | "other";66export type DesktopRuntimeArch = "arm64" | "x64" | "other";
67export type DesktopTheme = "light" | "dark" | "system";
6768
68export interface DesktopRuntimeInfo {69export interface DesktopRuntimeInfo {
69 hostArch: DesktopRuntimeArch;70 hostArch: DesktopRuntimeArch;
...@@ -97,6 +98,7 @@ export interface DesktopBridge {...@@ -97,6 +98,7 @@ export interface DesktopBridge {
97 getWsUrl: () => string | null;98 getWsUrl: () => string | null;
98 pickFolder: () => Promise<string | null>;99 pickFolder: () => Promise<string | null>;
99 confirm: (message: string) => Promise<boolean>;100 confirm: (message: string) => Promise<boolean>;
101 setTheme: (theme: DesktopTheme) => Promise<void>;
100 showContextMenu: <T extends string>(102 showContextMenu: <T extends string>(
101 items: readonly ContextMenuItem<T>[],103 items: readonly ContextMenuItem<T>[],
102 position?: { x: number; y: number },104 position?: { x: number; y: number },