import { app, BrowserWindow, Menu, screen, session, shell } from 'electron'; import { ElectronBridgeWindowState, MACOS_TRAFFIC_LIGHTS_POSITION, WINDOW_STATE_CHANGED, } from '@iptvnator/shared/interfaces'; import { join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { rendererAppName, rendererAppPort } from './constants'; import { isPerformanceCaptureEnabled, isRendererConsoleTraceEnabled, isSqlStatementCountEnabled, isWindowTraceEnabled, performanceCounters, trace, traceStartupPhase, } from './services/debug-trace'; import { MAIN_WINDOW_BACKGROUND_COLOR, showMainWindowWhenLoaded, } from './services/main-window-first-show'; import { attachMainWindowPerformanceCounters } from './services/performance-counters'; import { STARTUP_WINDOW_MODE, store, WINDOW_BOUNDS, } from './services/store.service'; import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util'; import { attachZoomLevelPersistence, persistZoomLevel, } from './services/window-zoom-level'; import { attachRendererReloadFallback, resolveRoutedRendererUrl, restoreRendererRoute, } from './services/renderer-reload-fallback'; import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util'; import { FULLSCREEN_LAUNCH_SWITCH, resolveStartupWindowMode, } from './services/startup-window-mode'; import { requestFullScreen, trackNativeFullScreen, } from './services/native-fullscreen-transitions'; const externalBrowserProtocols = new Set(['http:', 'https:']); const trustedDevRendererHosts = new Set([ 'localhost', '127.0.0.1', '[::1]', '::1', ]); function parseUrl(url: string): URL | null { try { return new URL(url); } catch { return null; } } function getPackagedRendererIndexPath(): string { return resolve(__dirname, '..', rendererAppName, 'index.html'); } function getFilePathFromUrl(url: URL): string | null { try { return fileURLToPath(url); } catch { return null; } } export function isExternalBrowserUrl(url: string): boolean { const parsedUrl = parseUrl(url); return Boolean( parsedUrl && externalBrowserProtocols.has(parsedUrl.protocol) ); } export function isTrustedRendererNavigationUrl( url: string, isDevelopmentMode: boolean, packagedRendererIndexPath = getPackagedRendererIndexPath() ): boolean { const parsedUrl = parseUrl(url); if (!parsedUrl) { return false; } if (parsedUrl.protocol === 'file:') { const filePath = getFilePathFromUrl(parsedUrl); if (isDevelopmentMode || !filePath) { return false; } return resolve(filePath) === resolve(packagedRendererIndexPath); } if (!isDevelopmentMode) { return false; } return ( parsedUrl.protocol === 'http:' && trustedDevRendererHosts.has(parsedUrl.hostname) && parsedUrl.port === String(rendererAppPort) ); } export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructorOptions['webPreferences'] { // The frame-copy embedded MPV experiment needs the preload script to // load the shm frame-reader native addon, which the renderer sandbox // forbids. Only that opt-in flag relaxes the sandbox; context isolation // and nodeIntegration:false stay on either way, so page code never // gains Node access. Revisit before the engine can become a default. const frameCopyExperiment = isEmbeddedMpvFeatureEnabled() && ['1', 'true', 'yes', 'on'].includes( (process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY ?? '') .trim() .toLowerCase() ) && isFrameCopyRuntimeUsable(); return { contextIsolation: true, nodeIntegration: false, sandbox: !frameCopyExperiment, webSecurity: true, // Chromium's default. A hidden or minimized window must report // `document.hidden` so idle timers can pause and the playback // keep-awake gate can release the display; Chromium itself keeps // audible media and picture-in-picture running at full rate. backgroundThrottling: true, preload: join(__dirname, 'main.preload.js'), }; } export async function clearElectronServiceWorkerStorage( electronSession: Pick = session.defaultSession ): Promise { try { await electronSession.clearStorageData({ storages: ['serviceworkers', 'cachestorage'], }); traceStartupPhase('electron-service-worker-storage:cleared'); } catch (error) { console.warn('Failed to clear Electron service worker storage:', error); traceStartupPhase( 'electron-service-worker-storage:clear-failed', () => error ); } } function attachWindowTrace(mainWindow: Electron.BrowserWindow): void { if (!isWindowTraceEnabled()) { return; } const webContents = mainWindow.webContents; trace('window', 'created', { id: mainWindow.id, }); mainWindow.on('unresponsive', () => { trace('window', 'unresponsive', { id: mainWindow.id, url: webContents.getURL(), }); }); mainWindow.on('responsive', () => { trace('window', 'responsive', { id: mainWindow.id, url: webContents.getURL(), }); }); webContents.on('did-start-loading', () => { trace('window', 'did-start-loading', { id: mainWindow.id, url: webContents.getURL(), }); }); webContents.on('dom-ready', () => { trace('window', 'dom-ready', { id: mainWindow.id, url: webContents.getURL(), }); }); webContents.on('did-finish-load', () => { trace('window', 'did-finish-load', { id: mainWindow.id, url: webContents.getURL(), }); }); webContents.on( 'did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { trace('window', 'did-fail-load', { errorCode, errorDescription, id: mainWindow.id, validatedURL, }); } ); webContents.on('did-navigate', (_event, url) => { trace('window', 'did-navigate', { id: mainWindow.id, url, }); }); webContents.on('render-process-gone', (_event, details) => { trace('window', 'render-process-gone', { details, id: mainWindow.id, url: webContents.getURL(), }); }); if (isRendererConsoleTraceEnabled()) { webContents.on( 'console-message', (_event, level, message, line, sourceId) => { trace('renderer-console', 'message', { level, line, message, sourceId, }); } ); } } export default class App { // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. static mainWindow: Electron.BrowserWindow | null = null; private static readonly mainWindowListeners: Array< (mainWindow: Electron.BrowserWindow) => void > = []; static application: Electron.App; static BrowserWindow; private static loadedMainWindow: Electron.BrowserWindow | null = null; private static mainWindowLoadPromise: Promise | null = null; /** * Whether the `--fullscreen` launch switch has already shaped a window. * See initMainWindow: the switch is consumed by the first window so a * window re-created later in the same process (macOS Dock) follows the * stored setting instead. */ private static launchFullscreenSwitchConsumed = false; private static rendererLoadingEnabled = false; private static shouldOpenDevTools() { return process.env.ELECTRON_OPEN_DEVTOOLS === '1'; } public static isDevelopmentMode() { // First check ELECTRON_IS_DEV environment variable (used by E2E tests) // This allows E2E tests to run in production mode without packaging if ('ELECTRON_IS_DEV' in process.env) { return parseInt(process.env.ELECTRON_IS_DEV, 10) === 1; } // Fall back to Electron's built-in app.isPackaged // This is the most reliable way to detect if the app is packaged return !app.isPackaged; } private static onWindowAllClosed() { if (process.platform !== 'darwin') { App.application.quit(); } } private static onClose() { // Dereference the window object, usually you would store windows // in an array if your app supports multi windows, this is the time // when you should delete the corresponding element. App.mainWindow = null; } private static startMainWindowLoad(): void { void App.loadMainWindow().catch((error) => { console.error('Failed to load main window:', error); }); } private static onReady() { // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. if (rendererAppName) { App.initMainWindow(); if (App.rendererLoadingEnabled) { App.startMainWindowLoad(); } } } /** * Registers a listener that needs the current main window, and re-runs it * whenever a new one is created. * * The main window is not created once per process: on macOS the window can * be closed and rebuilt (dock `activate`, or a second launch handed over by * the single-instance guard) while the process lives on. Anything that * caches the window — `download-broadcast`'s module-level reference, for * one — would otherwise keep pointing at a destroyed window and silently * stop delivering to the renderer. Fires immediately when a window already * exists, so callers registering after startup do not miss the first one. */ static onMainWindowCreated( listener: (mainWindow: Electron.BrowserWindow) => void ): void { App.mainWindowListeners.push(listener); if (App.mainWindow && !App.mainWindow.isDestroyed()) { listener(App.mainWindow); } } private static notifyMainWindowCreated( mainWindow: Electron.BrowserWindow ): void { for (const listener of App.mainWindowListeners) { listener(mainWindow); } } /** * Brings the app back to a windowed state, re-creating the main window if * it is gone. * * On macOS closing the last window deliberately keeps the process alive * (`onWindowAllClosed`), so this is the recovery path for both the dock * `activate` event and a second launch that the single-instance guard * hands over to this process. */ static ensureMainWindow() { if (App.mainWindow === null) { App.onReady(); } if (App.rendererLoadingEnabled) { App.startMainWindowLoad(); } } private static onActivate() { // 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. App.ensureMainWindow(); } private static handleRendererNavigation( event: Electron.Event, url: string ): void { if (isTrustedRendererNavigationUrl(url, App.isDevelopmentMode())) { return; } event.preventDefault(); // A renderer-initiated reload (`location.reload()`, e.g. the // settings unsaved-changes guard) on an in-app route arrives here as // a routed file:// URL with no file behind it. Send it straight to // the packaged index with that route instead of cancelling it. if (!App.isDevelopmentMode() && App.mainWindow) { const rendererIndexPath = getPackagedRendererIndexPath(); const route = resolveRoutedRendererUrl(url, rendererIndexPath); if (route !== null) { restoreRendererRoute(App.mainWindow, rendererIndexPath, route); return; } } if (isExternalBrowserUrl(url)) { shell.openExternal(url); } } /** * Hide the native title bar on every desktop platform. macOS keeps the * system traffic lights (overlay), while Windows/Linux rely on the * renderer-drawn window controls (`app-window-controls`) wired up via the * WINDOW:* IPC channels. `frame` stays untouched so native resize borders * and snapping keep working. */ private static getPlatformTitleBarOptions(): Electron.BrowserWindowConstructorOptions { if (process.platform === 'darwin') { return { titleBarStyle: 'hidden', titleBarOverlay: true, trafficLightPosition: { ...MACOS_TRAFFIC_LIGHTS_POSITION }, }; } return { titleBarStyle: 'hidden' }; } private static attachWindowStateEvents(win: Electron.BrowserWindow): void { // Only Windows/Linux render custom window controls that subscribe // to these pushes; macOS keeps the native traffic lights, so // sending state updates there would be dead IPC traffic. if (process.platform === 'darwin') { return; } // Window state is never re-read at event time: on Windows both // isFullScreen() and isMaximized() can still report the // pre-transition value while the matching event fires (notably for // HTML-element fullscreen, i.e. the video player). Since the // renderer replaces both flags on every push and no later event // corrects a stale one, polling left the controls hidden forever // after leaving fullscreen — and, for the companion flag, the // maximize/restore glyph stuck on the wrong icon. // // Instead the state is seeded once here (window creation, so no // transition is in flight) and each event patches only the flag it // names. const state: ElectronBridgeWindowState = { isMaximized: win.isMaximized(), isFullScreen: win.isFullScreen(), }; // Native (OS-level) and HTML-element fullscreen are tracked apart // and OR-ed into the pushed flag. Electron remembers when the window // was already natively fullscreen before the player entered HTML // fullscreen and then leaves ONLY the HTML state on exit — no // 'leave-full-screen' fires and the window stays fullscreen. A single // flag cleared by 'leave-html-full-screen' would un-hide the window // controls over a window that is still fullscreen, which a fullscreen // launch or F11 followed by the player's F → Esc makes routine. const fullscreen = { native: state.isFullScreen, html: false }; const push = (patch: Partial) => { Object.assign(state, patch); if (win.isDestroyed()) { return; } // A copy per push: the renderer must not observe later // mutations of the tracked state. win.webContents.send(WINDOW_STATE_CHANGED, { ...state }); }; const pushFullScreen = () => push({ isFullScreen: fullscreen.native || fullscreen.html }); win.on('maximize', () => push({ isMaximized: true })); win.on('unmaximize', () => push({ isMaximized: false })); // The html variants cover HTML-element fullscreen; not every // platform/trigger emits both pairs, and duplicate pushes with the // same payload are harmless. win.on('enter-full-screen', () => { fullscreen.native = true; pushFullScreen(); }); win.on('leave-full-screen', () => { fullscreen.native = false; pushFullScreen(); }); win.on('enter-html-full-screen', () => { fullscreen.html = true; pushFullScreen(); }); win.on('leave-html-full-screen', () => { fullscreen.html = false; pushFullScreen(); }); } /** * Persist the state restored on the next launch: window bounds and the * zoom level. Runs from both the window 'close' and app 'before-quit' * handlers, since either can be the last to run before the process ends. * The zoom half is a no-op until the window's preload took ownership of * the level (`services/window-zoom-level.ts`). */ private static persistWindowState(win: Electron.BrowserWindow): void { store.set(WINDOW_BOUNDS, win.getNormalBounds()); persistZoomLevel(win); } private static initMainWindow() { const workAreaSize = screen.getPrimaryDisplay().workAreaSize; const width = Math.min(1280, workAreaSize.width || 1280); const height = Math.min(720, workAreaSize.height || 720); const savedWindowBounds = store.get(WINDOW_BOUNDS); const startupWindowMode = resolveStartupWindowMode({ // One-shot: the switch describes the launch, not every window // this process ever opens. On macOS the process outlives its // last window and the Dock re-creates it through this same // path, which must then follow the stored setting only. cliHasFullscreenSwitch: !App.launchFullscreenSwitchConsumed && app.commandLine.hasSwitch(FULLSCREEN_LAUNCH_SWITCH), storedMode: store.get(STARTUP_WINDOW_MODE), }); App.launchFullscreenSwitchConsumed = true; // Create the browser window. App.mainWindow = new BrowserWindow({ title: 'IPTVnator', width: width, height: height, show: false, // The splash colour: the window can be shown before its first // paint (main-window-first-show.ts). backgroundColor: MAIN_WINDOW_BACKGROUND_COLOR, webPreferences: getMainWindowWebPreferences(), ...savedWindowBounds, // Fullscreen is a constructor option: the window is created // hidden and enters fullscreen before its first paint. The saved // bounds stay spread in above — they are the normal bounds the // window returns to, and the close handler keeps persisting // getNormalBounds(), so a fullscreen session never corrupts them. ...(startupWindowMode === 'fullscreen' ? { fullscreen: true } : {}), minHeight: 600, minWidth: 900, ...App.getPlatformTitleBarOptions(), }); App.mainWindow.setMenu(null); attachMainWindowPerformanceCounters( App.mainWindow, performanceCounters, { capture: isPerformanceCaptureEnabled(), sqlStatements: isSqlStatementCountEnabled(), } ); attachWindowTrace(App.mainWindow); App.attachWindowStateEvents(App.mainWindow); // Seeds the F11 tracker's fullscreen state now, while no transition // can be in flight; from here on it follows the window's events. trackNativeFullScreen(App.mainWindow); App.notifyMainWindowCreated(App.mainWindow); if (!savedWindowBounds) { App.mainWindow.center(); } // Shown at ready-to-show or did-finish-load, whichever comes first // (see main-window-first-show.ts). const mainWindow = App.mainWindow; showMainWindowWhenLoaded(mainWindow, () => { // maximize() on a hidden window shows it (Electron docs), so it // waits for the document like show() does — any earlier and a // blank window flashes before the splash is there. if (startupWindowMode === 'maximized') { mainWindow.maximize(); } mainWindow.show(); // macOS ignores the constructor's `fullscreen` while the window // is hidden — an NSWindow can only toggle fullscreen once it is // on screen — so the request is repeated after show() wherever // it has not taken yet. Windows/Linux honoured it at creation // (before the first paint) and are left alone. It goes through // the transition tracker so an F11 pressed during the animation // reads the pending target and leaves fullscreen instead of // asking for it again. if ( startupWindowMode === 'fullscreen' && !mainWindow.isFullScreen() ) { requestFullScreen(mainWindow, true); } }); // Route target="_blank" / window.open() to the OS default browser App.mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (isExternalBrowserUrl(url)) { shell.openExternal(url); } return { action: 'deny' }; }); App.mainWindow.webContents.on( 'will-navigate', App.handleRendererNavigation ); App.mainWindow.webContents.on( 'will-redirect', App.handleRendererNavigation ); // The preload restores the zoom level on every document load; the // main process only saves it back before a reload drops it. attachZoomLevelPersistence(App.mainWindow); // A reload on an in-app route asks file:// for a path that does not // exist; re-load the packaged index with that route instead of // leaving Chromium's error page (see renderer-reload-fallback.ts). attachRendererReloadFallback( App.mainWindow, getPackagedRendererIndexPath() ); // Emitted when the window is closed. App.mainWindow.on('closed', () => { // Dereference the window object, usually you would store windows // in an array if your app supports multi windows, this is the time // when you should delete the corresponding element. App.mainWindow = null; App.loadedMainWindow = null; App.mainWindowLoadPromise = null; }); App.mainWindow.on('close', () => { if (App.mainWindow) { App.persistWindowState(App.mainWindow); } }); // Enable context menu for input fields only App.mainWindow.webContents.on('context-menu', (event, params) => { const { isEditable, editFlags } = params; // Check if this is an editable field (input, textarea, contenteditable) // editFlags.canPaste is a good indicator of an input field if (isEditable && editFlags.canPaste) { const menu = Menu.buildFromTemplate([ { label: 'Cut', role: 'cut', enabled: editFlags.canCut, }, { label: 'Copy', role: 'copy', enabled: editFlags.canCopy, }, { label: 'Paste', role: 'paste', enabled: editFlags.canPaste, }, { type: 'separator', }, { label: 'Select All', role: 'selectAll', enabled: editFlags.canSelectAll, }, ]); menu.popup(); } }); } private static async loadMainWindowContent( mainWindow: Electron.BrowserWindow ): Promise { // load the index.html of the app. if (App.isDevelopmentMode()) { const loadPromise = mainWindow.loadURL( `http://localhost:${rendererAppPort}` ); if (App.shouldOpenDevTools()) { mainWindow.webContents.openDevTools(); } await loadPromise; } else { await clearElectronServiceWorkerStorage(); await mainWindow.loadFile(getPackagedRendererIndexPath()); } } static async loadMainWindow(): Promise { App.rendererLoadingEnabled = true; if (!rendererAppName || !App.mainWindow) { return; } if (App.loadedMainWindow === App.mainWindow) { return; } if (!App.mainWindowLoadPromise) { const mainWindow = App.mainWindow; App.mainWindowLoadPromise = App.loadMainWindowContent(mainWindow) .then(() => { App.loadedMainWindow = mainWindow; }) .finally(() => { App.mainWindowLoadPromise = null; }); } await App.mainWindowLoadPromise; } static main(app: Electron.App, browserWindow: typeof BrowserWindow) { // we pass the Electron.App object and the // Electron.BrowserWindow into this function // so this class has no dependencies. This // makes the code easier to write tests for App.BrowserWindow = browserWindow; App.application = app; App.application.on('window-all-closed', App.onWindowAllClosed); // Quit when all windows are closed. if (App.application.isReady()) { App.onReady(); } else { App.application.on('ready', App.onReady); // App is ready to load data } App.application.on('activate', App.onActivate); // App is activated App.application.on('before-quit', () => { if (App.mainWindow) App.persistWindowState(App.mainWindow); }); } }