feat: reading custom css from external file (#2012)

* feat: reading custom css from external file

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Simon Bråten
2026-05-25 23:53:53 +02:00
committed by GitHub
parent 22d37135ae
commit f098f848a3
5 changed files with 253 additions and 4 deletions
+2 -1
View File
@@ -92,6 +92,7 @@
"expand": "Expand",
"example": "Example",
"externalLinks": "External links",
"openFolder": "Open folder",
"faster": "Faster",
"favorite": "Favorite",
"filter_one": "Filter",
@@ -785,7 +786,7 @@
"crossfadeDuration": "Crossfade duration",
"crossfadeStyle": "Crossfade style",
"crossfadeStyle_description": "Select the crossfade style to use for the audio player",
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization",
"customCss_description": "Custom CSS content. Note: content and remote urls are disallowed properties. A preview of your content is shown below. Additional fields you didn't set are present due to sanitization. Desktop: feishin reads and writes custom.css in the app config directory and reloads it when the file changes",
"customCss": "Custom CSS",
"customCssEnable_description": "Allow for writing custom CSS",
"customCssEnable": "Enable custom CSS",
+112 -1
View File
@@ -1,7 +1,18 @@
import type { TitleTheme } from '/@/shared/types/types';
import type { FSWatcher } from 'fs';
import { app, dialog, ipcMain, nativeTheme, OpenDialogOptions, safeStorage } from 'electron';
import {
app,
BrowserWindow,
dialog,
ipcMain,
nativeTheme,
OpenDialogOptions,
safeStorage,
shell,
} from 'electron';
import Store from 'electron-store';
import { promises as fs, watch as fsWatch } from 'fs';
import path from 'path';
const getFrame = () => {
@@ -26,6 +37,67 @@ const storePath = isDevelopment
? path.normalize(`${defaultUserDataPath}-dev`)
: path.normalize(defaultUserDataPath);
const CUSTOM_CSS_FILENAME = 'custom.css';
const customCssPath = path.join(storePath, CUSTOM_CSS_FILENAME);
let customCssWatcher: FSWatcher | null = null;
let customCssDebounce: NodeJS.Timeout | null = null;
const readCustomCss = async (): Promise<{ content: string; exists: boolean }> => {
try {
const content = await fs.readFile(customCssPath, 'utf8');
return { content, exists: true };
} catch (error) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code === 'ENOENT') {
return { content: '', exists: false };
}
console.error('Failed to read custom css file', error);
return { content: '', exists: false };
}
};
const notifyCustomCssUpdate = async () => {
const { content, exists } = await readCustomCss();
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send('custom-css-updated', {
content,
exists,
path: customCssPath,
});
});
};
const scheduleCustomCssUpdate = () => {
if (customCssDebounce) {
clearTimeout(customCssDebounce);
}
customCssDebounce = setTimeout(() => {
notifyCustomCssUpdate().catch((error) => {
console.error('Failed to broadcast custom css update', error);
});
}, 100);
};
const startCustomCssWatcher = async () => {
if (customCssWatcher) return;
try {
await fs.mkdir(storePath, { recursive: true });
customCssWatcher = fsWatch(storePath, (eventType, filename) => {
if (!filename) return;
if (filename.toString() !== CUSTOM_CSS_FILENAME) return;
if (eventType === 'change' || eventType === 'rename') {
scheduleCustomCssUpdate();
}
});
} catch (error) {
console.error('Failed to watch custom css file', error);
}
};
export const store = new Store<any>({
beforeEachMigration: (_store, context) => {
console.log(`settings migrate from ${context.fromVersion}${context.toVersion}`);
@@ -120,3 +192,42 @@ ipcMain.handle('open-file-selector', async (_event, options: OpenDialogOptions)
return result.filePaths[0] || null;
});
ipcMain.handle('custom-css-get', async () => {
const { content, exists } = await readCustomCss();
return {
content,
exists,
path: customCssPath,
};
});
ipcMain.handle('custom-css-save', async (_event, data: { content: string }) => {
const content = typeof data?.content === 'string' ? data.content : '';
await fs.mkdir(storePath, { recursive: true });
await fs.writeFile(customCssPath, content, 'utf8');
await notifyCustomCssUpdate();
return true;
});
ipcMain.handle('custom-css-open-folder', async () => {
await fs.mkdir(storePath, { recursive: true });
await shell.openPath(storePath);
return true;
});
app.whenReady()
.then(() => startCustomCssWatcher())
.catch((error) => console.error('Failed to start custom css watcher', error));
app.on('before-quit', () => {
if (customCssWatcher) {
customCssWatcher.close();
customCssWatcher = null;
}
if (customCssDebounce) {
clearTimeout(customCssDebounce);
customCssDebounce = null;
}
});
+34
View File
@@ -10,6 +10,36 @@ const openApplicationDirectory = async () => {
return ipcRenderer.invoke('open-application-directory');
};
const getCustomCss = async (): Promise<
| undefined
| {
content: string;
exists: boolean;
path?: string;
}
> => {
return ipcRenderer.invoke('custom-css-get');
};
const saveCustomCss = async (content: string) => {
return ipcRenderer.invoke('custom-css-save', { content });
};
const openCustomCssFolder = async () => {
return ipcRenderer.invoke('custom-css-open-folder');
};
const customCssUpdatedListener = (
cb: (data: { content?: string; exists?: boolean; path?: string }) => void,
) => {
const listener = (_event: unknown, data: { content?: string; exists?: boolean }) => cb(data);
ipcRenderer.on('custom-css-updated', listener);
return () => {
ipcRenderer.removeListener('custom-css-updated', listener);
};
};
const playerErrorListener = (cb: (data: { code: number }) => void) => {
ipcRenderer.on('player-error-listener', (_, data) => cb(data));
};
@@ -88,14 +118,17 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
export const utils = {
checkForUpdates,
customCssUpdatedListener,
disableAutoUpdates,
download,
forceGarbageCollection,
getCustomCss,
isLinux,
isMacOS,
isWindows,
mainMessageListener,
openApplicationDirectory,
openCustomCssFolder,
openItem,
playerErrorListener,
rendererOpenCommandPalette,
@@ -105,6 +138,7 @@ export const utils = {
rendererTogglePrivateMode,
rendererToggleSidebar,
rendererUpdateAvailable,
saveCustomCss,
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
+73 -1
View File
@@ -15,7 +15,12 @@ import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useNativeMenuSync } from '/@/renderer/hooks/use-native-menu-sync';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
import {
useCssSettings,
useHotkeySettings,
useLanguage,
useSettingsStoreActions,
} from '/@/renderer/store';
import { useAppTheme } from '/@/renderer/themes/use-app-theme';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { WebAudio } from '/@/shared/types/types';
@@ -31,6 +36,7 @@ const UpdateAvailableDialog = lazy(() =>
);
const ipc = isElectron() ? window.api.ipc : null;
const utils = isElectron() ? window.api.utils : null;
export const App = () => {
return <ThemedApp />;
@@ -89,6 +95,7 @@ const AppEffects = () => (
<>
<SyncSettingsEffect />
<UpdateCheckEffect />
<CustomCssFileEffect />
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
@@ -142,6 +149,71 @@ const CssSettingsEffect = () => {
return null;
};
const CustomCssFileEffect = () => {
const { setSettings } = useSettingsStoreActions();
const { content } = useCssSettings();
const latestContentRef = useRef(content);
useEffect(() => {
latestContentRef.current = content;
}, [content]);
useEffect(() => {
if (!isElectron() || !utils) return;
let disposed = false;
const applyContent = (rawContent: string | undefined) => {
const sanitized = sanitizeCss(`<style>${rawContent ?? ''}`);
if (sanitized !== latestContentRef.current) {
setSettings({
css: {
content: sanitized,
},
});
}
};
const loadCustomCss = async () => {
try {
const result = await utils.getCustomCss();
if (disposed || !result) return;
if (!result.exists && latestContentRef.current) {
await utils.saveCustomCss(latestContentRef.current);
return;
}
applyContent(result.content);
} catch (error) {
console.error('Failed to load custom css', error);
}
};
const handleCustomCssUpdated = (data: { content?: string; exists?: boolean }) => {
if (disposed) return;
if (data?.exists === false) {
applyContent('');
return;
}
applyContent(data?.content);
};
const removeCustomCssUpdatedListener =
utils.customCssUpdatedListener(handleCustomCssUpdated);
loadCustomCss();
return () => {
disposed = true;
removeCustomCssUpdatedListener();
};
}, [setSettings]);
return null;
};
const GlobalShortcutsEffect = () => {
const { bindings } = useHotkeySettings();
@@ -1,3 +1,4 @@
import isElectron from 'is-electron';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,18 +15,39 @@ export const StylesSettings = memo(() => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const utils = isElectron() ? window.api.utils : null;
const isDesktop = isElectron();
const { content, enabled } = useCssSettings();
const [css, setCss] = useState(content);
const { setSettings } = useSettingsStoreActions();
const handleSave = () => {
const handleSave = async () => {
setSettings({
css: {
content: css,
enabled,
},
});
if (utils) {
try {
await utils.saveCustomCss(css);
} catch (error) {
console.error('Failed to save custom css file', error);
}
}
};
const handleOpenFolder = async () => {
if (!utils) return;
try {
await utils.openCustomCssFolder();
} catch (error) {
console.error('Failed to open custom css folder', error);
}
};
useEffect(() => {
@@ -62,6 +84,15 @@ export const StylesSettings = memo(() => {
<SettingsOptions
control={
<>
{isDesktop && (
<Button
onClick={handleOpenFolder}
size="compact-md"
variant="subtle"
>
{t('common.openFolder', { postProcess: 'titleCase' })}
</Button>
)}
{open && (
<Button
onClick={handleSave}