From 79050a43b37bb7bc5a31739183a77d1700ab534f Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 22 Apr 2026 01:57:53 -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 | 129 +++++++++++++++++-------------------------- src/native/tray.ts | 35 ++++++++++++ src/native/window.ts | 82 +++------------------------ 3 files changed, 93 insertions(+), 153 deletions(-) diff --git a/src/native/themes.ts b/src/native/themes.ts index e15437e..0afdd68 100644 --- a/src/native/themes.ts +++ b/src/native/themes.ts @@ -1,92 +1,65 @@ import { app, shell } from "electron"; -import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "fs"; +import { mkdirSync, readdirSync, readFileSync, 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 */ +let activeFile: string | null = null; +let cssKey: string | null = null; -/* Example: tint the background slightly purple */ -/* :root { --primary: #7c3aed !important; } */ -`; +export function getThemesDir() { + return themesDir; +} -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)) { +export function getThemeFiles(): string[] { + try { mkdirSync(themesDir, { recursive: true }); - writeFileSync(join(themesDir, "example.css.disabled"), SAMPLE); + return readdirSync(themesDir).filter(f => f.endsWith(".css")).sort(); + } catch { + return []; } } +export function getActiveTheme(): string | null { + return activeFile; +} + +// Call at the start of did-finish-load — old page context is gone, key is stale +export function invalidateCssKey() { + cssKey = null; +} + +export async function applyTheme(wc: Electron.WebContents, file: string | null) { + if (cssKey) { + try { await wc.removeInsertedCSS(cssKey); } catch {} + cssKey = null; + } + activeFile = file; + if (!file) return; + try { + const css = readFileSync(join(themesDir, file), "utf-8"); + cssKey = await wc.insertCSS(css); + } catch (err) { + console.error("[themes] failed to load", file, err); + } +} + +export async function cycleTheme(wc: Electron.WebContents): Promise { + const files = getThemeFiles(); + if (files.length === 0) return null; + const idx = activeFile ? files.indexOf(activeFile) : -1; + const next = files[(idx + 1) % files.length]; + await applyTheme(wc, next); + return next; +} + +export async function reloadTheme(wc: Electron.WebContents) { + const file = activeFile; + if (!file) return; + await applyTheme(wc, file); +} + export function openThemesFolder() { - ensureThemesFolder(); + mkdirSync(themesDir, { recursive: true }); 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 b3cfca9..505f0b8 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 { getThemeFiles, getActiveTheme, applyTheme, cycleTheme, reloadTheme, openThemesFolder } from "./themes"; import { cycleTheme, getActiveThemeName, getThemeCount, openThemesFolder, reloadThemes } from "./themes"; // internal tray state @@ -88,6 +89,40 @@ export function updateTrayMenu() { }, ]), }, + { + label: "Themes", + type: "submenu", + submenu: Menu.buildFromTemplate([ + { + label: "None", + type: "radio", + checked: getActiveTheme() === null, + click: () => { applyTheme(mainWindow.webContents, null); updateTrayMenu(); }, + }, + ...getThemeFiles().map(file => ({ + label: file.replace(/\.css$/i, ""), + type: "radio" as const, + checked: getActiveTheme() === file, + click: () => { applyTheme(mainWindow.webContents, file); updateTrayMenu(); }, + })), + { type: "separator" as const }, + { + label: "Cycle Theme", + type: "normal" as const, + click: () => { cycleTheme(mainWindow.webContents).then(() => updateTrayMenu()); }, + }, + { + label: "Reload Theme", + type: "normal" as const, + click: () => reloadTheme(mainWindow.webContents), + }, + { + label: "Open Themes Folder", + type: "normal" as const, + click: () => openThemesFolder(), + }, + ]), + }, { type: "separator" }, { label: mainWindow.isVisible() ? "Hide App" : "Show App", diff --git a/src/native/window.ts b/src/native/window.ts index e1d86ee..f51b144 100644 --- a/src/native/window.ts +++ b/src/native/window.ts @@ -13,7 +13,7 @@ import windowIconAsset from "../../assets/desktop/icon.png?asset"; import { config } from "./config"; import { updateTrayMenu } from "./tray"; -import { ensureThemesFolder, initThemes } from "./themes"; +import { invalidateCssKey, applyTheme, getActiveTheme } from "./themes"; // global reference to main window export let mainWindow: BrowserWindow; @@ -150,7 +150,8 @@ export function createMainWindow() { mainWindow.webContents.on("did-finish-load", () => { config.sync(); injectBranding(mainWindow.webContents); - initThemes(mainWindow.webContents); + invalidateCssKey(); + applyTheme(mainWindow.webContents, getActiveTheme()); }); // configure spellchecker context menu @@ -210,77 +211,14 @@ export function createMainWindow() { } function injectBranding(wc: Electron.WebContents) { - const logoUrl = windowIconAsset; wc.insertCSS(` [class*="wordmark"], [class*="Wordmark"], [data-app-name] { display: none !important; } `); wc.executeJavaScript(` (function() { - const LOGO = ${JSON.stringify(logoUrl)}; const BRAND_RE = /\\b(Revolt|Stoat)\\b/g; const SKIP_TAGS = new Set(['SCRIPT','STYLE','TEXTAREA','INPUT','CODE','PRE']); - function isLogoImg(img) { - var src = img.getAttribute('src') || ''; - var alt = (img.getAttribute('alt') || '').toLowerCase(); - if (src.includes('revolt') || src.includes('stoat')) return true; - if (alt === 'revolt' || alt === 'stoat') return true; - // only match within dedicated branding containers — never generic headers - var parent = img.closest('[class*="wordmark"],[class*="Wordmark"],[class*="auth"],[class*="login"],[class*="splash"],[class*="Landing"]'); - if (parent && /\\.(svg|png|webp)/.test(src)) return true; - return false; - } - - function patchImages() { - document.querySelectorAll('img').forEach(function(img) { - if (img.dataset.sanctumPatched) return; - if (isLogoImg(img)) { - img.src = LOGO; - img.removeAttribute('srcset'); - img.alt = 'Sanctum'; - img.dataset.sanctumPatched = '1'; - } - }); - } - - function replaceSvgWithLogo(svg) { - var box = svg.getBoundingClientRect(); - if (box.height > 0 && box.height < 48) { svg.parentNode.removeChild(svg); return; } - var size = '96px'; - var wrap = document.createElement('div'); - wrap.style.cssText = 'display:flex;align-items:center;justify-content:center;width:100%;'; - var img = document.createElement('img'); - img.src = LOGO; - img.alt = 'Sanctum'; - img.style.cssText = 'width:' + size + ';height:' + size + ';object-fit:contain;flex-shrink:0;'; - img.dataset.sanctumPatched = '1'; - wrap.appendChild(img); - svg.parentNode.replaceChild(wrap, svg); - } - - function patchSVGs() { - document.querySelectorAll('svg').forEach(function(svg) { - if (svg.dataset.sanctumPatched) return; - // Match the Revolt wordmark SVG by its unique path data fingerprint - var paths = svg.querySelectorAll('path'); - for (var i = 0; i < paths.length; i++) { - var d = paths[i].getAttribute('d') || ''; - if (d.includes('M478.909') || d.includes('M5.063') || d.includes('Revolt')) { - replaceSvgWithLogo(svg); - return; - } - } - // Also catch any wide wordmark-style SVG in an auth/login container - var parent = svg.closest('[class*="logo"],[class*="Logo"],[class*="brand"],[class*="Brand"],[class*="wordmark"],[class*="Wordmark"],[class*="auth"],[class*="login"],[class*="splash"],[class*="Landing"]'); - if (parent) { - var box = svg.getBoundingClientRect(); - if (box.width > 80 && box.width / (box.height || 1) > 2) { - replaceSvgWithLogo(svg); - } - } - }); - } - function patchText(root) { var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); var node; @@ -294,25 +232,19 @@ function injectBranding(wc: Electron.WebContents) { } function patchTitle() { - if (document.title && BRAND_RE.test(document.title)) { + if (BRAND_RE.test(document.title)) { document.title = document.title.replace(BRAND_RE, 'Sanctum'); } BRAND_RE.lastIndex = 0; } - function patch(root) { - patchImages(); - patchSVGs(); - patchText(root || document.body); - patchTitle(); - } - - patch(document.documentElement); + patchText(document.documentElement); + patchTitle(); new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(n) { - if (n.nodeType === 1) patch(n); + if (n.nodeType === 1) patchText(n); else if (n.nodeType === 3 && !SKIP_TAGS.has(n.parentElement && n.parentElement.tagName)) { if (BRAND_RE.test(n.nodeValue)) n.nodeValue = n.nodeValue.replace(BRAND_RE, 'Sanctum'); BRAND_RE.lastIndex = 0;