Merge remote-tracking branch 'origin/master' into claude/parental-control-feature-31dde2

# Conflicts:
#	apps/electron-backend/src/main.ts
This commit is contained in:
4gray committed 2026-09-26 23:04:22 +02:00
commit a030e1af2f
63 files changed
+4917 -262

No files matched your search

@@ -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<Required<CompileCacheModule>> {
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<symbol, unknown>)[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',
});
});
});
});
@@ -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<symbol, unknown>)[OUTCOME_KEY] = outcome;
}
export function readCompileCacheOutcome(): CompileCacheOutcome {
const outcome = (globalThis as Record<symbol, unknown>)[OUTCOME_KEY] as
CompileCacheOutcome | undefined;
return (
outcome ?? { status: 'unavailable', reason: 'main.entry did not run' }
);
}
@@ -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,180 @@
/**
* 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 ParentalLockEvents from '../events/parental-lock.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();
ParentalLockEvents.bootstrapParentalLockEvents();
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;
};
+35
View File
@@ -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');
+78 -132
View File
@@ -1,51 +1,24 @@
import { registerM3uSourceProbe } from './app/events/m3u-source-probe';
import { registerSourceProbeCancellation } from './app/events/source-probe-control';
// Select persistence before eager imports (notably electron-conf) cache userData.
import './app/services/electron-profile-bootstrap';
import { app, BrowserWindow } from 'electron';
import { autoUpdater } from 'electron-updater';
import fixPath from 'fix-path';
import App from './app/app';
import { initDatabase } from './app/database/connection';
import DatabaseEvents from './app/events/database.events';
import {
resetStaleDownloads,
setMainWindow as setDownloadsMainWindow,
} from './app/events/database/downloads.events';
import { setRecordingsMainWindow } from './app/events/database/recording-broadcast';
import { reconcileStaleRecordings } from './app/events/database/recording-recovery';
import ElectronEvents from './app/events/electron.events';
import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
} from './app/events/embedded-mpv.events';
import EpgEvents from './app/events/epg.events';
import AppUpdateEvents from './app/events/app-update.events';
import { shutdownMpvSession } from './app/events/mpv-session.service';
import PlayerEvents from './app/events/player.events';
import { shutdownVlcSession } from './app/events/vlc-session.service';
import PlaylistEvents from './app/events/playlist.events';
import PlaylistOpenEvents from './app/events/playlist-open.events';
import RemoteControlEvents from './app/events/remote-control.events';
import ParentalLockEvents from './app/events/parental-lock.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,
@@ -62,6 +35,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()) {
@@ -100,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() {
@@ -143,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');
@@ -151,76 +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();
ParentalLockEvents.bootstrapParentalLockEvents();
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');
@@ -233,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();
}
}
@@ -304,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();
});
});