mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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