Files
iptvnator/apps/electron-backend/src/app/app.ts
T
b315f8564d fix(electron): show the main window once its document has loaded; enforce J1 IPC and mutation counters (#1828)
* fix(electron): show the main window once its document has loaded; enforce J1 IPC and mutation counters

Re-lands #1788, which merged into #1782's branch after #1782 had already
reached master, so none of it is on master.

The hidden main window was shown on ready-to-show only. On Linux under
X11, when the startup scripts run before the window's first frame, the
next frame comes about a second later: nothing is on screen and the
splash's requestAnimationFrame waits, so J1's first card came ~940 ms
after load instead of ~480 ms in most runner launches (18 bridge calls /
1,031-1,033 DOM mutations instead of 15 / 558).

The window is now shown at ready-to-show or the main frame's
did-finish-load, whichever comes first, with the splash colour as its
background so showing before the first paint does not flash. The journey
gate keeps the app's did-finish-load listeners away from its about:blank
detour, as it already does for ready-to-show.

Three dispatched runs on this branch (37192092882, 37192097790,
37192103151) read 15 calls and 558 mutations in all 18 iterations,
stable: true. Both become baselines (slack 0), and the Performance
journeys job checks them with check-journey-ratchet.mjs --only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(perf): record the evidence PR of the J1 runtime baselines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: 4gray <fourgray@proton.me>
2026-10-06 10:34:08 +02:00

727 lines
26 KiB
TypeScript

import { app, BrowserWindow, Menu, screen, session, shell } from 'electron';
import {
ElectronBridgeWindowState,
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<Electron.Session, 'clearStorageData'> = session.defaultSession
): Promise<void> {
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<void> | 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: { x: 16, y: 20 },
};
}
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<ElectronBridgeWindowState>) => {
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<void> {
// 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<void> {
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);
});
}
}