From 510bc28e4a5ea0d005d687431d13f9a9c8ef9a72 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 22 Apr 2026 01:48:29 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20CSS=20theme=20support=20=E2=80=94=20the?= =?UTF-8?q?mes=20folder,=20tray=20cycle/reload/open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/native/themes.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++ src/native/tray.ts | 30 +++++++++++++++ src/native/window.ts | 4 ++ 3 files changed, 126 insertions(+) create mode 100644 src/native/themes.ts diff --git a/src/native/themes.ts b/src/native/themes.ts new file mode 100644 index 0000000..e15437e --- /dev/null +++ b/src/native/themes.ts @@ -0,0 +1,92 @@ +import { app, shell } from "electron"; +import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "fs"; +import { join } from "path"; + +import { updateTrayMenu } from "./tray"; + +const themesDir = join(app.getPath("userData"), "themes"); + +const SAMPLE = `/* Sanctum – example theme + Rename this file to anything.css to activate it */ + +/* Example: tint the background slightly purple */ +/* :root { --primary: #7c3aed !important; } */ +`; + +let themes: { name: string; file: string }[] = []; +let activeIndex = -1; // -1 = no theme +let activeKey: string | null = null; +let activeWc: Electron.WebContents | null = null; + +export function ensureThemesFolder() { + if (!existsSync(themesDir)) { + mkdirSync(themesDir, { recursive: true }); + writeFileSync(join(themesDir, "example.css.disabled"), SAMPLE); + } +} + +export function openThemesFolder() { + ensureThemesFolder(); + shell.openPath(themesDir); +} + +export function getActiveThemeName() { + if (activeIndex === -1 || activeIndex >= themes.length) return "None"; + return themes[activeIndex].name; +} + +export function getThemeCount() { + return themes.length; +} + +function scan() { + try { + const files = readdirSync(themesDir).filter(f => f.endsWith(".css")).sort(); + themes = files.map(f => ({ name: f.replace(/\.css$/, ""), file: join(themesDir, f) })); + } catch { + themes = []; + } +} + +async function applyActive() { + if (!activeWc || activeWc.isDestroyed()) return; + + if (activeKey) { + await activeWc.removeInsertedCSS(activeKey).catch(() => {}); + activeKey = null; + } + + if (activeIndex < 0 || activeIndex >= themes.length) return; + + try { + const css = readFileSync(themes[activeIndex].file, "utf-8"); + activeKey = await activeWc.insertCSS(css); + console.log(`[themes] applied: ${themes[activeIndex].name}`); + } catch (err) { + console.error("[themes] inject failed:", err); + } +} + +export async function initThemes(wc: Electron.WebContents) { + activeWc = wc; + scan(); + await applyActive(); +} + +export async function reloadThemes() { + scan(); + if (activeIndex >= themes.length) activeIndex = themes.length > 0 ? 0 : -1; + await applyActive(); + updateTrayMenu(); +} + +export async function cycleTheme() { + scan(); + if (themes.length === 0) { + console.log("[themes] no themes found in", themesDir); + return; + } + activeIndex = activeIndex >= themes.length - 1 ? -1 : activeIndex + 1; + await applyActive(); + updateTrayMenu(); +} diff --git a/src/native/tray.ts b/src/native/tray.ts index 053016a..b3cfca9 100644 --- a/src/native/tray.ts +++ b/src/native/tray.ts @@ -6,6 +6,7 @@ import { version } from "../../package.json"; import { mainWindow, quitApp } from "./window"; import { checkForUpdates } from "./updater"; +import { cycleTheme, getActiveThemeName, getThemeCount, openThemesFolder, reloadThemes } from "./themes"; // internal tray state let tray: Tray = null; @@ -58,6 +59,35 @@ export function updateTrayMenu() { type: "normal", click: () => checkForUpdates(), }, + { + label: "Themes", + type: "submenu", + submenu: Menu.buildFromTemplate([ + { + label: `Active: ${getActiveThemeName()}`, + type: "normal", + enabled: false, + }, + { type: "separator" }, + { + label: "Next Theme →", + type: "normal", + enabled: getThemeCount() > 0, + click: () => cycleTheme(), + }, + { + label: "Reload Themes", + type: "normal", + click: () => reloadThemes(), + }, + { type: "separator" }, + { + label: "Open Themes Folder", + type: "normal", + click: () => openThemesFolder(), + }, + ]), + }, { type: "separator" }, { label: mainWindow.isVisible() ? "Hide App" : "Show App", diff --git a/src/native/window.ts b/src/native/window.ts index e5fb2b9..e1d86ee 100644 --- a/src/native/window.ts +++ b/src/native/window.ts @@ -13,6 +13,7 @@ import windowIconAsset from "../../assets/desktop/icon.png?asset"; import { config } from "./config"; import { updateTrayMenu } from "./tray"; +import { ensureThemesFolder, initThemes } from "./themes"; // global reference to main window export let mainWindow: BrowserWindow; @@ -143,10 +144,13 @@ export function createMainWindow() { } }); + ensureThemesFolder(); + // send the config mainWindow.webContents.on("did-finish-load", () => { config.sync(); injectBranding(mainWindow.webContents); + initThemes(mainWindow.webContents); }); // configure spellchecker context menu