feat: CSS theme support — themes folder, tray cycle/reload/open
Some checks failed
Build & Release / build (push) Failing after 1m31s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MiTHRAL 2026-04-22 01:57:53 -04:00
parent 510bc28e4a
commit 79050a43b3
3 changed files with 93 additions and 153 deletions

View file

@ -1,92 +1,65 @@
import { app, shell } from "electron"; 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 { join } from "path";
import { updateTrayMenu } from "./tray";
const themesDir = join(app.getPath("userData"), "themes"); const themesDir = join(app.getPath("userData"), "themes");
const SAMPLE = `/* Sanctum example theme let activeFile: string | null = null;
Rename this file to anything.css to activate it */ let cssKey: string | null = null;
/* Example: tint the background slightly purple */ export function getThemesDir() {
/* :root { --primary: #7c3aed !important; } */ 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)) {
mkdirSync(themesDir, { recursive: true });
writeFileSync(join(themesDir, "example.css.disabled"), SAMPLE);
} }
export function getThemeFiles(): string[] {
try {
mkdirSync(themesDir, { recursive: true });
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<string | null> {
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() { export function openThemesFolder() {
ensureThemesFolder(); mkdirSync(themesDir, { recursive: true });
shell.openPath(themesDir); 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 { mainWindow, quitApp } from "./window";
import { checkForUpdates } from "./updater"; import { checkForUpdates } from "./updater";
import { getThemeFiles, getActiveTheme, applyTheme, cycleTheme, reloadTheme, openThemesFolder } from "./themes";
import { cycleTheme, getActiveThemeName, getThemeCount, openThemesFolder, reloadThemes } from "./themes"; import { cycleTheme, getActiveThemeName, getThemeCount, openThemesFolder, reloadThemes } from "./themes";
// internal tray state // 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" }, { type: "separator" },
{ {
label: mainWindow.isVisible() ? "Hide App" : "Show App", label: mainWindow.isVisible() ? "Hide App" : "Show App",

View file

@ -13,7 +13,7 @@ import windowIconAsset from "../../assets/desktop/icon.png?asset";
import { config } from "./config"; import { config } from "./config";
import { updateTrayMenu } from "./tray"; import { updateTrayMenu } from "./tray";
import { ensureThemesFolder, initThemes } from "./themes"; import { invalidateCssKey, applyTheme, getActiveTheme } from "./themes";
// global reference to main window // global reference to main window
export let mainWindow: BrowserWindow; export let mainWindow: BrowserWindow;
@ -150,7 +150,8 @@ export function createMainWindow() {
mainWindow.webContents.on("did-finish-load", () => { mainWindow.webContents.on("did-finish-load", () => {
config.sync(); config.sync();
injectBranding(mainWindow.webContents); injectBranding(mainWindow.webContents);
initThemes(mainWindow.webContents); invalidateCssKey();
applyTheme(mainWindow.webContents, getActiveTheme());
}); });
// configure spellchecker context menu // configure spellchecker context menu
@ -210,77 +211,14 @@ export function createMainWindow() {
} }
function injectBranding(wc: Electron.WebContents) { function injectBranding(wc: Electron.WebContents) {
const logoUrl = windowIconAsset;
wc.insertCSS(` wc.insertCSS(`
[class*="wordmark"], [class*="Wordmark"], [data-app-name] { display: none !important; } [class*="wordmark"], [class*="Wordmark"], [data-app-name] { display: none !important; }
`); `);
wc.executeJavaScript(` wc.executeJavaScript(`
(function() { (function() {
const LOGO = ${JSON.stringify(logoUrl)};
const BRAND_RE = /\\b(Revolt|Stoat)\\b/g; const BRAND_RE = /\\b(Revolt|Stoat)\\b/g;
const SKIP_TAGS = new Set(['SCRIPT','STYLE','TEXTAREA','INPUT','CODE','PRE']); 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) { function patchText(root) {
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
var node; var node;
@ -294,25 +232,19 @@ function injectBranding(wc: Electron.WebContents) {
} }
function patchTitle() { function patchTitle() {
if (document.title && BRAND_RE.test(document.title)) { if (BRAND_RE.test(document.title)) {
document.title = document.title.replace(BRAND_RE, 'Sanctum'); document.title = document.title.replace(BRAND_RE, 'Sanctum');
} }
BRAND_RE.lastIndex = 0; BRAND_RE.lastIndex = 0;
} }
function patch(root) { patchText(document.documentElement);
patchImages();
patchSVGs();
patchText(root || document.body);
patchTitle(); patchTitle();
}
patch(document.documentElement);
new MutationObserver(function(mutations) { new MutationObserver(function(mutations) {
mutations.forEach(function(m) { mutations.forEach(function(m) {
m.addedNodes.forEach(function(n) { 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)) { 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'); if (BRAND_RE.test(n.nodeValue)) n.nodeValue = n.nodeValue.replace(BRAND_RE, 'Sanctum');
BRAND_RE.lastIndex = 0; BRAND_RE.lastIndex = 0;