diff --git a/surfsense_local/electron/src/main/index.ts b/surfsense_local/electron/src/main/index.ts index 1de835749c..df29d0dd5e 100644 --- a/surfsense_local/electron/src/main/index.ts +++ b/surfsense_local/electron/src/main/index.ts @@ -1,6 +1,14 @@ import { join } from "node:path" -import { app, BrowserWindow, ipcMain, Menu, safeStorage, shell } from "electron" +import { + app, + BrowserWindow, + ipcMain, + Menu, + nativeTheme, + safeStorage, + shell, +} from "electron" // Static on purpose: electron-updater is CJS and exposes `autoUpdater` through // a getter, which `await import()` cannot see (named export comes back // undefined). require() honours it, and the getter is lazy so dev pays nothing. @@ -32,6 +40,11 @@ import { type Updates, type UpdateState, } from "./updater.ts" +import { + loadThemePreference, + saveThemePreference, + type ThemePreference, +} from "./theme-prefs.ts" import { loadWindowState, saveWindowState } from "./window-state.ts" const DEV_RENDERER_URL = "http://localhost:5173" @@ -185,6 +198,18 @@ function registerDocumentHandlers(dataDir: string): void { applyTitleBarOverlay(mainWindow, overlay) }) + ipcMain.handle("theme:set", (event, theme: unknown) => { + if ( + !trusted(event.sender) || + event.senderFrame !== event.sender.mainFrame + ) { + return + } + if (theme !== "dark" && theme !== "light" && theme !== "system") return + saveThemePreference(theme) + applyBackgroundColorToAllWindows(theme) + }) + ipcMain.handle("documents:open", async (event, workspaceId, documentId) => { if ( !trusted(event.sender) || @@ -296,6 +321,29 @@ function applyTitleBarOverlay( }) } +// Mirrors --app-shell in frontend/src/index.css (:root / .dark). Used as the +// BrowserWindow's native backgroundColor so a reload shows the right theme +// immediately instead of flashing Electron's default opaque white while the +// page is torn down and reloaded. +// https://www.electronjs.org/docs/latest/api/browser-window#showing-window-gracefully +const APP_SHELL_LIGHT = "#f3f2ee" +const APP_SHELL_DARK = "#101010" + +function resolveBackgroundColor(theme: ThemePreference): string { + const resolvedDark = + theme === "system" ? nativeTheme.shouldUseDarkColors : theme === "dark" + return resolvedDark ? APP_SHELL_DARK : APP_SHELL_LIGHT +} + +function currentWindows(): BrowserWindow[] { + return BrowserWindow.getAllWindows() +} + +function applyBackgroundColorToAllWindows(theme: ThemePreference): void { + const color = resolveBackgroundColor(theme) + for (const win of currentWindows()) win.setBackgroundColor(color) +} + // Packaged only. Dev keeps Electron's default View menu (reload + DevTools). // https://www.electronjs.org/docs/latest/tutorial/application-menu function installProductionMenu(): void { @@ -340,6 +388,7 @@ function createWindow(apiUrl: string): void { const savedState = app.isPackaged ? loadWindowState() : null const win = new BrowserWindow({ ...(savedState?.bounds ?? { width: 1280, height: 800 }), + backgroundColor: resolveBackgroundColor(loadThemePreference()), show: false, // https://www.electronjs.org/docs/latest/tutorial/custom-title-bar titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "hidden", @@ -398,6 +447,24 @@ function main(): void { app.on("web-contents-created", (_event, contents) => { denyAppWindows(contents) }) + + // The renderer's own matchMedia isn't a reliable single source of truth + // for the OS theme inside a packaged app (it can lag or diverge from what + // Chromium/Electron itself resolves), so nativeTheme is authoritative and + // the renderer only ever mirrors it: a sync read on preload boot for the + // first paint, then this push on every change. + ipcMain.on("theme:get-system", (event) => { + event.returnValue = nativeTheme.shouldUseDarkColors ? "dark" : "light" + }) + nativeTheme.on("updated", () => { + const systemTheme = nativeTheme.shouldUseDarkColors ? "dark" : "light" + for (const win of currentWindows()) { + win.webContents.send("theme:system-changed", systemTheme) + } + if (loadThemePreference() === "system") { + applyBackgroundColorToAllWindows("system") + } + }) app .whenReady() .then(async () => { diff --git a/surfsense_local/electron/src/main/theme-prefs.ts b/surfsense_local/electron/src/main/theme-prefs.ts new file mode 100644 index 0000000000..b061c15e11 --- /dev/null +++ b/surfsense_local/electron/src/main/theme-prefs.ts @@ -0,0 +1,47 @@ +import { readFileSync, renameSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import { app } from "electron" + +// Mirrors the Theme type in frontend/src/components/theme-provider.tsx. +export type ThemePreference = "dark" | "light" | "system" + +const THEME_VALUES: ThemePreference[] = ["dark", "light", "system"] + +function prefsPath(): string { + return join(app.getPath("userData"), "theme-prefs.json") +} + +function isThemePreference(value: unknown): value is ThemePreference { + return ( + typeof value === "string" && THEME_VALUES.includes(value as ThemePreference) + ) +} + +export function loadThemePreference(): ThemePreference { + try { + const parsed: unknown = JSON.parse(readFileSync(prefsPath(), "utf8")) + const theme = + parsed && typeof parsed === "object" + ? (parsed as { theme?: unknown }).theme + : undefined + return isThemePreference(theme) ? theme : "system" + } catch { + return "system" + } +} + +export function saveThemePreference(theme: ThemePreference): void { + try { + const path = prefsPath() + const temporary = `${path}.tmp` + writeFileSync(temporary, JSON.stringify({ theme })) + renameSync(temporary, path) + } catch (error) { + // ponytail: best-effort, same as window-state.ts; a stale/missing pref + // just falls back to "system" next launch. + process.stderr.write( + `[main] failed to save theme preference: ${String(error)}\n`, + ) + } +} diff --git a/surfsense_local/electron/src/preload/index.ts b/surfsense_local/electron/src/preload/index.ts index 5896430913..1b162cfeb8 100644 --- a/surfsense_local/electron/src/preload/index.ts +++ b/surfsense_local/electron/src/preload/index.ts @@ -4,6 +4,14 @@ import { contextBridge, ipcRenderer } from "electron" const FLAG = "--surfsense-api-url=" const arg = process.argv.find((a) => a.startsWith(FLAG)) +// nativeTheme (main process) is the single source of truth for the OS theme. +// A sync IPC read here means the value is already correct by the time the +// page's own first-paint script runs, and the "updated" push keeps it live. +let systemTheme = ipcRenderer.sendSync("theme:get-system") as "dark" | "light" +ipcRenderer.on("theme:system-changed", (_event, theme: unknown) => { + if (theme === "dark" || theme === "light") systemTheme = theme +}) + // the renderer talks HTTP to this base and never sees Node or the sidecars contextBridge.exposeInMainWorld("surfsense", { apiUrl: arg ? arg.slice(FLAG.length) : "http://127.0.0.1:8000", @@ -31,4 +39,18 @@ contextBridge.exposeInMainWorld("surfsense", { }): Promise => ipcRenderer.invoke("shell:titlebar-overlay", overlay), openExternal: (url: string): Promise => ipcRenderer.invoke("shell:open-external", url), + theme: { + set: (theme: "dark" | "light" | "system"): Promise => + ipcRenderer.invoke("theme:set", theme), + getSystemTheme: (): "dark" | "light" => systemTheme, + onSystemThemeChange: ( + listener: (theme: "dark" | "light") => void + ): (() => void) => { + const wrapped = (_event: unknown, theme: unknown) => { + if (theme === "dark" || theme === "light") listener(theme) + } + ipcRenderer.on("theme:system-changed", wrapped) + return () => ipcRenderer.removeListener("theme:system-changed", wrapped) + }, + }, }) diff --git a/surfsense_local/frontend/index.html b/surfsense_local/frontend/index.html index 87b2962035..b0dba5c141 100644 --- a/surfsense_local/frontend/index.html +++ b/surfsense_local/frontend/index.html @@ -2,6 +2,33 @@ + + SurfSense Community Local diff --git a/surfsense_local/frontend/src/components/theme-provider.tsx b/surfsense_local/frontend/src/components/theme-provider.tsx index 790944206b..735047f26b 100644 --- a/surfsense_local/frontend/src/components/theme-provider.tsx +++ b/surfsense_local/frontend/src/components/theme-provider.tsx @@ -32,6 +32,12 @@ function isTheme(value: string | null): value is Theme { return THEME_VALUES.includes(value as Theme) } +function syncMainProcessTheme(theme: Theme) { + // Lets main pick a matching BrowserWindow backgroundColor, so the next + // reload/launch doesn't flash the wrong theme before this effect can run. + void window.surfsense?.theme?.set(theme) +} + function syncTitleBarOverlay(theme: ResolvedTheme) { const platform = window.surfsense?.platform if (platform !== "win32" && platform !== "linux") { @@ -45,11 +51,15 @@ function syncTitleBarOverlay(theme: ResolvedTheme) { } function getSystemTheme(): ResolvedTheme { - if (window.matchMedia(COLOR_SCHEME_QUERY).matches) { - return "dark" + // nativeTheme (main process) is authoritative when running in Electron; + // matchMedia is only a fallback for environments without the bridge + // (tests, a bare `vite` preview outside the app shell). + const fromMain = window.surfsense?.theme?.getSystemTheme?.() + if (fromMain === "dark" || fromMain === "light") { + return fromMain } - return "light" + return window.matchMedia(COLOR_SCHEME_QUERY).matches ? "dark" : "light" } function disableTransitionsTemporarily() { @@ -126,6 +136,7 @@ export function ThemeProvider({ root.classList.remove("light", "dark") root.classList.add(resolvedTheme) syncTitleBarOverlay(resolvedTheme) + syncMainProcessTheme(nextTheme) if (restoreTransitions) { restoreTransitions() @@ -141,11 +152,16 @@ export function ThemeProvider({ return undefined } - const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY) const handleChange = () => { applyTheme("system") } + const onSystemThemeChange = window.surfsense?.theme?.onSystemThemeChange + if (onSystemThemeChange) { + return onSystemThemeChange(handleChange) + } + + const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY) mediaQuery.addEventListener("change", handleChange) return () => { diff --git a/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx b/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx index ac3f0cd3a4..e571a1cba6 100644 --- a/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx +++ b/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx @@ -76,8 +76,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) { SurfSense turns scattered documents, notes, and sources into one - searchable workspace. Nothing leaves this computer until you allow it; - you will be asked the first time. + searchable workspace. Nothing leaves your machine without asking first. diff --git a/surfsense_local/frontend/src/lib/api.ts b/surfsense_local/frontend/src/lib/api.ts index 3da5a1d9ee..a0d08488b0 100644 --- a/surfsense_local/frontend/src/lib/api.ts +++ b/surfsense_local/frontend/src/lib/api.ts @@ -25,6 +25,13 @@ declare global { symbolColor: string }) => Promise openExternal?: (url: string) => Promise + theme?: { + set: (theme: "dark" | "light" | "system") => Promise + getSystemTheme: () => "dark" | "light" + onSystemThemeChange: ( + listener: (theme: "dark" | "light") => void + ) => () => void + } } } }