diff --git a/.changes/electron-deferred-startup-wiring.md b/.changes/electron-deferred-startup-wiring.md new file mode 100644 index 000000000..126d72aad --- /dev/null +++ b/.changes/electron-deferred-startup-wiring.md @@ -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. diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts index 9f5fd9870..0ab7b7c23 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts @@ -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', diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts index f2db2a571..cb7ae28fc 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts @@ -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( diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts index 1e3ca2cdc..7f1afba19 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts @@ -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 { 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'), diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts index fac4cabe3..7e9fe1747 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts @@ -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, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index c3fcb9046..13189747d 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -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", diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts new file mode 100644 index 000000000..c1f54988e --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts @@ -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 { + 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); + } + }); +}); diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts new file mode 100644 index 000000000..a3394f951 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts @@ -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 { + readonly load: () => Promise; + 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 { + readonly module: TModule; + readonly result: TResult; +} + +export interface DeferredBootstrapWebContents { + once(event: 'did-start-loading', listener: () => void): unknown; +} + +export interface DeferredBootstrap { + /** 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>; +} + +export function createDeferredBootstrap( + options: DeferredBootstrapOptions +): DeferredBootstrap { + let started: Promise> | null = + null; + let loadedModule: TModule | null = null; + + const trigger = ( + source: DeferredBootstrapTrigger = 'explicit' + ): Promise> => { + 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, + }; +} diff --git a/apps/electron-backend/src/app/startup/deferred-events.ts b/apps/electron-backend/src/app/startup/deferred-events.ts new file mode 100644 index 000000000..38a708900 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-events.ts @@ -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; +} + +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 { + 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; +}; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 1bc492d8f..0961a0f9b 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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(); }); }); diff --git a/apps/electron-backend/webpack.config.cjs b/apps/electron-backend/webpack.config.cjs new file mode 100644 index 000000000..0923ee17a --- /dev/null +++ b/apps/electron-backend/webpack.config.cjs @@ -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; +}; diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index f198ab427..0734f9e15 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -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 diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 54bd1ecb9..9fb7fa6ae 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -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', ];