diff --git a/.changes/electron-startup-compile-cache.md b/.changes/electron-startup-compile-cache.md new file mode 100644 index 000000000..5eae9d738 --- /dev/null +++ b/.changes/electron-startup-compile-cache.md @@ -0,0 +1,9 @@ +--- +type: perf +area: electron +--- + +The desktop app now keeps a compiled copy of its startup code next to its +user data, so every launch after the first skips part of the JavaScript +compilation and reaches the window a little sooner. Set +`IPTVNATOR_DISABLE_COMPILE_CACHE=1` to turn this off. 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 0210c31d7..9f5fd9870 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,13 +62,23 @@ export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = { 'database-worker-source-map' ), }, - main: { + launcher: { javascript: buildFile( 'dist/apps/electron-backend/main.js', - 'main-javascript' + 'launcher-javascript' ), sourceMap: buildFile( 'dist/apps/electron-backend/main.js.map', + 'launcher-source-map' + ), + }, + main: { + javascript: buildFile( + 'dist/apps/electron-backend/main.app.js', + 'main-javascript' + ), + sourceMap: buildFile( + 'dist/apps/electron-backend/main.app.js.map', 'main-source-map' ), }, 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 3b1b36fa5..f2db2a571 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', + 'launcher', 'main', 'playlistRefreshWorker', 'preload', @@ -26,7 +27,8 @@ const RENDERER_KEYS = [ ] as const; const ELECTRON_PATHS = { databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js', - main: 'dist/apps/electron-backend/main.js', + launcher: 'dist/apps/electron-backend/main.js', + main: 'dist/apps/electron-backend/main.app.js', playlistRefreshWorker: 'dist/apps/electron-backend/workers/playlist-refresh.worker.js', preload: 'dist/apps/electron-backend/main.preload.js', @@ -44,6 +46,7 @@ export function parseXtreamBenchmarkBuildIdentity( electronInput['databaseWorker'], ELECTRON_PATHS.databaseWorker ), + launcher: pair(electronInput['launcher'], ELECTRON_PATHS.launcher), main: pair(electronInput['main'], ELECTRON_PATHS.main), playlistRefreshWorker: pair( electronInput['playlistRefreshWorker'], 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 35985e775..1e3ca2cdc 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 @@ -38,9 +38,14 @@ describe('Xtream benchmark build identity', () => { assert.deepEqual(identity.electron.main.javascript, { bytes: 4, - path: 'dist/apps/electron-backend/main.js', + path: 'dist/apps/electron-backend/main.app.js', sha256: sha256('main'), }); + assert.deepEqual(identity.electron.launcher.javascript, { + bytes: 8, + path: 'dist/apps/electron-backend/main.js', + sha256: sha256('launcher'), + }); assert.deepEqual(identity.electron.preload.sourceMap, { bytes: 11, path: 'dist/apps/electron-backend/main.preload.js.map', @@ -112,7 +117,7 @@ describe('Xtream benchmark build identity', () => { const symlinkRoot = await buildFixture(); const mainPath = join( symlinkRoot, - 'dist/apps/electron-backend/main.js' + 'dist/apps/electron-backend/main.app.js' ); const external = join(symlinkRoot, 'external-main.js'); await writeFile(external, 'main'); @@ -219,8 +224,10 @@ async function buildFixture(): Promise { mkdir(join(renderer, 'assets'), { recursive: true }), ]); await Promise.all([ - writeFile(join(backend, 'main.js'), 'main'), - writeFile(join(backend, 'main.js.map'), 'main-map'), + writeFile(join(backend, 'main.js'), 'launcher'), + writeFile(join(backend, 'main.js.map'), 'launcher-map'), + writeFile(join(backend, 'main.app.js'), 'main'), + writeFile(join(backend, 'main.app.js.map'), 'main-map'), writeFile(join(backend, 'main.preload.js'), 'preload'), writeFile(join(backend, 'main.preload.js.map'), 'preload-map'), writeFile(join(workers, 'database.worker.js'), 'database'), 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 98d779c2e..fac4cabe3 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 launcher: XtreamBuildPairIdentity; readonly main: XtreamBuildPairIdentity; readonly playlistRefreshWorker: XtreamBuildPairIdentity; readonly preload: XtreamBuildPairIdentity; @@ -35,7 +36,10 @@ const BACKEND_ROOT = 'dist/apps/electron-backend'; const RENDERER_ROOT = 'dist/apps/web'; const ELECTRON_PATHS = { databaseWorker: `${BACKEND_ROOT}/workers/database.worker.js`, - main: `${BACKEND_ROOT}/main.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`, + main: `${BACKEND_ROOT}/main.app.js`, playlistRefreshWorker: `${BACKEND_ROOT}/workers/playlist-refresh.worker.js`, preload: `${BACKEND_ROOT}/main.preload.js`, } as const; @@ -45,17 +49,25 @@ export async function captureXtreamBuildIdentity( ): Promise { try { if (!isAbsolute(workspaceRoot)) invalid(); - const [databaseWorker, main, playlistRefreshWorker, preload, renderer] = - await Promise.all([ - readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker), - readPair(workspaceRoot, ELECTRON_PATHS.main), - readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker), - readPair(workspaceRoot, ELECTRON_PATHS.preload), - readRenderer(workspaceRoot), - ]); + const [ + databaseWorker, + launcher, + main, + playlistRefreshWorker, + preload, + renderer, + ] = await Promise.all([ + readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker), + readPair(workspaceRoot, ELECTRON_PATHS.launcher), + readPair(workspaceRoot, ELECTRON_PATHS.main), + readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker), + readPair(workspaceRoot, ELECTRON_PATHS.preload), + readRenderer(workspaceRoot), + ]); return Object.freeze({ electron: Object.freeze({ databaseWorker, + launcher, main, playlistRefreshWorker, preload, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 291cd7adf..c3fcb9046 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -51,7 +51,13 @@ ], "options": { "outputPath": "dist/apps/electron-backend", - "main": "apps/electron-backend/src/main.ts", + "main": "apps/electron-backend/src/main.entry.ts", + "additionalEntryPoints": [ + { + "entryName": "main.app", + "entryPath": "apps/electron-backend/src/main.ts" + } + ], "tsConfig": "apps/electron-backend/tsconfig.app.json", "assets": [ "apps/electron-backend/src/assets", @@ -143,7 +149,13 @@ ], "options": { "outputPath": "dist/apps/electron-backend", - "main": "apps/electron-backend/src/main.ts", + "main": "apps/electron-backend/src/main.entry.ts", + "additionalEntryPoints": [ + { + "entryName": "main.app", + "entryPath": "apps/electron-backend/src/main.ts" + } + ], "tsConfig": "apps/electron-backend/tsconfig.app.json", "assets": [ "apps/electron-backend/src/assets", @@ -197,6 +209,13 @@ "options": { "name": "electron-backend", "frontendProject": "web", + "files": [ + { + "from": "electron-backend", + "to": "electron-backend", + "filter": ["main.app.js"] + } + ], "sourcePath": "dist/apps", "outputPath": "dist/packages", "prepackageOnly": true @@ -211,6 +230,13 @@ "options": { "name": "electron-backend", "frontendProject": "web", + "files": [ + { + "from": "electron-backend", + "to": "electron-backend", + "filter": ["main.app.js"] + } + ], "sourcePath": "dist/apps", "outputPath": "dist/executables" } diff --git a/apps/electron-backend/src/app/services/compile-cache.spec.ts b/apps/electron-backend/src/app/services/compile-cache.spec.ts new file mode 100644 index 000000000..e446b3764 --- /dev/null +++ b/apps/electron-backend/src/app/services/compile-cache.spec.ts @@ -0,0 +1,255 @@ +import { join } from 'node:path'; + +import { + COMPILE_CACHE_DIR_ENV, + COMPILE_CACHE_DISABLE_ENV, + enableStartupCompileCache, + isCompileCacheDisabled, + publishCompileCacheOutcome, + readCompileCacheOutcome, + resolveCompileCacheDirectory, + type CompileCacheEnableResult, + type CompileCacheModule, +} from './compile-cache'; + +const OUTCOME_KEY = Symbol.for('iptvnator.compileCacheOutcome'); +const userData = join('/profiles', 'iptvnator'); +const defaultDirectory = join(userData, 'v8-compile-cache'); + +function createModule( + result: CompileCacheEnableResult = { + status: 1, + directory: defaultDirectory, + } +): jest.Mocked> { + return { enableCompileCache: jest.fn().mockReturnValue(result) }; +} + +function enable( + env: NodeJS.ProcessEnv, + module: CompileCacheModule = createModule(), + userDataPath: () => string = () => userData +) { + return enableStartupCompileCache({ env, module, userDataPath }); +} + +describe('startup compile cache guard', () => { + afterEach(() => { + delete (globalThis as Record)[OUTCOME_KEY]; + }); + + describe('cache directory', () => { + it('lives under userData/v8-compile-cache by default', () => { + expect(resolveCompileCacheDirectory({}, () => userData)).toBe( + defaultDirectory + ); + }); + + it('prefers IPTVNATOR_COMPILE_CACHE_DIR and never asks for userData then', () => { + const userDataPath = jest.fn(() => userData); + + const directory = resolveCompileCacheDirectory( + { [COMPILE_CACHE_DIR_ENV]: ' /tmp/iptvnator-cache ' }, + userDataPath + ); + + expect(directory).toBe('/tmp/iptvnator-cache'); + expect(userDataPath).not.toHaveBeenCalled(); + }); + + it('ignores a blank IPTVNATOR_COMPILE_CACHE_DIR', () => { + expect( + resolveCompileCacheDirectory( + { [COMPILE_CACHE_DIR_ENV]: ' ' }, + () => userData + ) + ).toBe(defaultDirectory); + }); + + it('stays inside the E2E data directory, mirroring the userData override', () => { + const directory = resolveCompileCacheDirectory( + { IPTVNATOR_E2E_DATA_DIR: '/tmp/e2e-run' }, + () => userData + ); + + expect(directory).toBe( + join('/tmp/e2e-run', 'user-data', 'v8-compile-cache') + ); + }); + }); + + describe('kill switch', () => { + it.each(['1', 'true', 'yes', 'ON', ' on '])( + 'honours IPTVNATOR_DISABLE_COMPILE_CACHE=%j without touching node:module', + (value) => { + const module = createModule(); + const userDataPath = jest.fn(() => userData); + + const outcome = enableStartupCompileCache({ + env: { [COMPILE_CACHE_DISABLE_ENV]: value }, + module, + userDataPath, + }); + + expect(outcome).toEqual({ + status: 'disabled', + reason: COMPILE_CACHE_DISABLE_ENV, + }); + expect(module.enableCompileCache).not.toHaveBeenCalled(); + expect(userDataPath).not.toHaveBeenCalled(); + } + ); + + it.each(['', '0', 'false', 'off'])( + 'keeps the cache on for IPTVNATOR_DISABLE_COMPILE_CACHE=%j', + (value) => { + expect( + isCompileCacheDisabled({ + [COMPILE_CACHE_DISABLE_ENV]: value, + }) + ).toBe(false); + expect( + enable({ [COMPILE_CACHE_DISABLE_ENV]: value }).status + ).toBe('enabled'); + } + ); + + it('defaults to enabled when the variable is absent', () => { + expect(isCompileCacheDisabled({})).toBe(false); + }); + }); + + describe('enabling', () => { + it('enables the cache in the resolved directory', () => { + const module = createModule(); + + const outcome = enable({}, module); + + expect(module.enableCompileCache).toHaveBeenCalledWith( + defaultDirectory + ); + expect(outcome).toEqual({ + status: 'enabled', + directory: defaultDirectory, + }); + }); + + it('reports the directory Node settled on when it differs', () => { + const module = createModule({ + status: 1, + directory: '/resolved/elsewhere', + }); + + expect(enable({}, module)).toEqual({ + status: 'enabled', + directory: '/resolved/elsewhere', + }); + }); + + it('maps ALREADY_ENABLED without treating it as a failure', () => { + expect(enable({}, createModule({ status: 2 }))).toEqual({ + status: 'already-enabled', + directory: defaultDirectory, + }); + }); + + it('maps Node’s own DISABLED status and keeps its message', () => { + expect( + enable( + {}, + createModule({ + status: 3, + message: 'NODE_DISABLE_COMPILE_CACHE is set', + }) + ) + ).toEqual({ + status: 'disabled', + reason: 'NODE_DISABLE_COMPILE_CACHE is set', + }); + }); + + it('maps FAILED with the message Node gives', () => { + expect( + enable( + {}, + createModule({ + status: 0, + message: 'cannot create directory', + }) + ) + ).toEqual({ + status: 'failed', + directory: defaultDirectory, + reason: 'cannot create directory', + }); + }); + }); + + describe('failure tolerance', () => { + it('reports unavailable when Node has no enableCompileCache', () => { + expect(enable({}, {})).toEqual({ + status: 'unavailable', + reason: 'module.enableCompileCache is missing', + }); + }); + + it('swallows an exception thrown by enableCompileCache', () => { + const module: CompileCacheModule = { + enableCompileCache: () => { + throw new Error('EACCES: permission denied'); + }, + }; + + expect(enable({}, module)).toEqual({ + status: 'failed', + directory: defaultDirectory, + reason: 'EACCES: permission denied', + }); + }); + + it('swallows a failing userData lookup', () => { + const module = createModule(); + + const outcome = enable({}, module, () => { + throw new Error('app is not ready'); + }); + + expect(outcome).toEqual({ + status: 'failed', + reason: 'app is not ready', + }); + expect(module.enableCompileCache).not.toHaveBeenCalled(); + }); + + it('stringifies non-Error throwables', () => { + const module: CompileCacheModule = { + enableCompileCache: () => { + throw 'boom'; + }, + }; + + expect(enable({}, module).reason).toBe('boom'); + }); + }); + + describe('outcome hand-over to the bundle', () => { + it('reads back what the entry published', () => { + publishCompileCacheOutcome({ + status: 'enabled', + directory: defaultDirectory, + }); + + expect(readCompileCacheOutcome()).toEqual({ + status: 'enabled', + directory: defaultDirectory, + }); + }); + + it('explains a missing outcome instead of tracing undefined', () => { + expect(readCompileCacheOutcome()).toEqual({ + status: 'unavailable', + reason: 'main.entry did not run', + }); + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/compile-cache.ts b/apps/electron-backend/src/app/services/compile-cache.ts new file mode 100644 index 000000000..792d7be76 --- /dev/null +++ b/apps/electron-backend/src/app/services/compile-cache.ts @@ -0,0 +1,148 @@ +/** + * V8 compile cache for the Electron main process. + * + * `dist/apps/electron-backend/main.js` is built from `main.entry.ts`. It + * enables Node's on-disk compile cache and only then requires the real + * application bundle, `main.app.js` (built from `main.ts`), so the bundle and + * the packages it pulls in are compiled from cached bytecode on every launch + * after the first. V8 produces a code cache for the script it is compiling, + * which is why the call cannot live inside the bundle it is meant to cache. + * + * This module holds the decision logic so the entry stays a few lines and the + * guards are unit-testable: an environment kill switch, an explicit cache + * directory, and failure tolerance (a broken cache must never block startup). + */ +import { join } from 'node:path'; + +export const COMPILE_CACHE_DISABLE_ENV = 'IPTVNATOR_DISABLE_COMPILE_CACHE'; +export const COMPILE_CACHE_DIR_ENV = 'IPTVNATOR_COMPILE_CACHE_DIR'; +export const COMPILE_CACHE_DIR_NAME = 'v8-compile-cache'; + +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const OUTCOME_KEY = Symbol.for('iptvnator.compileCacheOutcome'); + +/** `module.constants.compileCacheStatus` of Node 22.1+, by value. */ +const NODE_STATUS = { + FAILED: 0, + ENABLED: 1, + ALREADY_ENABLED: 2, + DISABLED: 3, +} as const; + +export type CompileCacheStatus = + 'enabled' | 'already-enabled' | 'disabled' | 'unavailable' | 'failed'; + +export interface CompileCacheOutcome { + readonly status: CompileCacheStatus; + readonly directory?: string; + readonly reason?: string; +} + +export interface CompileCacheEnableResult { + readonly status: number; + readonly directory?: string; + readonly message?: string; +} + +/** The slice of `node:module` the entry relies on; injectable for tests. */ +export interface CompileCacheModule { + enableCompileCache?: (directory?: string) => CompileCacheEnableResult; +} + +export interface EnableCompileCacheOptions { + readonly env?: NodeJS.ProcessEnv; + readonly module: CompileCacheModule; + /** Electron's `userData`; only consulted without an explicit directory. */ + readonly userDataPath: () => string; +} + +export function isCompileCacheDisabled( + env: NodeJS.ProcessEnv = process.env +): boolean { + const value = env[COMPILE_CACHE_DISABLE_ENV]?.trim().toLowerCase(); + return value ? TRUE_VALUES.has(value) : false; +} + +export function resolveCompileCacheDirectory( + env: NodeJS.ProcessEnv, + userDataPath: () => string +): string { + const explicit = env[COMPILE_CACHE_DIR_ENV]?.trim(); + if (explicit) { + return explicit; + } + + // Mirrors getElectronUserDataPath() in @iptvnator/shared/database, which + // profile bootstrap applies later. The entry cannot import that library + // without loading the database stack ahead of the cache. + const e2eDataDir = env.IPTVNATOR_E2E_DATA_DIR?.trim(); + const userData = e2eDataDir + ? join(e2eDataDir, 'user-data') + : userDataPath(); + return join(userData, COMPILE_CACHE_DIR_NAME); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function enableStartupCompileCache( + options: EnableCompileCacheOptions +): CompileCacheOutcome { + const env = options.env ?? process.env; + if (isCompileCacheDisabled(env)) { + return { status: 'disabled', reason: COMPILE_CACHE_DISABLE_ENV }; + } + + const enable = options.module.enableCompileCache; + if (typeof enable !== 'function') { + return { + status: 'unavailable', + reason: 'module.enableCompileCache is missing', + }; + } + + let directory: string | undefined; + try { + directory = resolveCompileCacheDirectory(env, options.userDataPath); + const result = enable.call(options.module, directory); + switch (result.status) { + case NODE_STATUS.ENABLED: + return { + status: 'enabled', + directory: result.directory ?? directory, + }; + case NODE_STATUS.ALREADY_ENABLED: + return { + status: 'already-enabled', + directory: result.directory ?? directory, + }; + case NODE_STATUS.DISABLED: + return { + status: 'disabled', + reason: result.message ?? 'NODE_DISABLE_COMPILE_CACHE', + }; + default: + return { + status: 'failed', + directory, + reason: result.message ?? `status ${result.status}`, + }; + } + } catch (error) { + return { status: 'failed', directory, reason: describeError(error) }; + } +} + +/** Hands the entry's outcome to the bundle, which owns the startup trace. */ +export function publishCompileCacheOutcome(outcome: CompileCacheOutcome): void { + (globalThis as Record)[OUTCOME_KEY] = outcome; +} + +export function readCompileCacheOutcome(): CompileCacheOutcome { + const outcome = (globalThis as Record)[OUTCOME_KEY] as + CompileCacheOutcome | undefined; + return ( + outcome ?? { status: 'unavailable', reason: 'main.entry did not run' } + ); +} diff --git a/apps/electron-backend/src/main.entry.ts b/apps/electron-backend/src/main.entry.ts new file mode 100644 index 000000000..a1a89c4b6 --- /dev/null +++ b/apps/electron-backend/src/main.entry.ts @@ -0,0 +1,35 @@ +/** + * Process entry of the Electron main process. It becomes + * `dist/apps/electron-backend/main.js`: the file Electron, `nx serve`, the E2E + * fixtures and the packaged app launch. + * + * It enables the V8 compile cache and only then requires the application + * bundle, `main.app.js` (built from `main.ts`). V8 writes a code cache for + * the script being compiled, so a call inside the bundle would leave the + * bundle itself uncached; only this small file pays the uncached compile. + * Keep it free of imports beyond `electron`, Node built-ins and the guard + * helper: anything imported here is compiled before the cache is on. + */ +import { app } from 'electron'; +import * as nodeModule from 'node:module'; +import { + enableStartupCompileCache, + publishCompileCacheOutcome, + type CompileCacheModule, +} from './app/services/compile-cache'; + +declare const __non_webpack_require__: NodeJS.Require; + +publishCompileCacheOutcome( + enableStartupCompileCache({ + module: nodeModule as CompileCacheModule, + // Read before main.ts calls app.setName(), deliberately: renaming + // here would move every path derived from the app name, including + // the settings store's, for existing Linux profiles. On macOS and + // Windows the directory is the same either way. + userDataPath: () => app.getPath('userData'), + }) +); + +// Resolved next to this file at run time; webpack must not inline it. +__non_webpack_require__('./main.app.js'); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 07baaa93d..1bc492d8f 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -31,6 +31,7 @@ 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'; @@ -61,6 +62,10 @@ import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); +if (isStartupTraceEnabled()) { + trace('startup', 'compile-cache', readCompileCacheOutcome()); +} + // Before the first portal, playlist or update request leaves this process. applyElectronNetworkDefaults((line) => { if (isStartupTraceEnabled()) { diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index ee1e35ba3..f198ab427 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -32,6 +32,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset + - `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=` relocates it. The startup trace reports the outcome as `compile-cache` - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching @@ -93,8 +94,17 @@ classifying a zero rendered-frame signal as an infrastructure flake. ## Main-process ownership -The entry point is `apps/electron-backend/src/main.ts`; it bootstraps the database, -registers events and creates the main window. The preload is +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 +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 `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 40a709c6f..54bd1ecb9 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -472,6 +472,52 @@ 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 + * 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/main.preload.js', +]; + +function verifyPackagedMainProcessEntries(resourceDir, errors) { + const asarPath = path.join(resourceDir, 'app.asar'); + if (!fileExists(asarPath)) { + return; + } + + let entries; + try { + // @electron/asar lists entries with the host separator on Windows. + entries = new Set( + listPackage(asarPath).map((entry) => { + const normalized = entry.replace(/\\/g, '/'); + return normalized.startsWith('/') + ? normalized + : `/${normalized}`; + }) + ); + } catch (error) { + errors.push( + `Unable to list main-process entries in ${asarPath}: ${error.message}` + ); + return; + } + + const missing = REQUIRED_MAIN_PROCESS_ENTRIES.filter( + (entry) => !entries.has(entry) + ); + if (missing.length > 0) { + errors.push( + `Packaged app.asar is missing main-process entry files in ${asarPath}: ${missing.join(', ')}` + ); + } +} + function verifyLinuxLauncher(resourceDir, targetNames, errors) { let launcherLayout; try { @@ -711,6 +757,7 @@ function verifyResourceDir(resourceDir) { let linuxTargetNames; verifyPackagedPackageMetadata(resourceDir, errors); + verifyPackagedMainProcessEntries(resourceDir, errors); verifyPackagedDependencyClosure(resourceDir, errors); verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors);