mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user