feat(desktop): live-reload linked css theme files (#3099)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-02 01:02:30 +02:00
1 parent 21cb7ba69c
commit b375abc20a
51 files changed
+4353 -1510

No files matched your search

+11
View File
@@ -135,6 +135,14 @@ export interface ThemeDirectoryCssFile {
css: string;
}
export type ThemeLinkedFileError = 'not_allowed' | 'missing' | 'not_file' | 'too_large' | 'too_many' | 'read_failed';
export interface ThemeLinkedFileChange {
path: string;
css?: string;
error?: ThemeLinkedFileError;
}
export type VoiceBackgroundMediaKind = 'static' | 'animated' | 'video';
export interface VoiceBackgroundMediaCacheRequest {
@@ -648,6 +656,9 @@ export interface ElectronAPI {
readThemeLocalFiles: (paths: Array<string>) => Promise<Array<ThemeLocalFileReadResult>>;
clearThemeLocalFiles: () => Promise<void>;
importThemeDirectory: () => Promise<Array<ThemeDirectoryCssFile>>;
pickThemeLinkedFiles: (options?: {multiple?: boolean}) => Promise<Array<ThemeDirectoryCssFile>>;
watchThemeLinkedFiles: (paths: Array<string>) => Promise<void>;
onThemeLinkedFileChange: (callback: (change: ThemeLinkedFileChange) => void) => () => void;
cacheVoiceBackgroundMedia: (options: VoiceBackgroundMediaCacheRequest) => Promise<VoiceBackgroundMediaCacheResult>;
readVoiceBackgroundMedia: (id: string) => Promise<VoiceBackgroundMediaReadResult | null>;
deleteVoiceBackgroundMedia: (id: string) => Promise<void>;
+3
View File
@@ -82,6 +82,9 @@ const FALLBACK_STRINGS: Readonly<Record<string, string>> = Object.freeze({
'desktop.notifications.open': 'Open',
'desktop.themes.addLocalFiles': 'Add local files',
'desktop.themes.importFolder': 'Import folder',
'desktop.themes.importCss': 'Import CSS',
'desktop.themes.linkFile': 'Link file',
'desktop.themes.cssFilesFilter': 'CSS files',
'desktop.badge.unreadMessages': 'Unread messages',
'desktop.badge.unreadMessagesCount': 'Unread messages: {count}',
'desktop.startup.failedTitle': '{appName} failed to start',
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import os from 'node:os';
import path from 'node:path';
import {after, describe, test} from 'node:test';
import {fileURLToPath} from 'node:url';
import vm from 'node:vm';
const require = createRequire(import.meta.url);
const esbuild = require('esbuild');
const sourcePath = fileURLToPath(new URL('./ThemeLinkedFileWatch.ts', import.meta.url));
const source = fs.readFileSync(sourcePath, 'utf8');
const transformedSource = esbuild.transformSync(source, {
loader: 'ts',
format: 'cjs',
platform: 'node',
target: 'node20',
}).code;
function loadThemeLinkedFileWatch() {
const module = {exports: {}};
const context = vm.createContext({
module,
exports: module.exports,
require,
});
vm.runInContext(transformedSource, context, {filename: sourcePath});
return module.exports;
}
const {createThemeLinkedFileWatchSet, readThemeCssFile} = loadThemeLinkedFileWatch();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-theme-linked-'));
let fileCounter = 0;
after(() => {
fs.rmSync(tempRoot, {recursive: true, force: true});
});
function nextFilePath() {
fileCounter += 1;
return path.join(tempRoot, `theme-${fileCounter}.css`);
}
function createRecorder() {
const changes = [];
const waiters = [];
const emit = (change) => {
changes.push(change);
for (const waiter of [...waiters]) {
if (waiter.predicate(change)) {
waiters.splice(waiters.indexOf(waiter), 1);
clearTimeout(waiter.timer);
waiter.resolve(change);
}
}
};
const waitFor = (predicate, timeoutMs = 3000) =>
new Promise((resolve, reject) => {
const waiter = {predicate, resolve, timer: null};
waiter.timer = setTimeout(() => {
waiters.splice(waiters.indexOf(waiter), 1);
reject(new Error(`Timed out waiting for change, saw ${JSON.stringify(changes)}`));
}, timeoutMs);
waiters.push(waiter);
});
return {changes, emit, waitFor};
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function writeLater(filePath, contents) {
await delay(40);
fs.writeFileSync(filePath, contents);
}
describe('ThemeLinkedFileWatch', () => {
test('emits css on the first read', async () => {
const filePath = nextFilePath();
fs.writeFileSync(filePath, '.a{color:red}');
const recorder = createRecorder();
const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25);
try {
watchSet.replace([filePath]);
const change = await recorder.waitFor((item) => item.path === filePath);
assert.equal(change.css, '.a{color:red}');
} finally {
watchSet.dispose();
}
});
test('emits new css after an in-place write', async () => {
const filePath = nextFilePath();
fs.writeFileSync(filePath, '.a{color:red}');
const recorder = createRecorder();
const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25);
try {
watchSet.replace([filePath]);
await recorder.waitFor((item) => item.css === '.a{color:red}');
await writeLater(filePath, '.a{color:blue;padding:1px}');
await recorder.waitFor((item) => item.css === '.a{color:blue;padding:1px}');
} finally {
watchSet.dispose();
}
});
test('emits new css after a temp file and rename save', async () => {
const filePath = nextFilePath();
fs.writeFileSync(filePath, '.a{color:red}');
const recorder = createRecorder();
const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25);
try {
watchSet.replace([filePath]);
await recorder.waitFor((item) => item.css === '.a{color:red}');
await delay(40);
const tempPath = `${filePath}.tmp`;
fs.writeFileSync(tempPath, '.a{color:green}');
fs.renameSync(tempPath, filePath);
await recorder.waitFor((item) => item.css === '.a{color:green}');
} finally {
watchSet.dispose();
}
});
test('reports missing after unlink and css after the file returns', async () => {
const filePath = nextFilePath();
fs.writeFileSync(filePath, '.a{color:red}');
const recorder = createRecorder();
const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25);
try {
watchSet.replace([filePath]);
await recorder.waitFor((item) => item.css === '.a{color:red}');
await delay(40);
fs.unlinkSync(filePath);
await recorder.waitFor((item) => item.error === 'missing');
await writeLater(filePath, '.a{color:purple}');
await recorder.waitFor((item) => item.css === '.a{color:purple}');
} finally {
watchSet.dispose();
}
});
test('strips a leading byte order mark', async () => {
const filePath = nextFilePath();
fs.writeFileSync(filePath, '.a{color:red}');
const change = await readThemeCssFile(filePath);
assert.equal(change.css, '.a{color:red}');
});
test('rejects oversize files', async () => {
const filePath = nextFilePath();
fs.writeFileSync(filePath, 'a'.repeat(1024 * 1024 + 1));
const change = await readThemeCssFile(filePath);
assert.equal(change.css, undefined);
assert.equal(change.error, 'too_large');
});
test('refuses to follow a symlink', async () => {
const targetPath = nextFilePath();
const linkPath = nextFilePath();
fs.writeFileSync(targetPath, 'secret');
fs.symlinkSync(targetPath, linkPath);
const change = await readThemeCssFile(linkPath);
assert.equal(change.css, undefined);
assert.equal(change.error, 'not_file');
});
test('reports missing for an absent file', async () => {
const change = await readThemeCssFile(nextFilePath());
assert.equal(change.error, 'missing');
});
test('stops emitting for a path dropped by replace', async () => {
const keptPath = nextFilePath();
const droppedPath = nextFilePath();
fs.writeFileSync(keptPath, '.kept{}');
fs.writeFileSync(droppedPath, '.dropped{}');
const recorder = createRecorder();
const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25);
try {
const initialReads = Promise.all([
recorder.waitFor((item) => item.path === droppedPath),
recorder.waitFor((item) => item.path === keptPath),
]);
watchSet.replace([keptPath, droppedPath]);
await initialReads;
watchSet.replace([keptPath]);
await delay(40);
fs.writeFileSync(droppedPath, '.dropped{color:red}');
fs.writeFileSync(keptPath, '.kept{color:red}');
await recorder.waitFor((item) => item.css === '.kept{color:red}');
await delay(150);
assert.equal(
recorder.changes.some((item) => item.css === '.dropped{color:red}'),
false,
);
} finally {
watchSet.dispose();
}
});
test('dispose stops every event', async () => {
const filePath = nextFilePath();
fs.writeFileSync(filePath, '.a{color:red}');
const recorder = createRecorder();
const watchSet = createThemeLinkedFileWatchSet(recorder.emit, 25);
watchSet.replace([filePath]);
await recorder.waitFor((item) => item.css === '.a{color:red}');
watchSet.dispose();
const count = recorder.changes.length;
await delay(40);
fs.writeFileSync(filePath, '.a{color:blue}');
await delay(200);
assert.equal(recorder.changes.length, count);
});
});
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Buffer} from 'node:buffer';
import fs from 'node:fs';
import type {ThemeLinkedFileChange} from '@electron/common/Types';
const THEME_CSS_MAX_BYTES = 1024 * 1024;
interface ThemeLinkedFileWatchEntry {
listener: () => void;
generation: number;
}
export async function readThemeCssFile(filePath: string): Promise<ThemeLinkedFileChange> {
let handle: fs.promises.FileHandle | null = null;
try {
const linkStats = await fs.promises.lstat(filePath);
if (!linkStats.isFile()) {
return {path: filePath, error: 'not_file'};
}
handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
const stats = await handle.stat();
if (!stats.isFile()) {
return {path: filePath, error: 'not_file'};
}
if (stats.size > THEME_CSS_MAX_BYTES) {
return {path: filePath, error: 'too_large'};
}
const buffer = Buffer.alloc(THEME_CSS_MAX_BYTES + 1);
let bytesRead = 0;
while (bytesRead < buffer.length) {
const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
if (result.bytesRead === 0) break;
bytesRead += result.bytesRead;
}
if (bytesRead > THEME_CSS_MAX_BYTES) {
return {path: filePath, error: 'too_large'};
}
return {path: filePath, css: buffer.toString('utf8', 0, bytesRead).replace(/^\uFEFF/, '')};
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code;
if (code === 'ENOENT') {
return {path: filePath, error: 'missing'};
}
if (code === 'ELOOP') {
return {path: filePath, error: 'not_file'};
}
return {path: filePath, error: 'read_failed'};
} finally {
await handle?.close().catch(() => {});
}
}
export function createThemeLinkedFileWatchSet(
emit: (change: ThemeLinkedFileChange) => void,
intervalMs = 250,
): {replace: (paths: ReadonlyArray<string>) => void; dispose: () => void} {
const entries = new Map<string, ThemeLinkedFileWatchEntry>();
const read = (filePath: string): void => {
const entry = entries.get(filePath);
if (!entry) return;
entry.generation += 1;
const generation = entry.generation;
void readThemeCssFile(filePath).then((change) => {
const current = entries.get(filePath);
if (current !== entry || current.generation !== generation) return;
emit(change);
});
};
const unwatch = (filePath: string): void => {
const entry = entries.get(filePath);
if (!entry) return;
fs.unwatchFile(filePath, entry.listener);
entries.delete(filePath);
};
return {
replace(paths) {
const next = new Set(paths);
for (const filePath of [...entries.keys()]) {
if (!next.has(filePath)) unwatch(filePath);
}
for (const filePath of next) {
if (entries.has(filePath)) continue;
const entry: ThemeLinkedFileWatchEntry = {listener: () => read(filePath), generation: 0};
entries.set(filePath, entry);
fs.watchFile(filePath, {interval: intervalMs, persistent: false}, entry.listener);
read(filePath);
}
},
dispose() {
for (const filePath of [...entries.keys()]) unwatch(filePath);
},
};
}
+127 -15
View File
@@ -8,14 +8,62 @@ import {
getAllowedThemeLocalFiles,
} from '@electron/common/DesktopConfig';
import {createChildLogger} from '@electron/common/Logger';
import type {ThemeDirectoryCssFile, ThemeLocalFileReadResult, ThemeLocalFileReference} from '@electron/common/Types';
import type {
ThemeDirectoryCssFile,
ThemeLinkedFileChange,
ThemeLocalFileReadResult,
ThemeLocalFileReference,
} from '@electron/common/Types';
import {t} from '@electron/main/MainI18n';
import {BrowserWindow, dialog, ipcMain} from 'electron';
import {requirePrivilegedRendererDocumentSender} from '@electron/main/PrivilegedRendererDocuments';
import {createThemeLinkedFileWatchSet, readThemeCssFile} from '@electron/main/ThemeLinkedFileWatch';
import {BrowserWindow, dialog, ipcMain, type WebContents} from 'electron';
const logger = createChildLogger('ThemeLocalFiles');
const THEME_LOCAL_FILE_MAX_BYTES = 50 * 1024 * 1024;
const THEME_DIRECTORY_CSS_MAX_BYTES = 1024 * 1024;
const THEME_DIRECTORY_MAX_CSS_FILES = 200;
const THEME_LINKED_FILES_MAX_WATCHED = 500;
interface ThemeLinkedFileWatchSubscription {
watchSet: ReturnType<typeof createThemeLinkedFileWatchSet>;
emit: (change: ThemeLinkedFileChange) => void;
dispose: () => void;
}
const linkedFileWatchSets = new Map<number, ThemeLinkedFileWatchSubscription>();
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function getLinkedFileWatchSubscription(sender: WebContents): ThemeLinkedFileWatchSubscription {
const existing = linkedFileWatchSets.get(sender.id);
if (existing) return existing;
const senderId = sender.id;
const emit = (change: ThemeLinkedFileChange): void => {
if (!sender.isDestroyed()) {
sender.send('theme-linked-file-changed', change);
}
};
const watchSet = createThemeLinkedFileWatchSet(emit);
let disposed = false;
const onNavigation = (_event: Electron.Event, _url: string, isSameDoc: boolean, isMainFrame: boolean): void => {
if (isMainFrame && !isSameDoc) dispose();
};
const dispose = (): void => {
if (disposed) return;
disposed = true;
watchSet.dispose();
linkedFileWatchSets.delete(senderId);
sender.removeListener('destroyed', dispose);
sender.removeListener('did-start-navigation', onNavigation);
};
sender.once('destroyed', dispose);
sender.on('did-start-navigation', onNavigation);
const subscription = {watchSet, emit, dispose};
linkedFileWatchSets.set(senderId, subscription);
return subscription;
}
function getMimeType(filePath: string): string {
switch (path.extname(filePath).toLowerCase()) {
@@ -166,23 +214,87 @@ export function registerThemeLocalFileHandlers(getMainWindow: () => BrowserWindo
if (result.canceled || result.filePaths.length === 0) {
return [];
}
const directoryPath = result.filePaths[0];
let directoryPath: string;
try {
directoryPath = await fs.promises.realpath(result.filePaths[0]);
} catch (error) {
logger.warn('Failed to resolve selected theme directory', {directoryPath: result.filePaths[0], error});
return [];
}
const cssFiles = await collectCssFilesFromDirectory(directoryPath);
const themes: Array<ThemeDirectoryCssFile> = [];
for (const filePath of cssFiles) {
try {
const stats = await fs.promises.stat(filePath);
if (!stats.isFile() || stats.size > THEME_DIRECTORY_CSS_MAX_BYTES) continue;
const css = await fs.promises.readFile(filePath, 'utf8');
themes.push({
fileName: path.relative(directoryPath, filePath) || path.basename(filePath),
path: filePath,
css,
});
} catch (error) {
logger.warn('Failed to import CSS file from theme directory', {filePath, error});
const result = await readThemeCssFile(filePath);
if (result.css === undefined) {
logger.warn('Failed to import CSS file from theme directory', {filePath, error: result.error});
continue;
}
themes.push({
fileName: path.relative(directoryPath, filePath) || path.basename(filePath),
path: filePath,
css: result.css,
});
}
addAllowedThemeLocalFiles(themes.map((theme) => theme.path));
return themes;
});
ipcMain.handle('theme-linked-files-pick', async (event, options: unknown): Promise<Array<ThemeDirectoryCssFile>> => {
requirePrivilegedRendererDocumentSender(event, 'theme-linked-files-pick');
const multiple = !(isRecord(options) && options.multiple === false);
const parent = BrowserWindow.fromWebContents(event.sender) ?? getMainWindow();
const result = await showThemeOpenDialog(parent, {
title: t(multiple ? 'desktop.themes.importCss' : 'desktop.themes.linkFile'),
filters: [{name: t('desktop.themes.cssFilesFilter'), extensions: ['css']}],
properties: multiple ? ['openFile', 'multiSelections'] : ['openFile'],
});
if (result.canceled) {
return [];
}
const files: Array<ThemeDirectoryCssFile> = [];
for (const selectedPath of result.filePaths) {
let filePath: string;
try {
filePath = await fs.promises.realpath(selectedPath);
} catch (error) {
logger.warn('Failed to resolve selected theme CSS file', {filePath: selectedPath, error});
continue;
}
const read = await readThemeCssFile(filePath);
if (read.css === undefined) {
logger.warn('Failed to read selected theme CSS file', {filePath, error: read.error});
continue;
}
files.push({fileName: path.basename(filePath), path: filePath, css: read.css});
}
addAllowedThemeLocalFiles(files.map((file) => file.path));
return files;
});
ipcMain.handle('theme-linked-files-watch', (event, payload: unknown): void => {
requirePrivilegedRendererDocumentSender(event, 'theme-linked-files-watch');
const requested = Array.isArray(payload)
? [...new Set(payload.filter((item): item is string => typeof item === 'string'))]
: [];
const paths = requested.slice(0, THEME_LINKED_FILES_MAX_WATCHED);
const overflow = requested.slice(THEME_LINKED_FILES_MAX_WATCHED);
if (paths.length === 0) {
linkedFileWatchSets.get(event.sender.id)?.dispose();
return;
}
const allowedFiles = new Set(getAllowedThemeLocalFiles().map((entry) => path.resolve(entry)));
const allowed = paths.filter((filePath) => allowedFiles.has(path.resolve(filePath)));
const subscription = getLinkedFileWatchSubscription(event.sender);
for (const filePath of paths) {
if (!allowedFiles.has(path.resolve(filePath))) {
subscription.emit({path: filePath, error: 'not_allowed'});
}
}
for (const filePath of overflow) {
subscription.emit({path: filePath, error: 'too_many'});
}
if (allowed.length === 0) {
subscription.dispose();
return;
}
subscription.watchSet.replace(allowed);
});
}
+8
View File
@@ -50,6 +50,7 @@ import type {
StreamerModeCaptureAppStatus,
StreamingPriorityDiagnostics,
TextareaContextMenuParams,
ThemeLinkedFileChange,
TrayActionPayload,
TrayRuntimeStatePayload,
UpdaterContext,
@@ -353,6 +354,13 @@ const api: ElectronAPI = {
readThemeLocalFiles: (paths: Array<string>) => ipcRenderer.invoke('theme-local-files-read', paths),
clearThemeLocalFiles: () => ipcRenderer.invoke('theme-local-files-clear'),
importThemeDirectory: () => ipcRenderer.invoke('theme-directory-import'),
pickThemeLinkedFiles: (options?: {multiple?: boolean}) => ipcRenderer.invoke('theme-linked-files-pick', options),
watchThemeLinkedFiles: (paths: Array<string>) => ipcRenderer.invoke('theme-linked-files-watch', paths),
onThemeLinkedFileChange: (callback: (change: ThemeLinkedFileChange) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, change: ThemeLinkedFileChange) => callback(change);
ipcRenderer.on('theme-linked-file-changed', handler);
return () => ipcRenderer.removeListener('theme-linked-file-changed', handler);
},
cacheVoiceBackgroundMedia: (options) => ipcRenderer.invoke('voice-background-media-cache:write', options),
readVoiceBackgroundMedia: (id) => ipcRenderer.invoke('voice-background-media-cache:read', id),
deleteVoiceBackgroundMedia: (id) => ipcRenderer.invoke('voice-background-media-cache:delete', id),