From 636545cbb72e255b1b98b401eba9ba7a9db9bac4 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:16:02 +0200 Subject: [PATCH] refactor(electron-backend): split four files under the max-lines limit (#1278) Split epg.events.ts (514), the embedded MPV frame-copy adapter (428) and two of its specs (547, 539) below the 400-line hard limit, and teach the baseline generator to skip files that already carry a justified file-wide eslint-disable max-lines. The generated baseline list is unchanged: 128 entries before and after. No behavior change. --- AGENTS.md | 1 + CLAUDE.md | 8 +- .../src/app/events/epg-fetch.service.ts | 167 ++++++++ .../src/app/events/epg-mapping.service.ts | 130 ++++++ .../src/app/events/epg.events.ts | 298 ++----------- .../embedded-mpv-frame-copy-protocol.ts | 138 +++++++ .../services/embedded-mpv-frame-copy-spawn.ts | 94 +++++ ...d-mpv-frame-copy.adapter.linux-env.spec.ts | 295 +++++++++++++ .../embedded-mpv-frame-copy.adapter.spec.ts | 391 +----------------- ...ded-mpv-frame-copy.adapter.test-helpers.ts | 144 +++++++ .../embedded-mpv-frame-copy.adapter.ts | 194 ++------- ...edded-mpv-linux-linkage-validation.spec.ts | 147 +++++++ .../embedded-mpv-linux-linkage.spec.ts | 314 +------------- ...embedded-mpv-linux-linkage.test-helpers.ts | 196 +++++++++ docs/architecture/sqlite-db-worker.md | 10 +- tools/eslint/generate-max-lines-baseline.mjs | 57 ++- tools/eslint/max-lines-baseline.mjs | 2 + 17 files changed, 1460 insertions(+), 1126 deletions(-) create mode 100644 apps/electron-backend/src/app/events/epg-fetch.service.ts create mode 100644 apps/electron-backend/src/app/events/epg-mapping.service.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-spawn.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.linux-env.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.test-helpers.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-linux-linkage-validation.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.test-helpers.ts diff --git a/AGENTS.md b/AGENTS.md index c1b428902..83ca28a29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This file provides guidance to coding agents working in this repository. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. +- ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. - Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs. ## Documentation After Changes diff --git a/CLAUDE.md b/CLAUDE.md index bd872ab79..da9ffbaa2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,11 @@ Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. -Never add new files to the baseline. +Never add new files to the baseline — the list must only shrink. A new file +that genuinely cannot be split (for example a function serialized into another +process) instead carries its own file-wide +`/* eslint-disable max-lines -- */`; the generator skips those files, so +a justified exemption never lands in the baseline. ## Architecture @@ -606,7 +610,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 - - `epg.events.ts` - EPG IPC registration and freshness/fetch orchestration; worker lifecycle lives in `epg-worker.service.ts`, DB lookups in `epg-query.service.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 - `player.events.ts` - External player IPC registration; MPV/VLC lifecycle logic lives in `mpv-session.service.ts`, `vlc-session.service.ts`, and shared `external-player-*` helpers diff --git a/apps/electron-backend/src/app/events/epg-fetch.service.ts b/apps/electron-backend/src/app/events/epg-fetch.service.ts new file mode 100644 index 000000000..10b79f8a7 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-fetch.service.ts @@ -0,0 +1,167 @@ +import { eq } from 'drizzle-orm'; +import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; +import { epgWorkerService } from './epg-worker.service'; + +/** + * EPG freshness checks and multi-URL fetch orchestration. + * Worker lifecycle itself lives in `epg-worker.service.ts`; this module only + * decides *which* URLs are worth handing to it, and in what order. + */ + +const loggerLabel = '[EPG Events]'; + +/** Age after which stored EPG data is refetched. */ +export const EPG_FRESHNESS_MAX_AGE_HOURS = 12; + +export interface EpgFreshnessResult { + staleUrls: string[]; + freshUrls: string[]; +} + +export interface EpgFetchResult { + success: boolean; + message?: string; + skipped?: string[]; +} + +/** + * Check which EPG URLs have fresh data vs stale/missing data + * @param urls - EPG source URLs to check + * @param maxAgeHours - Maximum age in hours before data is considered stale + */ +export async function checkEpgFreshness( + urls: string[], + maxAgeHours: number +): Promise { + const staleUrls: string[] = []; + const freshUrls: string[] = []; + const cutoffTime = new Date( + Date.now() - maxAgeHours * 60 * 60 * 1000 + ).toISOString(); + + try { + const db = await getDatabase(); + + for (const url of urls) { + if (!url?.trim()) continue; + + const result = await db + .select({ updatedAt: schema.epgChannels.updatedAt }) + .from(schema.epgChannels) + .where(eq(schema.epgChannels.sourceUrl, url)) + .limit(1); + + const isFresh = + result.length > 0 && + result[0].updatedAt && + result[0].updatedAt >= cutoffTime; + + if (isFresh) { + freshUrls.push(url); + epgWorkerService.markFetchedUrl(url); + } else { + staleUrls.push(url); + } + } + } catch (error) { + console.error(loggerLabel, 'Error checking EPG freshness:', error); + return { staleUrls: urls, freshUrls: [] }; + } + + if (freshUrls.length > 0) { + console.log( + loggerLabel, + `EPG fresh (skipping): ${freshUrls.length} source(s)` + ); + } + if (staleUrls.length > 0) { + console.log( + loggerLabel, + `EPG stale (will fetch): ${staleUrls.length} source(s)` + ); + } + + return { staleUrls, freshUrls }; +} + +/** + * Handle EPG fetch from URLs + * Automatically skips URLs with fresh data (less than 12 hours old) + * Processes URLs sequentially to avoid SQLite database locking issues + */ +export async function handleFetchEpg( + urls: string[], + options: ElectronBridgeTrustOptions = {} +): Promise { + const validUrls = urls.filter((url) => url?.trim()); + + if (validUrls.length === 0) { + return { success: false, message: 'No valid URLs provided' }; + } + + const { staleUrls, freshUrls } = await checkEpgFreshness( + validUrls, + EPG_FRESHNESS_MAX_AGE_HOURS + ); + + if (staleUrls.length === 0) { + return { + success: true, + message: 'All EPG data is fresh', + skipped: freshUrls, + }; + } + + // Exclude URLs already processed this session — otherwise the loop sends + // a 'queued' status, then fetchEpgFromUrl silently skips the URL and no + // completion update ever arrives, leaving the UI stuck at "queued". + const urlsToFetch = staleUrls.filter( + (url) => !epgWorkerService.hasFetchedUrl(url) + ); + + if (urlsToFetch.length === 0) { + console.log( + loggerLabel, + `All ${staleUrls.length} stale URL(s) already fetched this session; skipping` + ); + return { success: true, skipped: freshUrls }; + } + + urlsToFetch.forEach((url, index) => { + epgWorkerService.sendProgressToRenderer( + url, + 'queued', + undefined, + undefined, + index + 1 + ); + }); + + const errors: string[] = []; + for (const url of urlsToFetch) { + try { + await epgWorkerService.fetchEpgFromUrl(url, options); + } catch (error) { + console.error( + loggerLabel, + `Error fetching EPG from ${url}:`, + error + ); + errors.push( + error instanceof Error ? error.message : String(error) + ); + } + } + + if (errors.length > 0) { + return { + success: errors.length < urlsToFetch.length, + message: errors.join('; '), + skipped: freshUrls, + }; + } + + return { success: true, skipped: freshUrls }; +} diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.ts b/apps/electron-backend/src/app/events/epg-mapping.service.ts new file mode 100644 index 000000000..58a09e0ac --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-mapping.service.ts @@ -0,0 +1,130 @@ +import { getDatabase } from '../database/connection'; +import { + deleteEpgMapping, + getEpgMapping, + getEpgMappingsBatch, + searchEpgChannels, + setEpgMapping, +} from '../database/operations/epg-mapping.operations'; + +/** + * Manual EPG channel mapping resolution and CRUD, called at the IPC boundary + * before EPG queries run. Every method fails soft: a mapping lookup must never + * take down an EPG request, it just falls back to the unmapped channel id. + */ + +export interface EpgMappingRecord { + id: number; + channelKey: string; + epgChannelId: string; + playlistId: string | null; +} + +export interface EpgChannelSearchResult { + id: string; + displayName: string; + iconUrl: string | null; +} + +/** + * Batch-resolve channel IDs through manual mappings. + * Returns a Map of original ID → mapped ID (identity when no mapping). + */ +export async function resolveChannelIds( + channelIds: string[] +): Promise> { + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelIds); + return mappings; + } catch { + return new Map(); + } +} + +/** + * Run a batch query against mapping-resolved channel IDs and remap the results + * back onto the original request keys the renderer asked for. + */ +export async function queryByResolvedChannelIds( + channelIds: string[], + query: (resolvedIds: string[]) => Promise> +): Promise> { + const resolvedMap = await resolveChannelIds(channelIds); + const resolvedIds = channelIds.map((id) => resolvedMap.get(id) ?? id); + const results = await query(resolvedIds); + + const remapped: Record = {}; + for (const originalId of channelIds) { + const resolvedId = resolvedMap.get(originalId) ?? originalId; + remapped[originalId] = results[resolvedId] ?? null; + } + return remapped; +} + +export async function handleGetEpgMapping( + channelKey: string +): Promise { + try { + const db = await getDatabase(); + return getEpgMapping(db, channelKey); + } catch { + return null; + } +} + +export async function handleGetEpgMappingsBatch( + channelKeys: string[] +): Promise> { + if (!Array.isArray(channelKeys) || channelKeys.length === 0) { + return {}; + } + + try { + const db = await getDatabase(); + const mappings = await getEpgMappingsBatch(db, channelKeys); + return Object.fromEntries(mappings); + } catch { + return {}; + } +} + +export async function handleSetEpgMapping( + channelKey: string, + epgChannelId: string, + playlistId?: string +): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return setEpgMapping(db, channelKey, epgChannelId, playlistId); + } catch { + return { success: false }; + } +} + +export async function handleDeleteEpgMapping( + channelKey: string +): Promise<{ success: boolean }> { + try { + const db = await getDatabase(); + return deleteEpgMapping(db, channelKey); + } catch { + return { success: false }; + } +} + +export async function handleSearchEpgChannels( + searchTerm: string, + limit?: number +): Promise { + if (!searchTerm?.trim()) { + return []; + } + + try { + const db = await getDatabase(); + return searchEpgChannels(db, searchTerm, limit); + } catch { + return []; + } +} diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 795f93a75..c7085939a 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,29 +1,29 @@ -import { eq } from 'drizzle-orm'; import { ipcMain } from 'electron'; import { ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; -import { getDatabase } from '../database/connection'; -import * as schema from '../database/schema'; import { epgQueryService } from './epg-query.service'; import { epgWorkerService } from './epg-worker.service'; +import { checkEpgFreshness, handleFetchEpg } from './epg-fetch.service'; +import type { EpgFetchResult, EpgFreshnessResult } from './epg-fetch.service'; import { - getEpgMapping, - getEpgMappingsBatch, - setEpgMapping, - deleteEpgMapping, - searchEpgChannels, -} from '../database/operations/epg-mapping.operations'; + handleDeleteEpgMapping, + handleGetEpgMapping, + handleGetEpgMappingsBatch, + handleSearchEpgChannels, + handleSetEpgMapping, + queryByResolvedChannelIds, +} from './epg-mapping.service'; /** * EPG Events Handler * Manages EPG IPC registration and delegates worker/query behavior. + * Freshness and fetch orchestration live in `epg-fetch.service.ts`; manual + * channel-mapping resolution and CRUD live in `epg-mapping.service.ts`. */ export default class EpgEvents { - private static readonly loggerLabel = '[EPG Events]'; - /** * Bootstrap EPG events */ @@ -136,11 +136,11 @@ export default class EpgEvents { } ); - // EPG channel mapping CRUD + // EPG channel mapping CRUD — handled entirely by epg-mapping.service. ipcMain.handle( 'EPG_MAPPING_GET', async (_event, args: { channelKey: string }) => { - return this.handleGetEpgMapping(args.channelKey); + return handleGetEpgMapping(args.channelKey); } ); @@ -154,7 +154,7 @@ export default class EpgEvents { playlistId?: string; } ) => { - return this.handleSetEpgMapping( + return handleSetEpgMapping( args.channelKey, args.epgChannelId, args.playlistId @@ -165,14 +165,14 @@ export default class EpgEvents { ipcMain.handle( 'EPG_MAPPING_GET_BATCH', async (_event, args: { channelKeys: string[] }) => { - return this.handleGetEpgMappingsBatch(args.channelKeys); + return handleGetEpgMappingsBatch(args.channelKeys); } ); ipcMain.handle( 'EPG_MAPPING_DELETE', async (_event, args: { channelKey: string }) => { - return this.handleDeleteEpgMapping(args.channelKey); + return handleDeleteEpgMapping(args.channelKey); } ); @@ -182,158 +182,25 @@ export default class EpgEvents { _event, args: { searchTerm: string; limit?: number } ) => { - return this.handleSearchEpgChannels( - args.searchTerm, - args.limit - ); + return handleSearchEpgChannels(args.searchTerm, args.limit); } ); return ipcMain; } - /** - * Check which EPG URLs have fresh data vs stale/missing data - * @param urls - EPG source URLs to check - * @param maxAgeHours - Maximum age in hours before data is considered stale - */ private static async checkEpgFreshness( urls: string[], maxAgeHours: number - ): Promise<{ staleUrls: string[]; freshUrls: string[] }> { - const staleUrls: string[] = []; - const freshUrls: string[] = []; - const cutoffTime = new Date( - Date.now() - maxAgeHours * 60 * 60 * 1000 - ).toISOString(); - - try { - const db = await getDatabase(); - - for (const url of urls) { - if (!url?.trim()) continue; - - const result = await db - .select({ updatedAt: schema.epgChannels.updatedAt }) - .from(schema.epgChannels) - .where(eq(schema.epgChannels.sourceUrl, url)) - .limit(1); - - const isFresh = - result.length > 0 && - result[0].updatedAt && - result[0].updatedAt >= cutoffTime; - - if (isFresh) { - freshUrls.push(url); - epgWorkerService.markFetchedUrl(url); - } else { - staleUrls.push(url); - } - } - } catch (error) { - console.error( - this.loggerLabel, - 'Error checking EPG freshness:', - error - ); - return { staleUrls: urls, freshUrls: [] }; - } - - if (freshUrls.length > 0) { - console.log( - this.loggerLabel, - `EPG fresh (skipping): ${freshUrls.length} source(s)` - ); - } - if (staleUrls.length > 0) { - console.log( - this.loggerLabel, - `EPG stale (will fetch): ${staleUrls.length} source(s)` - ); - } - - return { staleUrls, freshUrls }; + ): Promise { + return checkEpgFreshness(urls, maxAgeHours); } - /** - * Handle EPG fetch from URLs - * Automatically skips URLs with fresh data (less than 12 hours old) - * Processes URLs sequentially to avoid SQLite database locking issues - */ private static async handleFetchEpg( urls: string[], options: ElectronBridgeTrustOptions = {} - ): Promise<{ success: boolean; message?: string; skipped?: string[] }> { - const validUrls = urls.filter((url) => url?.trim()); - - if (validUrls.length === 0) { - return { success: false, message: 'No valid URLs provided' }; - } - - const { staleUrls, freshUrls } = await this.checkEpgFreshness( - validUrls, - 12 - ); - - if (staleUrls.length === 0) { - return { - success: true, - message: 'All EPG data is fresh', - skipped: freshUrls, - }; - } - - // Exclude URLs already processed this session — otherwise the loop sends - // a 'queued' status, then fetchEpgFromUrl silently skips the URL and no - // completion update ever arrives, leaving the UI stuck at "queued". - const urlsToFetch = staleUrls.filter( - (url) => !epgWorkerService.hasFetchedUrl(url) - ); - - if (urlsToFetch.length === 0) { - console.log( - this.loggerLabel, - `All ${staleUrls.length} stale URL(s) already fetched this session; skipping` - ); - return { success: true, skipped: freshUrls }; - } - - urlsToFetch.forEach((url, index) => { - epgWorkerService.sendProgressToRenderer( - url, - 'queued', - undefined, - undefined, - index + 1 - ); - }); - - const errors: string[] = []; - for (const url of urlsToFetch) { - try { - await this.fetchEpgFromUrl(url, options); - } catch (error) { - console.error( - this.loggerLabel, - `Error fetching EPG from ${url}:`, - error - ); - errors.push( - error instanceof Error ? error.message : String(error) - ); - } - } - - if (errors.length > 0) { - return { - success: errors.length < urlsToFetch.length, - message: errors.join('; '), - skipped: freshUrls, - }; - } - - return { success: true, skipped: freshUrls }; + ): Promise { + return handleFetchEpg(urls, options); } private static async fetchEpgFromUrl( @@ -354,21 +221,9 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - const resolvedMap = await this.resolveChannelIds(channelIds); - const resolvedIds = channelIds.map( - (id) => resolvedMap.get(id) ?? id + return queryByResolvedChannelIds(channelIds, (resolvedIds) => + epgQueryService.getCurrentProgramsBatch(resolvedIds, options) ); - const results = await epgQueryService.getCurrentProgramsBatch( - resolvedIds, - options - ); - // Remap results back to the original request keys. - const remapped: Record = {}; - for (const originalId of channelIds) { - const resolvedId = resolvedMap.get(originalId) ?? originalId; - remapped[originalId] = results[resolvedId] ?? null; - } - return remapped; } private static async handleGetAllChannels(): Promise<{ @@ -382,21 +237,9 @@ export default class EpgEvents { channelIds: string[], options?: { sourceUrls?: string[] } ): Promise> { - const resolvedMap = await this.resolveChannelIds(channelIds); - const resolvedIds = channelIds.map( - (id) => resolvedMap.get(id) ?? id + return queryByResolvedChannelIds(channelIds, (resolvedIds) => + epgQueryService.getChannelMetadata(resolvedIds, options) ); - const results = await epgQueryService.getChannelMetadata( - resolvedIds, - options - ); - // Remap results back to the original request keys. - const remapped: Record = {}; - for (const originalId of channelIds) { - const resolvedId = resolvedMap.get(originalId) ?? originalId; - remapped[originalId] = results[resolvedId] ?? null; - } - return remapped; } private static async handleGetChannelsByRange( @@ -413,97 +256,6 @@ export default class EpgEvents { return epgQueryService.getChannelsByRange(skip, limit); } - // --------------------------------------------------------------------------- - // EPG mapping resolution — called at the IPC boundary before queries. - // --------------------------------------------------------------------------- - - /** - * Batch-resolve multiple channel IDs through manual mappings. - * Returns a Map of original ID → mapped ID (identity when no mapping). - */ - private static async resolveChannelIds( - channelIds: string[] - ): Promise> { - try { - const db = await getDatabase(); - const mappings = await getEpgMappingsBatch(db, channelIds); - return mappings; - } catch { - return new Map(); - } - } - - // --------------------------------------------------------------------------- - // EPG mapping CRUD handlers - // --------------------------------------------------------------------------- - - private static async handleGetEpgMapping( - channelKey: string - ): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> { - try { - const db = await getDatabase(); - return getEpgMapping(db, channelKey); - } catch { - return null; - } - } - - private static async handleGetEpgMappingsBatch( - channelKeys: string[] - ): Promise> { - if (!Array.isArray(channelKeys) || channelKeys.length === 0) { - return {}; - } - - try { - const db = await getDatabase(); - const mappings = await getEpgMappingsBatch(db, channelKeys); - return Object.fromEntries(mappings); - } catch { - return {}; - } - } - - private static async handleSetEpgMapping( - channelKey: string, - epgChannelId: string, - playlistId?: string - ): Promise<{ success: boolean }> { - try { - const db = await getDatabase(); - return setEpgMapping(db, channelKey, epgChannelId, playlistId); - } catch { - return { success: false }; - } - } - - private static async handleDeleteEpgMapping( - channelKey: string - ): Promise<{ success: boolean }> { - try { - const db = await getDatabase(); - return deleteEpgMapping(db, channelKey); - } catch { - return { success: false }; - } - } - - private static async handleSearchEpgChannels( - searchTerm: string, - limit?: number - ): Promise> { - if (!searchTerm?.trim()) { - return []; - } - - try { - const db = await getDatabase(); - return searchEpgChannels(db, searchTerm, limit); - } catch { - return []; - } - } - static async clearEpgData(): Promise { return epgWorkerService.clearEpgData(); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts new file mode 100644 index 000000000..51d6146b1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-protocol.ts @@ -0,0 +1,138 @@ +import { + EmbeddedMpvFrameSource, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import type { NativeEmbeddedMpvSessionSnapshot } from './embedded-mpv-native.service'; + +/** + * Wire protocol for the `iptvnator_mpv_helper` frame-copy process: + * tab-separated commands over stdin, JSON events over stdout. + * + * Kept separate from the adapter so the encoding rules and the event contract + * can be read (and tested) without the process-lifecycle machinery around them. + */ + +/** + * Percent-escape the protocol's structural characters so a title or URL can + * never inject an extra field or line into a command. + */ +export function encodeProtocolValue(value: string): string { + return value + .replace(/%/g, '%25') + .replace(/\t/g, '%09') + .replace(/\n/g, '%0A') + .replace(/\r/g, '%0D'); +} + +export function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { + return { + status: 'loading', + positionSeconds: 0, + durationSeconds: null, + volume: 1, + streamUrl: '', + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: { active: false }, + }; +} + +/** Build the `load` command line for a resolved playback target. */ +export function buildLoadPlaybackCommand( + playback: ResolvedPortalPlayback +): string { + const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`]; + if (playback.title) { + fields.push( + `opt.force-media-title=${encodeProtocolValue(playback.title)}` + ); + } + if (playback.userAgent) { + fields.push(`opt.user-agent=${encodeProtocolValue(playback.userAgent)}`); + } + if (playback.referer) { + fields.push(`opt.referrer=${encodeProtocolValue(playback.referer)}`); + } + if ( + typeof playback.startTime === 'number' && + Number.isFinite(playback.startTime) && + playback.startTime >= 0 + ) { + fields.push(`opt.start=${playback.startTime}`); + } + if (playback.headers && Object.keys(playback.headers).length > 0) { + const headerFields = Object.entries(playback.headers) + .map(([key, value]) => `${key}: ${value}`) + .join(','); + fields.push( + `opt.http-header-fields=${encodeProtocolValue(headerFields)}` + ); + } + return `load\t${fields.join('\t')}`; +} + +/** The mutable per-session state a helper event can act on. */ +export interface HelperEventTarget { + readonly id: string; + snapshot: NativeEmbeddedMpvSessionSnapshot; + frameSource: EmbeddedMpvFrameSource | null; +} + +export interface HelperEventHandlers { + resolveReaderPath: () => string; + onFrameSourceChanged: ( + sessionId: string, + source: EmbeddedMpvFrameSource + ) => void; +} + +/** Apply one decoded helper stdout event to the session. */ +export function applyHelperEvent( + session: HelperEventTarget, + event: Record, + handlers: HelperEventHandlers +): void { + switch (event.event) { + case 'snapshot': { + const { event: _ignored, ...snapshot } = event; + session.snapshot = { + ...session.snapshot, + ...(snapshot as Partial), + } as NativeEmbeddedMpvSessionSnapshot; + break; + } + case 'shm': { + const source: EmbeddedMpvFrameSource = { + shmName: String(event.name ?? ''), + width: Number(event.width ?? 0), + height: Number(event.height ?? 0), + generation: Number(event.generation ?? 0), + readerPath: handlers.resolveReaderPath(), + }; + session.frameSource = source; + handlers.onFrameSourceChanged(session.id, source); + break; + } + case 'fatal': + session.snapshot.status = 'error'; + session.snapshot.error = String( + event.error ?? 'Embedded MPV helper failed.' + ); + break; + case 'log': + if (event.level === 'error' || event.level === 'fatal') { + console.error( + `[embedded-mpv-fc][${session.id}][mpv/${String( + event.prefix ?? '' + )}] ${String(event.text ?? '').trim()}` + ); + } + break; + default: + break; + } +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-spawn.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-spawn.ts new file mode 100644 index 000000000..d0480626f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-spawn.ts @@ -0,0 +1,94 @@ +import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces'; +import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyRuntimeMode, + LinuxFrameCopyHelperLaunchFileSystem, +} from './embedded-mpv-frame-copy-runtime'; + +/** + * Resolves how a frame-copy helper process is launched: the offscreen render + * size in device pixels, and — on Linux — the sanitized loader environment and + * graphics-provider wrapper the validated runtime requires. + */ + +export interface FrameCopyHelperSpawnRequest { + bounds: EmbeddedMpvBounds; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: LinuxFrameCopyHelperLaunchFileSystem; + helperPath: string; + initialVolume?: number; + resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null; + scale: number; + sessionId: string; +} + +export interface FrameCopyHelperSpawnPlan { + args: string[]; + command: string; + env?: NodeJS.ProcessEnv; + height: number; + width: number; +} + +export function resolveFrameCopyHelperSpawn( + request: FrameCopyHelperSpawnRequest +): FrameCopyHelperSpawnPlan { + const { + bounds, + environment, + helperLaunchFileSystem, + helperPath, + initialVolume, + resolveRuntimeMode, + scale, + sessionId, + } = request; + + const width = Math.max(16, Math.round(bounds.width * scale)); + const height = Math.max(16, Math.round(bounds.height * scale)); + const helperArgs = [ + '--shm-base', + `/${sessionId}`, + '--width', + String(width), + '--height', + String(height), + '--volume', + String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), + // Lip-sync compensation for the video path's added latency + // (~10 ms measured on M1 Pro); tunable until calibration + // lands, see the architecture doc. + ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY + ? ['--audio-delay', process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY] + : []), + ]; + + if (process.platform !== 'linux') { + return { args: helperArgs, command: helperPath, height, width }; + } + + const runtimeMode = resolveRuntimeMode(); + if (!runtimeMode) { + throw new Error( + 'A validated Linux frame-copy runtime is not available.' + ); + } + const launch = createLinuxFrameCopyHelperLaunch({ + environment: environment ?? process.env, + helperPath, + helperArgs, + runtimeMode, + fileSystem: helperLaunchFileSystem, + }); + if (!launch.usable) { + throw new Error('The connected Snap graphics provider is not available.'); + } + + return { + args: launch.args, + command: launch.command, + env: launch.env, + height, + width, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.linux-env.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.linux-env.spec.ts new file mode 100644 index 000000000..b2b542a7f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.linux-env.spec.ts @@ -0,0 +1,295 @@ +import type { Stats } from 'fs'; +import path from 'path'; + +const spawnMock = jest.fn(); +jest.mock('child_process', () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), +})); + +import type { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import { + createFrameCopyAdapter, + createFrameCopySession, + fakeStat, + FakeHelperProcess, + GRAPHICS_SELECTOR_ENVIRONMENT, + HOSTILE_LOADER_ENVIRONMENT, +} from './embedded-mpv-frame-copy.adapter.test-helpers'; + +/** + * The Linux side of session creation: probe/playback must share one sanitized + * loader environment, and a bundled Snap runtime must launch through the + * graphics provider wrapper with trusted GL roots ahead of generic Snap + * libraries. General adapter behavior lives in + * embedded-mpv-frame-copy.adapter.spec.ts. + */ +describe('EmbeddedMpvFrameCopyAdapter Linux loader environment', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + let child: FakeHelperProcess; + let adapter: EmbeddedMpvFrameCopyAdapter; + + beforeEach(() => { + jest.useFakeTimers(); + child = new FakeHelperProcess(); + spawnMock.mockReset(); + spawnMock.mockReturnValue(child); + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + }); + + afterEach(() => { + jest.useRealTimers(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(process, 'arch', { value: originalArch }); + }); + + const createSession = () => createFrameCopySession(adapter); + + it('uses a sanitized system environment for the real helper session', () => { + ({ adapter } = createFrameCopyAdapter('/opt/iptvnator/native/helper', { + runtimeMode: 'system', + environment: { + PATH: '/usr/bin', + HOME: '/home/user', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + })); + + createSession(); + + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/bin', + HOME: '/home/user', + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + }); + + it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + ({ adapter } = createFrameCopyAdapter(path.join(nativeDir, 'helper'), { + runtimeMode: 'bundled', + helperLaunchFileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => undefined, + }, + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + })); + + createSession(); + + expect(spawnMock.mock.calls[0][0]).toBe( + path.join( + snapRoot, + 'graphics', + 'bin', + 'graphics-core22-provider-wrapper' + ) + ); + expect(spawnMock.mock.calls[0][1][0]).toBe( + path.join(nativeDir, 'helper') + ); + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl', + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', + ].join(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ].join(':'), + }, + }); + }); + + it('refuses a Linux session without a validated runtime mode', () => { + ({ adapter } = createFrameCopyAdapter('/native/helper', { + runtimeMode: null, + })); + + expect(() => createSession()).toThrow( + 'validated Linux frame-copy runtime' + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index 10698bd55..0947fe4bb 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -1,5 +1,3 @@ -import { EventEmitter } from 'events'; -import type { Stats } from 'fs'; import path from 'path'; const spawnMock = jest.fn(); @@ -7,134 +5,34 @@ jest.mock('child_process', () => ({ spawn: (...args: unknown[]) => spawnMock(...args), })); -import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; -import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; - -const HOSTILE_LOADER_ENVIRONMENT = { - BASH_ENV: '/tmp/hostile-bash-env', - ENV: '/tmp/hostile-shell-env', - BASHOPTS: 'extdebug', - SHELLOPTS: 'xtrace', - PS4: '$(/tmp/hostile-trace-hook)', - BASH_XTRACEFD: '9', - CDPATH: '/tmp/hostile-cdpath', - 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', - LD_AUDIT: '/tmp/audit.so', - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_ORIGIN_PATH: '/tmp/hostile-origin', - LD_PRELOAD: '/tmp/inject.so', - __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', - __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', - __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', - __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', - GBM_BACKEND: '../../../../../tmp/hostile-gbm', - GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', - LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', - MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', - LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', - LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', - VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', - VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', - VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', - VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', - VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', - VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', - VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', - VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', -} as const; - -const GRAPHICS_SELECTOR_ENVIRONMENT = { - LIBGL_ALWAYS_SOFTWARE: '1', - GALLIUM_DRIVER: 'llvmpipe', -} as const; - -function fakeStat( - kind: 'directory' | 'file' -): Pick { - return { - isDirectory: () => kind === 'directory', - isFile: () => kind === 'file', - isSymbolicLink: () => false, - }; -} - -class FakeHelperProcess extends EventEmitter { - exitCode: number | null = null; - readonly stdout = new EventEmitter(); - readonly stderr = new EventEmitter(); - readonly stdin = { - writable: true, - written: [] as string[], - write(line: string) { - this.written.push(line); - return true; - }, - }; - readonly kill = jest.fn((signal?: string) => { - this.exitCode = 0; - this.emit('exit', 0, signal ?? null); - return true; - }); - - emitStdout(payload: object): void { - this.stdout.emit('data', Buffer.from(`${JSON.stringify(payload)}\n`)); - } -} +import type { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import { + createFrameCopyAdapter, + createFrameCopySession, + FakeHelperProcess, + type FrameSourceChange, +} from './embedded-mpv-frame-copy.adapter.test-helpers'; +// The Linux loader-environment contract lives in +// embedded-mpv-frame-copy.adapter.linux-env.spec.ts. describe('EmbeddedMpvFrameCopyAdapter', () => { let child: FakeHelperProcess; - let frameSourceChanges: Array<{ sessionId: string; shmName: string }>; + let frameSourceChanges: FrameSourceChange[]; let adapter: EmbeddedMpvFrameCopyAdapter; - const createAdapter = ( - helperPath: string | null = '/native/helper', - { - runtimeMode = 'system', - environment, - helperLaunchFileSystem, - }: { - runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; - environment?: NodeJS.ProcessEnv; - helperLaunchFileSystem?: { - lstatSync(filePath: string): Stats; - accessSync(filePath: string, mode: number): void; - }; - } = {} - ) => { - frameSourceChanges = []; - return new EmbeddedMpvFrameCopyAdapter({ - resolveHelperPath: () => helperPath, - resolveRuntimeMode: () => runtimeMode, - environment, - helperLaunchFileSystem, - getScaleFactor: () => 2, - onFrameSourceChanged: (sessionId, source) => - frameSourceChanges.push({ - sessionId, - shmName: source.shmName, - }), - } as ConstructorParameters[0]); - }; - beforeEach(() => { jest.useFakeTimers(); child = new FakeHelperProcess(); spawnMock.mockReset(); spawnMock.mockReturnValue(child); - adapter = createAdapter(); + ({ adapter, frameSourceChanges } = createFrameCopyAdapter()); }); afterEach(() => { jest.useRealTimers(); }); - const createSession = () => - adapter.createSession( - Buffer.alloc(0), - { x: 0, y: 0, width: 640, height: 360 }, - 'Title', - 0.8 - ); + const createSession = () => createFrameCopySession(adapter); it('spawns the helper with device-pixel size and initial volume', () => { const sessionId = createSession(); @@ -153,269 +51,6 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ]); }); - describe('Linux loader environment', () => { - const originalPlatform = process.platform; - const originalArch = process.arch; - - beforeEach(() => { - Object.defineProperty(process, 'platform', { value: 'linux' }); - Object.defineProperty(process, 'arch', { value: 'x64' }); - }); - - afterEach(() => { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - Object.defineProperty(process, 'arch', { value: originalArch }); - }); - - it('uses a sanitized system environment for the real helper session', () => { - adapter = createAdapter('/opt/iptvnator/native/helper', { - runtimeMode: 'system', - environment: { - PATH: '/usr/bin', - HOME: '/home/user', - ...HOSTILE_LOADER_ENVIRONMENT, - ...GRAPHICS_SELECTOR_ENVIRONMENT, - }, - }); - - createSession(); - - expect(spawnMock.mock.calls[0][2]).toEqual({ - stdio: ['pipe', 'pipe', 'pipe'], - env: { - PATH: '/usr/bin', - HOME: '/home/user', - ...GRAPHICS_SELECTOR_ENVIRONMENT, - }, - }); - }); - - it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => { - const snapRoot = '/snap/iptvnator/42'; - const nativeDir = path.join( - snapRoot, - 'resources', - 'app.asar.unpacked', - 'electron-backend', - 'native' - ); - adapter = createAdapter(path.join(nativeDir, 'helper'), { - runtimeMode: 'bundled', - helperLaunchFileSystem: { - lstatSync: (candidatePath) => - fakeStat( - candidatePath.endsWith('/graphics') - ? 'directory' - : 'file' - ) as Stats, - accessSync: () => undefined, - }, - environment: { - PATH: '/snap/bin:/usr/bin', - SNAP: snapRoot, - SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', - SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', - SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), - GBM_BACKENDS_PATH: '/tmp/hostile-gbm', - LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', - LIBVA_DRIVERS_PATH: '/tmp/hostile-va', - __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: - '/tmp/hostile-egl-platform', - __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', - VK_LAYER_PATH: '/tmp/hostile-vulkan', - XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', - XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', - XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', - XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', - ...HOSTILE_LOADER_ENVIRONMENT, - ...GRAPHICS_SELECTOR_ENVIRONMENT, - }, - }); - - createSession(); - - expect(spawnMock.mock.calls[0][0]).toBe( - path.join( - snapRoot, - 'graphics', - 'bin', - 'graphics-core22-provider-wrapper' - ) - ); - expect(spawnMock.mock.calls[0][1][0]).toBe( - path.join(nativeDir, 'helper') - ); - expect(spawnMock.mock.calls[0][2]).toEqual({ - stdio: ['pipe', 'pipe', 'pipe'], - env: { - PATH: '/usr/sbin:/usr/bin:/sbin:/bin', - SNAP: snapRoot, - SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl', - SNAP_ARCH: 'amd64', - SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', - SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), - ...GRAPHICS_SELECTOR_ENVIRONMENT, - GBM_BACKENDS_PATH: [ - path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'gbm' - ), - '/var/lib/snapd/lib/gl/gbm', - ].join(':'), - LIBGL_DRIVERS_PATH: path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'dri' - ), - LIBVA_DRIVERS_PATH: path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'dri' - ), - __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'egl', - 'egl_external_platform.d' - ), - __EGL_VENDOR_LIBRARY_DIRS: [ - '/var/lib/snapd/lib/glvnd/egl_vendor.d', - path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'glvnd', - 'egl_vendor.d' - ), - ].join(':'), - VK_LAYER_PATH: [ - path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'vulkan', - 'implicit_layer.d' - ), - path.join( - snapRoot, - 'graphics', - 'usr', - 'share', - 'vulkan', - 'explicit_layer.d' - ), - ].join(':'), - XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), - XDG_CONFIG_DIRS: [ - path.join(snapRoot, 'etc', 'xdg'), - '/etc/xdg', - ].join(':'), - XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), - XDG_DATA_DIRS: [ - path.join(snapRoot, 'graphics', 'usr', 'share'), - path.join(snapRoot, 'gnome-platform', 'usr', 'share'), - path.join(snapRoot, 'usr', 'share'), - '/usr/share', - ].join(':'), - LD_LIBRARY_PATH: [ - path.join(nativeDir, 'lib'), - '/var/lib/snapd/lib/gl', - path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu' - ), - path.join( - snapRoot, - 'graphics', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'vdpau' - ), - '/usr/lib/x86_64-linux-gnu', - path.join( - snapRoot, - 'gnome-platform', - 'lib', - 'x86_64-linux-gnu' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'mesa' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'mesa-egl' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'dri' - ), - path.join( - snapRoot, - 'gnome-platform', - 'usr', - 'lib', - 'x86_64-linux-gnu', - 'pulseaudio' - ), - path.join(snapRoot, 'lib'), - path.join(snapRoot, 'usr', 'lib'), - path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), - path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), - ].join(':'), - }, - }); - }); - - it('refuses a Linux session without a validated runtime mode', () => { - adapter = createAdapter('/native/helper', { runtimeMode: null }); - - expect(() => createSession()).toThrow( - 'validated Linux frame-copy runtime' - ); - expect(spawnMock).not.toHaveBeenCalled(); - }); - }); - it('caches helper snapshot events for getSessionSnapshot', () => { const sessionId = createSession(); child.emitStdout({ @@ -511,7 +146,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { }); it('reports unsupported without a helper binary', () => { - const withoutHelper = createAdapter(null); + const { adapter: withoutHelper } = createFrameCopyAdapter(null); expect(withoutHelper.isSupported()).toBe(false); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.test-helpers.ts new file mode 100644 index 000000000..128a612d3 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.test-helpers.ts @@ -0,0 +1,144 @@ +import { EventEmitter } from 'events'; +import type { Stats } from 'fs'; +import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; + +/** + * Shared fixtures for the frame-copy adapter specs. The `child_process` spawn + * mock stays in each spec file (it must be registered before the adapter is + * imported), and is handed to `createFrameCopyAdapter` via the spec's own + * `beforeEach`. + */ + +/** + * Every loader/shell override the sanitized helper environment must strip. + * Kept in one place so both specs assert against the same hostile input. + */ +export const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +/** Selectors that must survive sanitization (CI uses them for llvmpipe). */ +export const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', +} as const; + +export function fakeStat( + kind: 'directory' | 'file' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => false, + }; +} + +export class FakeHelperProcess extends EventEmitter { + exitCode: number | null = null; + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly stdin = { + writable: true, + written: [] as string[], + write(line: string) { + this.written.push(line); + return true; + }, + }; + readonly kill = jest.fn((signal?: string) => { + this.exitCode = 0; + this.emit('exit', 0, signal ?? null); + return true; + }); + + emitStdout(payload: object): void { + this.stdout.emit('data', Buffer.from(`${JSON.stringify(payload)}\n`)); + } +} + +export interface CreateFrameCopyAdapterOptions { + runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: { + lstatSync(filePath: string): Stats; + accessSync(filePath: string, mode: number): void; + }; +} + +export interface FrameSourceChange { + sessionId: string; + shmName: string; +} + +/** + * Build an adapter plus the list that records its frame-source callbacks. + * `getScaleFactor` is fixed at 2 so specs can assert device-pixel maths. + */ +export function createFrameCopyAdapter( + helperPath: string | null = '/native/helper', + { + runtimeMode = 'system', + environment, + helperLaunchFileSystem, + }: CreateFrameCopyAdapterOptions = {} +): { + adapter: EmbeddedMpvFrameCopyAdapter; + frameSourceChanges: FrameSourceChange[]; +} { + const frameSourceChanges: FrameSourceChange[] = []; + const adapter = new EmbeddedMpvFrameCopyAdapter({ + resolveHelperPath: () => helperPath, + resolveRuntimeMode: () => runtimeMode, + environment, + helperLaunchFileSystem, + getScaleFactor: () => 2, + onFrameSourceChanged: (sessionId, source) => + frameSourceChanges.push({ + sessionId, + shmName: source.shmName, + }), + } as ConstructorParameters[0]); + return { adapter, frameSourceChanges }; +} + +/** The standard 640x360 session every spec opens. */ +export function createFrameCopySession( + adapter: EmbeddedMpvFrameCopyAdapter +): string { + return adapter.createSession( + Buffer.alloc(0), + { x: 0, y: 0, width: 640, height: 360 }, + 'Title', + 0.8 + ); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts index 7c14720b0..b16331b8d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -7,7 +7,13 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util'; -import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime'; +import { + applyHelperEvent, + buildLoadPlaybackCommand, + createInitialSnapshot, + encodeProtocolValue, +} from './embedded-mpv-frame-copy-protocol'; +import { resolveFrameCopyHelperSpawn } from './embedded-mpv-frame-copy-spawn'; import type { EmbeddedMpvFrameCopyRuntimeMode, LinuxFrameCopyHelperLaunchFileSystem, @@ -24,11 +30,10 @@ import type { * size, audio) and publishes BGRA frames into a shared-memory ring that the * preload frame pump uploads to a renderer canvas. * - * Protocol: tab-separated commands over stdin, JSON events over stdout. - * The helper's `snapshot` events already carry the - * NativeEmbeddedMpvSessionSnapshot shape, so this adapter is mostly a - * process-lifecycle wrapper plus a snapshot cache that the existing - * EmbeddedMpvNativeService polling consumes unchanged. + * The wire protocol lives in `embedded-mpv-frame-copy-protocol.ts` and the + * launch/environment rules in `embedded-mpv-frame-copy-spawn.ts`, so this + * class is mostly a process-lifecycle wrapper plus a snapshot cache that the + * existing EmbeddedMpvNativeService polling consumes unchanged. */ export interface EmbeddedMpvFrameCopyAdapterOptions { @@ -56,31 +61,6 @@ interface FrameCopyRuntimeSession { const HELPER_QUIT_GRACE_MS = 500; const HELPER_KILL_GRACE_MS = 2000; -function encodeProtocolValue(value: string): string { - return value - .replace(/%/g, '%25') - .replace(/\t/g, '%09') - .replace(/\n/g, '%0A') - .replace(/\r/g, '%0D'); -} - -function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { - return { - status: 'loading', - positionSeconds: 0, - durationSeconds: null, - volume: 1, - streamUrl: '', - audioTracks: [], - selectedAudioTrackId: null, - subtitleTracks: [], - selectedSubtitleTrackId: null, - playbackSpeed: 1, - aspectOverride: 'no', - recording: { active: false }, - }; -} - export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { private readonly sessions = new Map(); @@ -113,62 +93,24 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } const sessionId = `impv-fc-${randomUUID().slice(0, 8)}`; - const scale = this.options.getScaleFactor(); - const width = Math.max(16, Math.round(bounds.width * scale)); - const height = Math.max(16, Math.round(bounds.height * scale)); - const helperArgs = [ - '--shm-base', - `/${sessionId}`, - '--width', - String(width), - '--height', - String(height), - '--volume', - String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), - // Lip-sync compensation for the video path's added latency - // (~10 ms measured on M1 Pro); tunable until calibration - // lands, see the architecture doc. - ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY - ? [ - '--audio-delay', - process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, - ] - : []), - ]; - let helperCommand = helperPath; - let resolvedHelperArgs = helperArgs; - let helperEnvironment: NodeJS.ProcessEnv | undefined; - if (process.platform === 'linux') { - const runtimeMode = this.options.resolveRuntimeMode(); - if (!runtimeMode) { - throw new Error( - 'A validated Linux frame-copy runtime is not available.' - ); - } - const launch = createLinuxFrameCopyHelperLaunch({ - environment: this.options.environment ?? process.env, - helperPath, - helperArgs, - runtimeMode, - fileSystem: this.options.helperLaunchFileSystem, - }); - if (!launch.usable) { - throw new Error( - 'The connected Snap graphics provider is not available.' - ); - } - helperCommand = launch.command; - resolvedHelperArgs = launch.args; - helperEnvironment = launch.env; - } + const plan = resolveFrameCopyHelperSpawn({ + bounds, + environment: this.options.environment, + helperLaunchFileSystem: this.options.helperLaunchFileSystem, + helperPath, + initialVolume, + resolveRuntimeMode: this.options.resolveRuntimeMode, + scale: this.options.getScaleFactor(), + sessionId, + }); - const child = spawn(helperCommand, resolvedHelperArgs, { + const child = spawn(plan.command, plan.args, { stdio: ['pipe', 'pipe', 'pipe'], - ...(helperEnvironment ? { env: helperEnvironment } : {}), + ...(plan.env ? { env: plan.env } : {}), }); console.log( - `[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)` + `[embedded-mpv-fc][${sessionId}] spawn ${plan.width}x${plan.height} (pid pending)` ); const session: FrameCopyRuntimeSession = { id: sessionId, @@ -215,40 +157,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void { - const fields: string[] = [ - `url=${encodeProtocolValue(playback.streamUrl)}`, - ]; - if (playback.title) { - fields.push( - `opt.force-media-title=${encodeProtocolValue(playback.title)}` - ); - } - if (playback.userAgent) { - fields.push( - `opt.user-agent=${encodeProtocolValue(playback.userAgent)}` - ); - } - if (playback.referer) { - fields.push( - `opt.referrer=${encodeProtocolValue(playback.referer)}` - ); - } - if ( - typeof playback.startTime === 'number' && - Number.isFinite(playback.startTime) && - playback.startTime >= 0 - ) { - fields.push(`opt.start=${playback.startTime}`); - } - if (playback.headers && Object.keys(playback.headers).length > 0) { - const headerFields = Object.entries(playback.headers) - .map(([key, value]) => `${key}: ${value}`) - .join(','); - fields.push( - `opt.http-header-fields=${encodeProtocolValue(headerFields)}` - ); - } - this.send(sessionId, `load\t${fields.join('\t')}`); + this.send(sessionId, buildLoadPlaybackCommand(playback)); } setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void { @@ -362,7 +271,13 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { newlineIndex = session.stdoutBuffer.indexOf('\n'); if (!line) continue; try { - this.handleEvent(session, JSON.parse(line)); + applyHelperEvent(session, JSON.parse(line), { + resolveReaderPath: () => this.resolveReaderPath(), + // Call through `this.options` so a callback that relies on + // its own receiver keeps working, as it did inline. + onFrameSourceChanged: (id, source) => + this.options.onFrameSourceChanged(id, source), + }); } catch { console.error( `[embedded-mpv-fc][${session.id}] unparseable event: ${line}` @@ -371,51 +286,6 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } } - private handleEvent( - session: FrameCopyRuntimeSession, - event: Record - ): void { - switch (event.event) { - case 'snapshot': { - const { event: _ignored, ...snapshot } = event; - session.snapshot = { - ...session.snapshot, - ...(snapshot as Partial), - } as NativeEmbeddedMpvSessionSnapshot; - break; - } - case 'shm': { - const source: EmbeddedMpvFrameSource = { - shmName: String(event.name ?? ''), - width: Number(event.width ?? 0), - height: Number(event.height ?? 0), - generation: Number(event.generation ?? 0), - readerPath: this.resolveReaderPath(), - }; - session.frameSource = source; - this.options.onFrameSourceChanged(session.id, source); - break; - } - case 'fatal': - session.snapshot.status = 'error'; - session.snapshot.error = String( - event.error ?? 'Embedded MPV helper failed.' - ); - break; - case 'log': - if (event.level === 'error' || event.level === 'fatal') { - console.error( - `[embedded-mpv-fc][${session.id}][mpv/${String( - event.prefix ?? '' - )}] ${String(event.text ?? '').trim()}` - ); - } - break; - default: - break; - } - } - private resolveReaderPath(): string { const helperPath = this.options.resolveHelperPath(); return helperPath diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage-validation.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage-validation.spec.ts new file mode 100644 index 000000000..de90ba8e8 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage-validation.spec.ts @@ -0,0 +1,147 @@ +import { unlinkSync } from 'fs'; +import path from 'path'; + +import { + cleanupTemporaryDirectories, + createArtifactFixture, + loadLinkageModule, + readelfDynamic, +} from './embedded-mpv-linux-linkage.test-helpers'; + +/** + * Artifact-level linkage validation: only the helper may link libmpv, and it + * must do so through `$ORIGIN/lib` with no RPATH. SONAME resolution and build + * mode inputs live in embedded-mpv-linux-linkage.spec.ts. + */ +describe('Linux Embedded MPV linkage validation', () => { + afterEach(() => { + cleanupTemporaryDirectories(); + }); + + it('accepts only process-isolated Linux frame-copy linkage', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).not.toThrow(); + }); + + it('rejects a helper linked to the wrong libmpv SONAME', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + fixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.3'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i); + }); + + it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const rpathFixture = createArtifactFixture(); + rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/host/lib'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: rpathFixture.outputDir, + readDynamicSection: rpathFixture.readDynamicSection, + }) + ).toThrow(/helper must not contain RPATH/i); + + const runpathFixture = createArtifactFixture(); + runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RUNPATH', '$ORIGIN'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: runpathFixture.outputDir, + readDynamicSection: runpathFixture.readDynamicSection, + }) + ).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i); + }); + + it.each([ + ['embedded_mpv.node', 'addon'], + ['embedded_mpv_frame_reader.node', 'frame reader'], + ])('rejects Electron-side libmpv linkage from %s', (fileName, label) => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i')); + }); + + it('rejects missing artifacts and readelf failures', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const missingArtifactFixture = createArtifactFixture(); + unlinkSync( + path.join( + missingArtifactFixture.outputDir, + 'embedded_mpv_frame_reader.node' + ) + ); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: missingArtifactFixture.outputDir, + readDynamicSection: missingArtifactFixture.readDynamicSection, + }) + ).toThrow(/missing.*frame reader/i); + + const readelfFailureFixture = createArtifactFixture(); + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: readelfFailureFixture.outputDir, + readDynamicSection: () => { + throw new Error('readelf is unavailable'); + }, + }) + ).toThrow(/readelf is unavailable/); + }); + + it('runs cleanup before rethrowing the original transaction failure', () => { + const { runWithCleanup } = loadLinkageModule(); + const calls: string[] = []; + const failure = new Error('post-link validation failed'); + + expect(() => + runWithCleanup( + () => { + calls.push('operation'); + throw failure; + }, + () => calls.push('cleanup') + ) + ).toThrow(failure); + expect(calls).toEqual(['operation', 'cleanup']); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts index 5ce94928d..12e71dc5d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts @@ -1,157 +1,22 @@ -import { - existsSync, - mkdtempSync, - mkdirSync, - rmSync, - symlinkSync, - unlinkSync, - writeFileSync, -} from 'fs'; -import { createHash } from 'crypto'; -import { createRequire } from 'module'; -import { tmpdir } from 'os'; +import { symlinkSync, unlinkSync, writeFileSync } from 'fs'; import path from 'path'; -const linkageModulePath = path.resolve( - __dirname, - '../../../embedded-mpv-linux-linkage.cjs' -); -const requireBuildHelper = createRequire(__filename); - -interface RuntimeFileRecord { - name: string; - size: number; - sha256: string; -} - -interface SonameFixture { - exactPath: string; - outputLibDir: string; - runtimeDependencyClosure: { - entries: Array<{ - name: string; - needed: string[]; - rpath: string[]; - runpath: string[]; - soname: string | null; - }>; - }; - runtimeFiles: RuntimeFileRecord[]; -} - -function loadLinkageModule(): { - parseReadelfDynamic: (output: string) => { - needed: string[]; - rpath: string[]; - runpath: string[]; - soname: string[]; - }; - resolveVerifiedLinuxLibMpvSoname: (options: { - outputLibDir: string; - readDynamicSection: (filePath: string) => string; - runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; - runtimeFiles: RuntimeFileRecord[]; - }) => string; - resolveLinuxFrameCopyLinkageInputs: (options: { - buildInputMode: string; - outputLibDir: string; - packagedLibmpvSoname: string | null; - readDynamicSection: (filePath: string) => string; - runtimeLibDir: string; - }) => { - expectedLibmpvSoname: string | null; - linkerLibraryDir: string; - }; - runWithCleanup: (operation: () => T, cleanup: () => void) => T; - validateLinuxFrameCopyLinkage: (options: { - expectedLibmpvSoname: string; - outputDir: string; - readDynamicSection: (filePath: string) => string; - }) => void; -} { - expect(existsSync(linkageModulePath)).toBe(true); - return requireBuildHelper(linkageModulePath); -} - -function sha256(contents: Buffer): string { - return createHash('sha256').update(contents).digest('hex'); -} - -function runtimeFile(name: string, contents: Buffer): RuntimeFileRecord { - return { - name, - size: contents.byteLength, - sha256: sha256(contents), - }; -} - -function readelfDynamic( - entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]> -): string { - return entries - .map( - ([tag, value], index) => - ` 0x${index - .toString(16) - .padStart(16, '0')} (${tag}) Library value: [${value}]` - ) - .join('\n'); -} - -describe('Linux Embedded MPV linkage verification', () => { - const temporaryDirectories: string[] = []; +import { + cleanupTemporaryDirectories, + createSonameFixture, + loadLinkageModule, + readelfDynamic, +} from './embedded-mpv-linux-linkage.test-helpers'; +/** + * SONAME resolution and build-mode linker inputs. The artifact-level linkage + * checks live in embedded-mpv-linux-linkage-validation.spec.ts. + */ +describe('Linux Embedded MPV linkage resolution', () => { afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } + cleanupTemporaryDirectories(); }); - function temporaryDirectory(): string { - const directory = mkdtempSync( - path.join(tmpdir(), 'iptvnator-mpv-linkage-') - ); - temporaryDirectories.push(directory); - return directory; - } - - function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture { - const outputLibDir = path.join(temporaryDirectory(), 'lib'); - mkdirSync(outputLibDir, { recursive: true }); - const contents = Buffer.from('verified libmpv ELF contents'); - const aliasPath = path.join(outputLibDir, 'libmpv.so'); - const exactPath = path.join(outputLibDir, soname); - writeFileSync(aliasPath, contents); - writeFileSync(exactPath, contents); - - return { - exactPath, - outputLibDir, - runtimeFiles: [ - runtimeFile('libmpv.so', contents), - runtimeFile(soname, contents), - ], - runtimeDependencyClosure: { - entries: [ - { - name: 'libmpv.so', - needed: [], - rpath: [], - runpath: ['$ORIGIN'], - soname, - }, - { - name: soname, - needed: [], - rpath: [], - runpath: ['$ORIGIN'], - soname, - }, - ], - }, - }; - } - it('parses every dynamic tag without hiding duplicate SONAME entries', () => { const { parseReadelfDynamic } = loadLinkageModule(); @@ -383,157 +248,4 @@ describe('Linux Embedded MPV linkage verification', () => { }) ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); }); - - function createArtifactFixture(): { - outputDir: string; - readDynamicSection: (filePath: string) => string; - outputs: Record; - } { - const outputDir = temporaryDirectory(); - const outputs: Record = { - 'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]), - 'embedded_mpv_frame_reader.node': readelfDynamic([]), - iptvnator_mpv_helper: readelfDynamic([ - ['NEEDED', 'libmpv.so.2'], - ['NEEDED', 'libEGL.so.1'], - ['RUNPATH', '$ORIGIN/lib'], - ]), - }; - for (const artifact of Object.keys(outputs)) { - writeFileSync(path.join(outputDir, artifact), 'ELF'); - } - return { - outputDir, - outputs, - readDynamicSection: (filePath: string) => - outputs[path.basename(filePath)], - }; - } - - it('accepts only process-isolated Linux frame-copy linkage', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const fixture = createArtifactFixture(); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: fixture.outputDir, - readDynamicSection: fixture.readDynamicSection, - }) - ).not.toThrow(); - }); - - it('rejects a helper linked to the wrong libmpv SONAME', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const fixture = createArtifactFixture(); - - fixture.outputs.iptvnator_mpv_helper = readelfDynamic([ - ['NEEDED', 'libmpv.so.3'], - ['RUNPATH', '$ORIGIN/lib'], - ]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: fixture.outputDir, - readDynamicSection: fixture.readDynamicSection, - }) - ).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i); - }); - - it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const rpathFixture = createArtifactFixture(); - rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ - ['NEEDED', 'libmpv.so.2'], - ['RPATH', '/host/lib'], - ['RUNPATH', '$ORIGIN/lib'], - ]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: rpathFixture.outputDir, - readDynamicSection: rpathFixture.readDynamicSection, - }) - ).toThrow(/helper must not contain RPATH/i); - - const runpathFixture = createArtifactFixture(); - runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ - ['NEEDED', 'libmpv.so.2'], - ['RUNPATH', '$ORIGIN'], - ]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: runpathFixture.outputDir, - readDynamicSection: runpathFixture.readDynamicSection, - }) - ).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i); - }); - - it.each([ - ['embedded_mpv.node', 'addon'], - ['embedded_mpv_frame_reader.node', 'frame reader'], - ])('rejects Electron-side libmpv linkage from %s', (fileName, label) => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const fixture = createArtifactFixture(); - fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: fixture.outputDir, - readDynamicSection: fixture.readDynamicSection, - }) - ).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i')); - }); - - it('rejects missing artifacts and readelf failures', () => { - const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); - const missingArtifactFixture = createArtifactFixture(); - unlinkSync( - path.join( - missingArtifactFixture.outputDir, - 'embedded_mpv_frame_reader.node' - ) - ); - - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: missingArtifactFixture.outputDir, - readDynamicSection: missingArtifactFixture.readDynamicSection, - }) - ).toThrow(/missing.*frame reader/i); - - const readelfFailureFixture = createArtifactFixture(); - expect(() => - validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: 'libmpv.so.2', - outputDir: readelfFailureFixture.outputDir, - readDynamicSection: () => { - throw new Error('readelf is unavailable'); - }, - }) - ).toThrow(/readelf is unavailable/); - }); - - it('runs cleanup before rethrowing the original transaction failure', () => { - const { runWithCleanup } = loadLinkageModule(); - const calls: string[] = []; - const failure = new Error('post-link validation failed'); - - expect(() => - runWithCleanup( - () => { - calls.push('operation'); - throw failure; - }, - () => calls.push('cleanup') - ) - ).toThrow(failure); - expect(calls).toEqual(['operation', 'cleanup']); - }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.test-helpers.ts new file mode 100644 index 000000000..bd7d14045 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.test-helpers.ts @@ -0,0 +1,196 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from 'fs'; +import { createHash } from 'crypto'; +import { createRequire } from 'module'; +import { tmpdir } from 'os'; +import path from 'path'; + +/** + * Shared fixtures for the Linux frame-copy linkage specs. The module under + * test is the CommonJS build helper `embedded-mpv-linux-linkage.cjs`, loaded + * through `createRequire` because it is packaging tooling rather than app code. + */ + +const linkageModulePath = path.resolve( + __dirname, + '../../../embedded-mpv-linux-linkage.cjs' +); +const requireBuildHelper = createRequire(__filename); + +export interface RuntimeFileRecord { + name: string; + size: number; + sha256: string; +} + +export interface SonameFixture { + exactPath: string; + outputLibDir: string; + runtimeDependencyClosure: { + entries: Array<{ + name: string; + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string | null; + }>; + }; + runtimeFiles: RuntimeFileRecord[]; +} + +export interface ArtifactFixture { + outputDir: string; + readDynamicSection: (filePath: string) => string; + outputs: Record; +} + +export interface LinkageModule { + parseReadelfDynamic: (output: string) => { + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string[]; + }; + resolveVerifiedLinuxLibMpvSoname: (options: { + outputLibDir: string; + readDynamicSection: (filePath: string) => string; + runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; + runtimeFiles: RuntimeFileRecord[]; + }) => string; + resolveLinuxFrameCopyLinkageInputs: (options: { + buildInputMode: string; + outputLibDir: string; + packagedLibmpvSoname: string | null; + readDynamicSection: (filePath: string) => string; + runtimeLibDir: string; + }) => { + expectedLibmpvSoname: string | null; + linkerLibraryDir: string; + }; + runWithCleanup: (operation: () => T, cleanup: () => void) => T; + validateLinuxFrameCopyLinkage: (options: { + expectedLibmpvSoname: string; + outputDir: string; + readDynamicSection: (filePath: string) => string; + }) => void; +} + +export function loadLinkageModule(): LinkageModule { + expect(existsSync(linkageModulePath)).toBe(true); + return requireBuildHelper(linkageModulePath); +} + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +export function runtimeFile( + name: string, + contents: Buffer +): RuntimeFileRecord { + return { + name, + size: contents.byteLength, + sha256: sha256(contents), + }; +} + +/** Render a `readelf -d` style dynamic section from tag/value pairs. */ +export function readelfDynamic( + entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]> +): string { + return entries + .map( + ([tag, value], index) => + ` 0x${index + .toString(16) + .padStart(16, '0')} (${tag}) Library value: [${value}]` + ) + .join('\n'); +} + +/** + * Temporary-directory registry. Specs call `createTemporaryDirectory()` while + * building fixtures and `cleanupTemporaryDirectories()` from their `afterEach`. + */ +const temporaryDirectories: string[] = []; + +export function createTemporaryDirectory(): string { + const directory = mkdtempSync( + path.join(tmpdir(), 'iptvnator-mpv-linkage-') + ); + temporaryDirectories.push(directory); + return directory; +} + +export function cleanupTemporaryDirectories(): void { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +} + +/** A verified libmpv copy: matching alias + exact-SONAME regular files. */ +export function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture { + const outputLibDir = path.join(createTemporaryDirectory(), 'lib'); + mkdirSync(outputLibDir, { recursive: true }); + const contents = Buffer.from('verified libmpv ELF contents'); + const aliasPath = path.join(outputLibDir, 'libmpv.so'); + const exactPath = path.join(outputLibDir, soname); + writeFileSync(aliasPath, contents); + writeFileSync(exactPath, contents); + + return { + exactPath, + outputLibDir, + runtimeFiles: [ + runtimeFile('libmpv.so', contents), + runtimeFile(soname, contents), + ], + runtimeDependencyClosure: { + entries: [ + { + name: 'libmpv.so', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + { + name: soname, + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + ], + }, + }; +} + +/** A built output directory with process-isolated linkage. */ +export function createArtifactFixture(): ArtifactFixture { + const outputDir = createTemporaryDirectory(); + const outputs: Record = { + 'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]), + 'embedded_mpv_frame_reader.node': readelfDynamic([]), + iptvnator_mpv_helper: readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['NEEDED', 'libEGL.so.1'], + ['RUNPATH', '$ORIGIN/lib'], + ]), + }; + for (const artifact of Object.keys(outputs)) { + writeFileSync(path.join(outputDir, artifact), 'ELF'); + } + return { + outputDir, + outputs, + readDynamicSection: (filePath: string) => + outputs[path.basename(filePath)], + }; +} diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 52bdbb860..85432ddcf 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -386,11 +386,15 @@ Current sources: Main-process EPG ownership is split across focused event modules: 1. `apps/electron-backend/src/app/events/epg.events.ts` registers EPG IPC - handlers and owns freshness/fetch orchestration. -2. `apps/electron-backend/src/app/events/epg-worker.service.ts` owns EPG + handlers and delegates to the modules below. +2. `apps/electron-backend/src/app/events/epg-fetch.service.ts` owns EPG + freshness checks and multi-URL fetch orchestration. +3. `apps/electron-backend/src/app/events/epg-mapping.service.ts` owns manual + EPG channel-mapping resolution and CRUD at the IPC boundary. +4. `apps/electron-backend/src/app/events/epg-worker.service.ts` owns EPG worker creation, renderer progress updates, fetch worker lifecycle, and clear-worker lifecycle. -3. `apps/electron-backend/src/app/events/epg-query.service.ts` owns EPG +5. `apps/electron-backend/src/app/events/epg-query.service.ts` owns EPG channel/program database lookups, metadata resolution, and DB row mapping. Keep worker lifecycle state out of the IPC registration layer. Add new EPG DB diff --git a/tools/eslint/generate-max-lines-baseline.mjs b/tools/eslint/generate-max-lines-baseline.mjs index a92204f12..2fe912693 100644 --- a/tools/eslint/generate-max-lines-baseline.mjs +++ b/tools/eslint/generate-max-lines-baseline.mjs @@ -5,6 +5,11 @@ * files that already exceed the max-lines limit enforced in eslint.config.mjs. * Baselined files are exempt from the rule; the list should only shrink. * + * Files that carry their own file-wide `eslint-disable max-lines` comment are + * skipped: they are already exempt with a written justification next to the + * code, which is the preferred escape hatch for a file that genuinely cannot + * be split (for example a function serialized into another process). + * * Usage: node tools/eslint/generate-max-lines-baseline.mjs */ @@ -34,8 +39,7 @@ function collectTsFiles(dir, results) { return results; } -function countLines(filePath) { - const content = readFileSync(filePath, 'utf8'); +function countLines(content) { if (content.length === 0) { return 0; } @@ -43,19 +47,58 @@ function countLines(filePath) { return content.endsWith('\n') ? lines - 1 : lines; } +/** + * True when the file already suppresses max-lines with a file-wide + * `/* eslint-disable *\/` comment. Those files are deliberately exempt with a + * reviewed justification, so baselining them too would be redundant — and + * would wrongly imply they merely predate the rule. + */ +function hasInlineMaxLinesDisable(content) { + // Only a file-wide `eslint-disable` block comment can suppress max-lines; + // the rule reports at the line where the limit is exceeded, so + // `eslint-disable-next-line` cannot apply to it. + const disableComments = content.matchAll( + /\/\*\s*eslint-disable\s*([^*]*?)\*\//g + ); + for (const [, ruleList = ''] of disableComments) { + // `/* eslint-disable */` with no rules disables everything. + const rules = ruleList.split('--')[0].trim(); + if (rules === '') { + return true; + } + if ( + rules + .split(',') + .some((rule) => rule.trim() === 'max-lines') + ) { + return true; + } + } + return false; +} + const offenders = scanRoots .flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), [])) - .map((filePath) => ({ - file: path.relative(workspaceRoot, filePath).split(path.sep).join('/'), - lines: countLines(filePath), - })) - .filter(({ lines }) => lines > MAX_LINES) + .map((filePath) => { + const content = readFileSync(filePath, 'utf8'); + return { + file: path + .relative(workspaceRoot, filePath) + .split(path.sep) + .join('/'), + lines: countLines(content), + disabled: hasInlineMaxLinesDisable(content), + }; + }) + .filter(({ lines, disabled }) => lines > MAX_LINES && !disabled) .sort((a, b) => a.file.localeCompare(b.file)); const banner = `// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand. // TypeScript files that predate the max-lines (${MAX_LINES}) ESLint rule. // This list should only shrink: split a file below the limit, rerun the // generator, and commit the result. Never add new files here. +// A new file that genuinely cannot be split takes a file-wide +// \`/* eslint-disable max-lines -- */\` instead; the generator skips those. `; const body = offenders.map(({ file }) => ` '${file}',`).join('\n'); diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index 30190ab82..f9eabcf59 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -2,6 +2,8 @@ // TypeScript files that predate the max-lines (400) ESLint rule. // This list should only shrink: split a file below the limit, rerun the // generator, and commit the result. Never add new files here. +// A new file that genuinely cannot be split takes a file-wide +// `/* eslint-disable max-lines -- */` instead; the generator skips those. export const maxLinesBaseline = [ 'apps/electron-backend-e2e/src/catalog-sorting.e2e.ts', 'apps/electron-backend-e2e/src/category-management.e2e.ts',