diff --git a/.changes/playlist-open-from-file-association.md b/.changes/playlist-open-from-file-association.md new file mode 100644 index 000000000..412344500 --- /dev/null +++ b/.changes/playlist-open-from-file-association.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playlist +--- + +Opening an .m3u/.m3u8 file from the command line or by double-clicking it now +actually imports the playlist — previously nothing happened at all. Opening a +playlist while IPTVnator is already running works too, and on macOS the file +arrives through the system "open with" event. diff --git a/.changes/playlist-register-m3u-file-type.md b/.changes/playlist-register-m3u-file-type.md new file mode 100644 index 000000000..6eae6beee --- /dev/null +++ b/.changes/playlist-register-m3u-file-type.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playlist +--- + +IPTVnator now registers itself with the operating system as a handler for .m3u +and .m3u8 files, so Finder, Explorer and Linux file managers offer it in "Open +with" and a double-click actually opens the playlist. Installing or updating the +app is enough — no manual file-type setup. diff --git a/AGENTS.md b/AGENTS.md index b4a8b5223..3d24958a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ This file provides guidance to coding agents working in this repository. - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black. - If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field. -- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue. ### Trace / Debug Startup diff --git a/CLAUDE.md b/CLAUDE.md index 76a7bee83..56fff708f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ nx run electron-backend:make - When the task is Electron automation/debugging, use the `electron` skill - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window (symptoms: `tab list` shows `about:blank`, empty snapshots, black screenshots). Inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the app page's `webSocketDebuggerUrl`. -- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue. For startup tracing or white-screen debugging: @@ -591,7 +591,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication -- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. +- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded. **Database**: @@ -624,6 +624,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - **Event handlers**: `apps/electron-backend/src/app/events/` - `database.events.ts` - Database CRUD operations - `playlist.events.ts` - Playlist import/update + - `playlist-open.events.ts` - Playlist files handed over by the OS (argv, file association, macOS `open-file`); the queue itself lives in `services/playlist-open-request.ts` - `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts` - `xtream.events.ts` - Xtream Codes API - `stalker.events.ts` - Stalker portal API @@ -645,6 +646,45 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Xtream Codes API (`username`, `password`, `serverUrl`) - Stalker portal (`macAddress`, `url`) +**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed +on the command line, opened through a file association, or delivered by macOS' +`open-file` event is normalized to an absolute path in the main process +(`services/playlist-open-request.ts`) and queued there. The renderer +(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the +`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is +what makes the main process flush. `OPEN_FILE` is the only way out of the +queue, and a request stays there until the renderer confirms receipt via +`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the +listener runs, and a reload or dead render process keeps the `WebContents` +alive, so a successful push is not proof of delivery. Anything unacknowledged +is replayed to the next renderer that announces itself. The renderer +imports them on a single promise chain so a burst arrives in a deterministic +order. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`) +for the same reason: each action carries a different playlist, so a newer add +must never cancel an older one's write, EPG fetch and navigation. The import +itself reuses the normal file path +(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence, +playlist-scoped EPG, and the navigation to the new playlist all behave exactly +like a dialog import. + +The OS-level registration that makes those paths reachable is +`fileAssociations` in `electron-builder.json` — one entry per extension, each +with its own `mimeType`. Electron Builder derives all three platform +registrations from it: macOS `CFBundleDocumentTypes` (which is what makes +`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the +desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for +deb/rpm/pacman. Two traps: it assigns the derived `MimeType` *after* spreading +`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and +must not be used; and it appends `%U` to `Exec`, so Linux file managers hand +over percent-encoded `file://` URIs rather than paths — +`createPlaylistOpenRequest` decodes them before the extension check. `%U` is +also the *plural* exec code, so a multi-file selection arrives as one launch +with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all +of them and `enqueueAll` queues the batch, because stopping at the first match +would silently drop the rest of the selection. Adding an exec code to +`linux.executableArgs` would suppress the `%U` but also pass that code to the +app as a real argument, so it is not an option. + **Video Players**: - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 80f4f119a..920427dcf 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -81,7 +81,13 @@ type ElectronFixtures = { }; export type LaunchElectronAppOptions = { + /** Electron/Chromium switches — they must precede the entry point. */ args?: readonly string[]; + /** + * Arguments for the app itself, appended *after* the entry point so they + * land in `process.argv` the way a file-association launch does. + */ + appArgs?: readonly string[]; environmentInheritance?: 'all' | 'runtime-only'; env?: Record; omitEnvKeys?: readonly string[]; @@ -184,10 +190,14 @@ export { expect }; * * Headless Linux CI has no usable sandbox or GPU, and an Electron started * without these flags there dies on a signal instead of running — so any - * helper that spawns the app itself has to use the same list. + * helper that spawns the app itself has to use the same list. `appArgs` land + * after the entry point, which is where the OS puts an opened file's path. */ -function buildElectronLaunchArgs(extraArgs: readonly string[] = []): string[] { - const args = [...extraArgs, electronMainPath]; +function buildElectronLaunchArgs( + extraArgs: readonly string[] = [], + appArgs: readonly string[] = [] +): string[] { + const args = [...extraArgs, electronMainPath, ...appArgs]; if (process.platform === 'linux' && process.env['CI']) { args.unshift('--no-sandbox', '--disable-gpu'); @@ -207,7 +217,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = buildElectronLaunchArgs(options.args); + const args = buildElectronLaunchArgs(options.args, options.appArgs); const electronApp = await electron.launch({ args, @@ -476,11 +486,16 @@ function attachElectronProcessDiagnostics( */ export async function launchCompetingElectronInstance( dataDir: string, - timeoutMs = 30000 + options: { + /** Arguments for the app itself, e.g. a playlist path to open. */ + appArgs?: readonly string[]; + timeoutMs?: number; + } = {} ): Promise { + const { appArgs = [], timeoutMs = 30000 } = options; // In a Node context the `electron` package resolves to its binary path. const electronBinaryPath = require('electron') as unknown as string; - const child = spawn(electronBinaryPath, buildElectronLaunchArgs(), { + const child = spawn(electronBinaryPath, buildElectronLaunchArgs([], appArgs), { env: { ...process.env, ELECTRON_IS_DEV: '0', diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts index 03d3c8f81..22f264e56 100644 --- a/apps/electron-backend-e2e/src/file-import.e2e.ts +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -1,9 +1,13 @@ +import { copyFileSync } from 'node:fs'; +import { join } from 'node:path'; import { closeElectronApp, expect, importM3uPlaylistFromNativeDialog, + launchCompetingElectronInstance, launchElectronApp, m3uFixturePath, + openSources, test, } from './electron-test-fixtures'; @@ -24,4 +28,80 @@ test.describe('Electron Native Playlist Import', () => { await closeElectronApp(app); } }); + + test('@m3u @electron opens a playlist passed on the command line', async ({ + dataDir, + }) => { + // Mirrors a file-association double-click on Windows/Linux: the path + // is the app's own argv, resolved and queued in the main process + // before the renderer exists. + const app = await launchElectronApp(dataDir, { + appArgs: [m3uFixturePath], + }); + + try { + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30000, + }); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(4); + } finally { + await closeElectronApp(app); + } + }); + + test('@m3u @electron opens every playlist of a multi-file selection', async ({ + dataDir, + }) => { + // Selecting several playlists in a file manager is a single launch + // with one argument per file, because the generated desktop entry + // ends in `%U`. Stopping at the first would drop the rest. + const secondPlaylist = join(dataDir, 'second-selection.m3u'); + copyFileSync(m3uFixturePath, secondPlaylist); + + const app = await launchElectronApp(dataDir, { + appArgs: [m3uFixturePath, secondPlaylist], + }); + + try { + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30000, + }); + await openSources(app.mainWindow); + await expect( + app.mainWindow.locator('app-playlist-item') + ).toHaveCount(2, { timeout: 30000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('@m3u @electron opens a playlist handed over by a second launch', async ({ + dataDir, + }) => { + // The second process never gets a window: the single-instance guard + // makes it quit and forwards its argv to the running app. + const app = await launchElectronApp(dataDir); + + try { + const competing = await launchCompetingElectronInstance(dataDir, { + appArgs: [m3uFixturePath], + }); + + expect( + competing.timedOut, + `Competing instance did not exit. stderr: ${competing.stderr}` + ).toBe(false); + + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30000, + }); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(4); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index b05d01be3..e38defbd9 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -55,6 +55,11 @@ import { createWorkerTerminationGenerationApi, type WorkerTerminationGenerationApi, } from './worker-termination-generation'; +import { + assertWorkerSampleCaptureValid, + createWorkerSampleDeadlineApi, + type WorkerSampleDeadlineApi, +} from './worker-sample-deadline'; import { createRendererProcessRssCaptureApi, type RendererProcessRssCaptureApi, @@ -230,6 +235,9 @@ export async function installMainCapture( performanceTimelineMergeApiFactorySource: createPerformanceTimelineMergeApi.toString(), stateKey: MAIN_CAPTURE_STATE_KEY, + workerSampleDeadlineApiFactorySource: + createWorkerSampleDeadlineApi.toString(), + workerSampleDeadlineMs: 5_000, workerTerminationGenerationApiFactorySource: createWorkerTerminationGenerationApi.toString(), xtreamIpcMarkerCaptureFactorySource: @@ -325,7 +333,9 @@ export async function installMainCapture( resolvedProfileHandle: CpuProfileHandle | null; requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; + sampleKey: object; samplePromise: Promise | null; + sampleTimedOut: boolean; samplingStarted: boolean; snapshotPath: string | null; terminatedEpochMs: number | null; @@ -399,6 +409,9 @@ export async function installMainCapture( restoreFactory( input.workerTerminationGenerationApiFactorySource ); + const workerSampleDeadlineApi = restoreFactory( + input.workerSampleDeadlineApiFactorySource + ); const diagnosticsPerformancePhaseEventParserFactory = restoreFunction< typeof createDiagnosticsPerformancePhaseEventParser >(input.diagnosticsPerformancePhaseEventParserFactorySource); @@ -467,6 +480,7 @@ export async function installMainCapture( active: false, captureGeneration: 0, captureInvalidReasons: [] as string[], + capturePoisonedReason: null as string | null, captureOptions: null as MainCaptureStartOptions | null, captureStartedEpochMs: null as number | null, cpuStart: null as NodeJS.CpuUsage | null, @@ -735,7 +749,9 @@ export async function installMainCapture( resolvedProfileHandle: null, requestPerformance: [], responseEpochMs: null, + sampleKey: {}, samplePromise: null, + sampleTimedOut: false, samplingStarted: false, snapshotPath: null, terminatedEpochMs: null, @@ -864,59 +880,91 @@ export async function installMainCapture( return record; }; const sampleWorker = (record: WorkerRecord): Promise => { - if (record.finalized) { + if (record.finalized || record.sampleTimedOut) { return Promise.resolve(); } if (record.samplePromise) { return record.samplePromise; } - record.samplePromise = (async () => { - try { - const stats = await record.worker.getHeapStatistics?.(); - if (stats) { - const heapUsedSample = stats.used_heap_size; - if ( - typeof heapUsedSample === 'number' && - Number.isFinite(heapUsedSample) && - heapUsedSample >= 0 - ) { - record.heapUsedSampleCount += 1; - record.heapPeak = Math.max( - record.heapPeak, - heapUsedSample - ); + const captureGeneration = record.captureGeneration; + const sampleKey = {}; + record.sampleKey = sampleKey; + const boundedSample = workerSampleDeadlineApi + .run({ + apply: ({ cpu, elu, stats }) => { + if (stats) { + const heapUsedSample = stats.used_heap_size; + if ( + typeof heapUsedSample === 'number' && + Number.isFinite(heapUsedSample) && + heapUsedSample >= 0 + ) { + record.heapUsedSampleCount += 1; + record.heapPeak = Math.max( + record.heapPeak, + heapUsedSample + ); + } + const externalMemorySample = stats.external_memory; + if ( + typeof externalMemorySample === 'number' && + Number.isFinite(externalMemorySample) && + externalMemorySample >= 0 + ) { + record.externalMemorySampleCount += 1; + record.externalPeak = Math.max( + record.externalPeak, + externalMemorySample + ); + } } - const externalMemorySample = stats.external_memory; - if ( - typeof externalMemorySample === 'number' && - Number.isFinite(externalMemorySample) && - externalMemorySample >= 0 - ) { - record.externalMemorySampleCount += 1; - record.externalPeak = Math.max( - record.externalPeak, - externalMemorySample - ); + if (cpu) { + record.cpuFirst ??= cpu; + record.cpuLast = cpu; } + if (elu) { + record.elu = elu.utilization; + } + }, + capturedGeneration: captureGeneration, + currentIdentity: () => ({ + captureGeneration: state.captureGeneration, + recordGeneration: record.captureGeneration, + sampleKey: record.sampleKey, + }), + onTimeout: () => { + record.sampleTimedOut = true; + state.capturePoisonedReason = 'worker-sample-timeout'; + invalidateCapture('worker-sample-timeout'); + recordTimeline({ + operationId: record.operationId ?? undefined, + playlistId: record.playlistId ?? undefined, + type: `${record.kind}-sample-timeout`, + }); + }, + operation: async () => { + const stats = await record.worker.getHeapStatistics?.(); + const cpu = await record.worker.cpuUsage?.(); + const elu = + record.worker.performance?.eventLoopUtilization( + record.eluStart ?? undefined + ) ?? null; + return { cpu, elu, stats }; + }, + sampleKey, + timeoutMs: input.workerSampleDeadlineMs, + }) + .then(() => undefined) + .finally(() => { + if ( + record.sampleKey === sampleKey && + record.samplePromise === boundedSample + ) { + record.samplePromise = null; } - const cpu = await record.worker.cpuUsage?.(); - if (cpu) { - record.cpuFirst ??= cpu; - record.cpuLast = cpu; - } - const elu = record.worker.performance?.eventLoopUtilization( - record.eluStart ?? undefined - ); - if (elu) { - record.elu = elu.utilization; - } - } catch { - // A one-shot worker may terminate between sampling calls. - } - })().finally(() => { - record.samplePromise = null; - }); - return record.samplePromise; + }); + record.samplePromise = boundedSample; + return boundedSample; }; const resetWorkerForCapture = (record: WorkerRecord): void => { record.cancelPostedEpochMs = null; @@ -945,7 +993,9 @@ export async function installMainCapture( record.resolvedProfileHandle = null; record.requestPerformance = []; record.responseEpochMs = null; + record.sampleKey = {}; record.samplePromise = null; + record.sampleTimedOut = false; record.samplingStarted = false; record.snapshotPath = null; record.terminatedEpochMs = null; @@ -1563,6 +1613,11 @@ export async function installMainCapture( const startCapture = async ( options: MainCaptureStartOptions ): Promise => { + if (state.capturePoisonedReason !== null) { + throw new Error( + `xtream-main-capture-poisoned:${state.capturePoisonedReason}` + ); + } const captureStartedEpochMs = nowEpochMs(); state.rendererWindowSession?.detach(); state.rendererWindowSession = null; @@ -1641,6 +1696,11 @@ export async function installMainCapture( databaseRequestIdentityCapture.successMarkerCount(), }), start: async (options: MainCaptureStartOptions): Promise => { + if (state.capturePoisonedReason !== null) { + throw new Error( + `xtream-main-capture-poisoned:${state.capturePoisonedReason}` + ); + } databaseWorkerPostGcCutoffApi.beginCapture(); await startCapture(options); }, @@ -1754,7 +1814,7 @@ export async function installMainCapture( ); } await Promise.all( - currentDatabaseRecords.map((record) => + currentWorkerRecords.map((record) => joinFinalWorkerSample(record) ) ); @@ -1962,7 +2022,8 @@ export async function installMainCapture( ? currentDatabaseRecords[0] : null; const nextCaptureUnavailableReason = - cutoff.lateRequestCount > 0 + state.capturePoisonedReason ?? + (cutoff.lateRequestCount > 0 ? 'database-worker-activity-after-cutoff' : dbRequests.size > 0 ? 'database-worker-not-idle' @@ -1980,7 +2041,7 @@ export async function installMainCapture( null ? (databaseRecord?.postGcHeapUnavailableReason ?? 'post-gc-capture-invalid') - : null; + : null); if (nextCaptureUnavailableReason === null) { databaseWorkerPostGcCutoffApi.rolloverCapture(); await startCapture(nextOptions); @@ -2488,6 +2549,7 @@ export async function rolloverMainCapture( }, { options, stateKey: MAIN_CAPTURE_STATE_KEY } ); + assertWorkerSampleCaptureValid(transport.xtream.invalidReasons); if (transport.rollover === null) { throw new Error('main-capture-rollover-status-missing'); } @@ -2512,6 +2574,7 @@ export async function stopMainCapture( }, MAIN_CAPTURE_STATE_KEY ); + assertWorkerSampleCaptureValid(transport.xtream.invalidReasons); return selectMainCaptureGeneration(transport); } diff --git a/apps/electron-backend-e2e/src/performance/worker-sample-capture-validity.spec.ts b/apps/electron-backend-e2e/src/performance/worker-sample-capture-validity.spec.ts new file mode 100644 index 000000000..95e502d7d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-sample-capture-validity.spec.ts @@ -0,0 +1,73 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface WorkerSampleDeadlineModule { + assertWorkerSampleCaptureValid?: ( + invalidReasons: readonly string[] + ) => void; +} + +const modulePromise = import( + new URL('./worker-sample-deadline.ts', import.meta.url).href +).then((module) => module as WorkerSampleDeadlineModule); + +test('rejects a timed-out worker sample before returning plain capture metrics', async () => { + const module = await modulePromise; + const assertCaptureValid = module.assertWorkerSampleCaptureValid; + assert.equal(typeof assertCaptureValid, 'function'); + + assert.doesNotThrow(() => assertCaptureValid?.([])); + assert.throws( + () => assertCaptureValid?.(['worker-sample-timeout']), + /main-capture-worker-sample-timeout/ + ); +}); + +test('plain capture stop and rollover both enforce worker sample validity', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const rolloverStart = source.indexOf( + 'export async function rolloverMainCapture' + ); + const stopStart = source.indexOf('export async function stopMainCapture'); + const xtreamRolloverStart = source.indexOf( + 'export async function rolloverXtreamMainCapture' + ); + const rollover = source.slice(rolloverStart, stopStart); + const stop = source.slice(stopStart, xtreamRolloverStart); + + assert.match( + rollover, + /assertWorkerSampleCaptureValid\(transport\.xtream\.invalidReasons\)/ + ); + assert.match( + stop, + /assertWorkerSampleCaptureValid\(transport\.xtream\.invalidReasons\)/ + ); +}); + +test('capture stop settles final samples for every current worker', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const stopStart = source.indexOf('stop: async ('); + const stopEnd = source.indexOf( + 'xtreamStatus: (): XtreamMainCaptureStatus', + stopStart + ); + const stop = source.slice(stopStart, stopEnd); + + assert.match( + stop, + /await Promise\.all\(\s*currentWorkerRecords\.map\(\(record\) =>\s*joinFinalWorkerSample\(record\)/ + ); + assert.doesNotMatch( + stop, + /currentDatabaseRecords\.map\(\(record\) =>\s*joinFinalWorkerSample\(record\)/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-sample-deadline.spec.ts b/apps/electron-backend-e2e/src/performance/worker-sample-deadline.spec.ts new file mode 100644 index 000000000..4b826fa2b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-sample-deadline.spec.ts @@ -0,0 +1,295 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface WorkerSampleDeadlineIdentity { + readonly captureGeneration: number; + readonly recordGeneration: number | null; + readonly sampleKey: object; +} + +type WorkerSampleDeadlineOutcome = + | { readonly status: 'completed' } + | { readonly error: unknown; readonly status: 'failed' } + | { readonly status: 'stale' } + | { readonly status: 'timed-out' }; + +interface WorkerSampleDeadlineApi { + run(input: { + readonly apply: (value: T) => void; + readonly capturedGeneration: number | null; + readonly currentIdentity: () => WorkerSampleDeadlineIdentity; + readonly onTimeout: () => void; + readonly operation: () => Promise; + readonly sampleKey: object; + readonly timers?: WorkerSampleDeadlineTimers; + readonly timeoutMs: number; + }): Promise; +} + +interface WorkerSampleDeadlineTimers { + clearTimeout(handle: unknown): void; + setTimeout(callback: () => void, timeoutMs: number): unknown; +} + +interface WorkerSampleDeadlineModule { + createWorkerSampleDeadlineApi?: () => WorkerSampleDeadlineApi; +} + +const modulePromise = import( + new URL('./worker-sample-deadline.ts', import.meta.url).href +) + .then((module) => module as WorkerSampleDeadlineModule) + .catch(() => null); + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function createScheduler(): WorkerSampleDeadlineTimers & { + readonly activeCount: () => number; + readonly fireNext: () => void; +} { + const tasks = new Map void>(); + + return { + activeCount: () => tasks.size, + clearTimeout(handle: unknown): void { + tasks.delete(handle); + }, + fireNext(): void { + const next = tasks.entries().next().value as + [unknown, () => void] | undefined; + assert.ok(next, 'expected one pending deadline'); + tasks.delete(next[0]); + next[1](); + }, + setTimeout(callback): object { + const handle = {}; + tasks.set(handle, callback); + return handle; + }, + }; +} + +async function restoreSerializableApi(): Promise { + const module = await modulePromise; + assert.ok(module, 'worker sample deadline module must exist'); + const factory = module.createWorkerSampleDeadlineApi; + assert.equal(typeof factory, 'function'); + + const source = factory.toString(); + assert.doesNotMatch(source, /__name/); + const restoredFactory = Function( + `"use strict"; return (${source});` + )() as () => WorkerSampleDeadlineApi; + return restoredFactory(); +} + +test('applies a worker sample only while its generation and identity are current', async () => { + const scheduler = createScheduler(); + const api = await restoreSerializableApi(); + const operation = deferred(); + const sampleKey = {}; + const applied: number[] = []; + + const outcomePromise = api.run({ + apply: (value) => applied.push(value), + capturedGeneration: 7, + currentIdentity: () => ({ + captureGeneration: 7, + recordGeneration: 7, + sampleKey, + }), + onTimeout: () => assert.fail('the operation completed in time'), + operation: () => operation.promise, + sampleKey, + timers: scheduler, + timeoutMs: 5_000, + }); + operation.resolve(42); + + assert.deepEqual(await outcomePromise, { status: 'completed' }); + assert.deepEqual(applied, [42]); + assert.equal(scheduler.activeCount(), 0); +}); + +test('ignores an already queued deadline callback after successful completion', async () => { + const api = await restoreSerializableApi(); + const operation = deferred(); + const sampleKey = {}; + const applied: number[] = []; + let queuedTimeout: (() => void) | null = null; + let timeoutCount = 0; + const timers: WorkerSampleDeadlineTimers = { + clearTimeout(): void { + // The callback may already be queued when cancellation runs. + }, + setTimeout(callback): object { + queuedTimeout = callback; + return {}; + }, + }; + + const outcomePromise = api.run({ + apply: (value) => applied.push(value), + capturedGeneration: 8, + currentIdentity: () => ({ + captureGeneration: 8, + recordGeneration: 8, + sampleKey, + }), + onTimeout: () => { + timeoutCount += 1; + }, + operation: () => operation.promise, + sampleKey, + timers, + timeoutMs: 5_000, + }); + operation.resolve(43); + + assert.deepEqual(await outcomePromise, { status: 'completed' }); + assert.deepEqual(applied, [43]); + assert.ok(queuedTimeout); + queuedTimeout(); + assert.equal(timeoutCount, 0); + assert.deepEqual(applied, [43]); +}); + +test('times out a stalled sample and ignores its late result', async () => { + const scheduler = createScheduler(); + const api = await restoreSerializableApi(); + const operation = deferred(); + const sampleKey = {}; + const applied: number[] = []; + let timeoutCount = 0; + + const outcomePromise = api.run({ + apply: (value) => applied.push(value), + capturedGeneration: 11, + currentIdentity: () => ({ + captureGeneration: 11, + recordGeneration: 11, + sampleKey, + }), + onTimeout: () => { + timeoutCount += 1; + }, + operation: () => operation.promise, + sampleKey, + timers: scheduler, + timeoutMs: 5_000, + }); + scheduler.fireNext(); + + assert.deepEqual(await outcomePromise, { status: 'timed-out' }); + assert.equal(timeoutCount, 1); + assert.deepEqual(applied, []); + + operation.resolve(99); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual( + applied, + [], + 'a non-cancellable Worker promise must not mutate capture state after its deadline' + ); +}); + +test('fails closed when capture rollover makes a pending sample stale', async () => { + const scheduler = createScheduler(); + const api = await restoreSerializableApi(); + const operation = deferred(); + const sampleKey = {}; + const applied: number[] = []; + let currentGeneration = 4; + + const outcomePromise = api.run({ + apply: (value) => applied.push(value), + capturedGeneration: 4, + currentIdentity: () => ({ + captureGeneration: currentGeneration, + recordGeneration: currentGeneration, + sampleKey, + }), + onTimeout: () => assert.fail('a stale sample is not a timeout'), + operation: () => operation.promise, + sampleKey, + timers: scheduler, + timeoutMs: 5_000, + }); + currentGeneration = 5; + operation.resolve(7); + + assert.deepEqual(await outcomePromise, { status: 'stale' }); + assert.deepEqual(applied, []); + assert.equal(scheduler.activeCount(), 0); +}); + +test('the main capture bounds worker sampling and invalidates timed-out evidence', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + const sampleWorkerStart = source.indexOf('const sampleWorker ='); + const sampleWorkerEnd = source.indexOf( + 'const resetWorkerForCapture', + sampleWorkerStart + ); + const sampleWorker = source.slice(sampleWorkerStart, sampleWorkerEnd); + const startCaptureStart = source.indexOf('const startCapture ='); + const startCaptureEnd = source.indexOf('const api =', startCaptureStart); + const startCapture = source.slice(startCaptureStart, startCaptureEnd); + const apiStartStart = source.indexOf( + 'start: async (options: MainCaptureStartOptions)' + ); + const apiStartEnd = source.indexOf( + 'beginMeasurement: async', + apiStartStart + ); + const apiStart = source.slice(apiStartStart, apiStartEnd); + const stopStart = source.indexOf('stop: async ('); + const stopEnd = source.indexOf( + 'xtreamStatus: (): XtreamMainCaptureStatus', + stopStart + ); + const stopCapture = source.slice(stopStart, stopEnd); + + assert.match( + source, + /workerSampleDeadlineApiFactorySource:\s+createWorkerSampleDeadlineApi\.toString\(\)/ + ); + assert.match(source, /workerSampleDeadlineMs:\s+5_000/); + assert.match(source, /capturePoisonedReason:\s+null as string \| null/); + assert.match(source, /sampleKey: object/); + assert.match(source, /sampleTimedOut: boolean/); + assert.match(sampleWorker, /workerSampleDeadlineApi\s*\.run\(/); + assert.match(sampleWorker, /invalidateCapture\('worker-sample-timeout'\)/); + assert.match( + sampleWorker, + /state\.capturePoisonedReason\s*=\s*'worker-sample-timeout'/ + ); + assert.match(sampleWorker, /record\.sampleKey === sampleKey/); + assert.match(sampleWorker, /record\.samplePromise === boundedSample/); + assert.match( + startCapture, + /if \(state\.capturePoisonedReason !== null\)[\s\S]*xtream-main-capture-poisoned/ + ); + assert.match( + apiStart, + /if \(state\.capturePoisonedReason !== null\)[\s\S]*xtream-main-capture-poisoned[\s\S]*databaseWorkerPostGcCutoffApi\.beginCapture\(\)/ + ); + assert.match( + stopCapture, + /const nextCaptureUnavailableReason =\s+state\.capturePoisonedReason \?\?/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-sample-deadline.ts b/apps/electron-backend-e2e/src/performance/worker-sample-deadline.ts new file mode 100644 index 000000000..2b0310132 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-sample-deadline.ts @@ -0,0 +1,175 @@ +export interface WorkerSampleDeadlineIdentity { + readonly captureGeneration: number; + readonly recordGeneration: number | null; + readonly sampleKey: object; +} + +export type WorkerSampleDeadlineOutcome = + | { readonly status: 'completed' } + | { readonly error: unknown; readonly status: 'failed' } + | { readonly status: 'stale' } + | { readonly status: 'timed-out' }; + +export interface WorkerSampleDeadlineInput { + readonly apply: (value: T) => void; + readonly capturedGeneration: number | null; + readonly currentIdentity: () => WorkerSampleDeadlineIdentity; + readonly onTimeout: () => void; + readonly operation: () => Promise; + readonly sampleKey: object; + readonly timers?: WorkerSampleDeadlineTimers; + readonly timeoutMs: number; +} + +export interface WorkerSampleDeadlineTimers { + clearTimeout(handle: unknown): void; + setTimeout(callback: () => void, timeoutMs: number): unknown; +} + +export interface WorkerSampleDeadlineApi { + run( + input: WorkerSampleDeadlineInput + ): Promise; +} + +export function assertWorkerSampleCaptureValid( + invalidReasons: readonly string[] +): void { + if (invalidReasons.includes('worker-sample-timeout')) { + throw new Error('main-capture-worker-sample-timeout'); + } +} + +export function createWorkerSampleDeadlineApi(): WorkerSampleDeadlineApi { + const defaultTimers: WorkerSampleDeadlineTimers = { + clearTimeout(handle: unknown): void { + globalThis.clearTimeout( + handle as ReturnType + ); + }, + setTimeout(callback: () => void, timeoutMs: number): unknown { + return globalThis.setTimeout(callback, timeoutMs); + }, + }; + + const helpers = { + isCurrent(input: WorkerSampleDeadlineInput): boolean { + try { + const current = input.currentIdentity(); + return ( + Number.isSafeInteger(input.capturedGeneration) && + Number(input.capturedGeneration) > 0 && + current.captureGeneration === input.capturedGeneration && + current.recordGeneration === input.capturedGeneration && + current.sampleKey === input.sampleKey + ); + } catch { + return false; + } + }, + + run( + input: WorkerSampleDeadlineInput + ): Promise { + if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0) { + return Promise.resolve({ + error: new RangeError( + 'worker sample deadline must be a positive finite number' + ), + status: 'failed', + }); + } + + const timers = input.timers ?? defaultTimers; + return new Promise( + (resolvePromise) => { + let settled = false; + let timeoutHandle: unknown; + let timeoutScheduled = false; + const callbacks = { + complete(outcome: WorkerSampleDeadlineOutcome): void { + if (settled) { + return; + } + settled = true; + if (timeoutScheduled) { + try { + timers.clearTimeout(timeoutHandle); + } catch { + // Best-effort cleanup must not hide evidence. + } + } + resolvePromise(outcome); + }, + + onError(error: unknown): void { + if (settled) { + return; + } + callbacks.complete( + helpers.isCurrent(input) + ? { error, status: 'failed' } + : { status: 'stale' } + ); + }, + + onTimeout(): void { + if (settled) { + return; + } + if (!helpers.isCurrent(input)) { + callbacks.complete({ status: 'stale' }); + return; + } + try { + input.onTimeout(); + callbacks.complete({ status: 'timed-out' }); + } catch (error: unknown) { + callbacks.complete({ + error, + status: 'failed', + }); + } + }, + + onValue(value: T): void { + if (settled) { + return; + } + if (!helpers.isCurrent(input)) { + callbacks.complete({ status: 'stale' }); + return; + } + try { + input.apply(value); + callbacks.complete({ status: 'completed' }); + } catch (error: unknown) { + callbacks.complete({ + error, + status: 'failed', + }); + } + }, + }; + + try { + timeoutHandle = timers.setTimeout( + callbacks.onTimeout, + input.timeoutMs + ); + timeoutScheduled = true; + } catch (error: unknown) { + callbacks.complete({ error, status: 'failed' }); + return; + } + + void Promise.resolve() + .then(input.operation) + .then(callbacks.onValue, callbacks.onError); + } + ); + }, + }; + + return Object.freeze({ run: helpers.run }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts index 2f43572b6..6d3066d8b 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts @@ -50,6 +50,14 @@ export interface SeedXtreamExistingPortalOptions { export async function prearmXtreamDatabaseWorker( app: LaunchedElectronApp ): Promise { + // Main owns schema creation but loads the renderer first. Fence that + // startup promise before the independent worker opens the fresh database. + const downloads = await app.mainWindow.evaluate(() => + window.electron.downloadsGetList() + ); + if (!Array.isArray(downloads)) { + throw new Error('xtream-main-database-readiness-invalid'); + } const playlists = await app.mainWindow.evaluate(() => window.electron.dbGetAppPlaylists() ); diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts index fc529d740..31a47bf63 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts @@ -173,6 +173,60 @@ describe('Xtream raw iteration assembler', () => { ); }); + it('accepts sub-millisecond skew between renderer and main cancellation clocks', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const workerTerminal = input.mainCapture.cancelTimeline.find( + ({ type }) => type === 'db-cancel-terminal-received' + ); + assert.ok(workerTerminal); + const rendererTerminalEpochMs = workerTerminal.epochMs - 0.1; + assert.ok( + rendererTerminalEpochMs > + Number(input.rendererCapture.probe.cancellationClickEpochMs) + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + dbCancellationTerminalEpochMs: rendererTerminalEpochMs, + }, + }, + }) + ); + }); + + it('rejects a cancellation terminal that substantially predates the main receipt', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const workerTerminal = input.mainCapture.cancelTimeline.find( + ({ type }) => type === 'db-cancel-terminal-received' + ); + assert.ok(workerTerminal); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + dbCancellationTerminalEpochMs: + workerTerminal.epochMs - 2, + }, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + it('accepts main observing a network cancel response after the next renderer IPC starts', () => { const input = createXtreamAssemblerFixture( XTREAM_SCENARIO_ID.CANCEL_IMPORT diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts index e07ce6dd9..ed4b72541 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts @@ -26,6 +26,8 @@ import { type XtreamUiActionSample, } from './xtream-ui-action-probe'; +const CANCELLATION_CLOCK_SKEW_TOLERANCE_MS = 1; + export interface XtreamScenarioAssembly { readonly backgroundMetrics: { readonly actionLatencyMs: number; @@ -264,6 +266,8 @@ function cancellationEvidence( const preload = ipcPair(input.phaseCapture.ipcSpans, 'dbCancelOperation'); const authoritative = request.responseEpochMs; const painted = requireNumber(renderer.uiPaintedEpochMs); + // Renderer and main sample independent clocks, so allow their correlated + // terminal timestamps to invert only within the known sub-millisecond skew. if ( !dispatch || !receipt || @@ -271,7 +275,8 @@ function cancellationEvidence( dispatch.type !== 'db-cancel-dispatched' || receipt.type !== 'db-cancel-received' || workerTerminal.type !== 'db-cancel-terminal-received' || - renderer.dbCancellationTerminalEpochMs < workerTerminal.epochMs || + renderer.dbCancellationTerminalEpochMs < + workerTerminal.epochMs - CANCELLATION_CLOCK_SKEW_TOLERANCE_MS || renderer.dbCancellationTerminalEpochMs > painted || authoritative > painted ) { diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts index abaa38811..504049d1d 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -145,7 +145,14 @@ describe('Xtream scenario driver source wiring', () => { support, /export async function prearmXtreamDatabaseWorker/ ); - assert.match(support, /window\.electron\.dbGetAppPlaylists\(\)/); + const mainDatabaseReady = support.indexOf( + 'window.electron.downloadsGetList()' + ); + const workerPrearm = support.indexOf( + 'window.electron.dbGetAppPlaylists()' + ); + assert.ok(mainDatabaseReady >= 0); + assert.ok(workerPrearm > mainDatabaseReady); assert.ok(install >= 0); assert.ok(prearm > install); assert.ok(captureOptions > prearm); diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 54d2ddc9f..b624e65fa 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -9,6 +9,9 @@ import { APP_UPDATE_GET_STATUS, APP_UPDATE_INSTALL, APP_UPDATE_STATUS_CHANGED, + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, } from '@iptvnator/shared/interfaces'; import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; import { @@ -190,6 +193,38 @@ describe('main preload DB IPC contract', () => { ); }); + it('exposes the playlist open-request announcement and push channel', async () => { + const api = getExposedApi(); + const callback = jest.fn(); + const request = { + fileName: 'weekend.m3u', + filePath: '/home/user/weekend.m3u', + requestId: 'playlist-open-1', + }; + + await api.announcePlaylistOpenListener(); + await api.acknowledgePlaylistOpenRequest(request.requestId); + const unsubscribe = api.onPlaylistOpenRequest(callback); + const handler = mockIpcRenderer.on.mock.calls.at(-1)?.[1]; + + handler({}, request); + unsubscribe(); + + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith( + ANNOUNCE_PLAYLIST_OPEN_LISTENER + ); + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith( + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + 'playlist-open-1' + ); + expect(mockIpcRenderer.on).toHaveBeenCalledWith( + OPEN_FILE, + expect.any(Function) + ); + expect(callback).toHaveBeenCalledWith(request); + expect(mockIpcRenderer.off).toHaveBeenCalledWith(OPEN_FILE, handler); + }); + it('forwards request-scoped DB operation events and unregisters the listener', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 417607e41..75f256c63 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -6,6 +6,9 @@ import { APP_UPDATE_GET_STATUS, APP_UPDATE_INSTALL, APP_UPDATE_STATUS_CHANGED, + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, } from '@iptvnator/shared/interfaces/ipc-commands'; import { attachEmbeddedMpvFrameView, @@ -34,6 +37,7 @@ import type { ElectronBridgeEpgProgress, ElectronBridgePlaybackPositionInput, ElectronBridgePlaylistInput, + ElectronBridgePlaylistOpenRequest, ElectronBridgePlaylistUpsertInput, ElectronBridgeRemoteControlCommand, ElectronBridgeRemoteControlStatus, @@ -421,6 +425,20 @@ const electronApi: ElectronBridgeApi = { updatePlaylistFromFilePath: (filePath: string, title: string) => ipcRenderer.invoke('update-playlist-from-file-path', filePath, title), openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'), + onPlaylistOpenRequest: ( + callback: (request: ElectronBridgePlaylistOpenRequest) => void + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + request: ElectronBridgePlaylistOpenRequest + ) => callback(request); + ipcRenderer.on(OPEN_FILE, handler); + return () => ipcRenderer.off(OPEN_FILE, handler); + }, + announcePlaylistOpenListener: () => + ipcRenderer.invoke(ANNOUNCE_PLAYLIST_OPEN_LISTENER), + acknowledgePlaylistOpenRequest: (requestId: string) => + ipcRenderer.invoke(ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, requestId), getPathForFile: (file: File) => webUtils.getPathForFile(file), saveFileDialog: ( defaultPath: string, diff --git a/apps/electron-backend/src/app/events/playlist-open.events.spec.ts b/apps/electron-backend/src/app/events/playlist-open.events.spec.ts new file mode 100644 index 000000000..93b1a9844 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-open.events.spec.ts @@ -0,0 +1,156 @@ +import { + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, +} from '@iptvnator/shared/interfaces'; + +type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => unknown; + +type MockIpcEvent = { + sender: { + isDestroyed: jest.Mock; + send: jest.Mock; + }; +}; + +const mockRegisteredHandlers = new Map(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, +})); + +function createEvent(isDestroyed = false): MockIpcEvent { + return { + sender: { + isDestroyed: jest.fn(() => isDestroyed), + send: jest.fn(), + }, + }; +} + +const startupRequest = { + fileName: 'startup.m3u', + filePath: '/tmp/startup.m3u', + requestId: 'startup-1', +}; + +describe('PlaylistOpenEvents', () => { + let playlistOpenRequests: typeof import('../services/playlist-open-request').playlistOpenRequests; + let announceListener: IpcHandler; + let acknowledge: IpcHandler; + + beforeEach(async () => { + jest.resetModules(); + mockRegisteredHandlers.clear(); + + ({ playlistOpenRequests } = await import( + '../services/playlist-open-request' + )); + const { default: PlaylistOpenEvents } = await import( + './playlist-open.events' + ); + + PlaylistOpenEvents.bootstrapPlaylistOpenEvents(); + announceListener = mockRegisteredHandlers.get( + ANNOUNCE_PLAYLIST_OPEN_LISTENER + ) as IpcHandler; + acknowledge = mockRegisteredHandlers.get( + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST + ) as IpcHandler; + }); + + it('registers the announcement and acknowledgement handlers', () => { + expect(announceListener).toBeDefined(); + expect(acknowledge).toBeDefined(); + }); + + it('pushes requests queued before the renderer was ready', () => { + playlistOpenRequests.enqueue(startupRequest); + const event = createEvent(); + + announceListener(event); + + expect(event.sender.send).toHaveBeenCalledWith( + OPEN_FILE, + startupRequest + ); + expect(playlistOpenRequests.pendingRequests).toEqual([]); + expect(playlistOpenRequests.unacknowledgedRequests).toEqual([ + startupRequest, + ]); + }); + + it('drops a request once the renderer acknowledges it', () => { + playlistOpenRequests.enqueue(startupRequest); + announceListener(createEvent()); + + acknowledge(createEvent(), startupRequest.requestId); + + expect(playlistOpenRequests.unacknowledgedRequests).toEqual([]); + }); + + // A reload keeps the WebContents alive, so the push "succeeds" while the + // new document never sees it. The replay is what saves the file. + it('replays a request the renderer never acknowledged', () => { + playlistOpenRequests.enqueue(startupRequest); + announceListener(createEvent()); + + const reloadedEvent = createEvent(); + announceListener(reloadedEvent); + + expect(reloadedEvent.sender.send).toHaveBeenCalledWith( + OPEN_FILE, + startupRequest + ); + }); + + it('pushes later requests to the renderer that announced itself', () => { + const event = createEvent(); + + announceListener(event); + const liveRequest = { + fileName: 'second-launch.m3u', + filePath: '/tmp/second-launch.m3u', + requestId: 'second-launch-1', + }; + playlistOpenRequests.enqueue(liveRequest); + + expect(event.sender.send).toHaveBeenCalledWith(OPEN_FILE, liveRequest); + }); + + // A renderer that dies must not take the queued files with it: nothing + // leaves the queue before its push succeeded, so the next renderer to + // announce itself still receives them. + it('keeps requests queued when the announcing renderer is gone', () => { + playlistOpenRequests.enqueue(startupRequest); + const deadEvent = createEvent(true); + + announceListener(deadEvent); + + expect(deadEvent.sender.send).not.toHaveBeenCalled(); + expect(playlistOpenRequests.pendingRequests).toEqual([startupRequest]); + + const nextEvent = createEvent(); + announceListener(nextEvent); + + expect(nextEvent.sender.send).toHaveBeenCalledWith( + OPEN_FILE, + startupRequest + ); + }); + + it('keeps a request queued when the push itself throws', () => { + playlistOpenRequests.enqueue(startupRequest); + const event = createEvent(); + event.sender.send.mockImplementation(() => { + throw new Error('Object has been destroyed'); + }); + + expect(() => announceListener(event)).not.toThrow(); + expect(playlistOpenRequests.pendingRequests).toEqual([startupRequest]); + }); +}); diff --git a/apps/electron-backend/src/app/events/playlist-open.events.ts b/apps/electron-backend/src/app/events/playlist-open.events.ts new file mode 100644 index 000000000..6f21799d0 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-open.events.ts @@ -0,0 +1,49 @@ +/** + * Bridges the main-process playlist open queue to the renderer. + * + * The renderer subscribes to `OPEN_FILE` and then announces itself with a + * single invoke. That invoke carries no payload on purpose: everything — + * including the files queued before the window existed — leaves the queue + * through the same push, and only once the renderer acknowledges it. A + * renderer that reloads or dies mid-flush therefore leaves its requests in the + * queue, and the next one to announce itself receives them. + */ + +import { ipcMain } from 'electron'; +import { + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + ANNOUNCE_PLAYLIST_OPEN_LISTENER, + OPEN_FILE, +} from '@iptvnator/shared/interfaces'; +import { playlistOpenRequests } from '../services/playlist-open-request'; + +export default class PlaylistOpenEvents { + static bootstrapPlaylistOpenEvents(): Electron.IpcMain { + ipcMain.handle( + ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST, + (_event, requestId: string): void => { + playlistOpenRequests.acknowledge(requestId); + } + ); + + ipcMain.handle(ANNOUNCE_PLAYLIST_OPEN_LISTENER, (event): void => { + playlistOpenRequests.setDelivery((request) => { + if (event.sender.isDestroyed()) { + return false; + } + + try { + event.sender.send(OPEN_FILE, request); + } catch { + // The WebContents went away between the check and the + // send; keep the request for the next renderer. + return false; + } + + return true; + }); + }); + + return ipcMain; + } +} diff --git a/apps/electron-backend/src/app/services/playlist-open-request.spec.ts b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts new file mode 100644 index 000000000..d74913d67 --- /dev/null +++ b/apps/electron-backend/src/app/services/playlist-open-request.spec.ts @@ -0,0 +1,352 @@ +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + createPlaylistOpenRequest, + extractPlaylistOpenRequestsFromArgv, + isPlaylistFilePath, + PlaylistOpenRequest, + PlaylistOpenRequestQueue, +} from './playlist-open-request'; + +describe('createPlaylistOpenRequest', () => { + it('accepts m3u and m3u8 paths regardless of case', () => { + expect(createPlaylistOpenRequest('/tmp/list.m3u')).toMatchObject({ + fileName: 'list.m3u', + filePath: '/tmp/list.m3u', + }); + expect(createPlaylistOpenRequest('/tmp/List.M3U8')).toMatchObject({ + fileName: 'List.M3U8', + filePath: '/tmp/List.M3U8', + }); + }); + + it('rejects paths that are not playlist files', () => { + expect(createPlaylistOpenRequest('/tmp/movie.mkv')).toBeNull(); + expect(createPlaylistOpenRequest(' ')).toBeNull(); + expect(createPlaylistOpenRequest('')).toBeNull(); + }); + + it('resolves a relative path against the supplied working directory', () => { + expect( + createPlaylistOpenRequest('sub/list.m3u', '/home/user/playlists') + ).toMatchObject({ + fileName: 'list.m3u', + filePath: resolve('/home/user/playlists', 'sub/list.m3u'), + }); + }); + + it('falls back to the process working directory when none is supplied', () => { + expect(createPlaylistOpenRequest('list.m3u')).toMatchObject({ + fileName: 'list.m3u', + filePath: resolve(process.cwd(), 'list.m3u'), + }); + }); + + it('gives every request its own id', () => { + const first = createPlaylistOpenRequest('/tmp/a.m3u'); + const second = createPlaylistOpenRequest('/tmp/a.m3u'); + + expect(first?.requestId).toBeTruthy(); + expect(first?.requestId).not.toEqual(second?.requestId); + }); + + // The Linux desktop entry ends in `%U`, so a double-click in the file + // manager delivers a URI rather than a path. + it('accepts a file:// URI', () => { + const filePath = resolve('/tmp/playlists/list.m3u'); + + expect(createPlaylistOpenRequest(pathToFileURL(filePath).href)).toEqual( + expect.objectContaining({ fileName: 'list.m3u', filePath }) + ); + }); + + it('decodes percent-encoding before matching the extension', () => { + const filePath = resolve('/tmp/My Playlist.m3u'); + const uri = pathToFileURL(filePath).href; + + expect(uri).toContain('%20'); + expect(createPlaylistOpenRequest(uri)).toEqual( + expect.objectContaining({ + fileName: 'My Playlist.m3u', + filePath, + }) + ); + }); + + it('rejects a file:// URI that cannot be parsed', () => { + expect(createPlaylistOpenRequest('file://[bad/list.m3u')).toBeNull(); + }); + + it('rejects a file:// URI that is not a playlist', () => { + expect(createPlaylistOpenRequest('file:///tmp/movie.mkv')).toBeNull(); + }); + + it('recognizes playlist extensions through the exported guard', () => { + expect(isPlaylistFilePath('a.m3u')).toBe(true); + expect(isPlaylistFilePath('a.m3u8')).toBe(true); + expect(isPlaylistFilePath('a.m3u.txt')).toBe(false); + }); +}); + +describe('extractPlaylistOpenRequestsFromArgv', () => { + it('finds the playlist path in a packaged launch', () => { + expect( + extractPlaylistOpenRequestsFromArgv([ + '/opt/iptvnator/iptvnator', + '/home/user/list.m3u', + ]) + ).toMatchObject([ + { + fileName: 'list.m3u', + filePath: '/home/user/list.m3u', + }, + ]); + }); + + it('skips the executable, switches and the development entry point', () => { + expect( + extractPlaylistOpenRequestsFromArgv([ + '/usr/bin/electron', + '--remote-debugging-port=9222', + '--ozone-platform=x11', + '/workspace/dist/apps/electron-backend/main.js', + '/home/user/list.m3u8', + ]) + ).toMatchObject([ + { + fileName: 'list.m3u8', + filePath: '/home/user/list.m3u8', + }, + ]); + }); + + // `%U` hands over the whole selection, one argument per file. + it('keeps every playlist of a multi-file selection, in order', () => { + const requests = extractPlaylistOpenRequestsFromArgv([ + '/opt/iptvnator/iptvnator', + '--ozone-platform=x11', + 'file:///home/user/first.m3u', + '/home/user/movie.mkv', + 'file:///home/user/second%20list.m3u8', + ]); + + expect(requests).toHaveLength(2); + expect(requests).toMatchObject([ + { fileName: 'first.m3u', filePath: '/home/user/first.m3u' }, + { + fileName: 'second list.m3u8', + filePath: '/home/user/second list.m3u8', + }, + ]); + }); + + it('never treats the executable itself as the playlist', () => { + expect(extractPlaylistOpenRequestsFromArgv(['/opt/weird.m3u'])).toEqual( + [] + ); + }); + + it('returns nothing when no playlist argument is present', () => { + expect( + extractPlaylistOpenRequestsFromArgv([ + '/opt/iptvnator/iptvnator', + '--no-sandbox', + ]) + ).toEqual([]); + }); + + it('resolves a relative argument against the forwarded working directory', () => { + expect( + extractPlaylistOpenRequestsFromArgv( + ['/opt/iptvnator/iptvnator', 'list.m3u'], + '/home/user/tv' + ) + ).toMatchObject([ + { + fileName: 'list.m3u', + filePath: resolve('/home/user/tv', 'list.m3u'), + }, + ]); + }); +}); + +describe('PlaylistOpenRequestQueue', () => { + const request = (name: string): PlaylistOpenRequest => ({ + fileName: `${name}.m3u`, + filePath: `/tmp/${name}.m3u`, + requestId: name, + }); + + it('holds requests until a delivery target is registered', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.enqueue(request('first')); + queue.enqueue(request('second')); + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + + expect(delivered).toEqual([request('first'), request('second')]); + }); + + it('pushes straight through once a delivery target exists', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + queue.enqueue(request('live')); + + expect(delivered).toEqual([request('live')]); + }); + + it('ignores empty requests', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.enqueue(null); + queue.enqueue(undefined); + + expect(queue.pendingRequests).toEqual([]); + }); + + it('only drops a request once the renderer acknowledged it', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.enqueue(request('pending')); + expect(queue.pendingRequests).toEqual([request('pending')]); + + queue.setDelivery(() => true); + // Delivered, but `send()` returning proves nothing yet. + expect(queue.pendingRequests).toEqual([]); + expect(queue.unacknowledgedRequests).toEqual([request('pending')]); + + queue.acknowledge('pending'); + expect(queue.unacknowledgedRequests).toEqual([]); + }); + + // A reload or a dead render process keeps the WebContents alive, so + // delivery cannot detect it — only the missing ack can. + it('replays an unacknowledged request to the next renderer', () => { + const queue = new PlaylistOpenRequestQueue(); + const secondRendererGot: PlaylistOpenRequest[] = []; + + queue.setDelivery(() => true); + queue.enqueue(request('reloaded-away')); + expect(queue.unacknowledgedRequests).toEqual([ + request('reloaded-away'), + ]); + + queue.setDelivery((entry) => { + secondRendererGot.push(entry); + return true; + }); + + expect(secondRendererGot).toEqual([request('reloaded-away')]); + }); + + it('replays unacknowledged requests ahead of newly queued ones', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery(() => true); + queue.enqueue(request('older')); + queue.setDelivery(null); + queue.enqueue(request('newer')); + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + + expect(delivered).toEqual([request('older'), request('newer')]); + }); + + it('ignores an acknowledgement for an unknown request', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.setDelivery(() => true); + queue.enqueue(request('known')); + + expect(() => queue.acknowledge('stale')).not.toThrow(); + expect(queue.unacknowledgedRequests).toEqual([request('known')]); + }); + + it('delivers a whole selection in arrival order', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + queue.enqueueAll([request('one'), null, request('two')]); + + expect(delivered).toEqual([request('one'), request('two')]); + }); + + it('keeps the untouched remainder of a selection queued', () => { + const queue = new PlaylistOpenRequestQueue(); + let accept = true; + + queue.enqueueAll([request('one'), request('two')]); + queue.setDelivery(() => { + const outcome = accept; + accept = false; + return outcome; + }); + + expect(queue.pendingRequests).toEqual([request('two')]); + }); + + it('keeps a request queued when delivery reports a dead target', () => { + const queue = new PlaylistOpenRequestQueue(); + + queue.setDelivery(() => false); + queue.enqueue(request('orphan')); + + expect(queue.pendingRequests).toEqual([request('orphan')]); + expect(queue.unacknowledgedRequests).toEqual([]); + }); + + it('re-arms for the next renderer after a failed delivery', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + + queue.setDelivery(() => false); + queue.enqueue(request('orphan')); + + queue.setDelivery((entry) => { + delivered.push(entry); + return true; + }); + + expect(delivered).toEqual([request('orphan')]); + }); + + it('stops delivering at the first failure and preserves order', () => { + const queue = new PlaylistOpenRequestQueue(); + const delivered: PlaylistOpenRequest[] = []; + let accept = true; + + queue.enqueue(request('one')); + queue.enqueue(request('two')); + queue.setDelivery((entry) => { + if (!accept) { + return false; + } + + delivered.push(entry); + accept = false; + return true; + }); + + expect(delivered).toEqual([request('one')]); + expect(queue.pendingRequests).toEqual([request('two')]); + expect(queue.unacknowledgedRequests).toEqual([request('one')]); + }); +}); diff --git a/apps/electron-backend/src/app/services/playlist-open-request.ts b/apps/electron-backend/src/app/services/playlist-open-request.ts new file mode 100644 index 000000000..039eb4998 --- /dev/null +++ b/apps/electron-backend/src/app/services/playlist-open-request.ts @@ -0,0 +1,225 @@ +/** + * Playlist files handed to the app by the operating system. + * + * Three entry points converge here: + * - a first launch with a path argument (`iptvnator playlist.m3u`, which is + * also what the Windows file association does; the Linux one passes a + * `file://` URI instead), + * - a second launch while the app is already running, whose argv the + * single-instance guard forwards, + * - macOS, which never puts the path in argv and emits `open-file` instead. + * + * All three can fire before the renderer exists, so requests are queued in the + * main process until a renderer announces itself. A request leaves the queue + * only once that renderer *acknowledges* it: `webContents.send()` returns + * before the listener runs, and a reload or a dead render process keeps the + * `WebContents` alive, so a successful push is not proof of receipt. Anything + * unacknowledged is replayed to the next renderer that announces itself. + */ + +import { basename, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface PlaylistOpenRequest { + readonly fileName: string; + readonly filePath: string; + /** Identifies the request so the renderer can acknowledge it. */ + readonly requestId: string; +} + +/** Returns `false` when the target is gone and the request must stay queued. */ +export type PlaylistOpenRequestDelivery = ( + request: PlaylistOpenRequest +) => boolean; + +const PLAYLIST_FILE_PATTERN = /\.(m3u|m3u8)$/i; + +let requestCounter = 0; + +function nextRequestId(): string { + requestCounter += 1; + return `playlist-open-${requestCounter}`; +} + +export function isPlaylistFilePath(candidate: string): boolean { + return PLAYLIST_FILE_PATTERN.test(candidate.trim()); +} + +/** + * Linux desktop entries end in `%U`, so file managers hand over a + * percent-encoded `file:///…` URI rather than a path — Electron Builder appends + * that exec code unconditionally unless `linux.executableArgs` already carries + * one, and putting one there would also pass it to the app as a real argument. + * Every other source (macOS `open-file`, the Windows association command, a + * shell argument) supplies a plain path, which is returned untouched. + */ +function toLocalPath(candidate: string): string | null { + if (!/^file:\/\//i.test(candidate)) { + return candidate; + } + + try { + // Rejects a URI naming another host, whose path is not ours to read. + return fileURLToPath(candidate); + } catch { + return null; + } +} + +/** + * Normalizes an OS-supplied path or `file://` URI into an open request, or + * returns `null` when it is not a playlist file. Relative paths are resolved + * against the working + * directory of the process that supplied them — for a forwarded second launch + * that is *not* this process' cwd. + */ +export function createPlaylistOpenRequest( + filePath: string, + workingDirectory?: string +): PlaylistOpenRequest | null { + const candidate = filePath?.trim(); + // Decoded before the extension check so a percent-encoded name still ends + // in a recognizable `.m3u`. + const localPath = candidate ? toLocalPath(candidate) : null; + + if (!localPath || !isPlaylistFilePath(localPath)) { + return null; + } + + const absolutePath = isAbsolute(localPath) + ? localPath + : resolve(workingDirectory?.trim() || process.cwd(), localPath); + + return { + fileName: basename(absolutePath), + filePath: absolutePath, + requestId: nextRequestId(), + }; +} + +/** + * Picks every playlist path out of a process argv, in the order given. + * + * `argv[0]` is the executable and is always skipped; so is every switch, since + * Electron and Chromium add plenty of them (`--remote-debugging-port=9222`, + * `--ozone-platform=x11`, …) and a switch value is never the user's file. + * In development the renderer entry (`main.js`) sits in argv too, but it is + * not a playlist file so the extension check filters it out. + * + * Selecting several playlists in a Linux file manager delivers them as one + * launch with one argument each, because the desktop entry ends in `%U` — the + * plural exec code — so stopping at the first match would silently drop the + * rest of the selection. + */ +export function extractPlaylistOpenRequestsFromArgv( + argv: readonly string[], + workingDirectory?: string +): PlaylistOpenRequest[] { + const requests: PlaylistOpenRequest[] = []; + + for (const argument of argv.slice(1)) { + if (typeof argument !== 'string' || argument.startsWith('-')) { + continue; + } + + const request = createPlaylistOpenRequest(argument, workingDirectory); + + if (request) { + requests.push(request); + } + } + + return requests; +} + +export class PlaylistOpenRequestQueue { + private delivery: PlaylistOpenRequestDelivery | null = null; + private flushing = false; + private pending: PlaylistOpenRequest[] = []; + /** Pushed, waiting for the renderer to confirm it got them. */ + private awaitingAck: PlaylistOpenRequest[] = []; + + enqueue(request: PlaylistOpenRequest | null | undefined): void { + if (!request) { + return; + } + + this.pending.push(request); + this.flush(); + } + + /** + * Queues a whole selection at once. One flush for the batch, so a delivery + * that fails partway leaves the untouched remainder in arrival order. + */ + enqueueAll(requests: readonly (PlaylistOpenRequest | null | undefined)[]): void { + for (const request of requests) { + if (request) { + this.pending.push(request); + } + } + + this.flush(); + } + + /** Queued requests, in arrival order. Exposed for assertions. */ + get pendingRequests(): readonly PlaylistOpenRequest[] { + return this.pending; + } + + /** Delivered but not yet acknowledged. Exposed for assertions. */ + get unacknowledgedRequests(): readonly PlaylistOpenRequest[] { + return this.awaitingAck; + } + + /** + * Drops a request for good. `webContents.send()` resolves nothing and + * returns before the renderer has run its listener, so a push that merely + * *left* the main process is not proof of receipt — only this is. + */ + acknowledge(requestId: string): void { + this.awaitingAck = this.awaitingAck.filter( + (request) => request.requestId !== requestId + ); + } + + /** + * Points the queue at a renderer. Anything an earlier renderer never + * acknowledged goes back to the front of the queue: a reload or a crashed + * render process leaves the `WebContents` alive, so delivery cannot detect + * it and the request would otherwise be gone. + */ + setDelivery(delivery: PlaylistOpenRequestDelivery | null): void { + this.pending = [...this.awaitingAck, ...this.pending]; + this.awaitingAck = []; + this.delivery = delivery; + this.flush(); + } + + private flush(): void { + if (this.flushing) { + return; + } + + this.flushing = true; + try { + while (this.delivery && this.pending.length > 0) { + const next = this.pending[0]; + + if (!this.delivery(next)) { + // The renderer is gone. Keep everything queued for the + // next one instead of pushing into a dead window. + this.delivery = null; + return; + } + + this.pending.shift(); + this.awaitingAck.push(next); + } + } finally { + this.flushing = false; + } + } +} + +export const playlistOpenRequests = new PlaylistOpenRequestQueue(); diff --git a/apps/electron-backend/src/app/services/single-instance.spec.ts b/apps/electron-backend/src/app/services/single-instance.spec.ts index 6baec2e37..0714a32f2 100644 --- a/apps/electron-backend/src/app/services/single-instance.spec.ts +++ b/apps/electron-backend/src/app/services/single-instance.spec.ts @@ -7,6 +7,17 @@ import { type SingleInstanceWindow, } from './single-instance'; +/** + * Electron's real `second-instance` listener signature. The guard has to keep + * working when Electron hands over an empty command line, so the argv and + * working directory are modelled as optional here. + */ +type SecondInstanceHandler = ( + event: unknown, + argv: string[] | undefined, + workingDirectory: string | undefined +) => void; + function createApp(hasLock: boolean): jest.Mocked { return { quit: jest.fn(), @@ -60,7 +71,7 @@ describe('acquireSingleInstanceLock', () => { const app = createApp(true); expect( - acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + acquireSingleInstanceLock(app, () => null, jest.fn(), { env: {} }) ).toBe(true); expect(app.quit).not.toHaveBeenCalled(); expect(app.on).toHaveBeenCalledWith( @@ -73,7 +84,7 @@ describe('acquireSingleInstanceLock', () => { const app = createApp(false); expect( - acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + acquireSingleInstanceLock(app, () => null, jest.fn(), { env: {} }) ).toBe(false); expect(app.quit).toHaveBeenCalledTimes(1); expect(app.on).not.toHaveBeenCalled(); @@ -87,7 +98,9 @@ describe('acquireSingleInstanceLock', () => { }); const createMainWindow = jest.fn(); - acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + acquireSingleInstanceLock(app, () => window, createMainWindow, { + env: {}, + }); const [, handler] = app.on.mock.calls[0]; (handler as () => void)(); @@ -112,7 +125,9 @@ describe('acquireSingleInstanceLock', () => { : null; const createMainWindow = jest.fn(); - acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + acquireSingleInstanceLock(app, () => window, createMainWindow, { + env: {}, + }); const [, handler] = app.on.mock.calls[0]; (handler as () => void)(); @@ -127,12 +142,76 @@ describe('acquireSingleInstanceLock', () => { expect( acquireSingleInstanceLock(app, () => null, jest.fn(), { - [ALLOW_MULTIPLE_INSTANCES_ENV]: '1', + env: { [ALLOW_MULTIPLE_INSTANCES_ENV]: '1' }, }) ).toBe(true); expect(app.requestSingleInstanceLock).not.toHaveBeenCalled(); expect(app.quit).not.toHaveBeenCalled(); }); + + it('forwards a second launch command line before focusing the window', () => { + const app = createApp(true); + const window = createWindow(); + const onSecondInstance = jest.fn(); + + acquireSingleInstanceLock(app, () => window, jest.fn(), { + env: {}, + onSecondInstance, + }); + const [, handler] = app.on.mock.calls[0]; + (handler as SecondInstanceHandler)( + {}, + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u'], + '/home/user' + ); + + expect(onSecondInstance).toHaveBeenCalledWith( + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u'], + '/home/user' + ); + expect(onSecondInstance.mock.invocationCallOrder[0]).toBeLessThan( + window.focus.mock.invocationCallOrder[0] + ); + }); + + it('forwards a second launch that carries no arguments', () => { + const app = createApp(true); + const window = createWindow(); + const onSecondInstance = jest.fn(); + + acquireSingleInstanceLock(app, () => window, jest.fn(), { + env: {}, + onSecondInstance, + }); + const [, handler] = app.on.mock.calls[0]; + (handler as SecondInstanceHandler)({}, undefined, undefined); + + expect(onSecondInstance).toHaveBeenCalledWith([], ''); + expect(window.focus).toHaveBeenCalledTimes(1); + }); + + it('still forwards when the lock owner has to rebuild its window', () => { + const app = createApp(true); + const createMainWindow = jest.fn(); + const onSecondInstance = jest.fn(); + + acquireSingleInstanceLock(app, () => null, createMainWindow, { + env: {}, + onSecondInstance, + }); + const [, handler] = app.on.mock.calls[0]; + (handler as SecondInstanceHandler)( + {}, + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u8'], + '/home/user' + ); + + expect(onSecondInstance).toHaveBeenCalledWith( + ['/opt/iptvnator/iptvnator', '/home/user/list.m3u8'], + '/home/user' + ); + expect(createMainWindow).toHaveBeenCalledTimes(1); + }); }); describe('focusExistingWindow', () => { diff --git a/apps/electron-backend/src/app/services/single-instance.ts b/apps/electron-backend/src/app/services/single-instance.ts index 8186ed2ce..e6f2b762b 100644 --- a/apps/electron-backend/src/app/services/single-instance.ts +++ b/apps/electron-backend/src/app/services/single-instance.ts @@ -17,7 +17,14 @@ /** Minimal Electron `App` surface used by the guard, kept narrow for tests. */ export interface SingleInstanceApp { quit(): void; - on(event: 'second-instance', listener: () => void): unknown; + on( + event: 'second-instance', + listener: ( + event: unknown, + argv: string[], + workingDirectory: string + ) => void + ): unknown; requestSingleInstanceLock(): boolean; } @@ -74,6 +81,18 @@ function needsNewWindow( return !window || window.isDestroyed(); } +export interface SingleInstanceOptions { + env?: NodeJS.ProcessEnv; + /** + * Receives the second launch's command line before its window is focused. + * Electron discards that argv otherwise, and it is the only place a + * "open this playlist in the running app" request can come from on + * Windows/Linux. `workingDirectory` belongs to the *other* process, so a + * relative path must be resolved against it rather than against `cwd()`. + */ + onSecondInstance?: (argv: string[], workingDirectory: string) => void; +} + /** * Acquires the single instance lock. * @@ -88,9 +107,9 @@ export function acquireSingleInstanceLock( app: SingleInstanceApp, getMainWindow: () => SingleInstanceWindow | null | undefined, createMainWindow: () => void, - env: NodeJS.ProcessEnv = process.env + options: SingleInstanceOptions = {} ): boolean { - if (allowsMultipleInstances(env)) { + if (allowsMultipleInstances(options.env ?? process.env)) { return true; } @@ -99,7 +118,9 @@ export function acquireSingleInstanceLock( return false; } - app.on('second-instance', () => { + app.on('second-instance', (_event, argv, workingDirectory) => { + options.onSecondInstance?.(argv ?? [], workingDirectory ?? ''); + const mainWindow = getMainWindow(); if (needsNewWindow(mainWindow)) { diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts index 263f43f64..ab21a14cd 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts @@ -6,9 +6,7 @@ import { releaseDatabaseWorkerPerformanceCapture, startWorkerPerformanceCapture, type WorkerPerformanceCapture, - type WorkerPerformanceCaptureRuntime, } from './worker-performance-capture'; -import { DEFAULT_WORKER_PERFORMANCE_RUNTIME } from './worker-performance-capture.runtime'; import { createFakeRuntime } from './worker-performance-capture.test-harness'; const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; @@ -16,33 +14,6 @@ const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; const BLOCK_DURATION_MS = 20; const REAL_TIMER_TEST_TIMEOUT_MS = 30_000; -/** - * `monitorEventLoopDelay()` records nothing on its first internal timer tick — - * that tick only seeds the previous timestamp, so the first delay sample lands - * on the second tick. Condition-based arming therefore needs two event-loop - * turns, and it budgets for them with a 50ms deadline read from - * `readMonotonicMs()`. A machine running the full Jest suite in parallel can - * stretch a single turn past 20ms, so that deadline expires against the - * scheduler rather than against any defect in the capture code. - * - * Slowing only that deadline clock leaves the wait bounded by its other limit, - * the 50-poll ceiling, which is ~25x the two turns arming actually needs. - * Everything else stays production code: the real `monitorEventLoopDelay()` - * histogram, real `setTimeout()` polling, and real epoch/CPU/ELU boundaries. - * The scaled clock reaches nothing but the wait budgets — its only other - * consumer records phase events, and this spec records none. - */ -const WAIT_DEADLINE_CLOCK_SCALE = 50; - -function createRuntimeWithScaledWaitDeadline(): WorkerPerformanceCaptureRuntime { - return { - ...DEFAULT_WORKER_PERFORMANCE_RUNTIME, - readMonotonicMs: () => - DEFAULT_WORKER_PERFORMANCE_RUNTIME.readMonotonicMs() / - WAIT_DEADLINE_CLOCK_SCALE, - }; -} - describe('worker performance capture concurrency and real timers', () => { const originalProfilingValue = process.env[PROFILING_ENV]; @@ -108,9 +79,7 @@ describe('worker performance capture concurrency and real timers', () => { process.env[PROFILING_ENV] = '1'; // No `enabled` override: the env variable is the opt-in under test. - const capture = startWorkerPerformanceCapture({ - runtime: createRuntimeWithScaledWaitDeadline(), - }); + const capture = startWorkerPerformanceCapture(); expect(capture).not.toBeNull(); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts index a4951d6cc..ffad6be72 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts @@ -37,7 +37,10 @@ export function markEventLoopDelayUnavailable( export async function waitForHistogramCondition( capture: WorkerPerformanceCapture, - condition: (histogram: WorkerEventLoopDelayHistogram) => boolean + condition: (histogram: WorkerEventLoopDelayHistogram) => boolean, + options: { + readonly minimumPollsBeforeElapsedDeadline?: number; + } = {} ): Promise { const histogram = capture.eventLoopDelay; if ( @@ -58,6 +61,8 @@ export async function waitForHistogramCondition( const maximumPolls = Math.ceil( HISTOGRAM_WAIT_CAP_MS / HISTOGRAM_POLL_INTERVAL_MS ); + const minimumPollsBeforeElapsedDeadline = + options.minimumPollsBeforeElapsedDeadline ?? 0; let pollCount = 0; while (true) { @@ -80,16 +85,20 @@ export async function waitForHistogramCondition( } if ( !Number.isFinite(elapsedMs) || - elapsedMs >= HISTOGRAM_WAIT_CAP_MS || - pollCount >= maximumPolls + pollCount >= maximumPolls || + (pollCount >= minimumPollsBeforeElapsedDeadline && + elapsedMs >= HISTOGRAM_WAIT_CAP_MS) ) { return false; } - const delayMs = Math.min( - HISTOGRAM_POLL_INTERVAL_MS, - HISTOGRAM_WAIT_CAP_MS - Math.max(0, elapsedMs) - ); + const delayMs = + elapsedMs >= HISTOGRAM_WAIT_CAP_MS + ? HISTOGRAM_POLL_INTERVAL_MS + : Math.min( + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS - Math.max(0, elapsedMs) + ); try { pollCount += 1; await new Promise((resolvePromise) => { diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts index c56a2fb36..806200729 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts @@ -56,6 +56,63 @@ describe('worker performance capture resilience', () => { }); }); + it('allows the second required histogram turn after a delayed first turn', async () => { + const harness = createFakeRuntime({ + armHistogramAfterTimeoutCount: 2, + timeoutElapsedMs: [86.202, 1, 1], + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(harness.scheduledTimeouts.slice(0, 2)).toEqual([1, 1]); + expect(execution.performance).toMatchObject({ + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: 145, + invalidReason: null, + }); + }); + + it('times out after the second required turn when delayed arming never samples', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + timeoutElapsedMs: [86.202, 1], + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(harness.scheduledTimeouts).toEqual([1, 1]); + expect(execution).toMatchObject({ + result: 'result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + }, + }); + }); + it('cannot poll forever when the monotonic runtime clock stalls', async () => { const harness = createFakeRuntime({ armHistogram: false, diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts index 54517e637..aa5fb6f6d 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts @@ -4,11 +4,13 @@ import type { } from './worker-performance-capture'; interface FakeRuntimeOptions { + armHistogramAfterTimeoutCount?: number; armHistogram?: boolean; flushHistogram?: boolean; histogramDisableResult?: boolean; stallMonotonicClock?: boolean; threadCpuAvailable?: boolean; + timeoutElapsedMs?: readonly number[]; throwBoundaryCallbacks?: boolean; throwHistogramDisable?: boolean; throwHistogramCountAtRead?: number; @@ -130,15 +132,21 @@ export function createFakeRuntime( throw new Error('timer callback unavailable'); } if (options.stallMonotonicClock !== true) { - monotonicMs += delayMs; + monotonicMs += + options.timeoutElapsedMs?.[timeoutCount] ?? delayMs; } else if (timeoutCount >= 55) { throw new Error('test scheduler fail-safe'); } timeoutCount += 1; - if (timeoutCount === 1 && options.armHistogram !== false) { + const armAfterTimeoutCount = + options.armHistogramAfterTimeoutCount ?? 1; + if ( + timeoutCount === armAfterTimeoutCount && + options.armHistogram !== false + ) { histogramCount += 1; } else if ( - timeoutCount > 1 && + timeoutCount > armAfterTimeoutCount && options.armHistogram !== false && options.flushHistogram !== false ) { diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts index 3fa1a8baa..2f5cdb15a 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -103,7 +103,11 @@ export async function armWorkerPerformanceCapture( } const armed = await waitForHistogramCondition( capture, - (histogram) => histogram.count > 0 + (histogram) => histogram.count > 0, + { + // The histogram needs two turns before its first sample. + minimumPollsBeforeElapsedDeadline: 2, + } ); if (!armed && capture.invalidReason === null) { markEventLoopDelayUnavailable( diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 998f7162b..1dfc50e9e 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -19,6 +19,7 @@ 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'; @@ -39,6 +40,11 @@ import { import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic'; import { acquireSingleInstanceLock } from './app/services/single-instance'; +import { + createPlaylistOpenRequest, + extractPlaylistOpenRequestsFromArgv, + playlistOpenRequests, +} from './app/services/playlist-open-request'; import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -141,6 +147,7 @@ export default class Main { WindowEvents.bootstrapWindowEvents(); EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); PlaylistEvents.bootstrapPlaylistEvents(); + PlaylistOpenEvents.bootstrapPlaylistOpenEvents(); SharedEvents.bootstrapSharedEvents(); PlayerEvents.bootstrapPlayerEvents(); SettingsEvents.bootstrapSettingsEvents(); @@ -194,6 +201,24 @@ export default class Main { } runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { + // macOS never puts the opened file in argv — Launch Services delivers it + // through `open-file`, which can fire before `whenReady`. Registering the + // listener here (and calling preventDefault, or Electron logs a warning + // and treats the event as unhandled) is the only way to catch a launch by + // double-clicking a playlist in Finder. + app.on('open-file', (event, filePath) => { + event.preventDefault(); + playlistOpenRequests.enqueue(createPlaylistOpenRequest(filePath)); + }); + + // Windows/Linux file associations and plain `iptvnator playlist.m3u` + // launches arrive as arguments instead — one per file, since selecting + // several playlists at once is a single launch. The renderer drains the + // queue once it is ready. + playlistOpenRequests.enqueueAll( + extractPlaylistOpenRequestsFromArgv(process.argv) + ); + // handle setup events as quickly as possible Main.initialize(); @@ -205,7 +230,20 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { !acquireSingleInstanceLock( app, () => App.mainWindow, - () => App.ensureMainWindow() + () => App.ensureMainWindow(), + { + // A second launch is also how the OS says "open this playlist + // in the app you already have running". Its argv is the only + // carrier for that, and it is relative to *its* cwd. + onSecondInstance: (argv, workingDirectory) => { + playlistOpenRequests.enqueueAll( + extractPlaylistOpenRequestsFromArgv( + argv, + workingDirectory + ) + ); + }, + } ) ) { return; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 11a918152..cea7f4f70 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -21,13 +21,13 @@ import { import { AUTO_UPDATE_PLAYLISTS, Language, - OPEN_FILE, Settings, STORE_KEY, Theme, createDevLogger, } from '@iptvnator/shared/interfaces'; import { SettingsService } from './services/settings.service'; +import { PlaylistOpenRequestService } from './services/playlist-open-request.service'; import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component'; const debugAppComponent = createDevLogger('AppComponent'); @@ -59,6 +59,7 @@ export class AppComponent implements OnInit { private translate = inject(TranslateService); private settingsService = inject(SettingsService); private settingsStore = inject(SettingsStore); + private playlistOpenRequests = inject(PlaylistOpenRequestService); private runtime = inject(RuntimeCapabilitiesService); private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS); @@ -72,27 +73,12 @@ export class AppComponent implements OnInit { document.body.classList.add('frameless-platform'); } - const electronProcess = this.dataService.remote?.process; - if ( - this.dataService.isElectron && - electronProcess && - (electronProcess.platform === 'linux' || - electronProcess.platform === 'win32') && - electronProcess.argv.length > 2 - ) { - const filePath = electronProcess.argv.find( - (filepath: string) => - filepath.endsWith('.m3u') || filepath.endsWith('.m3u8') - ); - if (filePath) { - const filePathsArray = filePath.split('/'); - const fileName = filePathsArray[filePathsArray.length - 1]; - this.dataService.sendIpcEvent(OPEN_FILE, { - filePath, - fileName, - }); - } - } + // Playlist files the OS asked us to open (command line argument, file + // association, macOS `open-file`) are resolved in the main process and + // queued there until the renderer subscribes. Start listening as early + // as possible so a first-launch file is not delayed behind app init. + this.playlistOpenRequests.start(); + effect(() => { const size = this.settingsStore.coverSize?.() ?? 'medium'; document.documentElement.dataset.coverSize = size; diff --git a/apps/web/src/app/services/playlist-open-request.service.spec.ts b/apps/web/src/app/services/playlist-open-request.service.spec.ts new file mode 100644 index 000000000..5c37e74f3 --- /dev/null +++ b/apps/web/src/app/services/playlist-open-request.service.spec.ts @@ -0,0 +1,218 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util'; +import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces'; +import { PlaylistOpenRequestService } from './playlist-open-request.service'; + +type BridgeMock = { + acknowledgePlaylistOpenRequest: jest.Mock; + announcePlaylistOpenListener: jest.Mock; + onPlaylistOpenRequest: jest.Mock; +}; + +describe('PlaylistOpenRequestService', () => { + let service: PlaylistOpenRequestService; + let importService: { importFromPath: jest.Mock }; + let snackBar: { open: jest.Mock }; + let bridge: BridgeMock; + let pushRequest: (request: ElectronBridgePlaylistOpenRequest) => void; + let unsubscribe: jest.Mock; + let originalElectron: typeof window.electron | undefined; + + const request = (name: string): ElectronBridgePlaylistOpenRequest => ({ + fileName: `${name}.m3u`, + filePath: `/tmp/${name}.m3u`, + requestId: name, + }); + + /** Lets every queued promise callback settle. */ + const settle = () => + new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); + + beforeEach(() => { + originalElectron = window.electron; + pushRequest = () => undefined; + unsubscribe = jest.fn(); + importService = { + importFromPath: jest.fn().mockResolvedValue({ + ok: true, + title: 'Playlist', + }), + }; + snackBar = { open: jest.fn() }; + bridge = { + acknowledgePlaylistOpenRequest: jest + .fn() + .mockResolvedValue(undefined), + announcePlaylistOpenListener: jest + .fn() + .mockResolvedValue(undefined), + onPlaylistOpenRequest: jest.fn((callback) => { + pushRequest = callback; + return unsubscribe; + }), + }; + window.electron = bridge as unknown as typeof window.electron; + + TestBed.configureTestingModule({ + providers: [ + PlaylistOpenRequestService, + { provide: PlaylistFileImportService, useValue: importService }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + + service = TestBed.inject(PlaylistOpenRequestService); + }); + + afterEach(() => { + window.electron = originalElectron as typeof window.electron; + jest.restoreAllMocks(); + }); + + // The announcement makes the main process flush its queue at once, so a + // listener attached afterwards would miss every startup file. + it('subscribes before announcing itself', () => { + service.start(); + + expect( + bridge.onPlaylistOpenRequest.mock.invocationCallOrder[0] + ).toBeLessThan( + bridge.announcePlaylistOpenListener.mock.invocationCallOrder[0] + ); + }); + + it('imports a playlist the main process pushes', async () => { + service.start(); + pushRequest(request('startup')); + await settle(); + + expect(importService.importFromPath).toHaveBeenCalledWith( + '/tmp/startup.m3u', + 'startup.m3u' + ); + }); + + // `send()` returns before the listener runs, so the main process holds the + // request until this confirms a live renderer got it. + it('acknowledges a request as soon as it arrives', async () => { + importService.importFromPath.mockReturnValue(new Promise(() => undefined)); + + service.start(); + pushRequest(request('startup')); + + expect(bridge.acknowledgePlaylistOpenRequest).toHaveBeenCalledWith( + 'startup' + ); + }); + + it('keeps importing when acknowledging fails', async () => { + bridge.acknowledgePlaylistOpenRequest.mockRejectedValue( + new Error('gone') + ); + + service.start(); + pushRequest(request('startup')); + await settle(); + + expect(importService.importFromPath).toHaveBeenCalledWith( + '/tmp/startup.m3u', + 'startup.m3u' + ); + }); + + it('reports a failed import to the user', async () => { + importService.importFromPath.mockResolvedValue({ + ok: false, + reason: 'read-error', + }); + + service.start(); + pushRequest(request('broken')); + await settle(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.FILE_UPLOAD.OPEN_FAILED', + 'CLOSE', + expect.objectContaining({ panelClass: ['error-snackbar'] }) + ); + }); + + // The main-process queue flushes back to back, so a burst of pushes must + // not fan out into parallel file reads landing in arbitrary order — the + // last playlist imported is the one the app navigates to. + it('never runs two imports at the same time', async () => { + let inFlight = 0; + let maxInFlight = 0; + const resolvers: (() => void)[] = []; + + importService.importFromPath.mockImplementation(() => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + + return new Promise((resolvePromise) => { + resolvers.push(() => { + inFlight -= 1; + resolvePromise({ ok: true, title: 'Playlist' }); + }); + }); + }); + + service.start(); + pushRequest(request('one')); + pushRequest(request('two')); + pushRequest(request('three')); + + for (let step = 0; step < 12; step += 1) { + resolvers.shift()?.(); + await settle(); + } + + expect(maxInFlight).toBe(1); + expect( + importService.importFromPath.mock.calls.map(([path]) => path) + ).toEqual(['/tmp/one.m3u', '/tmp/two.m3u', '/tmp/three.m3u']); + }); + + it('keeps importing after one file fails', async () => { + importService.importFromPath + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ ok: true, title: 'Playlist' }); + + service.start(); + pushRequest(request('broken')); + pushRequest(request('healthy')); + await settle(); + + expect(importService.importFromPath).toHaveBeenCalledWith( + '/tmp/healthy.m3u', + 'healthy.m3u' + ); + }); + + it('starts only once', () => { + service.start(); + service.start(); + + expect(bridge.announcePlaylistOpenListener).toHaveBeenCalledTimes(1); + }); + + it('does nothing without the Electron bridge', () => { + window.electron = undefined as unknown as typeof window.electron; + + expect(() => service.start()).not.toThrow(); + expect(importService.importFromPath).not.toHaveBeenCalled(); + }); + + it('releases the listener on stop', () => { + service.start(); + service.stop(); + + expect(unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/services/playlist-open-request.service.ts b/apps/web/src/app/services/playlist-open-request.service.ts new file mode 100644 index 000000000..06af1781b --- /dev/null +++ b/apps/web/src/app/services/playlist-open-request.service.ts @@ -0,0 +1,122 @@ +import { Injectable, inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util'; +import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces'; + +type PlaylistOpenRequestBridge = { + acknowledgePlaylistOpenRequest?: (requestId: string) => Promise; + announcePlaylistOpenListener?: () => Promise; + onPlaylistOpenRequest?: ( + callback: (request: ElectronBridgePlaylistOpenRequest) => void + ) => () => void; +}; + +/** + * Imports playlist files the operating system handed to the app: a command + * line argument, a file association double-click, or macOS' `open-file`. + * + * The main process queues those requests until this service announces itself, + * so the listener has to be attached *before* the announcement — otherwise the + * push that follows it has nowhere to land. Electron only; in the PWA the + * bridge is absent and `start()` is a no-op. + */ +@Injectable({ providedIn: 'root' }) +export class PlaylistOpenRequestService { + private readonly importService = inject(PlaylistFileImportService); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + + private started = false; + private unsubscribe: (() => void) | undefined; + + /** + * Imports run strictly one after another, in arrival order. The main + * process flushes its queue back to back, so without this a burst — two + * playlists picked in Finder at once — would fan out into parallel + * file reads and land in a nondeterministic order, with an arbitrary one + * of them ending up the active playlist. `addPlaylist$` serialises the + * persistence side separately. + */ + private importChain: Promise = Promise.resolve(); + + start(): void { + if (this.started) { + return; + } + + const bridge = this.getBridge(); + if (!bridge?.announcePlaylistOpenListener) { + return; + } + + this.started = true; + + // Subscribe first: announcing makes the main process flush its queue + // immediately, and every request — queued or live — arrives this way. + this.unsubscribe = bridge.onPlaylistOpenRequest?.((request) => { + // Acknowledge on receipt rather than after the import: the main + // process only needs to know the request reached a live renderer, + // and holding the ack back until a slow import finished would + // replay it if the window were closed meanwhile. + void bridge + .acknowledgePlaylistOpenRequest?.(request?.requestId) + .catch(() => undefined); + this.chain(() => this.open(request)); + }); + + void bridge.announcePlaylistOpenListener().catch((error) => { + console.error( + 'Failed to announce the playlist open listener:', + error + ); + }); + } + + /** Appends a step to the import chain, never breaking it on failure. */ + private chain(step: () => Promise): void { + this.importChain = this.importChain.then(step).catch((error) => { + console.error('Failed to open a playlist file:', error); + }); + } + + stop(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.started = false; + } + + private async open( + request: ElectronBridgePlaylistOpenRequest + ): Promise { + if (!request?.filePath) { + return; + } + + const result = await this.importService.importFromPath( + request.filePath, + request.fileName + ); + + if (result.ok) { + return; + } + + this.snackBar.open( + this.translate.instant('HOME.FILE_UPLOAD.OPEN_FAILED', { + filename: request.fileName || request.filePath, + }), + this.translate.instant('CLOSE'), + { duration: 7000, panelClass: ['error-snackbar'] } + ); + } + + private getBridge(): PlaylistOpenRequestBridge | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as Window & { electron?: PlaylistOpenRequestBridge }) + .electron; + } +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 654096acb..ff289e07b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -151,7 +151,8 @@ "OR": "أو", "CHOOSE_PLAYLIST": "اختر قائمة تشغيل", "BROWSE": "تصفح الملفات", - "REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}." + "REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}.", + "OPEN_FAILED": "تعذر فتح \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "رابط قائمة التشغيل (M3U/M3U8)", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0084723e9..977afedec 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -151,7 +151,8 @@ "OR": "أو", "CHOOSE_PLAYLIST": "اختار قائمة تشغيل", "BROWSE": "تصفح الملفات", - "REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}." + "REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}.", + "OPEN_FAILED": "ما تقدرش تحل \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "رابط قائمة التشغيل (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 45b8749a8..67a7f21fc 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -151,7 +151,8 @@ "OR": "або", "CHOOSE_PLAYLIST": "выберыце плэйліст", "BROWSE": "Агляд файлаў", - "REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}." + "REJECTED": "На жаль, гэты фармат файлаў не падтрымліваецца {{filename}}.", + "OPEN_FAILED": "Не атрымалася адкрыць \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Спасылка на плэйліст (*.m3u або *.m3u8)", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index cba71cb40..abe7f8414 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -151,7 +151,8 @@ "OR": "oder", "CHOOSE_PLAYLIST": "Playlist auswählen", "BROWSE": "Dateien durchsuchen", - "REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}." + "REJECTED": "Datei wurde abgelehnt, nicht unterstütztes Dateiformat {{filename}}.", + "OPEN_FAILED": "\"{{filename}}\" konnte nicht geöffnet werden." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL der Playlist", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 92648d177..a7622308d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -151,7 +151,8 @@ "OR": "or", "CHOOSE_PLAYLIST": "επιλέξτε μια λίστα αναπαραγωγής", "BROWSE": "Περιήγηση αρχείων", - "REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}." + "REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}.", + "OPEN_FAILED": "Δεν ήταν δυνατό το άνοιγμα του \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Διεύθηνση URL λίστας αναπαραγωγής (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 727ecd09d..42fc881fd 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -151,7 +151,8 @@ "OR": "or", "CHOOSE_PLAYLIST": "click to browse", "BROWSE": "Browse files", - "REJECTED": "File was rejected, unsupported file format {{filename}}." + "REJECTED": "File was rejected, unsupported file format {{filename}}.", + "OPEN_FAILED": "Could not open \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Playlist URL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 9df3d17d3..88e13f1b5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -151,7 +151,8 @@ "OR": "o", "CHOOSE_PLAYLIST": "Escoga una lista de reproducción", "BROWSE": "Explorar archivos", - "REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}." + "REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}.", + "OPEN_FAILED": "No se pudo abrir \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Lista de reproducción (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 85a2652c9..0579ed2c4 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -151,7 +151,8 @@ "OR": "ou", "CHOOSE_PLAYLIST": "Choisir une liste de lecture", "BROWSE": "Parcourir les fichiers", - "REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}." + "REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}.", + "OPEN_FAILED": "Impossible d’ouvrir \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL de la liste de lecture (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 3beff9c9e..ea101b39c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -151,7 +151,8 @@ "OR": "vagy", "CHOOSE_PLAYLIST": "kattintson a tallózáshoz", "BROWSE": "Fájlok tallózása", - "REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum." + "REJECTED": "Elutasított fájl: {{filename}} – nem támogatott fájlformátum.", + "OPEN_FAILED": "A(z) \"{{filename}}\" nem nyitható meg." }, "URL_UPLOAD": { "PLAYLIST_URL": "Lejátszási lista URL-címe (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 9e110d47a..721069d00 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -151,7 +151,8 @@ "OR": "o", "CHOOSE_PLAYLIST": "scegli una playlist", "BROWSE": "Sfoglia file", - "REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}." + "REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}.", + "OPEN_FAILED": "Impossibile aprire \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL playlist (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 6051bc47b..8612ef627 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -151,7 +151,8 @@ "OR": "または", "CHOOSE_PLAYLIST": "プレイリストを選択", "BROWSE": "ファイルを参照", - "REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}" + "REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}", + "OPEN_FAILED": "\"{{filename}}\" を開けませんでした。" }, "URL_UPLOAD": { "PLAYLIST_URL": "プレイリストURL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 2b85d61d1..d8f86129d 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -151,7 +151,8 @@ "OR": "또는", "CHOOSE_PLAYLIST": "재생 목록을 선택", "BROWSE": "파일 찾아보기", - "REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}." + "REJECTED": "파일이 거부되었습니다. 지원되지 않는 파일 형식 {{filename}}.", + "OPEN_FAILED": "\"{{filename}}\" 파일을 열 수 없습니다." }, "URL_UPLOAD": { "PLAYLIST_URL": "재생 목록 URL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index fad64c58e..f6996b795 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -151,7 +151,8 @@ "OR": "of", "CHOOSE_PLAYLIST": "kies een afspeellijst", "BROWSE": "Bestanden bladeren", - "REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}." + "REJECTED": "Bestand geweigerd, niet-ondersteund bestandsformaat: {{filename}}.", + "OPEN_FAILED": "Kan \"{{filename}}\" niet openen." }, "URL_UPLOAD": { "PLAYLIST_URL": "Afspeellijst-URL (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index cb7be5465..eb5b4791b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -151,7 +151,8 @@ "OR": "lub", "CHOOSE_PLAYLIST": "wybierz listę odtwarzania", "BROWSE": "Przeglądaj pliki", - "REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}." + "REJECTED": "Plik został odrzucony, nieobsługiwany format pliku {{filename}}.", + "OPEN_FAILED": "Nie udało się otworzyć \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL listy odtwarzania (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index cfb5b07fb..f6aff24ab 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -151,7 +151,8 @@ "OR": "ou", "CHOOSE_PLAYLIST": "escolha uma playlist", "BROWSE": "Procurar arquivos", - "REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}." + "REJECTED": "O arquivo foi rejeitado, formato de arquivo não suportado {{filename}}.", + "OPEN_FAILED": "Não foi possível abrir \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "URL da playlist (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1d8915fe8..430a57b70 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -151,7 +151,8 @@ "OR": "или", "CHOOSE_PLAYLIST": "выберите плейлист", "BROWSE": "Выбрать файлы", - "REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}." + "REJECTED": "К сожалению, данный формат файлов не поддерживается {{filename}}.", + "OPEN_FAILED": "Не удалось открыть \"{{filename}}\"." }, "URL_UPLOAD": { "PLAYLIST_URL": "Ссылка на плейлист (*.m3u или *.m3u8)", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index d7ffb59e8..5baba7d7e 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -151,7 +151,8 @@ "OR": "veya", "CHOOSE_PLAYLIST": "oynatma listesi seçin", "BROWSE": "Dosyalara göz at", - "REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}." + "REJECTED": "Dosya reddedildi, desteklenmeyen dosya formatı {{filename}}.", + "OPEN_FAILED": "\"{{filename}}\" açılamadı." }, "URL_UPLOAD": { "PLAYLIST_URL": "Oynatma listesi URL'si (m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index b86a28784..4d3162d0c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -151,7 +151,8 @@ "OR": "或", "CHOOSE_PLAYLIST": "选择一个播放列表", "BROWSE": "浏览文件", - "REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。" + "REJECTED": "文件已被拒绝,不支持的文件格式 {{filename}}。", + "OPEN_FAILED": "无法打开 \"{{filename}}\"。" }, "URL_UPLOAD": { "PLAYLIST_URL": "播放一个播放列表(m3u, m3u8)", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2665f5508..f7f479b1e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -151,7 +151,8 @@ "OR": "或者", "CHOOSE_PLAYLIST": "選擇播放清單", "BROWSE": "瀏覽檔案", - "REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。" + "REJECTED": "檔案被拒絕,不支援的檔案格式 {{filename}}。", + "OPEN_FAILED": "無法開啟 \"{{filename}}\"。" }, "URL_UPLOAD": { "PLAYLIST_URL": "播放清單網址(m3u,m3u8)", diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index f8006d8e6..1ed1dc64f 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -137,11 +137,13 @@ Each enabled request gets a fresh event-loop-delay histogram and records: Histogram arming waits until the histogram has a sample; flushing waits for its sample count to advance after work ends. Both waits use condition-based timer -polling. Each wait stops after 50 ms of observed monotonic time or its bounded -poll count; arming and flushing have separate caps, and timer scheduling may -overshoot wall-clock time. A timeout or profiling API failure never replaces -the business response: timestamps and any independently available CPU/ELU -metrics remain valid, while event-loop delay is `null` with a fixed reason. +polling. Arming permits the two poll turns that `monitorEventLoopDelay()` needs +before its first sample, then applies the 50 ms elapsed deadline; it always +stops by the 50-poll ceiling. Flushing has no poll floor and stops after 50 ms +or 50 polls. The waits have separate caps, and timer scheduling may overshoot +wall-clock time. A timeout or profiling API failure never replaces the business +response: timestamps and any independently available CPU/ELU metrics remain +valid, while event-loop delay is `null` with a fixed reason. The long-lived database worker still executes concurrent requests without a profiling queue. If captures overlap, every overlapping response carries diff --git a/electron-builder.json b/electron-builder.json index fca1b100e..55a1728b3 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -2,6 +2,22 @@ "appId": "com.fourgray.iptvnator", "productName": "IPTVnator", "artifactName": "${name}-${version}-${os}-${arch}.${ext}", + "fileAssociations": [ + { + "ext": "m3u", + "name": "M3U Playlist", + "description": "IPTV playlist", + "mimeType": "audio/x-mpegurl", + "role": "Viewer" + }, + { + "ext": "m3u8", + "name": "M3U8 Playlist", + "description": "IPTV playlist", + "mimeType": "application/vnd.apple.mpegurl", + "role": "Viewer" + } + ], "publish": [ { "provider": "github", diff --git a/libs/m3u-state/src/lib/add-playlist.effect.spec.ts b/libs/m3u-state/src/lib/add-playlist.effect.spec.ts new file mode 100644 index 000000000..774c01a1b --- /dev/null +++ b/libs/m3u-state/src/lib/add-playlist.effect.spec.ts @@ -0,0 +1,152 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { Router } from '@angular/router'; +import { Actions } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { PlaylistsService, SettingsStore } from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { EMPTY, Subject } from 'rxjs'; +import { PlaylistActions } from './actions'; +import { PlaylistEffects } from './effects'; + +/** + * `addPlaylist$` persists a playlist and then fetches its EPG and navigates to + * it. Each action carries a different playlist, so a newer add must never + * cancel an older one's write — which is exactly what the effect's original + * `switchMap` did as soon as two playlists were added in quick succession + * (the OS handing over several files at once). + * + * Package sub-path exports (`@angular/core/testing`, `@angular/material/*`) + * do not resolve in this Jest target — the constraint the `libs/services` + * specs already document. So there is no TestBed here: the effect is built in + * a plain injection context, and the collaborators `addPlaylist$` never + * touches resolve to empty stubs through a permissive parent injector rather + * than being imported. + */ +describe('PlaylistEffects addPlaylist$', () => { + let actions$: Subject; + let effects: PlaylistEffects; + let writes: Map>; + let navigated: string[]; + + const playlist = (id: string): Playlist => + ({ + _id: id, + title: id, + filename: id, + playlist: { items: [] }, + favorites: [], + }) as unknown as Playlist; + + function startWrite(id: string): Subject { + const subject = new Subject(); + writes.set(id, subject); + return subject; + } + + function createEffects(): PlaylistEffects { + // Anything `addPlaylist$` does not reach (snack bar, storage, store, + // translations) resolves to an empty object rather than throwing. + const permissiveParent = { + get: () => ({}), + } as unknown as Injector; + + const injector = Injector.create({ + parent: permissiveParent, + providers: [ + { provide: Actions, useValue: new Actions(actions$) }, + // Sibling effects read the store while their fields + // initialise, so this one needs a real observable. + { + provide: Store, + useValue: { dispatch: jest.fn(), select: () => EMPTY }, + }, + { + provide: PlaylistsService, + useValue: { + addPlaylist: (target: Playlist) => + writes.get(target._id), + }, + }, + { provide: EpgService, useValue: { fetchEpg: jest.fn() } }, + { + provide: Router, + useValue: { + navigate: (commands: unknown[]) => { + navigated.push(String(commands.at(-1))); + return Promise.resolve(true); + }, + }, + }, + { + provide: SettingsStore, + useValue: { getSettings: () => ({ epgUrl: [] }) }, + }, + ], + }); + + return runInInjectionContext(injector, () => new PlaylistEffects()); + } + + beforeEach(() => { + actions$ = new Subject(); + writes = new Map(); + navigated = []; + effects = createEffects(); + effects.addPlaylist$.subscribe(); + }); + + it('navigates to a playlist once its write completes', () => { + const write = startWrite('solo'); + + actions$.next( + PlaylistActions.addPlaylist({ playlist: playlist('solo') }) + ); + write.next(playlist('solo')); + write.complete(); + + expect(navigated).toEqual(['solo']); + }); + + // The regression: under switchMap the second action unsubscribed the + // first write, so 'first' never reached its navigation/EPG side effects. + it('completes an in-flight add before starting the next one', () => { + const firstWrite = startWrite('first'); + const secondWrite = startWrite('second'); + + actions$.next( + PlaylistActions.addPlaylist({ playlist: playlist('first') }) + ); + actions$.next( + PlaylistActions.addPlaylist({ playlist: playlist('second') }) + ); + + // The second add must not have started while the first is pending. + expect(secondWrite.observed).toBe(false); + + firstWrite.next(playlist('first')); + firstWrite.complete(); + + expect(navigated).toEqual(['first']); + expect(secondWrite.observed).toBe(true); + + secondWrite.next(playlist('second')); + secondWrite.complete(); + + expect(navigated).toEqual(['first', 'second']); + }); + + it('skips temporary playlists without touching storage', () => { + const write = startWrite('temp'); + + actions$.next( + PlaylistActions.handleAddingPlaylistByUrl({ + playlist: playlist('temp'), + isTemporary: true, + }) + ); + + expect(write.observed).toBe(false); + expect(navigated).toEqual([]); + }); +}); diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index d827eb6cd..126b2f4c5 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -13,6 +13,7 @@ import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateService } from '@ngx-translate/core'; import { EMPTY, + concatMap, filter, firstValueFrom, from, @@ -340,6 +341,12 @@ export class PlaylistEffects { ); }); + // concatMap, not switchMap: every action carries a *different* playlist, + // so a newer one must never cancel the previous playlist's write. Under + // switchMap two adds in quick succession — the OS handing over several + // playlist files at once is the realistic case — dropped the first + // playlist's EPG fetch and navigation on the floor. Serialising also keeps + // the last-added playlist as the one that ends up active. addPlaylist$ = createEffect( () => { return this.actions$.pipe( @@ -347,7 +354,7 @@ export class PlaylistEffects { PlaylistActions.addPlaylist, PlaylistActions.handleAddingPlaylistByUrl ), - switchMap((action) => { + concatMap((action) => { if ('isTemporary' in action && action.isTemporary) { return EMPTY; } diff --git a/libs/m3u-state/tsconfig.json b/libs/m3u-state/tsconfig.json index 7cac292e3..70cbd99dc 100644 --- a/libs/m3u-state/tsconfig.json +++ b/libs/m3u-state/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "isolatedModules": true, "target": "es2022", "moduleResolution": "bundler", "strict": true, diff --git a/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts index c3872dbbf..8e6b5fb1d 100644 --- a/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts +++ b/libs/playlist/shared/util/src/lib/playlist-file-import.service.spec.ts @@ -116,4 +116,94 @@ describe('PlaylistFileImportService', () => { expect(result).toEqual({ ok: false, reason: 'cancelled' }); expect(store.dispatch).not.toHaveBeenCalled(); }); + + describe('importFromPath', () => { + const playlist = { + _id: 'playlist-2', + title: 'CLI Source', + filename: 'cli-source', + count: 1, + importDate: '2026-05-04T12:00:00.000Z', + lastUsage: '2026-05-04T12:00:00.000Z', + autoRefresh: false, + filePath: '/tmp/cli-source.m3u', + favorites: [], + playlist: { + items: [], + }, + } as Playlist; + + it('parses an OS-supplied path and adds the playlist', async () => { + const updatePlaylistFromFilePath = jest + .fn() + .mockResolvedValue(playlist); + window.electron = { + updatePlaylistFromFilePath, + } as unknown as typeof window.electron; + + const result = await service.importFromPath( + '/tmp/cli-source.m3u', + 'cli-source.m3u' + ); + + expect(updatePlaylistFromFilePath).toHaveBeenCalledWith( + '/tmp/cli-source.m3u', + 'cli-source' + ); + expect(result).toEqual({ ok: true, title: 'CLI Source' }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.addPlaylist({ playlist }) + ); + }); + + it('derives the title from the path when no file name is supplied', async () => { + const updatePlaylistFromFilePath = jest + .fn() + .mockResolvedValue(playlist); + window.electron = { + updatePlaylistFromFilePath, + } as unknown as typeof window.electron; + + await service.importFromPath('C:\\Users\\tv\\Weekend.m3u8'); + + expect(updatePlaylistFromFilePath).toHaveBeenCalledWith( + 'C:\\Users\\tv\\Weekend.m3u8', + 'Weekend' + ); + }); + + it('rejects paths that are not playlists without touching the bridge', async () => { + const updatePlaylistFromFilePath = jest.fn(); + window.electron = { + updatePlaylistFromFilePath, + } as unknown as typeof window.electron; + + const result = await service.importFromPath('/tmp/movie.mkv'); + + expect(result).toEqual({ ok: false, reason: 'unsupported' }); + expect(updatePlaylistFromFilePath).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('reports a read error when the main process cannot parse the file', async () => { + window.electron = { + updatePlaylistFromFilePath: jest + .fn() + .mockRejectedValue(new Error('ENOENT')), + } as unknown as typeof window.electron; + + const result = await service.importFromPath('/tmp/missing.m3u'); + + expect(result).toEqual({ ok: false, reason: 'read-error' }); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('reports a read error outside Electron', async () => { + window.electron = undefined as unknown as typeof window.electron; + + const result = await service.importFromPath('/tmp/cli-source.m3u'); + + expect(result).toEqual({ ok: false, reason: 'read-error' }); + }); + }); }); diff --git a/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts index a2ae09384..cba5ee8ad 100644 --- a/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts +++ b/libs/playlist/shared/util/src/lib/playlist-file-import.service.ts @@ -17,6 +17,10 @@ type NativePlaylistFileImportWindow = Window & { electron?: { getPathForFile?: (file: File) => string; openPlaylistFromFile?: () => Promise; + updatePlaylistFromFilePath?: ( + filePath: string, + title: string + ) => Promise; }; }; @@ -29,7 +33,11 @@ export class PlaylistFileImportService { } isSupportedFile(file: File): boolean { - const lower = file.name.toLowerCase(); + return this.isSupportedPath(file.name); + } + + isSupportedPath(fileNameOrPath: string): boolean { + const lower = fileNameOrPath.trim().toLowerCase(); return M3U_EXTENSIONS.some((ext) => lower.endsWith(ext)); } @@ -57,6 +65,43 @@ export class PlaylistFileImportService { } } + /** + * Imports a playlist the main process already resolved to a path — the OS + * "open with" / command line route, where there is no `File` handle. The + * extension guard mirrors {@link isSupportedFile} so a path that slipped + * through is rejected here rather than read. + */ + async importFromPath( + filePath: string, + fileName?: string + ): Promise { + const title = this.normalizeTitle( + fileName?.trim() || this.baseNameFromPath(filePath) + ); + + if (!this.isSupportedPath(filePath)) { + return { ok: false, reason: 'unsupported' }; + } + + const parseFromPath = this.getNativeFilePathParser(); + if (!parseFromPath) { + return { ok: false, reason: 'read-error' }; + } + + try { + const playlist = await parseFromPath(filePath, title); + if (!playlist) { + return { ok: false, reason: 'read-error' }; + } + + this.store.dispatch(PlaylistActions.addPlaylist({ playlist })); + + return { ok: true, title: playlist.title || title }; + } catch { + return { ok: false, reason: 'read-error' }; + } + } + async importFile(file: File): Promise { if (!this.isSupportedFile(file)) { return { ok: false, reason: 'unsupported' }; @@ -86,6 +131,12 @@ export class PlaylistFileImportService { return { ok: true, title }; } + /** Renderer-side `basename`; handles both POSIX and Windows separators. */ + private baseNameFromPath(filePath: string): string { + const segments = filePath.trim().split(/[\\/]/); + return segments[segments.length - 1] || filePath; + } + private normalizeTitle(filename: string): string { const trimmed = filename.trim(); if (!trimmed) { @@ -124,6 +175,17 @@ export class PlaylistFileImportService { ?.openPlaylistFromFile; } + private getNativeFilePathParser(): + | ((filePath: string, title: string) => Promise) + | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as NativePlaylistFileImportWindow).electron + ?.updatePlaylistFromFilePath; + } + private getNativeFilePathResolver(): | ((file: File) => string) | undefined { diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 34e7b0449..c4dcd7d4e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -182,6 +182,18 @@ export interface ElectronBridgeWindowState { isFullScreen: boolean; } +/** + * A playlist file the operating system asked the app to open — a command line + * argument, a file association double-click, or macOS' `open-file` event. The + * main process resolves the path to an absolute one before handing it over. + */ +export interface ElectronBridgePlaylistOpenRequest { + fileName: string; + filePath: string; + /** Pass back to `acknowledgePlaylistOpenRequest` once received. */ + requestId: string; +} + export interface ElectronBridgeAppUpdateRelease { version: string; releaseDate?: string; @@ -575,6 +587,21 @@ export interface ElectronBridgeApi { title: string ) => Promise; openPlaylistFromFile: () => Promise; + onPlaylistOpenRequest: ( + callback: (request: ElectronBridgePlaylistOpenRequest) => void + ) => () => void; + /** + * Tells the main process that an `onPlaylistOpenRequest` listener is + * attached, so it can push the playlist files the OS handed over — both + * the ones queued before this renderer existed and any that arrive later. + * Call it *after* subscribing. + */ + announcePlaylistOpenListener: () => Promise; + /** + * Confirms a pushed request reached this renderer. Until it does, the main + * process keeps the request and replays it to the next renderer. + */ + acknowledgePlaylistOpenRequest: (requestId: string) => Promise; getPathForFile: (file: File) => string; saveFileDialog: ( defaultPath: string, diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 7bd02a66b..fc95950dd 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -18,7 +18,27 @@ export const PLAYLIST_CANCEL_REFRESH = 'PLAYLIST:CANCEL_REFRESH'; // General export const ERROR = 'ERROR'; +/** + * Main -> renderer push carrying an `ElectronBridgePlaylistOpenRequest`: a + * playlist file the OS asked the app to open, either before the window existed + * (first launch with a path argument) or while it was already running (second + * launch, macOS `open-file`). This is the only channel requests leave the + * main-process queue through. + */ export const OPEN_FILE = 'OPEN_FILE'; +/** + * Renderer -> main invoke announcing that an `OPEN_FILE` listener is attached. + * Carries no payload: it only tells the queue where to push. + */ +export const ANNOUNCE_PLAYLIST_OPEN_LISTENER = + 'announce-playlist-open-listener'; +/** + * Renderer -> main invoke confirming a pushed request was received. Until it + * arrives the request stays in the queue, so a renderer that reloads or dies + * mid-delivery gets it replayed instead of losing it. + */ +export const ACKNOWLEDGE_PLAYLIST_OPEN_REQUEST = + 'acknowledge-playlist-open-request'; // Views export const VIEW_SETTINGS = 'VIEW:SETTINGS'; diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index cbdc9c0cc..1982965b5 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -153,6 +153,43 @@ test('Linux package identity does not expose the internal Electron backend proje ); }); +test('playlist file associations are registered with the operating system', () => { + // Without these the OS never offers IPTVnator as a handler, so every + // runtime path for an OS-supplied playlist is unreachable by double-click. + assert.deepEqual(electronBuilderConfig.fileAssociations, [ + { + ext: 'm3u', + name: 'M3U Playlist', + description: 'IPTV playlist', + mimeType: 'audio/x-mpegurl', + role: 'Viewer', + }, + { + ext: 'm3u8', + name: 'M3U8 Playlist', + description: 'IPTV playlist', + mimeType: 'application/vnd.apple.mpegurl', + role: 'Viewer', + }, + ]); + + // Electron Builder derives the Linux desktop entry's MimeType from these + // `mimeType` fields, and assigns it *after* spreading `linux.desktop.entry` + // — so declaring MimeType there instead would be silently overwritten. + assert.equal( + electronBuilderConfig.linux?.desktop?.entry?.MimeType, + undefined + ); + + // Each association needs its own extension: Electron Builder derives the + // per-extension NSIS registry entries and the Linux `` in + // /usr/share/mime from `ext`, one mimeType per association. + const extensions = electronBuilderConfig.fileAssociations.map( + (association) => association.ext + ); + assert.equal(new Set(extensions).size, extensions.length); +}); + test('GitHub Releases auto-update metadata is generated and uploaded', () => { // \r?\n keeps this host-agnostic: Windows checkouts with autocrlf see // CRLF in the workflow file.