mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(desktop): live-reload linked css theme files (#3099)
This commit is contained in:
1 parent
21cb7ba69c
commit
b375abc20a
51 files changed
+4353
-1510
No files matched your search
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user