Files
feishin/src/main/index.ts
T
Kendall Garner 515cadb916 Better cross platform font handling (#2104)
* fix: better handling of custom font

Practically speaking, custom font seems to have only worked on Linux, because
`net.fetch` would include the mime type in the response headers which could validate the payload.
This doesn't appear to be the case on windows/macOS. Instead:

1. On Linux (or if some other system supports it), check the content type. If good, serve as normal
2. Otherwise, fetch the payload. Read the first four to five bytes and check for a valid magic number.

Additionally, to prevent arbitrary requests fetching other paths via injected content, sync the custom font path
to the main process, and then make _every_ request to `feishin:/` point to the same renderer path.
When setting the font, first send the path to the main process. This will register `feishin:/` to point
to the path provided. This is done via a promise-based set.

Finally, provide a default value for the file input (a best effort approximation for the last part of the file path)
on the file input component.

* make the linter happy
2026-06-02 19:34:16 +00:00

1178 lines
38 KiB
TypeScript

import type { UpdateCheckResult } from 'electron-updater';
import { is } from '@electron-toolkit/utils';
import {
app,
BrowserWindow,
BrowserWindowConstructorOptions,
desktopCapturer,
globalShortcut,
ipcMain,
Menu,
nativeImage,
nativeTheme,
net,
powerSaveBlocker,
protocol,
Rectangle,
screen,
session,
shell,
Tray,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log/main';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import { access, constants } from 'fs';
import path, { join } from 'path';
import semver from 'semver';
import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import { canHandleVisualizerDisplayMedia } from './features/core/visualizer';
import MenuBuilder, { MenuPlaybackState } from './menu';
import './features';
import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from './utils';
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
const ALPHA_UPDATER_CONFIG: {
bucket: string;
channel: string;
endpoint: string;
provider: 's3';
} = {
bucket: '',
channel: 'alpha',
endpoint: 'https://feishin-nightly-bucket.jeffvli.org',
provider: 's3',
};
const GITHUB_UPDATER_CONFIG = {
owner: 'jeffvli',
provider: 'github' as const,
repo: 'feishin',
};
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
class AppUpdater {
constructor() {
const effectiveChannel = store.get('release_channel') as string;
console.log('Effective update channel:', effectiveChannel);
if (effectiveChannel === 'alpha') {
checkAllChannelsAndGetBest().then(({ result, updater: updaterInstance }) => {
updaterInstance.autoInstallOnAppQuit = true;
updaterInstance.autoRunAppAfterInstall = true;
if (isMacOS()) {
if (result?.isUpdateAvailable) {
getMainWindow()?.webContents.send(
'update-available',
result.updateInfo.version,
);
}
} else {
updaterInstance.checkForUpdatesAndNotify();
}
});
return;
}
configureAndGetUpdater();
if (isMacOS()) {
autoUpdater.autoDownload = false;
autoUpdater
.checkForUpdates()
.then((result) => {
if (result?.isUpdateAvailable) {
getMainWindow()?.webContents.send(
'update-available',
result.updateInfo.version,
);
}
})
.catch((err) => console.error('Check for updates failed', err));
} else {
autoUpdater.checkForUpdatesAndNotify();
}
}
}
// When release channel is alpha, check alpha and latest for updates and return
// the updater + result for the newest version found (so alpha users can receive
// latest updates when they are newer than the current alpha).
async function checkAllChannelsAndGetBest(): Promise<{
result: null | UpdateCheckResult;
updater: UpdaterInstance;
}> {
const currentVersion = packageJson.version;
const candidates: Array<{
channel: 'alpha' | 'beta' | 'latest';
result: UpdateCheckResult;
updater: UpdaterInstance;
}> = [];
const alphaUpdater = createAlphaUpdaterInstance();
alphaUpdater.logger = autoUpdaterLogInterface;
alphaUpdater.channel = ALPHA_UPDATER_CONFIG.channel;
alphaUpdater.allowPrerelease = true;
alphaUpdater.disableDifferentialDownload = true;
alphaUpdater.allowDowngrade = true;
try {
console.log('Checking for updates on alpha channel');
const alphaResult = await alphaUpdater.checkForUpdates();
if (
alphaResult?.updateInfo?.version &&
alphaResult.isUpdateAvailable &&
semver.valid(alphaResult.updateInfo.version) &&
semver.gt(alphaResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'alpha', result: alphaResult, updater: alphaUpdater });
}
} catch (e) {
log.warn('Alpha channel check failed', e);
}
try {
autoUpdater.setFeedURL(GITHUB_UPDATER_CONFIG);
configureAutoUpdaterForChannel('latest');
console.log('Checking for updates on latest channel (GitHub)');
const latestResult = await autoUpdater.checkForUpdates();
if (
latestResult?.updateInfo?.version &&
latestResult.isUpdateAvailable &&
semver.valid(latestResult.updateInfo.version) &&
semver.gt(latestResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'latest', result: latestResult, updater: autoUpdater });
}
} catch (e) {
log.warn('Latest channel check failed', e);
}
if (candidates.length === 0) {
return { result: null, updater: alphaUpdater };
}
const best = candidates.reduce((a, b) =>
semver.gt(a.result.updateInfo.version, b.result.updateInfo.version) ? a : b,
);
if (best.channel === 'latest') {
configureAutoUpdaterForChannel('latest');
}
return { result: best.result, updater: best.updater };
}
function configureAndGetUpdater(): UpdaterInstance {
const isBetaVersion = packageJson.version.includes('-beta');
const isAlphaVersion = packageJson.version.includes('-alpha');
let releaseChannel = store.get('release_channel');
const isNotConfigured = !releaseChannel;
console.log('Release channel:', releaseChannel);
console.log('Is beta version:', isBetaVersion);
console.log('Is alpha version:', isAlphaVersion);
console.log('Is not configured:', isNotConfigured);
if (isNotConfigured) {
console.log('Release channel not configured, setting default channel');
const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest';
store.set('release_channel', defaultChannel);
releaseChannel = defaultChannel;
}
const effectiveChannel = store.get('release_channel') as string;
if (effectiveChannel === 'alpha') {
const updater = createAlphaUpdaterInstance();
log.transports.file.level = 'info';
updater.logger = autoUpdaterLogInterface;
updater.channel = ALPHA_UPDATER_CONFIG.channel;
updater.allowPrerelease = true;
updater.disableDifferentialDownload = true;
updater.allowDowngrade = true;
updater.autoInstallOnAppQuit = true;
updater.autoRunAppAfterInstall = true;
return updater;
}
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
if (effectiveChannel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowPrerelease = false;
}
return autoUpdater;
}
/**
* Configures the global autoUpdater for a specific GitHub channel (beta or latest).
* Used when checking multiple channels or when the winning channel is beta/latest.
*/
function configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void {
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
if (channel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowPrerelease = false;
}
}
function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
if (isMacOS()) {
return new MacUpdater(ALPHA_UPDATER_CONFIG);
}
if (isLinux()) {
return new AppImageUpdater(ALPHA_UPDATER_CONFIG);
}
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
}
protocol.registerSchemesAsPrivileged([
{ privileges: { bypassCSP: true, corsEnabled: true }, scheme: 'feishin' },
]);
process.on('uncaughtException', (error: any) => {
console.error('Error in main process', error);
});
if (store.get('ignore_ssl')) {
app.commandLine.appendSwitch('ignore-certificate-errors');
}
// From https://github.com/tutao/tutanota/commit/92c6ed27625fcf367f0fbcc755d83d7ff8fde94b
if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
const passwordStore = store.get('password_store', 'gnome-libsecret') as string;
app.commandLine.appendSwitch('password-store', passwordStore);
}
let mainWindow: BrowserWindow | null = null;
let tray: null | Tray = null;
let exitFromTray = false;
let forceQuit = false;
let powerSaveBlockerId: null | number = null;
let menuBuilder: MenuBuilder | null = null;
let currentPlaybackStatus: PlayerStatus = PlayerStatus.PAUSED;
let currentPrivateMode = false;
let currentRepeatMode: PlayerRepeat = PlayerRepeat.NONE;
let currentSidebarCollapsed = false;
let currentShuffleEnabled = false;
let playbackMenuAccelerators: MenuPlaybackState['accelerators'] = {};
let inputFocused = false;
ipcMain.on('input-focus-state', (_event, focused: boolean) => {
const next = !!focused;
if (inputFocused === next) return;
inputFocused = next;
if (isMacOS()) {
rebuildMainMenu();
}
});
if (process.env.NODE_ENV === 'production') {
import('source-map-support').then((sourceMapSupport) => {
sourceMapSupport.install();
});
}
const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
if (isDevelopment) {
import('electron-debug').then((electronDebug) => {
electronDebug.default();
});
}
const installExtensions = async () => {
import('electron-devtools-installer').then((installer) => {
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS'];
installer
.installExtension(
extensions.map((name) => installer[name]),
{ forceDownload },
)
.then((installedExtensions) => {
createLog({
message: `Installed extension: ${installedExtensions}`,
type: 'info',
});
})
.catch(() => {
// Ignore
});
});
};
const userDataPath = app.getPath('userData');
if (isDevelopment) {
const devUserDataPath = `${userDataPath}-dev`;
app.setPath('userData', devUserDataPath);
}
const RESOURCES_PATH = app.isPackaged
? path.join(path.dirname(app.getAppPath()), 'assets')
: path.join(__dirname, '../../assets');
const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};
export const getMainWindow = () => {
return mainWindow;
};
const rebuildMainMenu = () => {
if (!menuBuilder || !mainWindow) return;
menuBuilder.buildMenu({
accelerators: inputFocused ? {} : playbackMenuAccelerators,
playbackStatus: currentPlaybackStatus,
privateMode: currentPrivateMode,
repeatMode: currentRepeatMode,
shuffleEnabled: currentShuffleEnabled,
sidebarCollapsed: currentSidebarCollapsed,
});
if (process.platform !== 'darwin') {
Menu.setApplicationMenu(null);
}
};
export const sendToastToRenderer = ({
message,
type,
}: {
message: string;
type: 'error' | 'info' | 'success' | 'warning';
}) => {
getMainWindow()?.webContents.send('toast-from-main', {
message,
type,
});
};
const createWinThumbarButtons = () => {
if (isWindows()) {
getMainWindow()?.setThumbarButtons([
{
click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
icon: nativeImage.createFromPath(getAssetPath('skip-previous.png')),
tooltip: 'Previous Track',
},
{
click: () => getMainWindow()?.webContents.send('renderer-player-play-pause'),
icon: nativeImage.createFromPath(getAssetPath('play-circle.png')),
tooltip: 'Play/Pause',
},
{
click: () => getMainWindow()?.webContents.send('renderer-player-next'),
icon: nativeImage.createFromPath(getAssetPath('skip-next.png')),
tooltip: 'Next Track',
},
]);
}
};
const createTray = () => {
let trayIcon: Electron.NativeImage | string;
if (isMacOS()) {
const iconPath = getAssetPath('icons/IconTemplate.png');
const icon = nativeImage.createFromPath(iconPath);
icon.setTemplateImage(true);
trayIcon = icon;
} else if (isLinux()) {
trayIcon = getAssetPath('icons/icon.png');
} else {
trayIcon = getAssetPath('icons/icon.ico');
}
tray = new Tray(trayIcon);
const contextMenu = Menu.buildFromTemplate([
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-play-pause');
},
label: 'Play/Pause',
},
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-next');
},
label: 'Next Track',
},
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-previous');
},
label: 'Previous Track',
},
{
click: () => {
getMainWindow()?.webContents.send('renderer-player-stop');
},
label: 'Stop',
},
{
type: 'separator',
},
{
click: () => {
if (mainWindow === null) createWindow(false);
else {
mainWindow.show();
createWinThumbarButtons();
}
},
label: 'Open main window',
},
{
click: () => {
exitFromTray = true;
app.quit();
},
label: 'Quit',
},
]);
if (!isMacOS()) {
tray.on('click', () => {
if (store.get('window_minimize_to_tray')) {
if (mainWindow?.isVisible()) {
mainWindow?.hide();
} else {
mainWindow?.show();
createWinThumbarButtons();
}
} else {
mainWindow?.show();
createWinThumbarButtons();
}
});
}
tray.setToolTip('Feishin');
tray.setContextMenu(contextMenu);
};
const validateUrl = (url: string): boolean => {
// Minor security, really. Enforce only loading websites (http/https). file://
// URLs and the like should've already been blocked, but this is another check.
// Note that arbitrary web URLs are still allowed under this scheme, although
// that should really only be hit by Subsonic share url (or if artist homepage
// is allowed for ND extensions)
return url.startsWith('http://') || url.startsWith('https://');
};
async function createWindow(first = true): Promise<void> {
if (isDevelopment) {
await installExtensions().catch(console.log);
}
const nativeFrame = store.get('window_window_bar_style', 'linux') === 'linux';
store.set('window_has_frame', nativeFrame);
const nativeFrameConfig: Record<string, BrowserWindowConstructorOptions> = {
linux: {
autoHideMenuBar: true,
frame: true,
},
macOS: {
autoHideMenuBar: true,
frame: true,
titleBarStyle: 'default',
trafficLightPosition: { x: 10, y: 10 },
},
windows: {
autoHideMenuBar: true,
frame: true,
},
};
// Create the browser window.
mainWindow = new BrowserWindow({
autoHideMenuBar: true,
frame: false,
height: 900,
icon: isWindows() ? getAssetPath('icons/icon.ico') : getAssetPath('icons/icon.png'),
minHeight: 120,
minWidth: 480,
show: false,
webPreferences: {
allowRunningInsecureContent: !!store.get('ignore_ssl'),
backgroundThrottling: false,
contextIsolation: true,
devTools: true,
nodeIntegration: false,
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
webSecurity: !store.get('ignore_cors'),
},
width: 1440,
...(nativeFrame && isLinux() && nativeFrameConfig.linux),
...(nativeFrame && isMacOS() && nativeFrameConfig.macOS),
...(nativeFrame && isWindows() && nativeFrameConfig.windows),
});
// From https://github.com/electron/electron/issues/526#issuecomment-1663959513
const bounds = store.get('bounds') as Rectangle | undefined;
if (bounds) {
const screenArea = screen.getDisplayMatching(bounds).workArea;
if (
bounds.x > screenArea.x + screenArea.width ||
bounds.x < screenArea.x ||
bounds.y < screenArea.y ||
bounds.y > screenArea.y + screenArea.height
) {
if (bounds.width < screenArea.width && bounds.height < screenArea.height) {
mainWindow.setBounds({ height: bounds.height, width: bounds.width });
} else {
mainWindow.setBounds({ height: 900, width: 1440 });
}
} else {
mainWindow.setBounds(bounds);
}
}
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
mainWindow?.webContents.openDevTools();
});
ipcMain.on('window-dev-tools', () => {
mainWindow?.webContents.openDevTools();
});
ipcMain.on('window-maximize', () => {
mainWindow?.maximize();
});
ipcMain.on('window-unmaximize', () => {
mainWindow?.unmaximize();
});
ipcMain.on('window-minimize', () => {
mainWindow?.minimize();
});
ipcMain.on('window-close', () => {
mainWindow?.close();
});
ipcMain.on('window-quit', () => {
shutdownServer();
mainWindow?.close();
app.exit();
});
ipcMain.handle('window-clear-cache', async () => {
return mainWindow?.webContents.session.clearCache();
});
ipcMain.handle(
'app-check-for-updates',
async (): Promise<{ updateAvailable: boolean; version?: string }> => {
if (disableAutoUpdates()) {
console.log('Auto updates are disabled');
return { updateAvailable: false };
}
try {
console.log('Checking for updates');
const effectiveChannel = store.get('release_channel') as string;
let result: null | UpdateCheckResult;
let updater: UpdaterInstance;
if (effectiveChannel === 'alpha') {
const best = await checkAllChannelsAndGetBest();
result = best.result;
updater = best.updater;
} else {
updater = configureAndGetUpdater();
result = await updater.checkForUpdates();
}
const updateAvailable = result?.isUpdateAvailable ?? false;
console.log('Update available:', updateAvailable);
if (updateAvailable && store.get('disable_auto_updates') !== true) {
if (isMacOS()) {
getMainWindow()?.webContents.send(
'update-available',
result?.updateInfo?.version,
);
} else {
console.log('Downloading update');
updater.downloadUpdate();
}
}
return {
updateAvailable,
version: result?.updateInfo?.version,
};
} catch {
console.log('Error checking for updates');
return { updateAvailable: false };
}
},
);
ipcMain.on('app-restart', () => {
// Fix for .AppImage
if (process.env.APPIMAGE) {
app.exit();
app.relaunch({
args: process.argv.slice(1).concat(['--appimage-extract-and-run']),
execPath: process.env.APPIMAGE,
});
app.exit(0);
} else {
app.relaunch();
app.exit(0);
}
});
ipcMain.on('global-media-keys-enable', () => {
enableMediaKeys(mainWindow);
});
ipcMain.on('global-media-keys-disable', () => {
disableMediaKeys();
});
ipcMain.on('download-url', (_event, url: string) => {
mainWindow?.webContents.downloadURL(url);
});
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
}
const startWindowMinimized = store.get('window_start_minimized', false) as boolean;
mainWindow.on('ready-to-show', () => {
// mainWindow.show()
if (!mainWindow) {
throw new Error('"mainWindow" is not defined');
}
if (!first || !startWindowMinimized) {
const maximized = store.get('maximized');
const fullScreen = store.get('fullscreen');
if (maximized) {
mainWindow.maximize();
}
if (fullScreen) {
mainWindow.setFullScreen(true);
}
mainWindow.show();
createWinThumbarButtons();
}
});
mainWindow.on('closed', () => {
ipcMain.removeHandler('window-clear-cache');
ipcMain.removeHandler('app-check-for-updates');
mainWindow = null;
});
mainWindow.on('close', (event) => {
store.set('bounds', mainWindow?.getNormalBounds());
store.set('maximized', mainWindow?.isMaximized());
store.set('fullscreen', mainWindow?.isFullScreen());
if (!exitFromTray && store.get('window_exit_to_tray')) {
event.preventDefault();
mainWindow?.hide();
}
if (forceQuit) {
app.exit();
}
});
(mainWindow as any).on('minimize', (event: any) => {
if (store.get('window_minimize_to_tray') === true) {
event.preventDefault();
mainWindow?.hide();
}
});
if (isWindows()) {
app.setAppUserModelId('org.jeffvli.feishin');
}
if (isMacOS()) {
app.on('before-quit', () => {
forceQuit = true;
});
}
menuBuilder = new MenuBuilder(mainWindow);
rebuildMainMenu();
// Open URLs in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
if (validateUrl(edata.url)) {
shell.openExternal(edata.url);
}
return { action: 'deny' };
});
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
if (!canHandleVisualizerDisplayMedia()) {
callback({});
return;
}
if (!isMacOS()) {
callback({ audio: 'loopback' });
return;
}
desktopCapturer
.getSources({ thumbnailSize: { height: 0, width: 0 }, types: ['screen'] })
.then((sources) => {
const source = sources[0];
if (!source) {
callback({});
return;
}
callback({ audio: 'loopback', video: source });
})
.catch((err) => {
log.warn('desktopCapturer.getSources failed', err);
callback({});
});
});
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
new AppUpdater();
}
const theme = store.get('theme') as TitleTheme | undefined;
nativeTheme.themeSource = theme || 'dark';
mainWindow.webContents.setWindowOpenHandler((details) => {
if (validateUrl(details.url)) {
shell.openExternal(details.url);
}
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
}
// Only allow hardware media key handling if:
// 1. The "Enable Media Session" setting is enabled
// 2. The playback type is WEB (mpv not supported)
// 3. The platform is not Linux (because we are using mpris instead)
const enableMediaSession = store.get('mediaSession', false) as boolean;
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
const shouldDisableMediaFeatures =
isLinux() || !enableMediaSession || playbackType !== PlayerType.WEB;
const chromiumDisabledFeatures: string[] = [];
// Fractional scaling on Wayland: https://github.com/jeffvli/feishin/issues/1271#issuecomment-4063326712
if (isLinux()) {
chromiumDisabledFeatures.push('WaylandFractionalScaleV1');
}
if (shouldDisableMediaFeatures) {
chromiumDisabledFeatures.push('HardwareMediaKeyHandling', 'MediaSessionService');
}
if (chromiumDisabledFeatures.length > 0) {
app.commandLine.appendSwitch('disable-features', chromiumDisabledFeatures.join(','));
}
// https://github.com/electron/electron/issues/46538#issuecomment-2808806722
app.commandLine.appendSwitch('gtk-version', '3');
// Enable garbage collection API
app.commandLine.appendSwitch('js-flags', '--expose-gc');
// Must duplicate with the one in renderer process settings.store.ts
enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
LOCAL_SEARCH = 'localSearch',
MUTE = 'volumeMute',
NEXT = 'next',
PAUSE = 'pause',
PLAY = 'play',
PLAY_PAUSE = 'playPause',
PREVIOUS = 'previous',
SHUFFLE = 'toggleShuffle',
SKIP_BACKWARD = 'skipBackward',
SKIP_FORWARD = 'skipForward',
STOP = 'stop',
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
TOGGLE_QUEUE = 'toggleQueue',
TOGGLE_REPEAT = 'toggleRepeat',
VOLUME_DOWN = 'volumeDown',
VOLUME_UP = 'volumeUp',
}
const getMenuAccelerator = (
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
action: BindingActions,
) => {
const hotkey = data[action]?.hotkey;
if (!hotkey) return undefined;
return hotkeyToElectronAccelerator(hotkey);
};
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
[BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),
[BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),
[BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),
[BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'),
[BindingActions.PLAY_PAUSE]: () =>
getMainWindow()?.webContents.send('renderer-player-play-pause'),
[BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'),
[BindingActions.SHUFFLE]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'),
[BindingActions.SKIP_BACKWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-backward'),
[BindingActions.SKIP_FORWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
[BindingActions.TOGGLE_QUEUE]: () => {},
[BindingActions.TOGGLE_REPEAT]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
[BindingActions.VOLUME_DOWN]: () =>
getMainWindow()?.webContents.send('renderer-player-volume-down'),
[BindingActions.VOLUME_UP]: () =>
getMainWindow()?.webContents.send('renderer-player-volume-up'),
};
ipcMain.on(
'set-global-shortcuts',
(
_event,
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
) => {
// Since we're not tracking the previous shortcuts, we need to unregister all of them
globalShortcut.unregisterAll();
for (const shortcut of Object.keys(data)) {
const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;
const isValidHotkey =
data[shortcut as BindingActions].hotkey &&
data[shortcut as BindingActions].hotkey !== '';
if (isGlobalHotkey && isValidHotkey) {
const accelerator = hotkeyToElectronAccelerator(
data[shortcut as BindingActions].hotkey,
);
globalShortcut.register(accelerator, () => {
HOTKEY_ACTIONS[shortcut as BindingActions]();
});
}
}
playbackMenuAccelerators = {
next: getMenuAccelerator(data, BindingActions.NEXT),
playPause:
getMenuAccelerator(data, BindingActions.PLAY_PAUSE) ||
getMenuAccelerator(data, BindingActions.PLAY) ||
getMenuAccelerator(data, BindingActions.PAUSE),
previous: getMenuAccelerator(data, BindingActions.PREVIOUS),
repeat: getMenuAccelerator(data, BindingActions.TOGGLE_REPEAT),
seekBackward: getMenuAccelerator(data, BindingActions.SKIP_BACKWARD),
seekForward: getMenuAccelerator(data, BindingActions.SKIP_FORWARD),
shuffle: getMenuAccelerator(data, BindingActions.SHUFFLE),
stop: getMenuAccelerator(data, BindingActions.STOP),
volumeDown: getMenuAccelerator(data, BindingActions.VOLUME_DOWN),
volumeUp: getMenuAccelerator(data, BindingActions.VOLUME_UP),
};
if (isMacOS()) {
rebuildMainMenu();
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
}
},
);
ipcMain.on(
'logger',
(
_event,
data: {
message: string;
type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning';
},
) => {
createLog(data);
},
);
ipcMain.handle('power-save-blocker-start', (_event, { full }: { full: boolean }) => {
if (powerSaveBlockerId !== null) {
return powerSaveBlockerId;
}
powerSaveBlockerId = powerSaveBlocker.start(
full ? 'prevent-display-sleep' : 'prevent-app-suspension',
);
return powerSaveBlockerId;
});
ipcMain.handle('power-save-blocker-stop', () => {
if (powerSaveBlockerId !== null) {
const stopped = powerSaveBlocker.stop(powerSaveBlockerId);
powerSaveBlockerId = null;
return stopped;
}
return false;
});
ipcMain.handle('power-save-blocker-is-started', () => {
return powerSaveBlockerId !== null && powerSaveBlocker.isStarted(powerSaveBlockerId);
});
app.on('window-all-closed', () => {
globalShortcut.unregisterAll();
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
if (isMacOS()) {
mainWindow = null;
} else {
app.quit();
}
});
const FONT_HEADERS = new Set([
'font/collection',
'font/otf',
'font/sfnt',
'font/ttf',
'font/woff',
'font/woff2',
]);
const bytesToInt = (array: Uint8Array, length: number): number => {
let value = 0;
for (let i = 0; i < length; i++) {
value = (value << 8) + array[i];
}
return value;
};
const FONT_FOUR_BYTE_MAGIC_NUMBERS = new Set([
0x4f54544f, // font/otf
0x774f4632, // font/woff2
0x774f4646, // font/woff
]);
const FONT_FIVE_BYTE_MAGIC_NUMBERS = new Set([
0x0001000000, // ttf, collection, sfnt
]);
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
if (!singleInstance) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
} else if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
}
});
app.whenReady()
.then(() => {
protocol.handle('feishin', async () => {
const filePath = store.get('local_font_path');
if (typeof filePath !== 'string') {
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
}
const response = await net.fetch('file:' + filePath);
const contentType = response.headers.get('content-type');
// On Linux, the mime type is included in the response header
// In this case, we can forward the response with no further processing
if (contentType && FONT_HEADERS.has(contentType)) {
return response;
}
// Otherwise, let's check the magic number to see if
// the file is a font type. This is either four or five bytes
const payload = await response.arrayBuffer();
const magicNumber = new Uint8Array(payload.slice(0, 5));
const fiveHex = bytesToInt(magicNumber, 5);
const fourHex = bytesToInt(magicNumber, 4);
if (
FONT_FIVE_BYTE_MAGIC_NUMBERS.has(fiveHex) ||
FONT_FOUR_BYTE_MAGIC_NUMBERS.has(fourHex)
) {
// We have to create a new response with the payload, since it has been read now
return new Response(payload, {
headers: response.headers,
});
}
getMainWindow()?.webContents.send('custom-font-error', filePath);
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://umami.jeffvli.org; style-src 'self' 'unsafe-inline'; media-src 'self' http: https: data: blob:; img-src 'self' http: https: data: blob:; connect-src 'self' http: https: ws: wss:; default-src 'self';",
],
},
});
});
createWindow();
if (store.get('window_enable_tray', true)) {
createTray();
}
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow(false);
else if (!mainWindow.isVisible()) {
mainWindow.show();
createWinThumbarButtons();
}
});
})
.catch(console.log);
}
// Register 'open-item' handler globally, ensuring it is only registered once
if (!ipcMain.eventNames().includes('open-item')) {
ipcMain.handle('open-item', async (_event, path: string) => {
return new Promise<void>((resolve, reject) => {
access(path, constants.F_OK, (error) => {
if (error) {
reject(error);
return;
}
shell.showItemInFolder(path);
resolve();
});
});
});
}
// Register 'open-application-directory' handler globally, ensuring it is only registered once
if (!ipcMain.eventNames().includes('open-application-directory')) {
ipcMain.handle('open-application-directory', async () => {
const userDataPath = app.getPath('userData');
shell.openPath(userDataPath);
});
}
ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
currentPlaybackStatus = status;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentRepeatMode = repeat;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentShuffleEnabled = shuffle;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-private-mode', (_event, privateMode: boolean) => {
currentPrivateMode = privateMode;
if (!isMacOS()) return;
rebuildMainMenu();
});
ipcMain.on('update-sidebar-collapsed', (_event, collapsedSidebar: boolean) => {
currentSidebarCollapsed = collapsedSidebar;
if (!isMacOS()) return;
rebuildMainMenu();
});