mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
02b966895d
commit
636545cbb7
17 files changed
+1460
-1126
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+295
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+144
@@ -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)],
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
Reference in new issue
Block a user