Skip to content
Merged
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
69 changes: 68 additions & 1 deletion surfsense_local/electron/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand Down
47 changes: 47 additions & 0 deletions surfsense_local/electron/src/main/theme-prefs.ts
Original file line number Diff line number Diff line change
@@ -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`,
)
}
}
22 changes: 22 additions & 0 deletions surfsense_local/electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -31,4 +39,18 @@ contextBridge.exposeInMainWorld("surfsense", {
}): Promise<void> => ipcRenderer.invoke("shell:titlebar-overlay", overlay),
openExternal: (url: string): Promise<void> =>
ipcRenderer.invoke("shell:open-external", url),
theme: {
set: (theme: "dark" | "light" | "system"): Promise<void> =>
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)
},
},
})
27 changes: 27 additions & 0 deletions surfsense_local/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!--
Blocking, mirrors ThemeProvider's resolution logic (theme-provider.tsx)
so the correct theme paints in the first frame instead of flashing light
while the bundle loads and the ThemeProvider effect runs. Must stay in
sync with THEME_STORAGE_KEY / getSystemTheme there. window.surfsense is
already available here: preload runs before this script.
-->
<script>
;(() => {
try {
const storageKey = "surfsense:theme:v1"
const stored = localStorage.getItem(storageKey)
const systemTheme = window.surfsense?.theme?.getSystemTheme
? window.surfsense.theme.getSystemTheme()
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
const theme =
stored === "light" || stored === "dark" ? stored : systemTheme
const root = document.documentElement
root.classList.add(theme)
root.style.colorScheme = theme
} catch {
// no-op: fall back to the CSS defaults if storage/matchMedia throws
}
})()
</script>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SurfSense Community Local</title>
Expand Down
24 changes: 20 additions & 4 deletions surfsense_local/frontend/src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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() {
Expand Down Expand Up @@ -126,6 +136,7 @@ export function ThemeProvider({
root.classList.remove("light", "dark")
root.classList.add(resolvedTheme)
syncTitleBarOverlay(resolvedTheme)
syncMainProcessTheme(nextTheme)

if (restoreTransitions) {
restoreTransitions()
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
</CardTitle>
<CardDescription className="mx-auto max-w-md text-pretty">
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.
</CardDescription>
</CardHeader>
<CardFooter className="justify-center border-t-0 bg-transparent">
Expand Down
7 changes: 7 additions & 0 deletions surfsense_local/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ declare global {
symbolColor: string
}) => Promise<void>
openExternal?: (url: string) => Promise<void>
theme?: {
set: (theme: "dark" | "light" | "system") => Promise<void>
getSystemTheme: () => "dark" | "light"
onSystemThemeChange: (
listener: (theme: "dark" | "light") => void
) => () => void
}
}
}
}
Expand Down
Loading