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`. - 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. - 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. - 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. - 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 ## 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 rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files
are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline 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. 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 ## 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/` - **Event handlers**: `apps/electron-backend/src/app/events/`
- `database.events.ts` - Database CRUD operations - `database.events.ts` - Database CRUD operations
- `playlist.events.ts` - Playlist import/update - `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 - `xtream.events.ts` - Xtream Codes API
- `stalker.events.ts` - Stalker portal 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 - `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 { ipcMain } from 'electron';
import { import {
ElectronBridgeTrustOptions, ElectronBridgeTrustOptions,
EpgChannelMetadata, EpgChannelMetadata,
EpgProgram, EpgProgram,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
import { epgQueryService } from './epg-query.service'; import { epgQueryService } from './epg-query.service';
import { epgWorkerService } from './epg-worker.service'; import { epgWorkerService } from './epg-worker.service';
import { checkEpgFreshness, handleFetchEpg } from './epg-fetch.service';
import type { EpgFetchResult, EpgFreshnessResult } from './epg-fetch.service';
import { import {
getEpgMapping, handleDeleteEpgMapping,
getEpgMappingsBatch, handleGetEpgMapping,
setEpgMapping, handleGetEpgMappingsBatch,
deleteEpgMapping, handleSearchEpgChannels,
searchEpgChannels, handleSetEpgMapping,
} from '../database/operations/epg-mapping.operations'; queryByResolvedChannelIds,
} from './epg-mapping.service';
/** /**
* EPG Events Handler * EPG Events Handler
* Manages EPG IPC registration and delegates worker/query behavior. * 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 { export default class EpgEvents {
private static readonly loggerLabel = '[EPG Events]';
/** /**
* Bootstrap 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( ipcMain.handle(
'EPG_MAPPING_GET', 'EPG_MAPPING_GET',
async (_event, args: { channelKey: string }) => { async (_event, args: { channelKey: string }) => {
return this.handleGetEpgMapping(args.channelKey); return handleGetEpgMapping(args.channelKey);
} }
); );
@@ -154,7 +154,7 @@ export default class EpgEvents {
playlistId?: string; playlistId?: string;
} }
) => { ) => {
return this.handleSetEpgMapping( return handleSetEpgMapping(
args.channelKey, args.channelKey,
args.epgChannelId, args.epgChannelId,
args.playlistId args.playlistId
@@ -165,14 +165,14 @@ export default class EpgEvents {
ipcMain.handle( ipcMain.handle(
'EPG_MAPPING_GET_BATCH', 'EPG_MAPPING_GET_BATCH',
async (_event, args: { channelKeys: string[] }) => { async (_event, args: { channelKeys: string[] }) => {
return this.handleGetEpgMappingsBatch(args.channelKeys); return handleGetEpgMappingsBatch(args.channelKeys);
} }
); );
ipcMain.handle( ipcMain.handle(
'EPG_MAPPING_DELETE', 'EPG_MAPPING_DELETE',
async (_event, args: { channelKey: string }) => { async (_event, args: { channelKey: string }) => {
return this.handleDeleteEpgMapping(args.channelKey); return handleDeleteEpgMapping(args.channelKey);
} }
); );
@@ -182,158 +182,25 @@ export default class EpgEvents {
_event, _event,
args: { searchTerm: string; limit?: number } args: { searchTerm: string; limit?: number }
) => { ) => {
return this.handleSearchEpgChannels( return handleSearchEpgChannels(args.searchTerm, args.limit);
args.searchTerm,
args.limit
);
} }
); );
return ipcMain; 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( private static async checkEpgFreshness(
urls: string[], urls: string[],
maxAgeHours: number maxAgeHours: number
): Promise<{ staleUrls: string[]; freshUrls: string[] }> { ): Promise<EpgFreshnessResult> {
const staleUrls: string[] = []; return checkEpgFreshness(urls, maxAgeHours);
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 };
} }
/**
* 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( private static async handleFetchEpg(
urls: string[], urls: string[],
options: ElectronBridgeTrustOptions = {} options: ElectronBridgeTrustOptions = {}
): Promise<{ success: boolean; message?: string; skipped?: string[] }> { ): Promise<EpgFetchResult> {
const validUrls = urls.filter((url) => url?.trim()); return handleFetchEpg(urls, options);
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 };
} }
private static async fetchEpgFromUrl( private static async fetchEpgFromUrl(
@@ -354,21 +221,9 @@ export default class EpgEvents {
channelIds: string[], channelIds: string[],
options?: { sourceUrls?: string[] } options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgProgram | null>> { ): Promise<Record<string, EpgProgram | null>> {
const resolvedMap = await this.resolveChannelIds(channelIds); return queryByResolvedChannelIds(channelIds, (resolvedIds) =>
const resolvedIds = channelIds.map( epgQueryService.getCurrentProgramsBatch(resolvedIds, options)
(id) => resolvedMap.get(id) ?? id
); );
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<{ private static async handleGetAllChannels(): Promise<{
@@ -382,21 +237,9 @@ export default class EpgEvents {
channelIds: string[], channelIds: string[],
options?: { sourceUrls?: string[] } options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgChannelMetadata | null>> { ): Promise<Record<string, EpgChannelMetadata | null>> {
const resolvedMap = await this.resolveChannelIds(channelIds); return queryByResolvedChannelIds(channelIds, (resolvedIds) =>
const resolvedIds = channelIds.map( epgQueryService.getChannelMetadata(resolvedIds, options)
(id) => resolvedMap.get(id) ?? id
); );
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( private static async handleGetChannelsByRange(
@@ -413,97 +256,6 @@ export default class EpgEvents {
return epgQueryService.getChannelsByRange(skip, limit); 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> { static async clearEpgData(): Promise<void> {
return epgWorkerService.clearEpgData(); 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'; import path from 'path';
const spawnMock = jest.fn(); const spawnMock = jest.fn();
@@ -7,134 +5,34 @@ jest.mock('child_process', () => ({
spawn: (...args: unknown[]) => spawnMock(...args), spawn: (...args: unknown[]) => spawnMock(...args),
})); }));
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; import type { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; import {
createFrameCopyAdapter,
const HOSTILE_LOADER_ENVIRONMENT = { createFrameCopySession,
BASH_ENV: '/tmp/hostile-bash-env', FakeHelperProcess,
ENV: '/tmp/hostile-shell-env', type FrameSourceChange,
BASHOPTS: 'extdebug', } from './embedded-mpv-frame-copy.adapter.test-helpers';
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`));
}
}
// The Linux loader-environment contract lives in
// embedded-mpv-frame-copy.adapter.linux-env.spec.ts.
describe('EmbeddedMpvFrameCopyAdapter', () => { describe('EmbeddedMpvFrameCopyAdapter', () => {
let child: FakeHelperProcess; let child: FakeHelperProcess;
let frameSourceChanges: Array<{ sessionId: string; shmName: string }>; let frameSourceChanges: FrameSourceChange[];
let adapter: EmbeddedMpvFrameCopyAdapter; 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(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
child = new FakeHelperProcess(); child = new FakeHelperProcess();
spawnMock.mockReset(); spawnMock.mockReset();
spawnMock.mockReturnValue(child); spawnMock.mockReturnValue(child);
adapter = createAdapter(); ({ adapter, frameSourceChanges } = createFrameCopyAdapter());
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers();
}); });
const createSession = () => const createSession = () => createFrameCopySession(adapter);
adapter.createSession(
Buffer.alloc(0),
{ x: 0, y: 0, width: 640, height: 360 },
'Title',
0.8
);
it('spawns the helper with device-pixel size and initial volume', () => { it('spawns the helper with device-pixel size and initial volume', () => {
const sessionId = createSession(); 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', () => { it('caches helper snapshot events for getSessionSnapshot', () => {
const sessionId = createSession(); const sessionId = createSession();
child.emitStdout({ child.emitStdout({
@@ -511,7 +146,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
}); });
it('reports unsupported without a helper binary', () => { it('reports unsupported without a helper binary', () => {
const withoutHelper = createAdapter(null); const { adapter: withoutHelper } = createFrameCopyAdapter(null);
expect(withoutHelper.isSupported()).toBe(false); 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, ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces'; } from '@iptvnator/shared/interfaces';
import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util'; 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 { import type {
EmbeddedMpvFrameCopyRuntimeMode, EmbeddedMpvFrameCopyRuntimeMode,
LinuxFrameCopyHelperLaunchFileSystem, LinuxFrameCopyHelperLaunchFileSystem,
@@ -24,11 +30,10 @@ import type {
* size, audio) and publishes BGRA frames into a shared-memory ring that the * size, audio) and publishes BGRA frames into a shared-memory ring that the
* preload frame pump uploads to a renderer canvas. * preload frame pump uploads to a renderer canvas.
* *
* Protocol: tab-separated commands over stdin, JSON events over stdout. * The wire protocol lives in `embedded-mpv-frame-copy-protocol.ts` and the
* The helper's `snapshot` events already carry the * launch/environment rules in `embedded-mpv-frame-copy-spawn.ts`, so this
* NativeEmbeddedMpvSessionSnapshot shape, so this adapter is mostly a * class is mostly a process-lifecycle wrapper plus a snapshot cache that the
* process-lifecycle wrapper plus a snapshot cache that the existing * existing EmbeddedMpvNativeService polling consumes unchanged.
* EmbeddedMpvNativeService polling consumes unchanged.
*/ */
export interface EmbeddedMpvFrameCopyAdapterOptions { export interface EmbeddedMpvFrameCopyAdapterOptions {
@@ -56,31 +61,6 @@ interface FrameCopyRuntimeSession {
const HELPER_QUIT_GRACE_MS = 500; const HELPER_QUIT_GRACE_MS = 500;
const HELPER_KILL_GRACE_MS = 2000; 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 { export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
private readonly sessions = new Map<string, FrameCopyRuntimeSession>(); 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 sessionId = `impv-fc-${randomUUID().slice(0, 8)}`;
const scale = this.options.getScaleFactor(); const plan = resolveFrameCopyHelperSpawn({
const width = Math.max(16, Math.round(bounds.width * scale)); bounds,
const height = Math.max(16, Math.round(bounds.height * scale)); environment: this.options.environment,
const helperArgs = [ helperLaunchFileSystem: this.options.helperLaunchFileSystem,
'--shm-base', helperPath,
`/${sessionId}`, initialVolume,
'--width', resolveRuntimeMode: this.options.resolveRuntimeMode,
String(width), scale: this.options.getScaleFactor(),
'--height', sessionId,
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 child = spawn(helperCommand, resolvedHelperArgs, { const child = spawn(plan.command, plan.args, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
...(helperEnvironment ? { env: helperEnvironment } : {}), ...(plan.env ? { env: plan.env } : {}),
}); });
console.log( 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 = { const session: FrameCopyRuntimeSession = {
id: sessionId, id: sessionId,
@@ -215,40 +157,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
} }
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void { loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
const fields: string[] = [ this.send(sessionId, buildLoadPlaybackCommand(playback));
`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')}`);
} }
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void { setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
@@ -362,7 +271,13 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
newlineIndex = session.stdoutBuffer.indexOf('\n'); newlineIndex = session.stdoutBuffer.indexOf('\n');
if (!line) continue; if (!line) continue;
try { 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 { } catch {
console.error( console.error(
`[embedded-mpv-fc][${session.id}] unparseable event: ${line}` `[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 { private resolveReaderPath(): string {
const helperPath = this.options.resolveHelperPath(); const helperPath = this.options.resolveHelperPath();
return helperPath 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 { import { symlinkSync, unlinkSync, writeFileSync } from 'fs';
existsSync,
mkdtempSync,
mkdirSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { createHash } from 'crypto';
import { createRequire } from 'module';
import { tmpdir } from 'os';
import path from 'path'; import path from 'path';
const linkageModulePath = path.resolve( import {
__dirname, cleanupTemporaryDirectories,
'../../../embedded-mpv-linux-linkage.cjs' createSonameFixture,
); loadLinkageModule,
const requireBuildHelper = createRequire(__filename); readelfDynamic,
} from './embedded-mpv-linux-linkage.test-helpers';
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[] = [];
/**
* 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(() => { afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) { cleanupTemporaryDirectories();
rmSync(directory, { recursive: true, force: true });
}
}); });
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', () => { it('parses every dynamic tag without hiding duplicate SONAME entries', () => {
const { parseReadelfDynamic } = loadLinkageModule(); const { parseReadelfDynamic } = loadLinkageModule();
@@ -383,157 +248,4 @@ describe('Linux Embedded MPV linkage verification', () => {
}) })
).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); ).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: Main-process EPG ownership is split across focused event modules:
1. `apps/electron-backend/src/app/events/epg.events.ts` registers EPG IPC 1. `apps/electron-backend/src/app/events/epg.events.ts` registers EPG IPC
handlers and owns freshness/fetch orchestration. handlers and delegates to the modules below.
2. `apps/electron-backend/src/app/events/epg-worker.service.ts` owns EPG 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 worker creation, renderer progress updates, fetch worker lifecycle, and
clear-worker lifecycle. 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. channel/program database lookups, metadata resolution, and DB row mapping.
Keep worker lifecycle state out of the IPC registration layer. Add new EPG DB 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. * 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. * 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 * Usage: node tools/eslint/generate-max-lines-baseline.mjs
*/ */
@@ -34,8 +39,7 @@ function collectTsFiles(dir, results) {
return results; return results;
} }
function countLines(filePath) { function countLines(content) {
const content = readFileSync(filePath, 'utf8');
if (content.length === 0) { if (content.length === 0) {
return 0; return 0;
} }
@@ -43,19 +47,58 @@ function countLines(filePath) {
return content.endsWith('\n') ? lines - 1 : lines; 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 const offenders = scanRoots
.flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), [])) .flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), []))
.map((filePath) => ({ .map((filePath) => {
file: path.relative(workspaceRoot, filePath).split(path.sep).join('/'), const content = readFileSync(filePath, 'utf8');
lines: countLines(filePath), return {
})) file: path
.filter(({ lines }) => lines > MAX_LINES) .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)); .sort((a, b) => a.file.localeCompare(b.file));
const banner = `// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand. 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. // TypeScript files that predate the max-lines (${MAX_LINES}) ESLint rule.
// This list should only shrink: split a file below the limit, rerun the // This list should only shrink: split a file below the limit, rerun the
// generator, and commit the result. Never add new files here. // 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'); 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. // TypeScript files that predate the max-lines (400) ESLint rule.
// This list should only shrink: split a file below the limit, rerun the // This list should only shrink: split a file below the limit, rerun the
// generator, and commit the result. Never add new files here. // 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 = [ export const maxLinesBaseline = [
'apps/electron-backend-e2e/src/catalog-sorting.e2e.ts', 'apps/electron-backend-e2e/src/catalog-sorting.e2e.ts',
'apps/electron-backend-e2e/src/category-management.e2e.ts', 'apps/electron-backend-e2e/src/category-management.e2e.ts',