diff --git a/src/main/main.ts b/src/main/main.ts index 1d2932971..0409a21fd 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,6 +21,8 @@ import { Menu, nativeImage, BrowserWindowConstructorOptions, + protocol, + net, } from 'electron'; import electronLocalShortcut from 'electron-localshortcut'; import log from 'electron-log'; @@ -43,6 +45,8 @@ export default class AppUpdater { } } +protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]); + process.on('uncaughtException', (error: any) => { console.log('Error in main process', error); }); @@ -653,8 +657,34 @@ app.on('window-all-closed', () => { } }); +const FONT_HEADERS = [ + 'font/collection', + 'font/otf', + 'font/sfnt', + 'font/ttf', + 'font/woff', + 'font/woff2', +]; + app.whenReady() .then(() => { + protocol.handle('feishin', async (request) => { + const filePath = `file://${request.url.slice('feishin://'.length)}`; + const response = await net.fetch(filePath); + const contentType = response.headers.get('content-type'); + + if (!contentType || !FONT_HEADERS.includes(contentType)) { + getMainWindow()?.webContents.send('custom-font-error', filePath); + + return new Response(null, { + status: 403, + statusText: 'Forbidden', + }); + } + + return response; + }); + createWindow(); createTray(); app.on('activate', () => { diff --git a/src/main/preload/local-settings.ts b/src/main/preload/local-settings.ts index ee8044e0b..a57b08c4f 100644 --- a/src/main/preload/local-settings.ts +++ b/src/main/preload/local-settings.ts @@ -1,4 +1,4 @@ -import { ipcRenderer, webFrame } from 'electron'; +import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron'; import Store from 'electron-store'; const store = new Store(); @@ -39,9 +39,14 @@ const setZoomFactor = (zoomFactor: number) => { webFrame.setZoomFactor(zoomFactor / 100); }; +const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => { + ipcRenderer.on('custom-font-error', cb); +}; + export const localSettings = { disableMediaKeys, enableMediaKeys, + fontError, get, passwordGet, passwordRemove, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 52bf1bc5b..dc128474f 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1130,3 +1130,12 @@ export enum LyricSource { } export type LyricsOverride = Omit & { id: string }; + +// This type from https://wicg.github.io/local-font-access/#fontdata +// NOTE: it is still experimental, so this should be updates as appropriate +export type FontData = { + family: string; + fullName: string; + postscriptName: string; + style: string; +}; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 6d72976ab..26e7a522f 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model'; import { ModuleRegistry } from '@ag-grid-community/core'; import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model'; @@ -23,7 +23,7 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player'; import { AddToPlaylistContextModal } from '/@/renderer/features/playlists'; import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings'; import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store'; -import { PlaybackType, PlayerStatus } from '/@/renderer/types'; +import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types'; import '@ag-grid-community/styles/ag-grid.css'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); @@ -37,17 +37,48 @@ const remote = isElectron() ? window.electron.remote : null; export const App = () => { const theme = useTheme(); - const contentFont = useSettingsStore((state) => state.general.fontContent); + const { builtIn, custom, system, type } = useSettingsStore((state) => state.font); const { type: playbackType } = usePlaybackSettings(); const { bindings } = useHotkeySettings(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); const { clearQueue, restoreQueue } = useQueueControls(); const remoteSettings = useRemoteSettings(); + const textStyleRef = useRef(); useEffect(() => { - const root = document.documentElement; - root.style.setProperty('--content-font-family', contentFont); - }, [contentFont]); + if (type === FontType.SYSTEM && system) { + const root = document.documentElement; + root.style.setProperty('--content-font-family', 'dynamic-font'); + + if (!textStyleRef.current) { + textStyleRef.current = document.createElement('style'); + document.body.appendChild(textStyleRef.current); + } + + textStyleRef.current.textContent = ` + @font-face { + font-family: "dynamic-font"; + src: local("${system}"); + }`; + } else if (type === FontType.CUSTOM && custom) { + const root = document.documentElement; + root.style.setProperty('--content-font-family', 'dynamic-font'); + + if (!textStyleRef.current) { + textStyleRef.current = document.createElement('style'); + document.body.appendChild(textStyleRef.current); + } + + textStyleRef.current.textContent = ` + @font-face { + font-family: "dynamic-font"; + src: url("feishin://${custom}"); + }`; + } else { + const root = document.documentElement; + root.style.setProperty('--content-font-family', builtIn); + } + }, [builtIn, custom, system, type]); const providerValue = useMemo(() => { return { handlePlayQueueAdd }; diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx index f0b7c2aa0..061f6b006 100644 --- a/src/renderer/features/settings/components/general/application-settings.tsx +++ b/src/renderer/features/settings/components/general/application-settings.tsx @@ -1,14 +1,27 @@ +import type { IpcRendererEvent } from 'electron'; import isElectron from 'is-electron'; -import { NumberInput, Select } from '/@/renderer/components'; +import { FileInput, NumberInput, Select, toast } from '/@/renderer/components'; import { SettingsSection, SettingOption, } from '/@/renderer/features/settings/components/settings-section'; -import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { + useFontSettings, + useGeneralSettings, + useSettingsStoreActions, +} from '/@/renderer/store/settings.store'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FontType } from '/@/renderer/types'; const localSettings = isElectron() ? window.electron.localSettings : null; +const ipc = isElectron() ? window.electron.ipc : null; + +type Font = { + label: string; + value: string; +}; -const FONT_OPTIONS = [ +const FONT_OPTIONS: Font[] = [ { label: 'Archivo', value: 'Archivo' }, { label: 'Fredoka', value: 'Fredoka' }, { label: 'Inter', value: 'Inter' }, @@ -20,9 +33,99 @@ const FONT_OPTIONS = [ { label: 'Work Sans', value: 'Work Sans' }, ]; +const FONT_TYPES: Font[] = [{ label: 'Built-in font', value: FontType.BUILT_IN }]; + +if (window.queryLocalFonts) { + FONT_TYPES.push({ label: 'System font', value: FontType.SYSTEM }); +} + +if (isElectron()) { + FONT_TYPES.push({ label: 'Custom font', value: FontType.CUSTOM }); +} + export const ApplicationSettings = () => { const settings = useGeneralSettings(); + const fontSettings = useFontSettings(); const { setSettings } = useSettingsStoreActions(); + const [localFonts, setLocalFonts] = useState([]); + + const fontList = useMemo(() => { + if (fontSettings.custom) { + const newFile = new File([], fontSettings.custom.split(/(\\|\/)/g).pop()!); + newFile.path = fontSettings.custom; + return newFile; + } + return null; + }, [fontSettings.custom]); + + const onFontError = useCallback( + (_: IpcRendererEvent, file: string) => { + toast.error({ + message: `${file} is not a valid font file`, + }); + + setSettings({ + font: { + ...fontSettings, + custom: null, + }, + }); + }, + [fontSettings, setSettings], + ); + + useEffect(() => { + if (localSettings) { + localSettings.fontError(onFontError); + + return () => { + ipc!.removeAllListeners('custom-font-error'); + }; + } + + return () => {}; + }, [onFontError]); + + useEffect(() => { + const getFonts = async () => { + if ( + fontSettings.type === FontType.SYSTEM && + localFonts.length === 0 && + window.queryLocalFonts + ) { + try { + // WARNING (Oct 17 2023): while this query is valid for chromium-based + // browsers, it is still experimental, and so Typescript will complain + // @ts-ignore + const status = await navigator.permissions.query({ name: 'local-fonts' }); + + if (status.state === 'denied') { + throw new Error('Access denied to local fonts'); + } + + const data = await window.queryLocalFonts(); + setLocalFonts( + data.map((font) => ({ + label: font.fullName, + value: font.postscriptName, + })), + ); + } catch (error) { + toast.error({ + message: 'An error occurred when trying to get system fonts', + }); + + setSettings({ + font: { + ...fontSettings, + type: FontType.BUILT_IN, + }, + }); + } + } + }; + getFonts(); + }, [fontSettings, localFonts, setSettings]); const options: SettingOption[] = [ { @@ -36,27 +139,90 @@ export const ApplicationSettings = () => { isHidden: false, title: 'Language', }, + { + control: ( + { if (!e) return; setSettings({ - general: { - ...settings, - fontContent: e, + font: { + ...fontSettings, + builtIn: e, }, }); }} /> ), description: 'Sets the application content font', - isHidden: false, + isHidden: localFonts && fontSettings.type !== FontType.BUILT_IN, title: 'Font (Content)', }, + { + control: ( +