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