Ctrl K
ComunidadGuías…

src/main/index.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { is } from '@electron-toolkit/utils';
import {
    app,
    BrowserWindow,
    BrowserWindowConstructorOptions,
    globalShortcut,
    ipcMain,
    Menu,
    nativeImage,
    nativeTheme,
    net,
    powerSaveBlocker,
    protocol,
    Rectangle,
    screen,
    shell,
    Tray,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import { access, constants } from 'fs';
import path, { join } from 'path';

import { subscribePlayerStateEvent } from './features/core/player-state-broadcast';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { shutdownServer } from './features/core/remote';
import { store } from './features/core/settings';
import {
    AppUpdater,
    checkAllChannelsAndGetBest,
    configureAndGetUpdater,
    type UpdaterInstance,
} from './features/core/updater';
import MenuBuilder, { MenuPlaybackState } from './menu';
import {
    createLog,
    disableAutoUpdates,
    hotkeyToElectronAccelerator,
    isLinux,
    isMacOS,
    isWindows,
} from './utils';
import './features';

import { BindingActions, HotkeyBindings } from '/@/shared/types/hotkeys';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';

protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'samo' }]);

// The main process's ONLY crash diagnostics. Both handlers log and do nothing
// else — deliberately.
//
// These used to be duplicated in features/core/player, where they also called
// `cleanupMpv(true)`. Because both are process-global, a stray rejection in any
// unrelated subsystem killed the audio engine mid-playback. Teardown now lives
// entirely in that module's playback lifecycle (before-quit / SIGINT / SIGTERM,
// with a synchronous `exit` safety net), and nothing here touches mpv.
//
// `console.error` was replaced with `createLog` so these actually reach the
// electron-log file. A crash that only prints to a devtools console nobody has
// open is a crash that gets reported as "it just stopped working".
//
// Note that registering `uncaughtException` at all suppresses Electron's
// default crash-and-die behaviour, so the process keeps running afterwards in
// a state we cannot reason about. That is pre-existing behaviour and is left
// alone here rather than changed blind; it is worth revisiting separately.
process.on('uncaughtException', (error: unknown) => {
    createLog({ message: `[SYSTEM] Uncaught exception in main process: ${error}`, type: 'error' });
});

process.on('unhandledRejection', (reason: unknown) => {
    createLog({
        message: `[SYSTEM] Unhandled rejection in main process: ${reason}`,
        type: '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'] = {};

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(process.resourcesPath, 'assets')
    : path.join(__dirname, '../../assets');

const getAssetPath = (...paths: string[]): string => {
    const assetFileName = paths[1];

    if (
        !app.isPackaged &&
        paths[0] === 'icons' &&
        assetFileName &&
        ['icon.ico', 'icon.png'].includes(assetFileName)
    ) {
        return path.join(__dirname, '../../src/renderer/assets', ...paths);
    }

    return path.join(RESOURCES_PATH, ...paths);
};

export const getMainWindow = () => {
    return mainWindow;
};

const rebuildMainMenu = () => {
    if (!menuBuilder || !mainWindow) return;

    menuBuilder.buildMenu({
        accelerators: 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/trayTemplate.png');
        const icon = nativeImage.createFromPath(iconPath).resize({
            height: 26,
            width: 26,
        });
        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('samo');
    tray.setContextMenu(contextMenu);
};

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({
        acceptFirstMouse: true,
        autoHideMenuBar: true,
        // Paints black behind the web contents (resizes repaint the window
        // background before the renderer catches up) — no white flash.
        backgroundColor: '#000000',
        frame: false,
        ...(isMacOS() && {
            frame: true,
            titleBarStyle: 'hiddenInset',
            trafficLightPosition: { x: 14, y: 8 },
        }),
        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: true,
            preload: join(__dirname, '../preload/index.js'),
            sandbox: false,
            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('set-ignore-mouse-events', (_event, ignore: boolean) => {
        mainWindow?.setIgnoreMouseEvents(ignore, { forward: true });
    });

    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: Awaited<ReturnType<UpdaterInstance['checkForUpdates']>> | null;
                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();
            app.focus({ steal: true });
            mainWindow.focus();
            mainWindow.webContents.focus();
            createWinThumbarButtons();
        }
    });

    mainWindow.on('maximize', () => {
        mainWindow?.webContents.send('window-maximize-state', true);
    });

    mainWindow.on('unmaximize', () => {
        mainWindow?.webContents.send('window-maximize-state', false);
    });

    ipcMain.handle('window-is-maximized', () => {
        return mainWindow?.isMaximized() ?? false;
    });

    mainWindow.on('closed', () => {
        ipcMain.removeHandler('window-clear-cache');
        ipcMain.removeHandler('app-check-for-updates');
        ipcMain.removeHandler('window-is-maximized');
        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.bouliehaan.samo');
    }

    if (isMacOS()) {
        app.on('before-quit', () => {
            forceQuit = true;
        });
    }

    menuBuilder = new MenuBuilder(mainWindow);
    rebuildMainMenu();

    // samo never captures the screen. Nothing in the renderer calls
    // getDisplayMedia any more — this handler exists so that stays true even if
    // something later does, or if injected content tries.
    //
    // It used to feed the mpv visualizer: mpv plays outside the renderer, so the
    // only way to get samples for it was system audio. Electron's
    // `audio: 'loopback'` is Windows-only, so on macOS that audio could only be
    // obtained by attaching it to a live ScreenCaptureKit capture of the whole
    // display — which is why samo asked for Screen Recording, and why a
    // full-screen capture ran for as long as the visualizer was open. The
    // visualizer now reads the Web Audio graph and is simply unavailable under
    // mpv. See use-is-visualizer-available.ts.
    mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
        callback({});
    });

    if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
        new AppUpdater(getMainWindow);
    }

    const theme = store.get('theme') as TitleTheme | undefined;
    nativeTheme.themeSource = theme || 'dark';

    mainWindow.webContents.setWindowOpenHandler((details) => {
        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'));
    }
}

// Disable Chromium's hardware media key handling when:
// - Linux (MPRIS handles it instead)
// - MPV playback (not supported)
// - Windows/non-macOS desktop that hasn't opted into MediaSession
// On macOS + WEB, always enable so Bluetooth headphone controls reach the app
// via MPRemoteCommandCenter regardless of the MediaSession setting toggle.
const enableMediaSession = store.get('mediaSession', false) as boolean;
const playbackType = store.get('playbackType', PlayerType.WEB) as PlayerType;
const shouldDisableMediaFeatures =
    isLinux() || playbackType !== PlayerType.WEB || (!isMacOS() && !enableMediaSession);

const chromiumDisabledFeatures: string[] = [];
// Fractional scaling on Wayland: https://github.com/bouliehaan/samo/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');

const textEntryMenuAcceleratorHotkeys = new Set([
    'backspace',
    'delete',
    'enter',
    'escape',
    'space',
    'tab',
]);

const getMenuAccelerator = (data: HotkeyBindings, action: BindingActions) => {
    const hotkey = data[action]?.hotkey;

    if (!hotkey) return undefined;

    // Plain text-entry keys should stay renderer hotkeys only. Registering them
    // as native Electron menu accelerators bypasses focused input fields.
    if (textEntryMenuAcceleratorHotkeys.has(hotkey.toLowerCase())) {
        return undefined;
    }

    return hotkeyToElectronAccelerator(hotkey);
};

const HOTKEY_ACTIONS: Partial<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: HotkeyBindings) => {
    // 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', () => {
    if (powerSaveBlockerId !== null) {
        return powerSaveBlockerId;
    }

    powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep');
    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 = [
    'font/collection',
    'font/otf',
    'font/sfnt',
    'font/ttf',
    'font/woff',
    'font/woff2',
];

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('samo', async (request) => {
                const filePath = `file:${request.url.slice('samo:'.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();
            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);
    });
}

subscribePlayerStateEvent('playback', (status) => {
    currentPlaybackStatus = status;
    if (!isMacOS()) return;
    rebuildMainMenu();
});

subscribePlayerStateEvent('repeat', (repeat) => {
    currentRepeatMode = repeat;
    if (!isMacOS()) return;
    rebuildMainMenu();
});

subscribePlayerStateEvent('shuffle', (shuffle) => {
    currentShuffleEnabled = shuffle;
    if (!isMacOS()) return;
    rebuildMainMenu();
});

subscribePlayerStateEvent('privateMode', (privateMode) => {
    currentPrivateMode = privateMode;
    if (!isMacOS()) return;
    rebuildMainMenu();
});

subscribePlayerStateEvent('sidebarCollapsed', (collapsedSidebar) => {
    currentSidebarCollapsed = collapsedSidebar;
    if (!isMacOS()) return;
    rebuildMainMenu();
});