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.
This commit is contained in:
4gray authored and GitHub committed 2026-07-27 02:16:02 +02:00
1 parent 02b966895d
commit 636545cbb7
17 files changed
+1460 -1126

No files matched your search

+1
View File
@@ -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 -- <why> */`; 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
+6 -2
View File
@@ -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 -- <why> */`; 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
@@ -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<EpgFreshnessResult> {
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<EpgFetchResult> {
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 };
}
@@ -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<Map<string, string>> {
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<T>(
channelIds: string[],
query: (resolvedIds: string[]) => Promise<Record<string, T | null>>
): Promise<Record<string, T | null>> {
const resolvedMap = await resolveChannelIds(channelIds);
const resolvedIds = channelIds.map((id) => resolvedMap.get(id) ?? id);
const results = await query(resolvedIds);
const remapped: Record<string, T | null> = {};
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<EpgMappingRecord | null> {
try {
const db = await getDatabase();
return getEpgMapping(db, channelKey);
} catch {
return null;
}
}
export async function handleGetEpgMappingsBatch(
channelKeys: string[]
): Promise<Record<string, string>> {
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<EpgChannelSearchResult[]> {
if (!searchTerm?.trim()) {
return [];
}
try {
const db = await getDatabase();
return searchEpgChannels(db, searchTerm, limit);
} catch {
return [];
}
}
@@ -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<EpgFreshnessResult> {
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<EpgFetchResult> {
return handleFetchEpg(urls, options);
}
private static async fetchEpgFromUrl(
@@ -354,21 +221,9 @@ export default class EpgEvents {
channelIds: string[],
options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgProgram | null>> {
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<string, EpgProgram | null> = {};
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<Record<string, EpgChannelMetadata | null>> {
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<string, EpgChannelMetadata | null> = {};
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<Map<string, string>> {
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<Record<string, string>> {
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<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
if (!searchTerm?.trim()) {
return [];
}
try {
const db = await getDatabase();
return searchEpgChannels(db, searchTerm, limit);
} catch {
return [];
}
}
static async clearEpgData(): Promise<void> {
return epgWorkerService.clearEpgData();
}
@@ -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<string, unknown>,
handlers: HelperEventHandlers
): void {
switch (event.event) {
case 'snapshot': {
const { event: _ignored, ...snapshot } = event;
session.snapshot = {
...session.snapshot,
...(snapshot as Partial<NativeEmbeddedMpvSessionSnapshot>),
} 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;
}
}
@@ -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,
};
}
@@ -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();
});
});
@@ -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<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
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<typeof EmbeddedMpvFrameCopyAdapter>[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);
});
@@ -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<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
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<typeof EmbeddedMpvFrameCopyAdapter>[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
);
}
@@ -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<string, FrameCopyRuntimeSession>();
@@ -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<string, unknown>
): void {
switch (event.event) {
case 'snapshot': {
const { event: _ignored, ...snapshot } = event;
session.snapshot = {
...session.snapshot,
...(snapshot as Partial<NativeEmbeddedMpvSessionSnapshot>),
} 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
@@ -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']);
});
});
@@ -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: <T>(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<string, string>;
} {
const outputDir = temporaryDirectory();
const outputs: Record<string, string> = {
'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']);
});
});
@@ -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<string, string>;
}
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: <T>(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<string, string> = {
'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)],
};
}
+7 -3
View File
@@ -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
+50 -7
View File
@@ -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 -- <why> */\` instead; the generator skips those.
`;
const body = offenders.map(({ file }) => ` '${file}',`).join('\n');
+2
View File
@@ -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 -- <why> */` 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',