From f098f848a37ee6a6b61de6e564663ce78f7c08f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Br=C3=A5ten?= <97455552+hexahigh@users.noreply.github.com> Date: Mon, 25 May 2026 23:53:53 +0200 Subject: [PATCH] feat: reading custom css from external file (#2012) * feat: reading custom css from external file --------- Co-authored-by: jeffvli --- src/i18n/locales/en.json | 3 +- src/main/features/core/settings/index.ts | 113 +++++++++++++++++- src/preload/utils.ts | 34 ++++++ src/renderer/app.tsx | 74 +++++++++++- .../components/advanced/styles-settings.tsx | 33 ++++- 5 files changed, 253 insertions(+), 4 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b3427c95d..3988b99f5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/main/features/core/settings/index.ts b/src/main/features/core/settings/index.ts index c08dcb128..c48f7a806 100644 --- a/src/main/features/core/settings/index.ts +++ b/src/main/features/core/settings/index.ts @@ -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({ 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; + } +}); diff --git a/src/preload/utils.ts b/src/preload/utils.ts index ed2c3c153..0a0f67ed8 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -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, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index ba4de9028..88f84c60e 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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 ; @@ -89,6 +95,7 @@ const AppEffects = () => ( <> + @@ -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(`