feat: CSS theme support — themes folder, tray cycle/reload/open
All checks were successful
Build & Release / build (push) Successful in 2m57s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MiTHRAL 2026-04-22 01:48:29 -04:00
parent 213d1d3039
commit 510bc28e4a
3 changed files with 126 additions and 0 deletions

92
src/native/themes.ts Normal file
View file

@ -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();
}

View file

@ -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",

View file

@ -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