[feature]: Support using system fonts (#304)

* [feature]: Support using system fonts

Uses the **experimental** queryLocalFonts API, when prompted, to get the fonts and do CSS.
Resolves #270 and #288 (by proxy)

Caveats/notes:
- This is experimental, and is only supported by Chrome/Chromium/Edgeium (see https://caniuse.com/?search=querylocalfonts)
- As far as I can tell, the only way to dynamically change the font (shown in https://wicg.github.io/local-font-access/#example-style-with-local-fonts) was by DOM manipulation; css variables did not seem to work
- This shows **all** fonts, including their variants (bold/italic/etc); given that the style names could be localized, not sure of a way to parse this (on my system, for instance, I had 859 different combinations)
- I made fonts a separate top-level setting because it was easier to manipulate without causing as many rerenders; feel free to put that back

* add permission chec

* add electron magic to support custom font

* restrict content types
This commit is contained in:
Kendall Garner 2023-10-22 22:25:17 +00:00 committed by GitHub
parent e6ed9229c2
commit 74cab01013
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 282 additions and 20 deletions

View file

@ -21,6 +21,8 @@ import {
Menu, Menu,
nativeImage, nativeImage,
BrowserWindowConstructorOptions, BrowserWindowConstructorOptions,
protocol,
net,
} from 'electron'; } from 'electron';
import electronLocalShortcut from 'electron-localshortcut'; import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log'; 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) => { process.on('uncaughtException', (error: any) => {
console.log('Error in main process', error); 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() app.whenReady()
.then(() => { .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(); createWindow();
createTray(); createTray();
app.on('activate', () => { app.on('activate', () => {

View file

@ -1,4 +1,4 @@
import { ipcRenderer, webFrame } from 'electron'; import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
const store = new Store(); const store = new Store();
@ -39,9 +39,14 @@ const setZoomFactor = (zoomFactor: number) => {
webFrame.setZoomFactor(zoomFactor / 100); webFrame.setZoomFactor(zoomFactor / 100);
}; };
const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
ipcRenderer.on('custom-font-error', cb);
};
export const localSettings = { export const localSettings = {
disableMediaKeys, disableMediaKeys,
enableMediaKeys, enableMediaKeys,
fontError,
get, get,
passwordGet, passwordGet,
passwordRemove, passwordRemove,

View file

@ -1130,3 +1130,12 @@ export enum LyricSource {
} }
export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string }; export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { 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;
};

View file

@ -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 { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import { ModuleRegistry } from '@ag-grid-community/core'; import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model'; 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 { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings'; import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store'; 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'; import '@ag-grid-community/styles/ag-grid.css';
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
@ -37,17 +37,48 @@ const remote = isElectron() ? window.electron.remote : null;
export const App = () => { export const App = () => {
const theme = useTheme(); const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent); const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { type: playbackType } = usePlaybackSettings(); const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings(); const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd(); const handlePlayQueueAdd = useHandlePlayQueueAdd();
const { clearQueue, restoreQueue } = useQueueControls(); const { clearQueue, restoreQueue } = useQueueControls();
const remoteSettings = useRemoteSettings(); const remoteSettings = useRemoteSettings();
const textStyleRef = useRef<HTMLStyleElement>();
useEffect(() => { useEffect(() => {
const root = document.documentElement; if (type === FontType.SYSTEM && system) {
root.style.setProperty('--content-font-family', contentFont); const root = document.documentElement;
}, [contentFont]); 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(() => { const providerValue = useMemo(() => {
return { handlePlayQueueAdd }; return { handlePlayQueueAdd };

View file

@ -1,14 +1,27 @@
import type { IpcRendererEvent } from 'electron';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { NumberInput, Select } from '/@/renderer/components'; import { FileInput, NumberInput, Select, toast } from '/@/renderer/components';
import { import {
SettingsSection, SettingsSection,
SettingOption, SettingOption,
} from '/@/renderer/features/settings/components/settings-section'; } 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 localSettings = isElectron() ? window.electron.localSettings : null;
const ipc = isElectron() ? window.electron.ipc : null;
const FONT_OPTIONS = [ type Font = {
label: string;
value: string;
};
const FONT_OPTIONS: Font[] = [
{ label: 'Archivo', value: 'Archivo' }, { label: 'Archivo', value: 'Archivo' },
{ label: 'Fredoka', value: 'Fredoka' }, { label: 'Fredoka', value: 'Fredoka' },
{ label: 'Inter', value: 'Inter' }, { label: 'Inter', value: 'Inter' },
@ -20,9 +33,99 @@ const FONT_OPTIONS = [
{ label: 'Work Sans', value: 'Work Sans' }, { 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 = () => { export const ApplicationSettings = () => {
const settings = useGeneralSettings(); const settings = useGeneralSettings();
const fontSettings = useFontSettings();
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const [localFonts, setLocalFonts] = useState<Font[]>([]);
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[] = [ const options: SettingOption[] = [
{ {
@ -39,24 +142,87 @@ export const ApplicationSettings = () => {
{ {
control: ( control: (
<Select <Select
searchable data={FONT_TYPES}
data={FONT_OPTIONS} value={fontSettings.type}
defaultValue={settings.fontContent}
onChange={(e) => { onChange={(e) => {
if (!e) return; if (!e) return;
setSettings({ setSettings({
general: { font: {
...settings, ...fontSettings,
fontContent: e, type: e as FontType,
},
});
}}
/>
),
description:
'What font to use. Built-in font selects one of the fonts provided by Feishin. System font allows you to select any font provided by your OS. Custom allows you to provide your own font',
isHidden: FONT_TYPES.length === 1,
title: 'Use system font',
},
{
control: (
<Select
searchable
data={FONT_OPTIONS}
value={fontSettings.builtIn}
onChange={(e) => {
if (!e) return;
setSettings({
font: {
...fontSettings,
builtIn: e,
}, },
}); });
}} }}
/> />
), ),
description: 'Sets the application content font', description: 'Sets the application content font',
isHidden: false, isHidden: localFonts && fontSettings.type !== FontType.BUILT_IN,
title: 'Font (Content)', title: 'Font (Content)',
}, },
{
control: (
<Select
searchable
data={localFonts}
value={fontSettings.system}
w={300}
onChange={(e) => {
if (!e) return;
setSettings({
font: {
...fontSettings,
system: e,
},
});
}}
/>
),
description: 'Sets the application content font',
isHidden: !localFonts || fontSettings.type !== FontType.SYSTEM,
title: 'Font (Content)',
},
{
control: (
<FileInput
accept=".ttc,.ttf,.otf,.woff,.woff2"
defaultValue={fontList}
w={300}
onChange={(e) =>
setSettings({
font: {
...fontSettings,
custom: e?.path ?? null,
},
})
}
/>
),
description: 'Path to custom font',
isHidden: fontSettings.type !== FontType.CUSTOM,
title: 'Path to custom font',
},
{ {
control: ( control: (
<NumberInput <NumberInput

View file

@ -1,6 +1,6 @@
import { IpcRendererEvent } from 'electron'; import { IpcRendererEvent } from 'electron';
import { PlayerData, PlayerState } from './store'; import { PlayerData, PlayerState } from './store';
import { InternetProviderLyricResponse, QueueSong } from '/@/renderer/api/types'; import { FontData, InternetProviderLyricResponse, QueueSong } from '/@/renderer/api/types';
import { Remote } from '/@/main/preload/remote'; import { Remote } from '/@/main/preload/remote';
import { Mpris } from '/@/main/preload/mpris'; import { Mpris } from '/@/main/preload/mpris';
import { MpvPLayer, MpvPlayerListener } from '/@/main/preload/mpv-player'; import { MpvPLayer, MpvPlayerListener } from '/@/main/preload/mpv-player';
@ -76,6 +76,7 @@ declare global {
remote?: Remote; remote?: Remote;
utils?: Utils; utils?: Utils;
}; };
queryLocalFonts?: () => Promise<FontData[]>;
} }
} }

View file

@ -19,6 +19,7 @@ import {
PlaybackType, PlaybackType,
TableType, TableType,
Platform, Platform,
FontType,
} from '/@/renderer/types'; } from '/@/renderer/types';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
@ -111,10 +112,16 @@ export enum BindingActions {
} }
export interface SettingsState { export interface SettingsState {
font: {
builtIn: string;
custom: string | null;
system: string | null;
type: FontType;
};
general: { general: {
defaultFullPlaylist: boolean; defaultFullPlaylist: boolean;
followSystemTheme: boolean; followSystemTheme: boolean;
fontContent: string;
playButtonBehavior: Play; playButtonBehavior: Play;
resume: boolean; resume: boolean;
showQueueDrawerButton: boolean; showQueueDrawerButton: boolean;
@ -208,10 +215,15 @@ const getPlatformDefaultWindowBarStyle = (): Platform => {
const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle(); const platformDefaultWindowBarStyle: Platform = getPlatformDefaultWindowBarStyle();
const initialState: SettingsState = { const initialState: SettingsState = {
font: {
builtIn: 'Inter',
custom: null,
system: null,
type: FontType.BUILT_IN,
},
general: { general: {
defaultFullPlaylist: true, defaultFullPlaylist: true,
followSystemTheme: false, followSystemTheme: false,
fontContent: 'Inter',
playButtonBehavior: Play.NOW, playButtonBehavior: Play.NOW,
resume: false, resume: false,
showQueueDrawerButton: false, showQueueDrawerButton: false,
@ -542,3 +554,5 @@ export const useMpvSettings = () =>
export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow); export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow);
export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow); export const useRemoteSettings = () => useSettingsStore((state) => state.remote, shallow);
export const useFontSettings = () => useSettingsStore((state) => state.font, shallow);

View file

@ -203,3 +203,9 @@ export type SongUpdate = {
/** This volume is in range 0-100 */ /** This volume is in range 0-100 */
volume?: number; volume?: number;
}; };
export enum FontType {
BUILT_IN = 'builtIn',
CUSTOM = 'custom',
SYSTEM = 'system',
}