feat: CSS theme support — themes folder, tray cycle/reload/open
Some checks failed
Build & Release / build (push) Failing after 1m31s
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:
parent
510bc28e4a
commit
79050a43b3
3 changed files with 93 additions and 153 deletions
|
|
@ -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 }[] = [];
|
export function getThemeFiles(): string[] {
|
||||||
let activeIndex = -1; // -1 = no theme
|
try {
|
||||||
let activeKey: string | null = null;
|
|
||||||
let activeWc: Electron.WebContents | null = null;
|
|
||||||
|
|
||||||
export function ensureThemesFolder() {
|
|
||||||
if (!existsSync(themesDir)) {
|
|
||||||
mkdirSync(themesDir, { recursive: true });
|
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<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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
patchTitle();
|
||||||
patchSVGs();
|
|
||||||
patchText(root || document.body);
|
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue