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

+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);
});
}