perf(electron): load the main-process startup wiring after the window starts loading (#1702)

Registering IPC handlers costs 0.4 ms; evaluating the modules behind them (axios, drizzle-orm, better-sqlite3, electron-updater, fix-path) before the window could load was the real cost. main.ts now keeps only the pre-paint wiring and loads the rest as the deferred-events.js chunk inside the main window's did-start-loading listener, where the import and its registrations complete before any renderer invoke can arrive.

Interleaved A/B on the performance build: app.whenReady 323 -> 265 ms, did-finish-load 499 -> 447 ms; J1 journey spawnToDidFinishLoad ~405 -> ~365 ms with identical counters. Packaging ships the chunk explicitly, verify:package-layout requires it, and the benchmark build identity hashes it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-26 21:51:19 +02:00
1 parent 497b6076fa
commit e39c854a41
13 files changed
+565 -138

No files matched your search

@@ -0,0 +1,8 @@
---
type: perf
area: electron
---
The desktop app now opens its window before it prepares the portal, program
guide, download, player and update machinery, and does that preparation while
the window is already loading, so the first screen appears sooner.
@@ -62,6 +62,16 @@ export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = {
'database-worker-source-map'
),
},
deferredEvents: {
javascript: buildFile(
'dist/apps/electron-backend/deferred-events.js',
'deferred-events-javascript'
),
sourceMap: buildFile(
'dist/apps/electron-backend/deferred-events.js.map',
'deferred-events-source-map'
),
},
launcher: {
javascript: buildFile(
'dist/apps/electron-backend/main.js',
@@ -11,6 +11,7 @@ const RENDERER_FILE =
const BUILD_KEYS = ['electron', 'renderer'] as const;
const ELECTRON_KEYS = [
'databaseWorker',
'deferredEvents',
'launcher',
'main',
'playlistRefreshWorker',
@@ -27,6 +28,7 @@ const RENDERER_KEYS = [
] as const;
const ELECTRON_PATHS = {
databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js',
deferredEvents: 'dist/apps/electron-backend/deferred-events.js',
launcher: 'dist/apps/electron-backend/main.js',
main: 'dist/apps/electron-backend/main.app.js',
playlistRefreshWorker:
@@ -46,6 +48,10 @@ export function parseXtreamBenchmarkBuildIdentity(
electronInput['databaseWorker'],
ELECTRON_PATHS.databaseWorker
),
deferredEvents: pair(
electronInput['deferredEvents'],
ELECTRON_PATHS.deferredEvents
),
launcher: pair(electronInput['launcher'], ELECTRON_PATHS.launcher),
main: pair(electronInput['main'], ELECTRON_PATHS.main),
playlistRefreshWorker: pair(
@@ -46,6 +46,11 @@ describe('Xtream benchmark build identity', () => {
path: 'dist/apps/electron-backend/main.js',
sha256: sha256('launcher'),
});
assert.deepEqual(identity.electron.deferredEvents.javascript, {
bytes: 8,
path: 'dist/apps/electron-backend/deferred-events.js',
sha256: sha256('deferred'),
});
assert.deepEqual(identity.electron.preload.sourceMap, {
bytes: 11,
path: 'dist/apps/electron-backend/main.preload.js.map',
@@ -224,6 +229,8 @@ async function buildFixture(): Promise<string> {
mkdir(join(renderer, 'assets'), { recursive: true }),
]);
await Promise.all([
writeFile(join(backend, 'deferred-events.js'), 'deferred'),
writeFile(join(backend, 'deferred-events.js.map'), 'deferred-map'),
writeFile(join(backend, 'main.js'), 'launcher'),
writeFile(join(backend, 'main.js.map'), 'launcher-map'),
writeFile(join(backend, 'main.app.js'), 'main'),
@@ -18,6 +18,7 @@ export interface XtreamBuildPairIdentity {
export interface XtreamBenchmarkBuildIdentity {
readonly electron: {
readonly databaseWorker: XtreamBuildPairIdentity;
readonly deferredEvents: XtreamBuildPairIdentity;
readonly launcher: XtreamBuildPairIdentity;
readonly main: XtreamBuildPairIdentity;
readonly playlistRefreshWorker: XtreamBuildPairIdentity;
@@ -36,6 +37,9 @@ const BACKEND_ROOT = 'dist/apps/electron-backend';
const RENDERER_ROOT = 'dist/apps/web';
const ELECTRON_PATHS = {
databaseWorker: `${BACKEND_ROOT}/workers/database.worker.js`,
// main.app.js loads this chunk once the window starts loading; most IPC
// handlers and the database wiring live there.
deferredEvents: `${BACKEND_ROOT}/deferred-events.js`,
// main.js only enables the compile cache and requires main.app.js, but
// it decides startup behavior, so both belong to the identity.
launcher: `${BACKEND_ROOT}/main.js`,
@@ -51,6 +55,7 @@ export async function captureXtreamBuildIdentity(
if (!isAbsolute(workspaceRoot)) invalid();
const [
databaseWorker,
deferredEvents,
launcher,
main,
playlistRefreshWorker,
@@ -58,6 +63,7 @@ export async function captureXtreamBuildIdentity(
renderer,
] = await Promise.all([
readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker),
readPair(workspaceRoot, ELECTRON_PATHS.deferredEvents),
readPair(workspaceRoot, ELECTRON_PATHS.launcher),
readPair(workspaceRoot, ELECTRON_PATHS.main),
readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker),
@@ -67,6 +73,7 @@ export async function captureXtreamBuildIdentity(
return Object.freeze({
electron: Object.freeze({
databaseWorker,
deferredEvents,
launcher,
main,
playlistRefreshWorker,
+4 -2
View File
@@ -59,6 +59,7 @@
}
],
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"webpackConfig": "apps/electron-backend/webpack.config.cjs",
"assets": [
"apps/electron-backend/src/assets",
{
@@ -157,6 +158,7 @@
}
],
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"webpackConfig": "apps/electron-backend/webpack.config.cjs",
"assets": [
"apps/electron-backend/src/assets",
{
@@ -213,7 +215,7 @@
{
"from": "electron-backend",
"to": "electron-backend",
"filter": ["main.app.js"]
"filter": ["main.app.js", "deferred-events.js"]
}
],
"sourcePath": "dist/apps",
@@ -234,7 +236,7 @@
{
"from": "electron-backend",
"to": "electron-backend",
"filter": ["main.app.js"]
"filter": ["main.app.js", "deferred-events.js"]
}
],
"sourcePath": "dist/apps",
@@ -0,0 +1,145 @@
import { EventEmitter } from 'node:events';
import { createDeferredBootstrap } from './deferred-bootstrap';
interface FakeModule {
readonly name: string;
}
const fakeModule: FakeModule = { name: 'deferred' };
/** Mirrors webpack's node chunk loading: a synchronous require behind a resolved promise. */
const loadResolved = () => Promise.resolve(fakeModule);
function macrotask(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
describe('deferred main-process bootstrap', () => {
it('registers handlers before the next macrotask once the window starts loading', async () => {
const order: string[] = [];
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: (module) => {
order.push(`run:${module.name}`);
return 'registered';
},
});
bootstrap.armOn(webContents);
// An IPC message that the renderer sends right after it starts
// loading arrives as a macrotask; it must queue behind registration.
setImmediate(() => order.push('renderer-ipc'));
webContents.emit('did-start-loading');
await macrotask();
expect(order).toEqual(['run:deferred', 'renderer-ipc']);
expect(bootstrap.module).toBe(fakeModule);
});
it('runs the deferred work exactly once across both triggers', async () => {
const run = jest.fn(() => 'once');
const load = jest.fn(loadResolved);
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({ load, run });
bootstrap.armOn(webContents);
webContents.emit('did-start-loading');
webContents.emit('did-start-loading');
const explicit = bootstrap.trigger();
const outcome = await explicit;
expect(load).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(1);
expect(outcome).toEqual({ module: fakeModule, result: 'once' });
await expect(bootstrap.trigger()).resolves.toBe(outcome);
});
it('falls back to the explicit trigger when no window is available', async () => {
const sources: string[] = [];
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: () => undefined,
onTrigger: (source) => sources.push(source),
});
bootstrap.armOn(null);
bootstrap.armOn(undefined);
await bootstrap.trigger();
expect(sources).toEqual(['explicit']);
});
it('reports the trigger source and the duration', async () => {
const onTrigger = jest.fn();
const onDone = jest.fn();
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: () => undefined,
onTrigger,
onDone,
});
bootstrap.armOn(webContents);
webContents.emit('did-start-loading');
await bootstrap.trigger();
expect(onTrigger).toHaveBeenCalledTimes(1);
expect(onTrigger).toHaveBeenCalledWith('did-start-loading');
expect(onDone).toHaveBeenCalledTimes(1);
expect(onDone.mock.calls[0][0]).toBeGreaterThanOrEqual(0);
});
it('surfaces a failed load to every awaiting caller without running handlers', async () => {
const run = jest.fn();
const onError = jest.fn();
const bootstrap = createDeferredBootstrap({
load: () => Promise.reject(new Error('chunk missing')),
run,
onError,
});
const first = bootstrap.trigger();
const second = bootstrap.trigger();
await expect(first).rejects.toThrow('chunk missing');
await expect(second).rejects.toThrow('chunk missing');
expect(run).not.toHaveBeenCalled();
expect(bootstrap.module).toBeNull();
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});
it('reports a failure fired by the window event instead of leaving it unhandled', async () => {
const unhandled = jest.fn();
process.on('unhandledRejection', unhandled);
try {
const onError = jest.fn();
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: () => {
throw new Error('handler registration failed');
},
onError,
});
bootstrap.armOn(webContents);
webContents.emit('did-start-loading');
await macrotask();
await macrotask();
expect(onError).toHaveBeenCalledTimes(1);
expect(unhandled).not.toHaveBeenCalled();
// A later awaiting caller still sees the failure.
await expect(bootstrap.trigger()).rejects.toThrow(
'handler registration failed'
);
expect(onError).toHaveBeenCalledTimes(1);
} finally {
process.off('unhandledRejection', unhandled);
}
});
});
@@ -0,0 +1,88 @@
/**
* Runs a deferred piece of main-process startup exactly once, triggered by
* the main window's `did-start-loading` event or, as a fallback, explicitly.
*
* Ordering guarantee relied on by main.ts: `load()` is a webpack dynamic
* import of a sibling chunk, which on the Electron main target is a
* synchronous `require` wrapped in an already-resolved promise, and `run()`
* registers IPC handlers synchronously. Both therefore finish within the
* microtask checkpoint of the task that fired the trigger. A renderer IPC
* message is delivered as a separate macrotask, so no `invoke` can arrive
* between the renderer starting to load and the handlers existing.
*/
export interface DeferredBootstrapOptions<TModule, TResult> {
readonly load: () => Promise<TModule>;
readonly run: (module: TModule) => TResult;
readonly onTrigger?: (source: DeferredBootstrapTrigger) => void;
readonly onDone?: (durationMs: number) => void;
/**
* Called once when the load or the registration fails. The event
* listener has no caller to report to, so without this a failure would
* only surface as an unhandled rejection; the promise returned by
* `trigger()` still rejects for callers that await it.
*/
readonly onError?: (error: unknown) => void;
}
export type DeferredBootstrapTrigger = 'did-start-loading' | 'explicit';
export interface DeferredBootstrapOutcome<TModule, TResult> {
readonly module: TModule;
readonly result: TResult;
}
export interface DeferredBootstrapWebContents {
once(event: 'did-start-loading', listener: () => void): unknown;
}
export interface DeferredBootstrap<TModule, TResult> {
/** The loaded module, or null until the trigger has fired. */
readonly module: TModule | null;
/** Arms the `did-start-loading` trigger; a missing webContents is a no-op. */
armOn(webContents: DeferredBootstrapWebContents | null | undefined): void;
/** Starts load + run if not started yet; always returns the same promise. */
trigger(
source?: DeferredBootstrapTrigger
): Promise<DeferredBootstrapOutcome<TModule, TResult>>;
}
export function createDeferredBootstrap<TModule, TResult>(
options: DeferredBootstrapOptions<TModule, TResult>
): DeferredBootstrap<TModule, TResult> {
let started: Promise<DeferredBootstrapOutcome<TModule, TResult>> | null =
null;
let loadedModule: TModule | null = null;
const trigger = (
source: DeferredBootstrapTrigger = 'explicit'
): Promise<DeferredBootstrapOutcome<TModule, TResult>> => {
if (started) {
return started;
}
options.onTrigger?.(source);
const startedAt = performance.now();
started = options.load().then((module) => {
loadedModule = module;
const result = options.run(module);
options.onDone?.(performance.now() - startedAt);
return { module, result };
});
started.catch((error: unknown) => options.onError?.(error));
return started;
};
return {
get module() {
return loadedModule;
},
armOn(webContents) {
webContents?.once('did-start-loading', () => {
// Rejections are reported through onError and re-surface to
// whoever awaits trigger(); nothing to handle here.
trigger('did-start-loading').catch(() => undefined);
});
},
trigger,
};
}
@@ -0,0 +1,178 @@
/**
* Main-process work that only has to exist once the renderer has started
* loading: portal, EPG, download, player, probe, remote-control and update
* IPC, the database, and the recovery passes that follow the first load.
*
* main.ts loads this module through a dynamic import inside the main
* window's `did-start-loading` listener (see deferred-bootstrap.ts), so the
* heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3,
* electron-updater, fix-path) are evaluated while the renderer parses and
* runs its own bundle instead of before the window can load at all.
*
* Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no
* renderer `invoke` finds a missing handler depends on it.
*/
import { app } from 'electron';
import { autoUpdater } from 'electron-updater';
import { registerM3uSourceProbe } from '../events/m3u-source-probe';
import { registerSourceProbeCancellation } from '../events/source-probe-control';
import App from '../app';
import { initDatabase } from '../database/connection';
import DatabaseEvents from '../events/database.events';
import {
resetStaleDownloads,
setMainWindow as setDownloadsMainWindow,
} from '../events/database/downloads.events';
import { setRecordingsMainWindow } from '../events/database/recording-broadcast';
import { reconcileStaleRecordings } from '../events/database/recording-recovery';
import ElectronEvents from '../events/electron.events';
import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
} from '../events/embedded-mpv.events';
import EpgEvents from '../events/epg.events';
import AppUpdateEvents from '../events/app-update.events';
import { shutdownMpvSession } from '../events/mpv-session.service';
import PlayerEvents from '../events/player.events';
import { shutdownVlcSession } from '../events/vlc-session.service';
import PlaylistEvents from '../events/playlist.events';
import RemoteControlEvents from '../events/remote-control.events';
import SettingsEvents from '../events/settings.events';
import SharedEvents from '../events/shared.events';
import StalkerEvents from '../events/stalker.events';
import XtreamEvents from '../events/xtream.events';
import { registerStreamProbeHandlers } from '../events/stream-probe';
import { registerConnectivityGuardHandlers } from '../events/connectivity-guard.events';
import { isStartupTraceEnabled, trace } from '../services/debug-trace';
import { AppUpdateService } from '../services/app-update.service';
import {
onAppUpdateChannelChange,
readStoredAppUpdateChannel,
} from '../services/app-update-channel';
import { databaseWorkerClient } from '../services/database-worker-client';
import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service';
export interface DeferredEventsContext {
readonly appVersion: string;
readonly windowCloseGuard: ReturnType<typeof bootstrapWindowCloseGuard>;
}
export interface DeferredEventsHandles {
readonly appUpdateService: AppUpdateService;
}
export function bootstrapDeferredEvents(
context: DeferredEventsContext
): DeferredEventsHandles {
const { windowCloseGuard } = context;
const appUpdateService = new AppUpdateService({
app,
appVersion: context.appVersion,
channel: readStoredAppUpdateChannel(),
getMainWindow: () => App.mainWindow,
updater: () => autoUpdater,
// quitAndInstall() closes the windows before 'before-quit' fires
// (macOS), so without this an armed close guard would intercept
// the install's window close and strand the update.
prepareQuit: () => windowCloseGuard.allowNextClose(),
cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(),
});
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
onAppUpdateChannelChange((channel) => appUpdateService.setChannel(channel));
ElectronEvents.bootstrapElectronEvents();
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
PlaylistEvents.bootstrapPlaylistEvents();
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
registerM3uSourceProbe();
registerSourceProbeCancellation();
registerConnectivityGuardHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// Keep the downloads broadcaster bound to the live window. macOS can
// rebuild the window while the process runs, and a stale reference
// silently swallows every DOWNLOADS_UPDATE_EVENT.
App.onMainWindowCreated(setDownloadsMainWindow);
App.onMainWindowCreated(setRecordingsMainWindow);
return { appUpdateService };
}
/**
* Database initialization and recovery, after the first renderer load is
* underway so Linux Electron E2E can observe a BrowserWindow even when
* SQLite startup or download recovery is slow. IPC handlers call
* getDatabase() lazily and share the same initialization promise.
*/
export async function finishStartupAfterFirstLoad(): Promise<void> {
await initDatabase();
if (isStartupTraceEnabled()) {
trace('startup', 'init-database:done');
}
await resetStaleDownloads();
if (isStartupTraceEnabled()) {
trace('startup', 'reset-stale-downloads:done');
}
await reconcileStaleRecordings();
if (isStartupTraceEnabled()) {
trace('startup', 'reconcile-stale-recordings:done');
}
}
let fixPathScheduled = false;
/**
* Update process.env.PATH from the user's interactive login shell so that
* spawned external players (MPV/VLC) can be resolved by binary name.
*
* Runs after window creation + IPC handler registration so the 50-300 ms
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
* subsequent calls are no-ops. fix-path itself is imported here, on demand,
* so its module evaluation stays off the launch path as well.
*/
export function scheduleDeferredFixPath(): void {
if (fixPathScheduled || process.platform === 'win32') {
return;
}
fixPathScheduled = true;
setImmediate(() => {
import('fix-path')
.then(({ default: fixPath }) => {
fixPath();
if (isStartupTraceEnabled()) {
trace('startup', 'fix-path:done');
}
})
.catch((error) => {
console.warn('fix-path failed:', error);
});
});
}
/** Tears down sessions and the DB worker; safe when nothing was started. */
export function shutdownDeferredServices(): void {
shutdownEmbeddedMpv();
shutdownMpvSession();
shutdownVlcSession();
void databaseWorkerClient.shutdown();
}
/** The module shape main.ts receives from its dynamic import. */
export type DeferredEventsModule = {
readonly bootstrapDeferredEvents: typeof bootstrapDeferredEvents;
readonly finishStartupAfterFirstLoad: typeof finishStartupAfterFirstLoad;
readonly scheduleDeferredFixPath: typeof scheduleDeferredFixPath;
readonly shutdownDeferredServices: typeof shutdownDeferredServices;
};
+73 -130
View File
@@ -1,51 +1,24 @@
import { registerM3uSourceProbe } from './app/events/m3u-source-probe';
import { registerSourceProbeCancellation } from './app/events/source-probe-control';
// Select persistence before eager imports (notably electron-conf) cache userData.
import './app/services/electron-profile-bootstrap';
import { app, BrowserWindow } from 'electron';
import { autoUpdater } from 'electron-updater';
import fixPath from 'fix-path';
import App from './app/app';
import { initDatabase } from './app/database/connection';
import DatabaseEvents from './app/events/database.events';
import {
resetStaleDownloads,
setMainWindow as setDownloadsMainWindow,
} from './app/events/database/downloads.events';
import { setRecordingsMainWindow } from './app/events/database/recording-broadcast';
import { reconcileStaleRecordings } from './app/events/database/recording-recovery';
import ElectronEvents from './app/events/electron.events';
import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
} from './app/events/embedded-mpv.events';
import EpgEvents from './app/events/epg.events';
import AppUpdateEvents from './app/events/app-update.events';
import { shutdownMpvSession } from './app/events/mpv-session.service';
import PlayerEvents from './app/events/player.events';
import { shutdownVlcSession } from './app/events/vlc-session.service';
import PlaylistEvents from './app/events/playlist.events';
import PlaylistOpenEvents from './app/events/playlist-open.events';
import RemoteControlEvents from './app/events/remote-control.events';
import SettingsEvents from './app/events/settings.events';
import SharedEvents from './app/events/shared.events';
import SquirrelEvents from './app/events/squirrel.events';
import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
import { readCompileCacheOutcome } from './app/services/compile-cache';
import { applyElectronNetworkDefaults } from './app/util/network-defaults';
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
import { AppUpdateService } from './app/services/app-update.service';
import {
onAppUpdateChannelChange,
readStoredAppUpdateChannel,
} from './app/services/app-update-channel';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service';
import { registerStreamProbeHandlers } from './app/events/stream-probe';
import { registerConnectivityGuardHandlers } from './app/events/connectivity-guard.events';
import XtreamEvents from './app/events/xtream.events';
import { environment } from './environments/environment';
import {
createDeferredBootstrap,
type DeferredBootstrap,
} from './app/startup/deferred-bootstrap';
import type {
DeferredEventsHandles,
DeferredEventsModule,
} from './app/startup/deferred-events';
import {
isFrameCopyRuntimeUsable,
shouldPromotePersistedFrameCopyOptIn,
@@ -104,33 +77,11 @@ if (
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
}
let fixPathScheduled = false;
/**
* Update process.env.PATH from the user's interactive login shell so that
* spawned external players (MPV/VLC) can be resolved by binary name.
*
* Runs after window creation + IPC handler registration so the 50-300 ms
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
* subsequent calls are no-ops.
*/
function scheduleDeferredFixPath(): void {
if (fixPathScheduled || process.platform === 'win32') {
return;
}
fixPathScheduled = true;
setImmediate(() => {
try {
fixPath();
if (isStartupTraceEnabled()) {
trace('startup', 'fix-path:done');
}
} catch (error) {
console.warn('fix-path failed:', error);
}
});
}
/** Set once bootstrapAppEvents() arms the deferred group; read at quit. */
let deferredEvents: DeferredBootstrap<
DeferredEventsModule,
DeferredEventsHandles
> | null = null;
export default class Main {
static initialize() {
@@ -147,6 +98,13 @@ export default class Main {
App.main(app, BrowserWindow);
}
/**
* Everything the renderer may call before its first paint registers
* here, synchronously, before the window loads. The rest lives in
* app/startup/deferred-events.ts and is loaded inside the window's
* `did-start-loading` listener (see deferred-bootstrap.ts for why that
* still guarantees the handlers exist before any renderer invoke).
*/
static async bootstrapAppEvents() {
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:start');
@@ -155,75 +113,62 @@ export default class Main {
const windowCloseGuard = bootstrapWindowCloseGuard((listener) =>
App.onMainWindowCreated(listener)
);
const appUpdateService = new AppUpdateService({
app,
appVersion: environment.version,
channel: readStoredAppUpdateChannel(),
getMainWindow: () => App.mainWindow,
updater: () => autoUpdater,
// quitAndInstall() closes the windows before 'before-quit' fires
// (macOS), so without this an armed close guard would intercept
// the install's window close and strand the update.
prepareQuit: () => windowCloseGuard.allowNextClose(),
cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(),
});
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
onAppUpdateChannelChange((channel) =>
appUpdateService.setChannel(channel)
);
registerStaticHeaderShims();
ElectronEvents.bootstrapElectronEvents();
WindowEvents.bootstrapWindowEvents();
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
PlaylistEvents.bootstrapPlaylistEvents();
PlaylistOpenEvents.bootstrapPlaylistOpenEvents();
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
registerM3uSourceProbe();
registerSourceProbeCancellation();
registerConnectivityGuardHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// Keep the downloads broadcaster bound to the live window. macOS can
// rebuild the window while the process runs, and a stale reference
// silently swallows every DOWNLOADS_UPDATE_EVENT.
App.onMainWindowCreated(setDownloadsMainWindow);
App.onMainWindowCreated(setRecordingsMainWindow);
const deferred = createDeferredBootstrap<
DeferredEventsModule,
DeferredEventsHandles
>({
load: () =>
import(
/* webpackChunkName: "deferred-events" */ './app/startup/deferred-events.js'
),
run: (module) =>
module.bootstrapDeferredEvents({
appVersion: environment.version,
windowCloseGuard,
}),
onTrigger: (source) => {
if (isStartupTraceEnabled()) {
trace('startup', 'deferred-events:start', { source });
}
},
onDone: (durationMs) => {
if (isStartupTraceEnabled()) {
trace('startup', 'deferred-events:done', { durationMs });
}
},
// The window is open by now; without this a missing chunk would
// only show up as an unhandled rejection with no context.
onError: (error) => {
console.error(
'Deferred main-process startup failed; portal, EPG, database and download handlers are unavailable:',
error
);
if (isStartupTraceEnabled()) {
trace('startup', 'deferred-events:failed', error);
}
},
});
deferredEvents = deferred;
deferred.armOn(App.mainWindow?.webContents);
// Load the renderer only after IPC handlers are registered. On slower
// Linux CI hosts the renderer can otherwise invoke Electron bridge IPC
// before the main process has installed handlers.
await App.loadMainWindow();
void appUpdateService.checkForUpdatesOnStartup();
// Load the renderer only after the pre-paint handlers are registered.
// The deferred group registers as soon as the navigation starts; the
// fallback below covers a load that never gets that far. Its errors
// surface through the awaited trigger(), so they are swallowed here.
const loadingMainWindow = App.loadMainWindow();
void loadingMainWindow
.catch(() => undefined)
.then(() => deferred.trigger())
.catch(() => undefined);
await loadingMainWindow;
const { module, result } = await deferred.trigger();
void result.appUpdateService.checkForUpdatesOnStartup();
// Initialize the database after the first renderer load is underway so
// Linux Electron E2E can observe a BrowserWindow even when SQLite
// startup or download recovery is slow. IPC handlers call getDatabase()
// lazily and share the same initialization promise.
await initDatabase();
if (isStartupTraceEnabled()) {
trace('startup', 'init-database:done');
}
await resetStaleDownloads();
if (isStartupTraceEnabled()) {
trace('startup', 'reset-stale-downloads:done');
}
await reconcileStaleRecordings();
if (isStartupTraceEnabled()) {
trace('startup', 'reconcile-stale-recordings:done');
}
await module.finishStartupAfterFirstLoad();
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done');
@@ -236,7 +181,7 @@ export default class Main {
// takes to complete; the spawn would still find MPV/VLC at any of
// the well-known paths checked by getDefault*Path before falling
// back to bare-name PATH lookup.
scheduleDeferredFixPath();
module.scheduleDeferredFixPath();
}
}
@@ -307,9 +252,7 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => {
// playback and database work destroyed. 'will-quit' only fires once
// every window close was allowed through.
app.on('will-quit', () => {
shutdownEmbeddedMpv();
shutdownMpvSession();
shutdownVlcSession();
void databaseWorkerClient.shutdown();
// Nothing to tear down when the deferred group never loaded.
deferredEvents?.module?.shutdownDeferredServices();
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* nx-electron build hook (project.json `webpackConfig`).
*
* The backend compiles with TypeScript's NodeNext resolution, which spells a
* relative dynamic import with a `.js` extension (main.ts loads
* `./app/startup/deferred-events.js`). webpack must map that back onto the
* `.ts` source, which is what `resolve.extensionAlias` does.
*/
module.exports = (config) => {
// Async chunks keep their webpackChunkName instead of a numeric id, so
// packaging and the layout check can list them by name.
config.output = { ...config.output, chunkFilename: '[name].js' };
config.resolve = {
...config.resolve,
extensionAlias: {
...config.resolve?.extensionAlias,
'.js': ['.ts', '.js'],
},
};
return config;
};
+15 -5
View File
@@ -97,14 +97,24 @@ classifying a zero rendered-frame signal as an infrastructure flake.
The process entry is `apps/electron-backend/src/main.entry.ts` (built to
`dist/apps/electron-backend/main.js`): it enables the V8 compile cache under
`userData/v8-compile-cache` and then requires the application bundle,
`main.app.js`, built from `apps/electron-backend/src/main.ts`, which bootstraps
the database, registers events and creates the main window. The cache is
`main.app.js`, built from `apps/electron-backend/src/main.ts`. Before the window
loads, `main.ts` registers only what the renderer can call before its first paint
(window state, the close guard, playlist-open requests, request-header shims);
everything else, including the database, portal, EPG, download, player,
remote-control and update IPC, lives in
`apps/electron-backend/src/app/startup/deferred-events.ts`, built as the
`deferred-events.js` chunk and loaded inside the window's `did-start-loading`
listener. That import and its registrations finish within the same task, so no
renderer `invoke` can find a missing handler (`app/startup/deferred-bootstrap.ts`
holds the scheduler and its test); the startup trace reports it as
`deferred-events:start` and `deferred-events:done`. The cache is
disposable; `IPTVNATOR_DISABLE_COMPILE_CACHE=1` turns it off and
`IPTVNATOR_COMPILE_CACHE_DIR` relocates it (E2E runs keep it inside
`IPTVNATOR_E2E_DATA_DIR`). nx-electron packages the backend through an
allowlist, so `apps/electron-backend/project.json` lists `main.app.js` under the
`files` option of the `package` and `make` targets, and `verify:package-layout`
fails when either entry file is missing from `app.asar`. The preload is
allowlist, so `apps/electron-backend/project.json` lists `main.app.js` and
`deferred-events.js` under the `files` option of the `package` and `make`
targets, and `verify:package-layout` fails when any of the entry files is missing
from `app.asar`. The preload is
`apps/electron-backend/src/app/api/main.preload.ts`, with handlers under
`apps/electron-backend/src/app/events/`. The window follows the saved startup mode
(normal/maximized/fullscreen); `--fullscreen` overrides a single launch. Use
@@ -474,13 +474,15 @@ function verifyPackagedPackageMetadata(resourceDir, errors) {
/**
* The main-process entry is split: `main.js` enables the V8 compile cache and
* requires `main.app.js`, the application bundle. nx-electron packages the
* requires `main.app.js`, the application bundle, which loads the
* `deferred-events.js` chunk once the window starts loading. nx-electron packages the
* backend through an allowlist, so a missing bundle only surfaces as an
* uncaught "Cannot find module" at launch; fail the layout check instead.
*/
const REQUIRED_MAIN_PROCESS_ENTRIES = [
'/electron-backend/main.js',
'/electron-backend/main.app.js',
'/electron-backend/deferred-events.js',
'/electron-backend/main.preload.js',
];