Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka

# Conflicts:
#	CLAUDE.md
#	libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
4gray committed 2026-07-24 18:44:22 +02:00
commit 5b2dd3b8b0
425 files changed
+59216 -3432

No files matched your search

@@ -0,0 +1,27 @@
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';
export default defineConfig({
...baseConfig,
outputDir:
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke',
reporter: [
['list'],
[
'html',
{
outputFolder:
'../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke',
},
],
[
'json',
{
outputFile:
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json',
},
],
],
timeout: 120000,
webServer: [],
});
+20
View File
@@ -12,6 +12,26 @@
"e2e": {
"dependsOn": ["electron-backend:build-e2e"]
},
"packaged-frame-copy-smoke": {
"dependsOn": ["test-packaged-frame-copy-fixtures"],
"executor": "nx:run-commands",
"cache": false,
"outputs": [
"{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke",
"{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke"
],
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts"
}
},
"test-packaged-frame-copy-fixtures": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
+187 -1
View File
@@ -1,4 +1,6 @@
import { mkdirSync, readdirSync } from 'fs';
import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
import { createServer } from 'http';
import type { AddressInfo } from 'net';
import { join } from 'path';
import type { Page } from '@playwright/test';
import {
@@ -17,6 +19,90 @@ async function openDownloadsPage(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
}
interface RangeServerRequest {
ifRange?: string;
range?: string;
}
interface ThrottledRangeServer {
close: () => Promise<void>;
payload: Buffer;
requests: RangeServerRequest[];
url: string;
}
const RANGE_SERVER_ETAG = '"e2e-range-etag"';
/**
* Serves a payload slowly on the first (full) request so the UI has a wide
* window to pause mid-transfer, and answers Range requests with an immediate
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
*/
async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
const payload = Buffer.from(
Array.from({ length: 96 * 1024 }, (_, index) =>
String(index % 10)
).join('')
);
const requests: RangeServerRequest[] = [];
const server = createServer((req, res) => {
const range = req.headers.range;
const ifRange = req.headers['if-range'];
requests.push({
ifRange: typeof ifRange === 'string' ? ifRange : undefined,
range: typeof range === 'string' ? range : undefined,
});
const offset = range
? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN)
: 0;
if (range && Number.isFinite(offset)) {
res.writeHead(206, {
'Content-Length': payload.length - offset,
'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
res.end(payload.subarray(offset));
return;
}
res.writeHead(200, {
'Content-Length': payload.length,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
// First 16 KiB immediately, then a trickle: the transfer stays alive
// for tens of seconds unless it is paused or resumed via Range.
let sent = 16 * 1024;
res.write(payload.subarray(0, sent));
const timer = setInterval(() => {
if (sent >= payload.length) {
clearInterval(timer);
res.end();
return;
}
res.write(payload.subarray(sent, sent + 2 * 1024));
sent += 2 * 1024;
}, 150);
res.on('close', () => clearInterval(timer));
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const { port } = server.address() as AddressInfo;
return {
close: () =>
new Promise<void>((resolve) => server.close(() => resolve())),
payload,
requests,
url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`,
};
}
/**
* On a cold profile the renderer can query SQLite while the DB worker is
* still creating tables ("database is locked" / "no such table" on slow CI
@@ -163,4 +249,104 @@ test.describe('Electron Downloads', () => {
await fileServer.close();
}
});
test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const rangeServer = await createThrottledRangeServer();
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Pause Portal',
username: 'user1',
password: 'pass1',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openDownloadsPage(app.mainWindow);
const downloadsDir = join(dataDir, 'e2e-pause-downloads');
mkdirSync(downloadsDir, { recursive: true });
await app.electronApp.evaluate(({ dialog }, folder) => {
dialog.showOpenDialog = async () =>
({
canceled: false,
filePaths: [folder],
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
}, downloadsDir);
await app.mainWindow
.getByRole('button', { name: 'Change Folder' })
.click();
await expect(
app.mainWindow.locator('.downloads__folder-inline-path')
).toContainText('e2e-pause-downloads');
const startResult = await app.mainWindow.evaluate(
async ({ url, folder }) =>
window.electron?.downloadsStart?.({
playlistId: 'e2e-playlist',
xtreamId: 7373,
contentType: 'vod',
title: 'E2E Pause Movie',
url,
downloadFolder: folder,
}),
{ url: rangeServer.url, folder: downloadsDir }
);
expect(startResult?.error ?? null).toBeNull();
const item = app.mainWindow.locator('.downloads__item');
await expect(item).toHaveCount(1, { timeout: 20000 });
await expect(item.locator('.downloads__item-status')).toContainText(
'Downloading',
{ timeout: 20000 }
);
// Pause mid-transfer: the row flips to Paused and only a .part
// file exists on disk (final file absent, progress retained).
await item
.getByRole('button')
.filter({ hasText: 'pause' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
'Paused',
{ timeout: 20000 }
);
const pausedFiles = readdirSync(downloadsDir);
expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']);
const pausedBytes = statSync(
join(downloadsDir, 'E2E Pause Movie.mp4.part')
).size;
expect(pausedBytes).toBeGreaterThan(0);
expect(pausedBytes).toBeLessThan(rangeServer.payload.length);
// Resume: the runtime must continue via Range/If-Range instead of
// restarting, and the assembled file must match the payload.
await item
.getByRole('button')
.filter({ hasText: 'play_arrow' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
'Completed',
{ timeout: 30000 }
);
const resumeRequest = rangeServer.requests.find(
(entry) => entry.range
);
expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/);
expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG);
expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']);
const finalFile = readFileSync(
join(downloadsDir, 'E2E Pause Movie.mp4')
);
expect(finalFile.equals(rangeServer.payload)).toBe(true);
} finally {
await closeElectronApp(app);
await rangeServer.close();
}
});
});
@@ -9,14 +9,18 @@ import {
} from '@playwright/test';
import { createServer, Server } from 'http';
import {
accessSync,
constants as fsConstants,
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { dirname, join, resolve } from 'path';
export const workspaceRoot = resolve(__dirname, '../../..');
export const electronMainPath = join(
@@ -70,7 +74,7 @@ type ElectronFixtures = {
dataDir: string;
};
type LaunchElectronAppOptions = {
export type LaunchElectronAppOptions = {
env?: Record<string, string | undefined>;
};
@@ -171,7 +175,142 @@ export async function launchElectronApp(
};
}
function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void {
/**
* Resolve the x64 unpacked Linux executable produced by electron-builder.
* An explicit path wins so CI can point at an AppImage/Flatpak extraction
* without relying on electron-builder's local output directory names.
*/
export function resolvePackagedLinuxExecutable(
explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE']
): string | undefined {
if (explicitPath?.trim()) {
return resolve(explicitPath.trim());
}
const executablesRoot = join(workspaceRoot, 'dist', 'executables');
if (!existsSync(executablesRoot)) {
return undefined;
}
const unpackedDirectories = readdirSync(executablesRoot, {
withFileTypes: true,
})
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith('linux') &&
entry.name.endsWith('-unpacked') &&
!entry.name.includes('arm')
)
.sort((left, right) => {
const leftPriority = left.name === 'linux-unpacked' ? 0 : 1;
const rightPriority = right.name === 'linux-unpacked' ? 0 : 1;
return (
leftPriority - rightPriority ||
left.name.localeCompare(right.name)
);
});
for (const directory of unpackedDirectories) {
for (const executableName of ['IPTVnator', 'iptvnator']) {
const candidate = join(
executablesRoot,
directory.name,
executableName
);
try {
accessSync(candidate, fsConstants.X_OK);
if (statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Keep looking for the next unpacked x64 layout.
}
}
}
return undefined;
}
export function getPackagedLinuxNativeDir(executablePath: string): string {
return join(
dirname(resolve(executablePath)),
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
}
export function resolvePackagedElectronLaunchArgs(
getuid: (() => number) | undefined
): string[] {
const args = ['--ignore-gpu-blocklist'];
if (typeof getuid === 'function' && getuid() === 0) {
args.push('--no-sandbox');
}
return args;
}
/**
* Launch a real packaged Linux executable. Unlike the regular source E2E
* launcher, this deliberately keeps Chromium's GPU path enabled and ignores
* its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and
* CI's llvmpipe WebGL2 context must prove that the shared-memory frame reaches
* the renderer canvas.
*/
export async function launchPackagedElectronApp(
executablePath: string,
dataDir: string,
options: LaunchElectronAppOptions = {}
): Promise<LaunchedElectronApp> {
if (process.platform !== 'linux') {
throw new Error(
'The packaged embedded-MPV launcher is available on Linux only.'
);
}
const resolvedExecutablePath = resolve(executablePath);
try {
accessSync(resolvedExecutablePath, fsConstants.X_OK);
if (!statSync(resolvedExecutablePath).isFile()) {
throw new Error('not a regular file');
}
} catch (error) {
throw new Error(
`Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const electronApp = await electron.launch({
executablePath: resolvedExecutablePath,
args: resolvePackagedElectronLaunchArgs(process.getuid),
env: {
...process.env,
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS:
process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1',
...options.env,
ELECTRON_IS_DEV: '0',
IPTVNATOR_E2E_DATA_DIR: dataDir,
NODE_ENV: 'test',
},
});
attachElectronProcessDiagnostics(electronApp);
const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow);
return {
electronApp,
mainWindow,
};
}
function attachElectronProcessDiagnostics(
electronApp: ElectronApplication
): void {
if (!process.env['CI']) {
return;
}
@@ -235,10 +374,7 @@ async function waitForPromiseWithTimeout(
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(
() => resolvePromise(false),
timeoutMs
);
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
}),
]);
} finally {
@@ -297,11 +433,11 @@ async function waitForAppReady(page: Page): Promise<void> {
} catch (error) {
const diagnostics = await page.evaluate(() => ({
appRootLength:
document.querySelector('app-root')?.innerHTML.trim().length ?? 0,
document.querySelector('app-root')?.innerHTML.trim().length ??
0,
baseHref:
document
.querySelector('base')
?.getAttribute('href') ?? '<missing>',
document.querySelector('base')?.getAttribute('href') ??
'<missing>',
readyState: document.readyState,
title: document.title,
url: location.href,
@@ -357,7 +493,7 @@ export async function importM3uPlaylistFromNativeDialog(
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
await fileInput.evaluate((element, selectedFilePath) => {
(element as HTMLInputElement).dataset.filePathOverride =
(element as HTMLInputElement).dataset['filePathOverride'] =
selectedFilePath;
}, filePath);
await fileInput.setInputFiles(filePath);
@@ -501,15 +637,17 @@ async function clickDialogMethodOption(
label: RegExp,
legacySelector?: string
): Promise<void> {
const optionByRadio = dialog
.getByRole('radio', { name: label })
.first();
const optionByRadio = dialog.getByRole('radio', { name: label }).first();
if ((await optionByRadio.count()) > 0) {
await optionByRadio.click();
return;
}
for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
for (const tablistLabel of [
'Source method',
'Playlist category',
'M3U source',
]) {
const tablist = dialog
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
.first();
@@ -541,9 +679,7 @@ async function clickDialogMethodOption(
}
if (!legacySelector) {
throw new Error(
`Could not find dialog option matching ${label}.`
);
throw new Error(`Could not find dialog option matching ${label}.`);
}
await dialog.locator(legacySelector).click();
@@ -704,9 +840,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
const attributes = [
channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '',
channel.tvgLanguage
? `tvg-language="${channel.tvgLanguage}"`
: '',
channel.tvgLanguage ? `tvg-language="${channel.tvgLanguage}"` : '',
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
channel.logo ? `tvg-logo="${channel.logo}"` : '',
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
@@ -889,9 +1023,7 @@ export async function switchUnifiedCollectionContent(
await clickButtonToggleOption(toggleGroup, contentLabel);
}
export async function clearCurrentUnifiedCollection(
page: Page
): Promise<void> {
export async function clearCurrentUnifiedCollection(page: Page): Promise<void> {
await page
.getByRole('button', {
name: /Clear .* (favorites|recently viewed)/i,
@@ -1464,6 +1596,15 @@ export async function expectWorkspaceSearchStatus(
).toHaveText(expected);
}
/** The degraded-search hint chip must be absent (e.g. complete local search). */
export async function expectNoWorkspaceSearchStatus(
page: Page
): Promise<void> {
await expect(
page.locator('app-workspace-shell-header .search-chip--status')
).toHaveCount(0);
}
async function startPortalDebugCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const electronApi = window.electron as typeof window.electron & {
@@ -0,0 +1,288 @@
import assert = require('node:assert/strict');
import {
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, it } from 'node:test';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
} from './embedded-mpv-frame-copy-packaged-filesystem';
const temporaryDirectories = new Set<string>();
type PackageFixture = {
executablePath: string;
libmpvAliasPath: string;
libmpvSonamePath: string;
nativeDir: string;
packageRoot: string;
payloadPath: string;
runtimeManifestPath: string;
};
function createPackageFixture(): PackageFixture {
const packageRoot = mkdtempSync(
join(tmpdir(), 'iptvnator-packaged-fixture-source-')
);
temporaryDirectories.add(packageRoot);
const executablePath = join(packageRoot, 'IPTVnator');
const nativeDir = join(
packageRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const payloadPath = join(packageRoot, 'resources', 'payload.bin');
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
const runtimeLibraryDir = join(nativeDir, 'lib');
const libmpvSonamePath = join(runtimeLibraryDir, 'libmpv.so.2');
const libmpvAliasPath = join(runtimeLibraryDir, 'libmpv.so');
mkdirSync(runtimeLibraryDir, { recursive: true });
writeFileSync(executablePath, '#!/bin/sh\nexit 0\n');
chmodSync(executablePath, 0o755);
writeFileSync(payloadPath, 'packaged payload');
chmodSync(payloadPath, 0o640);
writeFileSync(libmpvSonamePath, 'packaged libmpv');
symlinkSync('libmpv.so.2', libmpvAliasPath);
writeFileSync(
runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: 'libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
if (process.platform !== 'win32') {
symlinkSync(
'payload.bin',
join(packageRoot, 'resources', 'payload-link')
);
}
return {
executablePath,
libmpvAliasPath,
libmpvSonamePath,
nativeDir,
packageRoot,
payloadPath,
runtimeManifestPath,
};
}
function entryExists(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
afterEach(() => {
for (const directory of temporaryDirectories) {
rmSync(directory, { force: true, recursive: true });
}
temporaryDirectories.clear();
});
describe('disposable unpacked package clone', () => {
it('requires a safe versioned libmpv target in the packaged manifest', () => {
const source = createPackageFixture();
assert.equal(
readPackagedRuntimeIdentity(source.nativeDir).libmpvSoname,
'libmpv.so.2'
);
writeFileSync(
source.runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: '../libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
assert.throws(
() => readPackagedRuntimeIdentity(source.nativeDir),
/libmpvSoname/
);
});
it('hides and restores a cloned regular dependency without changing the source package', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedLibmpvPath = join(clone.nativeDir, 'lib', 'libmpv.so.2');
const guard = createPackagedEntryGuard(clonedLibmpvPath, {
expectedKind: 'regular-file',
hiddenDirectory: clone.temporaryRoot,
});
try {
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
guard.hide();
assert.equal(entryExists(clonedLibmpvPath), false);
assert.equal(lstatSync(source.libmpvSonamePath).isFile(), true);
assert.equal(
readFileSync(source.libmpvSonamePath, 'utf8'),
'packaged libmpv'
);
guard.restore();
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
assert.equal(entryExists(source.libmpvSonamePath), true);
});
it('hides and restores a cloned symbolic link without dereferencing it', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedAliasPath = join(clone.nativeDir, 'lib', 'libmpv.so');
const guard = createPackagedEntryGuard(clonedAliasPath, {
expectedKind: 'symbolic-link',
hiddenDirectory: clone.temporaryRoot,
});
try {
guard.hide();
assert.equal(entryExists(clonedAliasPath), false);
assert.equal(
lstatSync(source.libmpvAliasPath).isSymbolicLink(),
true
);
assert.equal(readlinkSync(source.libmpvAliasPath), 'libmpv.so.2');
guard.restore();
assert.equal(lstatSync(clonedAliasPath).isSymbolicLink(), true);
assert.equal(readlinkSync(clonedAliasPath), 'libmpv.so.2');
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
});
it('hardlinks regular files and preserves modes, symlinks, and the source manifest', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
try {
assert.notEqual(clone.packageRoot, source.packageRoot);
assert.equal(
statSync(clone.executablePath).mode & 0o777,
statSync(source.executablePath).mode & 0o777
);
const clonedPayloadPath = join(
clone.packageRoot,
'resources',
'payload.bin'
);
assert.equal(
statSync(clonedPayloadPath).ino,
statSync(source.payloadPath).ino
);
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
if (process.platform !== 'win32') {
const clonedLinkPath = join(
clone.packageRoot,
'resources',
'payload-link'
);
assert.equal(lstatSync(clonedLinkPath).isSymbolicLink(), true);
assert.equal(readlinkSync(clonedLinkPath), 'payload.bin');
}
const clonedRuntimeManifestPath = join(
clone.nativeDir,
'embedded-mpv-runtime.json'
);
assert.equal(existsSync(clonedRuntimeManifestPath), true);
assert.equal(existsSync(source.runtimeManifestPath), true);
} finally {
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
assert.equal(existsSync(clone.temporaryRoot), false);
assert.equal(existsSync(source.runtimeManifestPath), true);
});
it('copies a regular file when hardlinking is unavailable', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath, {
linkFile() {
throw Object.assign(new Error('cross-device hardlink'), {
code: 'EXDEV',
});
},
});
temporaryDirectories.add(clone.temporaryRoot);
try {
assert.equal(statSync(clone.executablePath).mode & 0o777, 0o755);
const clonedPayloadPath = join(
clone.packageRoot,
'resources',
'payload.bin'
);
assert.equal(
readFileSync(clonedPayloadPath, 'utf8'),
readFileSync(source.payloadPath, 'utf8')
);
assert.notEqual(
statSync(clonedPayloadPath).ino,
statSync(source.payloadPath).ino
);
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
} finally {
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
});
});
@@ -0,0 +1,255 @@
import {
chmodSync,
copyFileSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
type Stats,
} from 'fs';
import { tmpdir } from 'os';
import { basename, dirname, join, resolve } from 'path';
import { getPackagedLinuxNativeDir } from './electron-test-fixtures';
export type DisposablePackagedLinuxApp = {
cleanup: () => void;
executablePath: string;
nativeDir: string;
packageRoot: string;
temporaryRoot: string;
};
export type DisposablePackagedLinuxAppOptions = {
linkFile?: (existingPath: string, newPath: string) => void;
};
export type PackagedRuntimeIdentity = {
arch: string;
libmpvSoname: string;
platform: string;
profile: string;
runtimeMode: string;
};
export type PackagedEntryKind = 'regular-file' | 'symbolic-link';
export type PackagedEntryGuard = {
hide: () => void;
restore: () => void;
};
export type PackagedEntryGuardOptions = {
expectedKind: PackagedEntryKind;
hiddenDirectory: string;
};
const HARDLINK_COPY_FALLBACK_CODES = new Set([
'EACCES',
'EMLINK',
'ENOSYS',
'ENOTSUP',
'EPERM',
'EXDEV',
]);
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
function entryKindMatches(
stats: Stats,
expectedKind: PackagedEntryKind
): boolean {
return expectedKind === 'regular-file'
? stats.isFile() && !stats.isSymbolicLink()
: stats.isSymbolicLink();
}
function entryExistsByLstat(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
function clonePackagedEntry(
sourcePath: string,
destinationPath: string,
linkFile: (existingPath: string, newPath: string) => void
): void {
const sourceStats = lstatSync(sourcePath);
if (sourceStats.isDirectory()) {
mkdirSync(destinationPath);
for (const entry of readdirSync(sourcePath)) {
clonePackagedEntry(
join(sourcePath, entry),
join(destinationPath, entry),
linkFile
);
}
chmodSync(destinationPath, sourceStats.mode & 0o7777);
return;
}
if (sourceStats.isSymbolicLink()) {
symlinkSync(readlinkSync(sourcePath), destinationPath);
return;
}
if (!sourceStats.isFile()) {
throw new Error(
`Unsupported packaged runtime entry type at ${sourcePath}.`
);
}
try {
linkFile(sourcePath, destinationPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (!code || !HARDLINK_COPY_FALLBACK_CODES.has(code)) {
throw error;
}
copyFileSync(sourcePath, destinationPath);
chmodSync(destinationPath, sourceStats.mode & 0o7777);
}
}
export function createDisposablePackagedLinuxApp(
executablePath: string,
options: DisposablePackagedLinuxAppOptions = {}
): DisposablePackagedLinuxApp {
const sourceExecutablePath = resolve(executablePath);
const sourcePackageRoot = dirname(sourceExecutablePath);
const temporaryRoot = mkdtempSync(
join(tmpdir(), 'iptvnator-packaged-frame-copy-')
);
const packageRoot = join(temporaryRoot, basename(sourcePackageRoot));
let cleaned = false;
try {
clonePackagedEntry(
sourcePackageRoot,
packageRoot,
options.linkFile ?? linkSync
);
} catch (error) {
rmSync(temporaryRoot, { force: true, recursive: true });
throw error;
}
const clonedExecutablePath = join(
packageRoot,
basename(sourceExecutablePath)
);
return {
cleanup() {
if (cleaned) {
return;
}
rmSync(temporaryRoot, { force: true, recursive: true });
cleaned = true;
},
executablePath: clonedExecutablePath,
nativeDir: getPackagedLinuxNativeDir(clonedExecutablePath),
packageRoot,
temporaryRoot,
};
}
export function createPackagedEntryGuard(
entryPath: string,
options: PackagedEntryGuardOptions
): PackagedEntryGuard {
const guardedEntryPath = resolve(entryPath);
const hiddenDirectory = resolve(options.hiddenDirectory);
const hiddenEntryPath = join(
hiddenDirectory,
`.${basename(guardedEntryPath)}.e2e-hidden-${process.pid}`
);
let hidden = false;
return {
hide() {
const entryStats = lstatSync(guardedEntryPath);
if (!entryKindMatches(entryStats, options.expectedKind)) {
throw new Error(
`Packaged entry is not a ${options.expectedKind}: ${guardedEntryPath}`
);
}
const hiddenDirectoryStats = lstatSync(hiddenDirectory);
if (
hiddenDirectoryStats.isSymbolicLink() ||
!hiddenDirectoryStats.isDirectory()
) {
throw new Error(
`Packaged entry stash is not a regular directory: ${hiddenDirectory}`
);
}
if (entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Stale hidden packaged entry exists: ${hiddenEntryPath}`
);
}
renameSync(guardedEntryPath, hiddenEntryPath);
hidden = true;
},
restore() {
if (!hidden) {
return;
}
if (!entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Hidden packaged entry disappeared before restore: ${hiddenEntryPath}`
);
}
if (entryExistsByLstat(guardedEntryPath)) {
throw new Error(
`Refusing to overwrite packaged entry during restore: ${guardedEntryPath}`
);
}
renameSync(hiddenEntryPath, guardedEntryPath);
hidden = false;
},
};
}
export function readPackagedRuntimeIdentity(
nativeDir: string
): PackagedRuntimeIdentity {
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
const parsed = JSON.parse(
readFileSync(runtimeManifestPath, 'utf8')
) as Partial<PackagedRuntimeIdentity>;
for (const field of [
'arch',
'platform',
'profile',
'runtimeMode',
] as const) {
if (typeof parsed[field] !== 'string' || !parsed[field]) {
throw new Error(
`Packaged runtime manifest ${field} is invalid at ${runtimeManifestPath}.`
);
}
}
if (
typeof parsed.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(parsed.libmpvSoname)
) {
throw new Error(
`Packaged runtime manifest libmpvSoname is invalid at ${runtimeManifestPath}.`
);
}
return parsed as PackagedRuntimeIdentity;
}
@@ -0,0 +1,191 @@
import assert = require('node:assert/strict');
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures';
import './embedded-mpv-frame-copy-packaged-filesystem.tests';
import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures';
import packagedPlaywrightConfig from '../playwright.packaged.config';
const projectRoot = resolve(__dirname, '..');
describe('packaged Electron launch arguments', () => {
it('keeps WebGL enabled for software rendering while disabling the sandbox only as root', () => {
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 1000),
['--ignore-gpu-blocklist']
);
assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), [
'--ignore-gpu-blocklist',
]);
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 0),
['--ignore-gpu-blocklist', '--no-sandbox']
);
});
});
describe('native-view playback proof', () => {
it('requires the loaded URL, a playing or paused state, and positive duration', () => {
const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m';
const baseSnapshot = {
durationSeconds: 2,
status: 'playing',
streamUrl: expectedUrl,
};
assert.equal(
isMeaningfulNativePlaybackSnapshot(baseSnapshot, expectedUrl),
true
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, status: 'paused' },
`${expectedUrl}#ignored`
),
true
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, durationSeconds: null },
expectedUrl
),
false
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, status: 'idle' },
expectedUrl
),
false
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, streamUrl: `${expectedUrl}?other=1` },
expectedUrl
),
false
);
});
it('loads the fixture and observes playback before disposing the native session', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
const fallbackStart = source.indexOf('const launchedFallbackApp');
const captureIndex = source.indexOf(
'installEmbeddedMpvSessionCapture',
fallbackStart
);
const loadIndex = source.indexOf(
'loadEmbeddedMpvPlayback',
fallbackStart
);
const proofIndex = source.indexOf(
'isMeaningfulNativePlaybackSnapshot',
fallbackStart
);
const exitCodeIndex = source.indexOf(
'electronApp.process().exitCode',
fallbackStart
);
const disposeIndex = source.indexOf(
'disposeEmbeddedMpvSession',
fallbackStart
);
assert.ok(fallbackStart >= 0);
assert.ok(captureIndex > fallbackStart);
assert.ok(loadIndex > captureIndex);
assert.ok(proofIndex > loadIndex);
assert.ok(exitCodeIndex > proofIndex);
assert.ok(disposeIndex > exitCodeIndex);
});
it('removes the manifest-declared libmpv target and expects the stable missing-library reason', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
const manifestIdentityIndex = source.indexOf(
'const runtimeIdentity = readPackagedRuntimeIdentity'
);
const guardIndex = source.indexOf(
'createPackagedEntryGuard(',
manifestIdentityIndex
);
const sonameIndex = source.indexOf(
'runtimeIdentity.libmpvSoname',
guardIndex
);
const hideIndex = source.indexOf('.hide()', sonameIndex);
const fallbackStart = source.indexOf(
'const launchedFallbackApp',
hideIndex
);
const missingReasonIndex = source.indexOf(
"frameCopyUnavailableReason: 'runtime-library-missing'",
fallbackStart
);
assert.ok(manifestIdentityIndex >= 0);
assert.ok(guardIndex > manifestIdentityIndex);
assert.ok(sonameIndex > guardIndex);
assert.ok(hideIndex > sonameIndex);
assert.ok(fallbackStart > hideIndex);
assert.ok(missingReasonIndex > fallbackStart);
assert.doesNotMatch(source, /createRuntimeManifestGuard/);
assert.doesNotMatch(source, /runtimeManifest\.hide/);
});
});
describe('dedicated packaged smoke target', () => {
it('inherits the GL mode from the workflow environment', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
assert.doesNotMatch(source, /LIBGL_ALWAYS_SOFTWARE\s*:/);
});
it('does not build the backend or start portal mock servers', () => {
const project = JSON.parse(
readFileSync(join(projectRoot, 'project.json'), 'utf8')
) as {
targets?: Record<
string,
{
cache?: boolean;
dependsOn?: unknown;
options?: { command?: string };
}
>;
};
const target = project.targets?.['packaged-frame-copy-smoke'];
const packagedConfig = readFileSync(
join(projectRoot, 'playwright.packaged.config.ts'),
'utf8'
);
if (!target) {
throw new Error('The packaged frame-copy smoke target is missing.');
}
assert.equal(target.cache, false);
assert.deepEqual(target.dependsOn, [
'test-packaged-frame-copy-fixtures',
]);
assert.match(
target.options?.command ?? '',
/playwright\.packaged\.config\.ts/
);
assert.match(
target.options?.command ?? '',
/embedded-mpv-frame-copy-packaged\.e2e\.ts/
);
assert.match(packagedConfig, /timeout:\s*120000/);
assert.match(packagedConfig, /webServer:\s*\[\]/);
assert.deepEqual(packagedPlaywrightConfig.webServer, []);
});
});
@@ -0,0 +1,344 @@
import type {
EmbeddedMpvSession,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { spawnSync } from 'child_process';
import { createServer, type Server } from 'http';
import sharp = require('sharp');
import {
closeElectronApp,
expect,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import type {
DisposablePackagedLinuxApp,
PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
declare global {
interface Window {
__packagedEmbeddedMpvSessions?: EmbeddedMpvSession[];
__packagedEmbeddedMpvUnsubscribe?: () => void;
}
}
export type LocalMediaServer = {
close: () => Promise<void>;
url: string;
};
function createTwoSecondY4mFixture(): Buffer {
const width = 64;
const height = 36;
const framesPerSecond = 10;
const frameCount = framesPerSecond * 2;
const yPlaneBytes = width * height;
const chromaPlaneBytes = (width / 2) * (height / 2);
const chunks: Buffer[] = [
Buffer.from(
`YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`,
'ascii'
),
];
for (let index = 0; index < frameCount; index += 1) {
const evenFrame = index % 2 === 0;
chunks.push(
Buffer.from('FRAME\n', 'ascii'),
Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21)
);
}
return Buffer.concat(chunks);
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const onError = (error: Error) => {
server.off('listening', onListening);
rejectPromise(error);
};
const onListening = () => {
server.off('error', onError);
resolvePromise();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, '127.0.0.1');
});
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
server.close((error) => {
if (error) {
rejectPromise(error);
return;
}
resolvePromise();
});
});
}
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
const body = createTwoSecondY4mFixture();
const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m';
const server = createServer((request, response) => {
const pathname = (request.url ?? '').split('?')[0];
if (pathname !== resourcePath) {
response.writeHead(404).end();
return;
}
const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/);
if (!range) {
response.writeHead(200, {
'Accept-Ranges': 'bytes',
'Content-Length': body.length,
'Content-Type': 'video/x-yuv4mpeg',
});
response.end(request.method === 'HEAD' ? undefined : body);
return;
}
const start = Number(range[1]);
const requestedEnd = range[2] ? Number(range[2]) : body.length - 1;
const end = Math.min(requestedEnd, body.length - 1);
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
start > end ||
start >= body.length
) {
response.writeHead(416, {
'Content-Range': `bytes */${body.length}`,
});
response.end();
return;
}
response.writeHead(206, {
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Content-Type': 'video/x-yuv4mpeg',
});
response.end(
request.method === 'HEAD'
? undefined
: body.subarray(start, end + 1)
);
});
await listen(server);
const address = server.address();
if (!address || typeof address === 'string') {
await closeServer(server);
throw new Error('Unable to resolve the local media server address.');
}
return {
close: () => closeServer(server),
url: `http://127.0.0.1:${address.port}${resourcePath}`,
};
}
export function assertNativeFallbackPrerequisites(): void {
if (!process.env['DISPLAY']) {
throw new Error(
'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).'
);
}
const mpv = spawnSync('mpv', ['--version'], {
stdio: 'ignore',
timeout: 3000,
});
if (mpv.status !== 0) {
throw new Error(
'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.'
);
}
}
export async function installEmbeddedMpvSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.mainWindow.evaluate(() => {
window.__packagedEmbeddedMpvUnsubscribe?.();
window.__packagedEmbeddedMpvSessions = [];
window.__packagedEmbeddedMpvUnsubscribe =
window.electron.onEmbeddedMpvSessionUpdate?.((session) => {
window.__packagedEmbeddedMpvSessions?.push(session);
});
});
}
export async function installFrameCanvasAndSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await installEmbeddedMpvSessionCapture(app);
await app.mainWindow.evaluate(() => {
document.querySelector('canvas[data-embedded-mpv-frame]')?.remove();
const canvas = document.createElement('canvas');
canvas.dataset['embeddedMpvFrame'] = '';
canvas.dataset['testId'] = 'packaged-embedded-mpv-frame';
Object.assign(canvas.style, {
background: '#000',
height: '180px',
left: '0',
position: 'fixed',
top: '0',
width: '320px',
zIndex: '2147483647',
});
document.body.append(canvas);
});
}
export async function getEmbeddedMpvSupport(
app: LaunchedElectronApp
): Promise<EmbeddedMpvSupport> {
return app.mainWindow.evaluate(async () => {
return window.electron.getEmbeddedMpvSupport();
});
}
export async function getLatestSession(
app: LaunchedElectronApp,
sessionId: string
): Promise<EmbeddedMpvSession | null> {
return app.mainWindow.evaluate((id) => {
const sessions =
window.__packagedEmbeddedMpvSessions?.filter(
(session) => session.id === id
) ?? [];
return sessions.at(-1) ?? null;
}, sessionId);
}
export function isMeaningfulNativePlaybackSnapshot(
snapshot: {
durationSeconds: number | null;
error?: string;
status: string;
streamUrl: string;
} | null,
expectedUrl: string
): boolean {
if (
!snapshot ||
snapshot.error ||
!['paused', 'playing'].includes(snapshot.status) ||
typeof snapshot.durationSeconds !== 'number' ||
!Number.isFinite(snapshot.durationSeconds) ||
snapshot.durationSeconds <= 0
) {
return false;
}
const normalizeUrl = (value: string): string => {
try {
const url = new URL(value);
url.hash = '';
return url.href;
} catch {
return value.trim();
}
};
return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl);
}
export async function renderedFrameSignal(
app: LaunchedElectronApp
): Promise<number> {
const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame');
const png = await canvas.screenshot();
const { data, info } = await sharp(png)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let signal = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
if (data[offset] + data[offset + 1] + data[offset + 2] > 30) {
signal += 1;
}
}
return signal;
}
export async function closeAndWaitForExit(
app: LaunchedElectronApp
): Promise<void> {
const processHandle = app.electronApp.process();
await closeElectronApp(app);
await expect
.poll(
() =>
processHandle.exitCode !== null ||
processHandle.signalCode !== null,
{ timeout: 10000 }
)
.toBe(true);
}
export async function cleanupPackagedFrameCopySmoke(options: {
apps: Array<LaunchedElectronApp | undefined>;
hiddenRuntimeEntry?: PackagedEntryGuard;
media?: LocalMediaServer;
packageClone?: DisposablePackagedLinuxApp;
}): Promise<void> {
const errors: unknown[] = [];
for (const app of options.apps) {
if (!app) {
continue;
}
try {
await closeAndWaitForExit(app);
} catch (error) {
errors.push(error);
}
}
if (options.hiddenRuntimeEntry) {
try {
options.hiddenRuntimeEntry.restore();
} catch (error) {
errors.push(error);
}
}
if (options.media) {
try {
await options.media.close();
} catch (error) {
errors.push(error);
}
}
if (options.packageClone) {
try {
options.packageClone.cleanup();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new Error(
`Packaged frame-copy smoke cleanup failed: ${errors
.map((error) =>
error instanceof Error ? error.message : String(error)
)
.join('; ')}`
);
}
}
@@ -0,0 +1,311 @@
import {
expect,
launchPackagedElectronApp,
resolvePackagedLinuxExecutable,
test,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { join } from 'path';
import {
assertNativeFallbackPrerequisites,
cleanupPackagedFrameCopySmoke,
closeAndWaitForExit,
createLocalMediaServer,
getEmbeddedMpvSupport,
getLatestSession,
installEmbeddedMpvSessionCapture,
installFrameCanvasAndSessionCapture,
isMeaningfulNativePlaybackSnapshot,
renderedFrameSignal,
type LocalMediaServer,
} from './embedded-mpv-frame-copy-packaged-fixtures';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
type DisposablePackagedLinuxApp,
type PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
const PACKAGED_FRAME_COPY_REQUIRED_ENV =
'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY';
const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
const packagedExecutable = resolvePackagedLinuxExecutable();
const packagedFrameCopyRequired = isTruthy(
process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV]
);
function isTruthy(value: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(value ?? '').trim().toLowerCase()
);
}
// This smoke drives the public preload API directly so it exercises the real
// packaged main process, helper, frame reader, WebGL pump, and pause controls
// without coupling runtime validation to playlist import/settings UI state.
test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
test.skip(
process.platform !== 'linux',
'The packaged frame-copy runtime is Linux-only.'
);
test.skip(
!packagedExecutable && !packagedFrameCopyRequired,
`Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.`
);
test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({
dataDir,
}) => {
expect(
process.arch,
'The official Linux frame-copy runtime is x64-only.'
).toBe('x64');
expect(
packagedExecutable,
`The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.`
).toBeTruthy();
const sourceExecutablePath = packagedExecutable as string;
assertNativeFallbackPrerequisites();
let packageClone: DisposablePackagedLinuxApp | undefined;
let hiddenRuntimeEntry: PackagedEntryGuard | undefined;
let media: LocalMediaServer | undefined;
let frameCopyApp: LaunchedElectronApp | undefined;
let fallbackApp: LaunchedElectronApp | undefined;
try {
packageClone =
createDisposablePackagedLinuxApp(sourceExecutablePath);
const executablePath = packageClone.executablePath;
const nativeDir = packageClone.nativeDir;
const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir);
hiddenRuntimeEntry = createPackagedEntryGuard(
join(nativeDir, 'lib', runtimeIdentity.libmpvSoname),
{
expectedKind: 'regular-file',
hiddenDirectory: packageClone.temporaryRoot,
}
);
const mediaServer = await createLocalMediaServer();
media = mediaServer;
expect(runtimeIdentity).toMatchObject({
arch: 'x64',
platform: 'linux',
runtimeMode: 'bundled',
});
expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile);
const launchedFrameCopyApp = await launchPackagedElectronApp(
executablePath,
dataDir,
{
env: {
[FRAME_COPY_OPT_IN_ENV]: '1',
},
}
);
frameCopyApp = launchedFrameCopyApp;
await expect
.poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp))
.toMatchObject({
engine: 'frame-copy',
frameCopyAvailable: true,
platform: 'linux',
supported: true,
});
await installFrameCanvasAndSessionCapture(launchedFrameCopyApp);
const created = await launchedFrameCopyApp.mainWindow.evaluate(
async () => {
return window.electron.createEmbeddedMpvSession(
{ x: 0, y: 0, width: 320, height: 180 },
'Packaged frame-copy smoke',
0
);
}
);
await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.setEmbeddedMpvPaused(sessionId, true);
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Two-second generated Y4M fixture',
isLive: false,
});
},
{ sessionId: created.id, streamUrl: mediaServer.url }
);
await expect
.poll(
() => getLatestSession(launchedFrameCopyApp, created.id),
{
timeout: 15000,
}
)
.toMatchObject({
status: 'paused',
streamUrl: mediaServer.url,
videoHeight: 36,
videoWidth: 64,
});
const attached = await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
return window.electron.attachEmbeddedMpvFrameView?.(
sessionId
);
},
created.id
);
expect(attached).toBe(true);
await expect(
launchedFrameCopyApp.mainWindow.getByTestId(
'packaged-embedded-mpv-frame'
)
).toHaveAttribute('width', '320');
await expect(
launchedFrameCopyApp.mainWindow.getByTestId(
'packaged-embedded-mpv-frame'
)
).toHaveAttribute('height', '180');
await expect
.poll(() => renderedFrameSignal(launchedFrameCopyApp), {
timeout: 15000,
})
.toBeGreaterThan(0);
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, false),
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.status,
{ timeout: 10000 }
)
.toBe('playing');
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, true),
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.status,
{ timeout: 10000 }
)
.toBe('paused');
await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
window.electron.detachEmbeddedMpvFrameView?.();
await window.electron.disposeEmbeddedMpvSession(sessionId);
window.__packagedEmbeddedMpvUnsubscribe?.();
},
created.id
);
await closeAndWaitForExit(launchedFrameCopyApp);
frameCopyApp = undefined;
hiddenRuntimeEntry.hide();
expect(readPackagedRuntimeIdentity(nativeDir)).toEqual(
runtimeIdentity
);
const launchedFallbackApp = await launchPackagedElectronApp(
executablePath,
dataDir,
{
env: {
[FRAME_COPY_OPT_IN_ENV]: '1',
},
}
);
fallbackApp = launchedFallbackApp;
const fallbackSupport =
await getEmbeddedMpvSupport(launchedFallbackApp);
expect(fallbackSupport).toMatchObject({
engine: 'native',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'runtime-library-missing',
platform: 'linux',
supported: true,
});
await installEmbeddedMpvSessionCapture(launchedFallbackApp);
const nativeSession = await launchedFallbackApp.mainWindow.evaluate(
async () => {
return window.electron.createEmbeddedMpvSession(
{ x: 0, y: 0, width: 320, height: 180 },
'Native-view fallback smoke',
0
);
}
);
expect(nativeSession.id).toMatch(/^embedded-mpv-/);
await launchedFallbackApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Native-view generated Y4M fixture',
isLive: false,
});
},
{ sessionId: nativeSession.id, streamUrl: mediaServer.url }
);
await expect
.poll(
async () =>
isMeaningfulNativePlaybackSnapshot(
await getLatestSession(
launchedFallbackApp,
nativeSession.id
),
mediaServer.url
),
{ timeout: 15000 }
)
.toBe(true);
expect(
launchedFallbackApp.electronApp.process().exitCode
).toBeNull();
expect(
launchedFallbackApp.electronApp.process().signalCode
).toBeNull();
await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => {
await window.electron.disposeEmbeddedMpvSession(sessionId);
window.__packagedEmbeddedMpvUnsubscribe?.();
}, nativeSession.id);
await expect
.poll(() => launchedFallbackApp.mainWindow.title())
.toContain('IPTVnator');
} finally {
await cleanupPackagedFrameCopySmoke({
apps: [frameCopyApp, fallbackApp],
hiddenRuntimeEntry,
media,
packageClone,
});
}
});
});
+9 -13
View File
@@ -6,8 +6,8 @@ import {
closeElectronApp,
defaultStalkerMacAddress,
expect,
expectNoWorkspaceSearchStatus,
expectWorkspaceSearchScope,
expectWorkspaceSearchStatus,
fillWorkspaceSearch,
goToDashboard,
importM3uPlaylistFromNativeDialog,
@@ -987,7 +987,7 @@ test.describe('Electron Workspace Search', () => {
}
});
test('@search @stalker shows loaded-only scope and filters channels on ITV routes', async ({
test('@search @stalker searches the complete channel list on ITV routes', async ({
dataDir,
request,
}) => {
@@ -1030,26 +1030,22 @@ test.describe('Electron Workspace Search', () => {
app.mainWindow,
`Live TV / ${sample.categoryName}`
);
await expectWorkspaceSearchStatus(
app.mainWindow,
'Loaded channels only'
);
// Entering Live TV preloads the COMPLETE channel list (Ministra
// get_all_channels), so search runs locally over every channel:
// no portal-side search request, and no "Loaded channels only"
// degraded hint.
await waitForPortalDebugEvent(app.mainWindow, {
provider: 'stalker',
operation: 'get_ordered_list',
operation: 'get_all_channels',
predicate: (event) => {
const requestPayload = event.request as {
params?: Record<string, string | number>;
};
return (
requestPayload.params?.['type'] === 'itv' &&
String(requestPayload.params?.['category']) ===
sample.categoryId &&
requestPayload.params?.['search'] === itvQuery
);
return requestPayload.params?.['type'] === 'itv';
},
});
await expectNoWorkspaceSearchStatus(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
+448 -96
View File
@@ -1,3 +1,4 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
@@ -10,6 +11,25 @@ const {
const {
removeStaleFrameCopyArtifacts,
} = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs');
const {
isLinuxSystemBuildInputManifest,
validateLinuxRuntimeManifest,
validateLinuxSystemBuildInputManifest,
} = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs');
const {
SOURCE_ARCHIVE_BINDING_NAME,
validateLinuxSourceArchiveBinding,
} = require('../../tools/embedded-mpv/linux-source-archive-contract.cjs');
const {
resolveLinuxFrameCopyLinkageInputs,
resolveVerifiedLinuxLibMpvSoname,
runWithCleanup,
validateLinuxFrameCopyLinkage,
} = require('./embedded-mpv-linux-linkage.cjs');
const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);
const LINUX_STAGED_RUNTIME_ORIGIN = 'vendored-lgpl';
const LINUX_SOURCE_RUNTIME_ORIGIN = 'vendored-lgpl-source-build';
const workspaceRoot = process.cwd();
const addonRoot = path.join(
@@ -109,6 +129,158 @@ function readRuntimeManifest(runtimeRoot) {
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
function readLinuxSourceArchiveBinding(runtimeRoot, errors) {
const bindingPath = path.join(runtimeRoot, SOURCE_ARCHIVE_BINDING_NAME);
if (!fs.existsSync(bindingPath)) {
return null;
}
let binding;
try {
const stat = fs.lstatSync(bindingPath);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error('binding must be a regular file');
}
binding = JSON.parse(fs.readFileSync(bindingPath, 'utf8'));
} catch (error) {
errors.push(
`source archive binding is invalid: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
errors.push(
...validateLinuxSourceArchiveBinding(binding).map(
(error) => `source archive binding is invalid: ${error}`
)
);
return binding;
}
function validatedLinuxSourceRuntime(runtimeRoot) {
const stagedManifest = readRuntimeManifest(runtimeRoot);
if (
stagedManifest === null ||
typeof stagedManifest !== 'object' ||
Array.isArray(stagedManifest)
) {
throw new Error(
`Staged Linux runtime manifest must be an object: ${path.join(
runtimeRoot,
'runtime-manifest.json'
)}`
);
}
const envelopeErrors = [];
if (stagedManifest.origin !== LINUX_STAGED_RUNTIME_ORIGIN) {
envelopeErrors.push(`origin must be "${LINUX_STAGED_RUNTIME_ORIGIN}"`);
}
if (stagedManifest.platform !== 'linux') {
envelopeErrors.push('platform must be "linux"');
}
if (stagedManifest.arch !== targetArch) {
envelopeErrors.push(`arch must be "${targetArch}"`);
}
if (
typeof stagedManifest.stagedAt !== 'string' ||
stagedManifest.stagedAt.trim().length === 0
) {
envelopeErrors.push('stagedAt must be a non-empty string');
}
if (!Array.isArray(stagedManifest.runtimeFiles)) {
envelopeErrors.push('runtimeFiles must be an array');
}
const systemBuildInputs = isLinuxSystemBuildInputManifest(stagedManifest);
let buildInputMode;
let sourceArchive = null;
let sourceRuntimeManifest;
if (systemBuildInputs) {
buildInputMode = 'system-build-inputs';
sourceRuntimeManifest = {
linuxBackend: stagedManifest.linuxBackend,
buildInputs: stagedManifest.buildInputs,
sourceDistribution: stagedManifest.sourceDistribution,
};
if (
Array.isArray(stagedManifest.runtimeFiles) &&
stagedManifest.runtimeFiles.length !== 0
) {
envelopeErrors.push(
'system build inputs must not declare staged runtime files'
);
}
if (stagedManifest.sourceBuildOrigin !== undefined) {
envelopeErrors.push(
'system build inputs must not declare sourceBuildOrigin'
);
}
} else {
buildInputMode = 'bundled-runtime';
sourceArchive = readLinuxSourceArchiveBinding(
runtimeRoot,
envelopeErrors
);
const sourceBuildOrigin = stagedManifest.sourceBuildOrigin;
const sourceMetadata = { ...stagedManifest };
delete sourceMetadata.sourceBuildOrigin;
delete sourceMetadata.stagedAt;
sourceRuntimeManifest = {
...sourceMetadata,
origin: sourceBuildOrigin,
};
if (sourceBuildOrigin !== LINUX_SOURCE_RUNTIME_ORIGIN) {
envelopeErrors.push(
`sourceBuildOrigin must be "${LINUX_SOURCE_RUNTIME_ORIGIN}"`
);
}
}
const sourceManifestErrors =
buildInputMode === 'system-build-inputs'
? validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)
: validateLinuxRuntimeManifest(sourceRuntimeManifest);
const declaredRuntimeFileNames = Array.isArray(
sourceRuntimeManifest.runtimeFiles
)
? sourceRuntimeManifest.runtimeFiles
.map((runtimeFile) => runtimeFile.name)
.sort()
: [];
const stagedRuntimeFileNames = listRuntimeFiles(
path.join(runtimeRoot, 'lib'),
runtimeFilePredicate
)
.map((runtimeFile) => path.basename(runtimeFile))
.sort();
if (
JSON.stringify(stagedRuntimeFileNames) !==
JSON.stringify(declaredRuntimeFileNames)
) {
envelopeErrors.push(
'staged lib directory must exactly match manifest runtimeFiles'
);
}
const errors = [...envelopeErrors, ...sourceManifestErrors];
if (errors.length > 0) {
throw new Error(
[
`Invalid staged Linux runtime at ${runtimeRoot}:`,
...errors.map((error) => `- ${error}`),
].join('\n')
);
}
return {
buildInputMode,
sourceArchive,
sourceRuntimeManifest,
sourceRuntimeValidated: buildInputMode === 'bundled-runtime',
};
}
function fileExists(filePath) {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}
@@ -183,9 +355,9 @@ function findWindowsLibMpv(runtimeRoot) {
}
/* Debian/Ubuntu install linker targets under the multiarch triple dir. The
* compiler's built-in search paths cover it for -l resolution either way;
* this keeps the -L flag and the helper's baked rpath pointing somewhere
* real. */
* compiler's built-in search paths cover it for -l resolution either way.
* This directory is a link-time input only; the helper runtime intentionally
* stays on the sanitized system loader contract. */
function defaultLinuxSystemLibDir() {
const multiarchTriples = {
arm: 'arm-linux-gnueabihf',
@@ -211,15 +383,19 @@ function findLinuxLibMpv(libDir) {
}
function resolveRuntime() {
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
const stagedLinuxRuntime =
targetPlatform === 'linux' && fs.existsSync(vendoredHeader)
? validatedLinuxSourceRuntime(vendoredRuntimeRoot)
: null;
const vendoredLibMpv =
targetPlatform === 'darwin'
? findLibMpv(vendoredLibDir)
: targetPlatform === 'win32'
? findWindowsLibMpv(vendoredRuntimeRoot)
: targetPlatform === 'linux'
? true
? stagedLinuxRuntime
: null;
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
if (vendoredLibMpv && fs.existsSync(vendoredHeader)) {
const windowsImportLib =
@@ -237,17 +413,23 @@ function resolveRuntime() {
binDir: vendoredBinDir,
manifest: readRuntimeManifest(vendoredRuntimeRoot),
windowsImportLib,
...(stagedLinuxRuntime ?? {}),
};
}
if (targetPlatform === 'linux') {
// Dev-first Linux flow (frame-copy helper links system libmpv): a
// distro libmpv-dev install is a full runtime — no staging needed.
// LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system
// paths for machines with a local (non-root) libmpv prefix.
// LIBMPV_INCLUDE_DIR overrides the header path.
// LINUX_NATIVE_LIBRARY_DIR overrides the link-time library directory,
// which must already be visible to the system dynamic loader.
const systemIncludeDir =
process.env.LIBMPV_INCLUDE_DIR || '/usr/include';
if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) {
const sourceRuntimeManifest = {
linuxBackend: 'process-isolated mpv --wid',
warning: 'Development-only unmanaged system libmpv toolchain.',
};
return {
origin: 'system-dev',
includeDir: systemIncludeDir,
@@ -255,10 +437,10 @@ function resolveRuntime() {
process.env.LINUX_NATIVE_LIBRARY_DIR ||
defaultLinuxSystemLibDir(),
binDir: undefined,
manifest: {
warning:
'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.',
},
manifest: sourceRuntimeManifest,
buildInputMode: 'system-dev',
sourceRuntimeManifest,
sourceRuntimeValidated: false,
windowsImportLib: null,
};
}
@@ -288,19 +470,141 @@ function resolveRuntime() {
return null;
}
function writeLinuxProcessRuntimeManifest(runtime) {
function hasStagedLinuxLibMpvLinkerInput(runtime) {
return (
Array.isArray(runtime.sourceRuntimeManifest?.runtimeFiles) &&
runtime.sourceRuntimeManifest.runtimeFiles.some(
(runtimeFile) => runtimeFile.name === 'libmpv.so'
)
);
}
function assertRequiredLinuxFrameCopyRuntime(runtime) {
if (targetPlatform !== 'linux' || !embeddedMpvRequired) {
return;
}
if (
runtime.buildInputMode !== 'bundled-runtime' ||
runtime.sourceRuntimeValidated !== true ||
!runtime.sourceArchive ||
!hasStagedLinuxLibMpvLinkerInput(runtime)
) {
cleanOutput();
throw new Error(
'Required Linux builds must use the validated bundled source runtime containing staged libmpv.'
);
}
}
function copyLinuxRuntimeClosureToNativeBuild(runtime) {
fs.rmSync(outputLibDir, { recursive: true, force: true });
const declaredRuntimeFiles =
runtime.buildInputMode === 'bundled-runtime'
? runtime.sourceRuntimeManifest.runtimeFiles
: [];
if (declaredRuntimeFiles.length === 0) {
return [];
}
fs.mkdirSync(outputLibDir, { recursive: true });
const copiedRuntimeFiles = [];
for (const runtimeFile of declaredRuntimeFiles) {
const sourcePath = path.join(runtime.libDir, runtimeFile.name);
let descriptor;
try {
descriptor = fs.openSync(
sourcePath,
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW
);
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
throw new Error(
`Staged Linux runtime path is not a regular file: ${sourcePath}`
);
}
const contents = fs.readFileSync(descriptor);
if (contents.byteLength !== runtimeFile.size) {
throw new Error(
`Size mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.`
);
}
const actualSha256 = crypto
.createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
throw new Error(
`SHA-256 mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
);
}
const destinationPath = path.join(outputLibDir, runtimeFile.name);
fs.writeFileSync(destinationPath, contents, { mode: 0o755 });
copiedRuntimeFiles.push({ ...runtimeFile });
} finally {
if (descriptor !== undefined) {
fs.closeSync(descriptor);
}
}
}
return copiedRuntimeFiles;
}
function writeLinuxFrameCopyBuildManifest(runtime) {
const copiedRuntimeFiles = copyLinuxRuntimeClosureToNativeBuild(runtime);
const libmpvSoname =
runtime.sourceRuntimeValidated === true &&
runtime.buildInputMode === 'bundled-runtime' &&
copiedRuntimeFiles.length > 0
? resolveVerifiedLinuxLibMpvSoname({
outputLibDir,
runtimeFiles: copiedRuntimeFiles,
runtimeDependencyClosure:
runtime.sourceRuntimeManifest.runtimeDependencyClosure,
readDynamicSection: readLinuxDynamicSection,
})
: null;
const packageRuntimeAvailable =
runtime.sourceRuntimeValidated === true &&
runtime.buildInputMode === 'bundled-runtime' &&
copiedRuntimeFiles.length > 0 &&
libmpvSoname !== null;
const manifest = {
...runtime.manifest,
origin: 'external-mpv-process',
schemaVersion: 1,
origin: 'linux-frame-copy-build',
generatedAt: new Date().toISOString(),
runtimeFiles: [],
linuxBackend:
runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid',
mpvExecutable: 'mpv',
platform: targetPlatform,
targetArch,
arch: targetArch,
buildInputMode: runtime.buildInputMode,
sourceRuntimeValidated: runtime.sourceRuntimeValidated,
allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES],
packageRuntimeAvailability: {
system: packageRuntimeAvailable,
bundled: packageRuntimeAvailable,
},
artifacts: {
addon: 'embedded_mpv.node',
frameReader: 'embedded_mpv_frame_reader.node',
helper: 'iptvnator_mpv_helper',
},
processIsolation: {
addonLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
},
nativeViewFallback: 'process-isolated mpv --wid',
libmpvSoname,
runtimeFiles: copiedRuntimeFiles,
runtimeTotalBytes: copiedRuntimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
sourceArchive: runtime.sourceArchive ?? null,
sourceRuntime: runtime.sourceRuntimeManifest,
};
fs.writeFileSync(
@@ -428,12 +732,33 @@ function runNodeGyp(command, env) {
}
}
function readLinuxDynamicSection(filePath) {
const result = spawnSync('readelf', ['-d', filePath], {
cwd: workspaceRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.error) {
throw new Error(
`Unable to run readelf for ${filePath}: ${result.error.message}`
);
}
if (result.status !== 0) {
throw new Error(
`readelf -d failed for ${filePath} with status ${
result.status ?? 1
}: ${(result.stderr ?? '').trim()}`
);
}
return result.stdout;
}
function main() {
fs.mkdirSync(outputDir, { recursive: true });
cleanDistNativeOutput();
cleanOutput();
if (targetPlatform !== process.platform) {
cleanOutput();
if (embeddedMpvRequired) {
throw new Error(
`Embedded MPV is required for ${targetPlatform}-${targetArch}, but this host is ${process.platform}-${process.arch}.`
@@ -465,58 +790,114 @@ function main() {
return;
}
const runtimeManifest =
targetPlatform === 'darwin'
? copyRuntimeToNativeBuild({
runtimeLibDir: runtime.libDir,
outputLibDir,
runtimeOrigin: runtime.origin,
runtimeManifest: {
targetArch,
...runtime.manifest,
},
})
: targetPlatform === 'linux'
? writeLinuxProcessRuntimeManifest(runtime)
: copyGenericRuntimeToNativeBuild(runtime);
fs.rmSync(unavailableMarkerFile, { force: true });
assertRequiredLinuxFrameCopyRuntime(runtime);
const electronPackageJson = require(
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
);
const electronVersion = electronPackageJson.version;
const env = {
...process.env,
npm_config_runtime: 'electron',
npm_config_target: electronVersion,
npm_config_arch: targetArch,
npm_config_disturl: 'https://electronjs.org/headers',
npm_config_build_from_source: 'true',
npm_config_update_binary: 'false',
LIBMPV_INCLUDE_DIR: runtime.includeDir,
...(targetPlatform === 'linux'
? {
LINUX_NATIVE_LIBRARY_DIR:
process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir,
}
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
...(runtime.windowsImportLib
? {
LIBMPV_IMPORT_LIB: path.join(
const buildNativeArtifacts = () => {
const runtimeManifest =
targetPlatform === 'darwin'
? copyRuntimeToNativeBuild({
runtimeLibDir: runtime.libDir,
outputLibDir,
path.basename(runtime.windowsImportLib)
),
}
: {}),
};
runtimeOrigin: runtime.origin,
runtimeManifest: {
targetArch,
...runtime.manifest,
},
})
: targetPlatform === 'linux'
? writeLinuxFrameCopyBuildManifest(runtime)
: copyGenericRuntimeToNativeBuild(runtime);
const linuxLinkageInputs =
targetPlatform === 'linux'
? resolveLinuxFrameCopyLinkageInputs({
buildInputMode: runtime.buildInputMode,
outputLibDir,
packagedLibmpvSoname: runtimeManifest.libmpvSoname,
readDynamicSection: readLinuxDynamicSection,
runtimeLibDir: runtime.libDir,
})
: null;
log(
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
);
cleanNativeBuildIntermediates();
try {
const electronPackageJson = require(
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
);
const electronVersion = electronPackageJson.version;
const env = {
...process.env,
npm_config_runtime: 'electron',
npm_config_target: electronVersion,
npm_config_arch: targetArch,
npm_config_disturl: 'https://electronjs.org/headers',
npm_config_build_from_source: 'true',
npm_config_update_binary: 'false',
LIBMPV_INCLUDE_DIR: runtime.includeDir,
...(targetPlatform === 'linux'
? {
LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:
linuxLinkageInputs.linkerLibraryDir,
}
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
...(runtime.windowsImportLib
? {
LIBMPV_IMPORT_LIB: path.join(
outputLibDir,
path.basename(runtime.windowsImportLib)
),
}
: {}),
};
log(
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
);
cleanNativeBuildIntermediates();
runNodeGyp('configure', env);
runNodeGyp('build', env);
if (!fs.existsSync(outputFile)) {
throw new Error(`Build finished without producing ${outputFile}.`);
}
if (targetPlatform === 'linux') {
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname,
outputDir,
readDynamicSection: readLinuxDynamicSection,
});
}
if (targetPlatform === 'darwin') {
patchAddonForBundledRuntime(outputFile, outputLibDir);
// The frame-copy helper executable links libmpv too and sits next
// to the same lib/ directory, so it gets the identical
// dependency-path rewrite + ad-hoc re-sign.
const frameHelperFile = path.join(
outputDir,
'iptvnator_mpv_helper'
);
if (fs.existsSync(frameHelperFile)) {
patchAddonForBundledRuntime(frameHelperFile, outputLibDir);
}
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
outputFile,
...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []),
...runtimeManifest.dylibs.map((dylib) =>
path.join(outputLibDir, dylib)
),
]);
if (
runtime.origin === 'vendored-lgpl' &&
forbiddenLinkErrors.length > 0
) {
throw new Error(forbiddenLinkErrors.join('\n'));
}
}
fs.rmSync(unavailableMarkerFile, { force: true });
};
try {
runWithCleanup(buildNativeArtifacts, cleanOutput);
} catch (error) {
if (!embeddedMpvRequired && runtime.origin === 'system-dev') {
// The system-dev fallback triggers on any machine with
@@ -528,40 +909,11 @@ function main() {
error instanceof Error ? error.message : String(error)
}`
);
cleanOutput();
return;
}
throw error;
}
if (!fs.existsSync(outputFile)) {
throw new Error(`Build finished without producing ${outputFile}.`);
}
if (targetPlatform === 'darwin') {
patchAddonForBundledRuntime(outputFile, outputLibDir);
// The frame-copy helper executable links libmpv too and sits next to
// the same lib/ directory, so it gets the identical dependency-path
// rewrite + ad-hoc re-sign.
const frameHelperFile = path.join(outputDir, 'iptvnator_mpv_helper');
if (fs.existsSync(frameHelperFile)) {
patchAddonForBundledRuntime(frameHelperFile, outputLibDir);
}
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
outputFile,
...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []),
...runtimeManifest.dylibs.map((dylib) =>
path.join(outputLibDir, dylib)
),
]);
if (
runtime.origin === 'vendored-lgpl' &&
forbiddenLinkErrors.length > 0
) {
throw new Error(forbiddenLinkErrors.join('\n'));
}
}
log(`Built ${path.relative(workspaceRoot, outputFile)}.`);
}
@@ -0,0 +1,340 @@
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
const LIBMPV_NEEDED_PATTERN = /^libmpv\.so(?:\..*)?$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const LINUX_FRAME_COPY_ARTIFACTS = Object.freeze([
Object.freeze({
fileName: 'embedded_mpv.node',
label: 'embedded MPV addon',
mayLinkLibmpv: false,
}),
Object.freeze({
fileName: 'embedded_mpv_frame_reader.node',
label: 'embedded MPV frame reader',
mayLinkLibmpv: false,
}),
Object.freeze({
fileName: 'iptvnator_mpv_helper',
label: 'embedded MPV frame-copy helper',
mayLinkLibmpv: true,
}),
]);
function parseReadelfDynamic(output) {
if (typeof output !== 'string') {
throw new TypeError('readelf dynamic output must be a string.');
}
const dynamic = {
needed: [],
rpath: [],
runpath: [],
soname: [],
};
const dynamicEntryPattern =
/\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g;
for (const [, tag, value] of output.matchAll(dynamicEntryPattern)) {
if (tag === 'NEEDED') {
dynamic.needed.push(value);
continue;
}
if (tag === 'SONAME') {
dynamic.soname.push(value);
continue;
}
dynamic[tag.toLowerCase()].push(
...value.split(':').filter((entry) => entry.length > 0)
);
}
return dynamic;
}
function exactlyOneRuntimeFile(runtimeFiles, name) {
if (!Array.isArray(runtimeFiles)) {
throw new Error('Linux runtimeFiles metadata must be an array.');
}
const matchingRecords = runtimeFiles.filter(
(runtimeFile) => runtimeFile?.name === name
);
if (matchingRecords.length !== 1) {
throw new Error(
`Linux runtime must contain exactly one exact runtimeFiles record for ${name}.`
);
}
const [runtimeFile] = matchingRecords;
if (
!Number.isInteger(runtimeFile.size) ||
runtimeFile.size <= 0 ||
typeof runtimeFile.sha256 !== 'string' ||
!SHA256_PATTERN.test(runtimeFile.sha256)
) {
throw new Error(
`Linux runtimeFiles record for ${name} has invalid size or SHA-256 metadata.`
);
}
return runtimeFile;
}
function readVerifiedRuntimeFile(filePath, runtimeFile) {
let stat;
try {
stat = fs.lstatSync(filePath);
} catch {
throw new Error(`Missing copied Linux runtime file: ${filePath}`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(
`Copied Linux runtime file must be a regular non-symbolic-link file: ${filePath}`
);
}
let descriptor;
try {
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
);
const descriptorStat = fs.fstatSync(descriptor);
if (!descriptorStat.isFile()) {
throw new Error(
`Copied Linux runtime path is not a regular file: ${filePath}`
);
}
const contents = fs.readFileSync(descriptor);
if (contents.byteLength !== runtimeFile.size) {
throw new Error(
`Size mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.`
);
}
const actualSha256 = crypto
.createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
throw new Error(
`SHA-256 mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
);
}
} finally {
if (descriptor !== undefined) {
fs.closeSync(descriptor);
}
}
}
function closureLibMpvSoname(runtimeDependencyClosure) {
if (!Array.isArray(runtimeDependencyClosure?.entries)) {
throw new Error(
'Validated Linux runtime dependency closure entries are required.'
);
}
const libMpvEntries = runtimeDependencyClosure.entries.filter(
(entry) =>
entry?.name === 'libmpv.so' ||
VERSIONED_LIBMPV_PATTERN.test(entry?.name)
);
const declaredSonames = libMpvEntries.map((entry) => entry.soname);
const uniqueSonames = new Set(declaredSonames);
if (
declaredSonames.length === 0 ||
declaredSonames.some(
(soname) =>
typeof soname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(soname)
) ||
uniqueSonames.size !== 1
) {
throw new Error(
'Validated Linux runtime closure must declare exactly one versioned libmpv SONAME.'
);
}
return declaredSonames[0];
}
function resolveVerifiedLinuxLibMpvSoname({
outputLibDir,
runtimeFiles,
runtimeDependencyClosure,
readDynamicSection,
}) {
if (typeof readDynamicSection !== 'function') {
throw new TypeError('Linux readelf dynamic reader is required.');
}
const expectedSoname = closureLibMpvSoname(runtimeDependencyClosure);
const linkerInputRecord = exactlyOneRuntimeFile(runtimeFiles, 'libmpv.so');
const exactSonameRecord = exactlyOneRuntimeFile(
runtimeFiles,
expectedSoname
);
const linkerInputPath = path.join(outputLibDir, 'libmpv.so');
const exactSonamePath = path.join(outputLibDir, expectedSoname);
readVerifiedRuntimeFile(linkerInputPath, linkerInputRecord);
readVerifiedRuntimeFile(exactSonamePath, exactSonameRecord);
const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath));
if (
dynamic.soname.length !== 1 ||
!VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0])
) {
throw new Error(
'Copied Linux libmpv.so must contain exactly one DT_SONAME with a versioned libmpv basename.'
);
}
if (dynamic.soname[0] !== expectedSoname) {
throw new Error(
`Copied Linux libmpv.so DT_SONAME ${dynamic.soname[0]} does not match validated closure SONAME ${expectedSoname}.`
);
}
return expectedSoname;
}
function resolveLinuxFrameCopyLinkageInputs({
buildInputMode,
outputLibDir,
packagedLibmpvSoname,
readDynamicSection,
runtimeLibDir,
}) {
const systemDevelopment = buildInputMode === 'system-dev';
const linkerLibraryDir = systemDevelopment ? runtimeLibDir : outputLibDir;
if (
typeof linkerLibraryDir !== 'string' ||
linkerLibraryDir.trim().length === 0
) {
throw new Error(
'Linux frame-copy linkage requires a non-empty linker library directory.'
);
}
if (!systemDevelopment) {
return {
expectedLibmpvSoname: packagedLibmpvSoname,
linkerLibraryDir,
};
}
if (typeof readDynamicSection !== 'function') {
throw new TypeError('Linux readelf dynamic reader is required.');
}
const linkerInputPath = path.join(linkerLibraryDir, 'libmpv.so');
const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath));
if (
dynamic.soname.length !== 1 ||
!VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0])
) {
throw new Error(
'The system-development libmpv linker input must contain exactly one versioned libmpv SONAME.'
);
}
return {
expectedLibmpvSoname: dynamic.soname[0],
linkerLibraryDir,
};
}
function assertRegularArtifact(filePath, label) {
let stat;
try {
stat = fs.lstatSync(filePath);
} catch {
throw new Error(`Missing ${label}: ${filePath}`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(
`${label} must be a regular non-symbolic-link file: ${filePath}`
);
}
}
function validateLinuxFrameCopyLinkage({
expectedLibmpvSoname,
outputDir,
readDynamicSection,
}) {
if (
typeof expectedLibmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(expectedLibmpvSoname)
) {
throw new Error(
'Linux frame-copy linkage validation requires an exact versioned libmpv SONAME.'
);
}
if (typeof readDynamicSection !== 'function') {
throw new TypeError('Linux readelf dynamic reader is required.');
}
for (const artifact of LINUX_FRAME_COPY_ARTIFACTS) {
const artifactPath = path.join(outputDir, artifact.fileName);
assertRegularArtifact(artifactPath, artifact.label);
const dynamic = parseReadelfDynamic(readDynamicSection(artifactPath));
const libMpvDependencies = dynamic.needed.filter((dependency) =>
LIBMPV_NEEDED_PATTERN.test(dependency)
);
if (!artifact.mayLinkLibmpv) {
if (libMpvDependencies.length > 0) {
throw new Error(
`${artifact.label} must not have a direct libmpv DT_NEEDED entry; found ${libMpvDependencies.join(', ')}.`
);
}
continue;
}
if (
libMpvDependencies.length !== 1 ||
libMpvDependencies[0] !== expectedLibmpvSoname
) {
throw new Error(
`${artifact.label} DT_NEEDED must contain exactly ${expectedLibmpvSoname}; found ${
libMpvDependencies.join(', ') || '<none>'
}.`
);
}
if (dynamic.rpath.length !== 0) {
throw new Error(
`${artifact.label} must not contain RPATH; found ${dynamic.rpath.join(':')}.`
);
}
if (
dynamic.runpath.length !== 1 ||
dynamic.runpath[0] !== '$ORIGIN/lib'
) {
throw new Error(
`${artifact.label} RUNPATH must be exactly $ORIGIN/lib; found ${
dynamic.runpath.join(':') || '<none>'
}.`
);
}
}
}
function runWithCleanup(operation, cleanup) {
try {
return operation();
} catch (error) {
cleanup();
throw error;
}
}
module.exports = {
parseReadelfDynamic,
resolveLinuxFrameCopyLinkageInputs,
resolveVerifiedLinuxLibMpvSoname,
runWithCleanup,
validateLinuxFrameCopyLinkage,
};
+5 -5
View File
@@ -158,14 +158,14 @@
],
"ldflags": [
"-pthread",
"-Wl,-rpath,'$$ORIGIN/lib'",
"-Wl,-rpath,<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")"
"-Wl,--enable-new-dtags",
"-Wl,-rpath,'$$ORIGIN/lib'"
],
"libraries": [
"-L<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")",
"-lmpv",
"-L<!(node -e \"const dir = process.env.LINUX_VERIFIED_RUNTIME_LIBRARY_DIR; if (!dir) { throw new Error('Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); } process.stdout.write(dir)\")",
"<!(node -e \"const path = require('path'); const dir = process.env.LINUX_VERIFIED_RUNTIME_LIBRARY_DIR; if (!dir) { throw new Error('Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); } process.stdout.write(path.join(dir, 'libmpv.so'))\")",
"-lEGL",
"-lOpenGL",
"-lGL",
"-lgbm",
"-ldl"
]
@@ -11,7 +11,7 @@
* earlier software renderer is recreated only if no hardware tier works.
* Each tier uses a desktop-GL 3.2 core context bound surfaceless (1x1 pbuffer
* fallback for drivers without EGL_KHR_surfaceless_context). The helper's
* own GL calls resolve at link time through libOpenGL (glvnd); mpv resolves
* own GL calls resolve at link time through libGL (glvnd); mpv resolves
* core symbols from that linked library and falls back to eglGetProcAddress
* for extensions.
*
@@ -188,6 +188,7 @@ public:
GlGetProcAddressFn procLoader() const { return glDlsymGetProcAddress; }
void* procLoaderCtx() const { return glDylib_; }
const char* renderApiName() const { return "cgl"; }
private:
CGLContextObj cgl_ = nullptr;
@@ -337,6 +338,7 @@ public:
GlGetProcAddressFn procLoader() const { return eglWrapGetProcAddress; }
void* procLoaderCtx() const { return nullptr; }
const char* renderApiName() const { return "egl"; }
private:
enum class DisplayTier { SurfacelessMesa, Default, Gbm };
@@ -780,6 +782,7 @@ public:
GlGetProcAddressFn procLoader() const { return wglLoaderGetProcAddress; }
void* procLoaderCtx() const { return opengl32_; }
const char* renderApiName() const { return "wgl"; }
private:
HWND window_ = nullptr;
@@ -41,6 +41,7 @@ namespace {
using frame_helper::Command;
using frame_helper::emitLine;
using frame_helper::GlContext;
using frame_helper::JsonWriter;
using frame_helper::RenderPipeline;
@@ -612,6 +613,7 @@ struct HelperArgs {
int width = 1280;
int height = 720;
double volume = 1;
bool runtimeProbe = false;
};
HelperArgs parseArgs(int argc, char** argv) {
@@ -627,12 +629,136 @@ HelperArgs parseArgs(int argc, char** argv) {
else if (arg == "--volume") args.volume = std::atof(next().c_str());
else if (arg == "--hwdec") args.hwdec = next();
else if (arg == "--audio-delay") args.audioDelay = next();
else if (arg == "--runtime-probe") args.runtimeProbe = true;
}
args.width = std::max(16, args.width);
args.height = std::max(16, args.height);
return args;
}
int runtimeProbeFailure(const std::string& reason,
const std::string& detail = "") {
JsonWriter writer;
writer.num("protocol", 1)
.boolean("usable", false)
.str("reason", reason);
if (!detail.empty()) writer.str("detail", detail);
emitLine(writer.finish());
return 1;
}
std::string libmpvClientApiVersion() {
const unsigned long version = mpv_client_api_version();
return std::to_string(version >> 16) + "." +
std::to_string(version & 0xffff);
}
std::string runtimeProbeShmName() {
#if defined(_WIN32)
const uint64_t processId = (uint64_t)GetCurrentProcessId();
#else
const uint64_t processId = (uint64_t)getpid();
#endif
return "/impv-fc-runtime-probe-" + std::to_string(processId);
}
/*
* Bounded startup capability probe: initialize an idle libmpv client and
* create the platform GL + mpv OpenGL render contexts, then create, validate,
* and destroy a minimal shared-memory ring. It deliberately does not create
* an FBO, open media, or enter either command loop.
*/
int runRuntimeProbe() {
mpv_handle* mpv = mpv_create();
if (!mpv) {
return runtimeProbeFailure("mpv-create-failed");
}
mpv_set_option_string(mpv, "vo", "libmpv");
mpv_set_option_string(mpv, "idle", "yes");
mpv_set_option_string(mpv, "input-default-bindings", "no");
mpv_set_option_string(mpv, "osc", "no");
const int initializeResult = mpv_initialize(mpv);
if (initializeResult < 0) {
const std::string error = mpv_error_string(initializeResult);
mpv_destroy(mpv);
return runtimeProbeFailure("mpv-initialize-failed", error);
}
GlContext gl;
std::string error;
if (!gl.create(error)) {
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("gl-context-create-failed", error);
}
if (!gl.makeCurrent(error)) {
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("gl-context-bind-failed", error);
}
mpv_opengl_init_params glInit = {
gl.procLoader(),
gl.procLoaderCtx(),
};
mpv_render_param renderParams[] = {
{MPV_RENDER_PARAM_API_TYPE,
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context* renderContext = nullptr;
const int renderResult =
mpv_render_context_create(&renderContext, mpv, renderParams);
if (renderResult < 0 || !renderContext) {
const std::string renderError =
renderResult < 0 ? mpv_error_string(renderResult)
: "render context unavailable";
if (renderContext) mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("mpv-render-context-failed", renderError);
}
frame_helper::ShmRing runtimeProbeRing;
const std::string shmName = runtimeProbeShmName();
if (!runtimeProbeRing.create(shmName, 16, 16, 1)) {
runtimeProbeRing.destroy();
mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("shared-memory-create-failed");
}
const bool sharedMemoryInitialized =
runtimeProbeRing.base != nullptr &&
runtimeProbeRing.header != nullptr &&
runtimeProbeRing.header->magic == FRAME_SHM_MAGIC &&
runtimeProbeRing.header->version == FRAME_SHM_VERSION &&
runtimeProbeRing.header->width == 16 &&
runtimeProbeRing.header->height == 16 &&
runtimeProbeRing.header->generation == 1;
runtimeProbeRing.destroy();
if (!sharedMemoryInitialized) {
mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("shared-memory-initialize-failed");
}
const std::string libmpvVersion = libmpvClientApiVersion();
mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
emitLine(JsonWriter()
.num("protocol", 1)
.boolean("usable", true)
.str("libmpv", libmpvVersion)
.str("renderApi", gl.renderApiName())
.finish());
return 0;
}
} // namespace
int main(int argc, char** argv) {
@@ -641,6 +767,9 @@ int main(int argc, char** argv) {
signal(SIGPIPE, SIG_IGN);
#endif
const HelperArgs args = parseArgs(argc, argv);
if (args.runtimeProbe) {
return runRuntimeProbe();
}
g_state.mpv = mpv_create();
if (!g_state.mpv) {
+27 -3
View File
@@ -36,7 +36,11 @@
"inputs": [
"production",
"^production",
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"options": {
"outputPath": "dist/apps/electron-backend",
@@ -98,7 +102,11 @@
"inputs": [
"production",
"^production",
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"options": {
"outputPath": "dist/apps/electron-backend",
@@ -193,11 +201,27 @@
}
},
"lint": {
"command": "eslint apps/electron-backend/**/*.ts"
"inputs": [
"default",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"command": "eslint apps/electron-backend/**/*.ts \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\""
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"inputs": [
"default",
"^production",
"{workspaceRoot}/jest.preset.js",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"options": {
"jestConfig": "apps/electron-backend/jest.config.ts"
}
@@ -526,6 +526,16 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
getEpgMapping: (channelKey: string) =>
ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }),
getEpgMappingsBatch: (channelKeys: string[]) =>
ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }),
setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) =>
ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }),
deleteEpgMapping: (channelKey: string) =>
ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }),
searchEpgChannels: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_CHANNEL_SEARCH', { searchTerm, limit }),
setMpvPlayerPath: (mpvPlayerPath: string) =>
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
setVlcPlayerPath: (vlcPlayerPath: string) =>
@@ -832,6 +842,10 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('DOWNLOADS_START', data),
downloadsCancel: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_CANCEL', downloadId),
downloadsPause: (downloadId: number) =>
ipcRenderer.invoke('DOWNLOADS_PAUSE', downloadId),
downloadsResume: (downloadId: number, downloadFolder: string) =>
ipcRenderer.invoke('DOWNLOADS_RESUME', downloadId, downloadFolder),
downloadsRetry: (downloadId: number, downloadFolder: string) =>
ipcRenderer.invoke('DOWNLOADS_RETRY', downloadId, downloadFolder),
downloadsRemove: (downloadId: number) =>
+29 -3
View File
@@ -133,8 +133,34 @@ describe('Electron app security helpers', () => {
it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1);
});
it.each([undefined, '0'])(
'does not probe frame-copy runtime for an inactive %s opt-in',
(explicitFrameCopy) => {
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
if (explicitFrameCopy === undefined) {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
} else {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY =
explicitFrameCopy;
}
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
expect(mockIsFrameCopyRuntimeUsable).not.toHaveBeenCalled();
}
);
it('probes frame-copy runtime for an explicit opt-in', () => {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1);
});
it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => {
@@ -215,9 +241,9 @@ describe('Electron app security helpers', () => {
expect(mainWindow.loadFile).toHaveBeenCalledWith(
expect.stringContaining('index.html')
);
expect(
mockClearStorageData.mock.invocationCallOrder[0]
).toBeLessThan(mainWindow.loadFile.mock.invocationCallOrder[0]);
expect(mockClearStorageData.mock.invocationCallOrder[0]).toBeLessThan(
mainWindow.loadFile.mock.invocationCallOrder[0]
);
});
it('continues packaged renderer loading when Electron service worker cleanup fails', async () => {
@@ -0,0 +1,151 @@
import {
buildCompoundFtsMatchQuery,
buildCompoundLikePatterns,
buildM3uPayloadCompoundPatterns,
getCompoundResidualTokenGroups,
getCompoundSearchWords,
getSearchWordPlans,
scoreSearchTextMatch,
shouldUseContentTitlePrefixIndex,
} from './content-search.util';
describe('content-search.util', () => {
describe('getCompoundSearchWords', () => {
it('extracts punctuation-joined words from the raw search term', () => {
expect(getCompoundSearchWords('A&E')).toEqual(['A&E']);
expect(getCompoundSearchWords('US A&E HD')).toEqual(['A&E']);
expect(getCompoundSearchWords('Spider-Man')).toEqual([
'Spider-Man',
]);
expect(getCompoundSearchWords("L'Équipe")).toEqual(["L'Équipe"]);
});
it('trims edge punctuation before deciding whether a word is compound', () => {
expect(getCompoundSearchWords('(A&E)')).toEqual(['A&E']);
expect(getCompoundSearchWords('"A&E:"')).toEqual(['A&E']);
});
it('ignores plain words, standalone punctuation and too-short fragments', () => {
expect(getCompoundSearchWords('History')).toEqual([]);
expect(getCompoundSearchWords('Tom & Jerry')).toEqual([]);
expect(getCompoundSearchWords('a&')).toEqual([]);
expect(getCompoundSearchWords('')).toEqual([]);
expect(getCompoundSearchWords(undefined)).toEqual([]);
});
});
describe('getSearchWordPlans', () => {
it('keeps each word with its own token groups and compound flag', () => {
expect(getSearchWordPlans('A&E HD')).toEqual([
{ compound: 'A&E', tokenGroups: [['a'], ['e']] },
{ compound: null, tokenGroups: [['hd']] },
]);
});
it('skips punctuation-only words', () => {
expect(getSearchWordPlans('Tom & Jerry')).toEqual([
{ compound: null, tokenGroups: [['tom']] },
{ compound: null, tokenGroups: [['jerry']] },
]);
});
});
describe('getCompoundResidualTokenGroups', () => {
it('returns the token groups of the non-compound words only', () => {
expect(getCompoundResidualTokenGroups('A&E HD')).toEqual([['hd']]);
expect(getCompoundResidualTokenGroups('A&E')).toEqual([]);
expect(getCompoundResidualTokenGroups('History')).toEqual([
['history'],
]);
});
});
describe('buildCompoundFtsMatchQuery', () => {
it('quotes each compound word as a trigram substring phrase', () => {
expect(buildCompoundFtsMatchQuery('A&E')).toBe('"a&e"');
expect(buildCompoundFtsMatchQuery('X-Men')).toBe('"x-men"');
});
it('keeps accented and diacritic-stripped variants', () => {
expect(buildCompoundFtsMatchQuery("L'Équipe")).toBe(
'("l\'équipe" OR "l\'equipe")'
);
});
it('joins multiple compound words with AND', () => {
expect(buildCompoundFtsMatchQuery('A&E X-Men')).toBe(
'"a&e" AND "x-men"'
);
});
it('returns an empty query for terms without compound words', () => {
expect(buildCompoundFtsMatchQuery('History Channel')).toBe('');
expect(buildCompoundFtsMatchQuery('tv')).toBe('');
});
});
describe('buildCompoundLikePatterns', () => {
it('builds case-variant contains patterns around the intact word', () => {
const patterns = buildCompoundLikePatterns('A&E');
expect(patterns).toContain('%a&e%');
expect(patterns).toContain('%A&E%');
expect(
patterns.every(
(pattern) =>
pattern.startsWith('%') && pattern.endsWith('%')
)
).toBe(true);
});
it('escapes LIKE wildcards inside the compound word', () => {
expect(buildCompoundLikePatterns('a_b')).toContain('%a\\_b%');
});
});
describe('buildM3uPayloadCompoundPatterns', () => {
it('scopes compound contains patterns to payload name/title fields', () => {
const patterns = buildM3uPayloadCompoundPatterns('A&E');
expect(patterns).toContain('%"name":"%a&e%"%');
expect(patterns).toContain('%"title":"%a&e%"%');
});
});
describe('shouldUseContentTitlePrefixIndex', () => {
it('still routes compound short-token terms through the prefix index', () => {
// The compound FTS lookup supplements the prefix arm instead of
// replacing it, so titles starting with "A & E" keep matching.
expect(shouldUseContentTitlePrefixIndex('A&E')).toBe(true);
expect(shouldUseContentTitlePrefixIndex('Spider-Man')).toBe(false);
});
});
describe('scoreSearchTextMatch', () => {
it('matches compound words anywhere in the title (issue #1161)', () => {
expect(scoreSearchTextMatch('US: A&E', 'A&E')).toBe(40);
expect(scoreSearchTextMatch('US | A&E HD', 'A&E HD')).toBe(40);
expect(scoreSearchTextMatch('(US) A&E', 'A&E')).toBe(40);
});
it('keeps exact and prefix compound matches ranked above mid-title ones', () => {
expect(scoreSearchTextMatch('A&E', 'A&E')).toBe(0);
expect(scoreSearchTextMatch('A&E HD', 'A&E')).toBe(10);
});
it('does not match the phrase across word boundaries', () => {
expect(scoreSearchTextMatch('Casa e Villa', 'A&E')).toBeNull();
expect(scoreSearchTextMatch('Bravo Espana', 'A&E')).toBeNull();
});
it('requires every extra token, not just the compound word', () => {
expect(scoreSearchTextMatch('US: A&E', 'A&E HD')).toBeNull();
});
it('keeps single short tokens anchored to the title start', () => {
expect(scoreSearchTextMatch('TV Sport News', 'tv')).toBe(10);
expect(scoreSearchTextMatch('Test TV', 'tv')).toBeNull();
});
});
});
@@ -0,0 +1,370 @@
/**
* Pure text helpers for the content search pipeline (global search,
* per-playlist search and M3U payload search). Everything here is
* side-effect free and independent of drizzle so it can be unit tested
* without database mocks; the SQL condition builders that consume these
* helpers live in `content.operations.ts`.
*/
/** Minimum length the trigram FTS index can match (trigram = 3 chars). */
const MIN_COMPOUND_WORD_LENGTH = 3;
const WORD_EDGE_PUNCTUATION_REGEXP = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu;
const INNER_PUNCTUATION_REGEXP = /[^\p{L}\p{N}]/u;
export function escapeLikePattern(term: string): string {
return term.replace(/[%_\\]/g, '\\$&');
}
export function normalizeSearchMatchText(value: unknown): string {
return typeof value === 'string'
? value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ')
: '';
}
function normalizeSqlSearchText(value: unknown): string {
return typeof value === 'string'
? value
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ')
: '';
}
function getSearchTokens(value: unknown): string[] {
const normalized = normalizeSearchMatchText(value);
return normalized ? normalized.split(' ') : [];
}
export function getSqlSearchTokenGroups(value: unknown): string[][] {
const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean);
const normalizedTokens = getSearchTokens(value);
const tokenCount = Math.max(rawTokens.length, normalizedTokens.length);
return Array.from({ length: tokenCount }, (_, index) =>
[...new Set([rawTokens[index], normalizedTokens[index]])].filter(
Boolean
)
).filter((group) => group.length > 0);
}
export function isShortSearchTokenGroup(tokens: readonly string[]): boolean {
return tokens.some((token) => token.length <= 2);
}
function getSqlSearchTokenVariants(value: unknown): string[] {
return [...new Set(getSqlSearchTokenGroups(value).flat())];
}
export function buildLikePatterns(
term: string,
mode: 'contains' | 'prefix' = 'contains'
): string[] {
const variants = new Set<string>();
for (const value of [term, ...getSqlSearchTokenVariants(term)]) {
const trimmedValue = value.trim();
if (!trimmedValue) {
continue;
}
const titleCase =
trimmedValue.length > 0
? trimmedValue.charAt(0).toLocaleUpperCase() +
trimmedValue.slice(1).toLocaleLowerCase()
: trimmedValue;
variants.add(trimmedValue);
variants.add(trimmedValue.toLocaleLowerCase());
variants.add(trimmedValue.toLocaleUpperCase());
variants.add(titleCase);
}
return [...variants].map((value) => {
const escapedValue = escapeLikePattern(value);
return mode === 'prefix' ? `${escapedValue}%` : `%${escapedValue}%`;
});
}
export function buildGlobPrefixPatterns(token: string): string[] {
const variants = new Set<string>();
for (const value of [token, ...getSqlSearchTokenVariants(token)]) {
variants.add(value);
variants.add(value.toLocaleLowerCase());
variants.add(value.toLocaleUpperCase());
variants.add(
value.charAt(0).toLocaleUpperCase() +
value.slice(1).toLocaleLowerCase()
);
}
return [...variants].map((value) => `${value}*`);
}
export function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean {
const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm);
return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup);
}
export function buildContentTitleFtsMatchQuery(searchTerm: string): string {
return getSqlSearchTokenGroups(searchTerm)
.map((tokens) => {
const quotedTokens = tokens
.filter((token) => token.length >= 3)
.map((token) => `"${token.replace(/"/g, '""')}"`);
if (quotedTokens.length <= 1) {
return quotedTokens[0] ?? '';
}
return `(${quotedTokens.join(' OR ')})`;
})
.filter(Boolean)
.join(' AND ');
}
export function shouldUseContentTitleFts(searchTerm: string): boolean {
return (
!shouldUseContentTitlePrefixIndex(searchTerm) &&
buildContentTitleFtsMatchQuery(searchTerm).length > 0
);
}
/**
* Whitespace-delimited words from the raw search term that still contain
* internal punctuation after trimming their edges — e.g. `a&e`, `x-men`,
* `l'équipe`. Tokenization splits these into (often 1-letter) fragments
* whose short-token handling anchors the search to the title start, so the
* intact word is preserved as an additional exact-substring search unit
* (issue #1161: "A&E" not finding "US: A&E").
*/
export function getCompoundSearchWords(value: unknown): string[] {
if (typeof value !== 'string') {
return [];
}
const words = new Set<string>();
for (const rawWord of value.split(/\s+/)) {
const word = rawWord.replace(WORD_EDGE_PUNCTUATION_REGEXP, '');
if (
word.length < MIN_COMPOUND_WORD_LENGTH ||
!INNER_PUNCTUATION_REGEXP.test(word)
) {
continue;
}
words.add(word);
}
return [...words];
}
/**
* One whitespace-delimited search word with its token groups. `compound` is
* the intact word when it contains internal punctuation (see
* `getCompoundSearchWords`), else null. SQL condition builders compose
* per-word conditions from this so a compound word's substring arm stays
* AND-constrained by the other words of the query — `A&E HD` must not let
* plain `A&E` titles flood the SQL candidate window before scoring runs.
*/
export interface SearchWordPlan {
compound: string | null;
tokenGroups: string[][];
}
export function getSearchWordPlans(value: unknown): SearchWordPlan[] {
if (typeof value !== 'string') {
return [];
}
const plans: SearchWordPlan[] = [];
for (const rawWord of value.split(/\s+/)) {
const word = rawWord.replace(WORD_EDGE_PUNCTUATION_REGEXP, '');
if (!word) {
continue;
}
const tokenGroups = getSqlSearchTokenGroups(word);
if (tokenGroups.length === 0) {
continue;
}
const isCompound =
word.length >= MIN_COMPOUND_WORD_LENGTH &&
INNER_PUNCTUATION_REGEXP.test(word);
plans.push({ compound: isCompound ? word : null, tokenGroups });
}
return plans;
}
/**
* Token groups of the non-compound words of the term. When the compound FTS
* arm looks up `"a&e"` for `A&E HD`, these groups (`hd`) are re-applied as
* LIKE conditions so the supplement cannot fill the candidate limit with
* titles that match the compound word alone.
*/
export function getCompoundResidualTokenGroups(value: unknown): string[][] {
return getSearchWordPlans(value)
.filter((plan) => plan.compound === null)
.flatMap((plan) => plan.tokenGroups);
}
/** As-typed plus diacritic-stripped forms, both long enough for trigram FTS. */
function getCompoundWordVariants(word: string): string[] {
const stripped = word.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
return [...new Set([word, stripped])].filter(
(variant) => variant.length >= MIN_COMPOUND_WORD_LENGTH
);
}
/**
* Case/diacritic LIKE variants of a compound word as contains patterns
* (`%a&e%`). SQLite LIKE is only case-insensitive for ASCII, so the same
* lower/upper/title-case expansion as `buildLikePatterns` is applied.
*/
export function buildCompoundLikePatterns(word: string): string[] {
const variants = new Set<string>();
for (const value of getCompoundWordVariants(word)) {
variants.add(value);
variants.add(value.toLocaleLowerCase());
variants.add(value.toLocaleUpperCase());
variants.add(
value.charAt(0).toLocaleUpperCase() +
value.slice(1).toLocaleLowerCase()
);
}
return [...variants].map((value) => `%${escapeLikePattern(value)}%`);
}
/**
* Trigram FTS match query treating each compound word as a quoted substring
* phrase: `"a&e"`, `("café" OR "cafe")`. The trigram tokenizer matches
* case-insensitive substrings of >= 3 chars, so this finds the compound word
* anywhere in the title without a table scan. Returns '' when the term has
* no compound words.
*/
export function buildCompoundFtsMatchQuery(searchTerm: string): string {
return getCompoundSearchWords(searchTerm)
.map((word) => {
const quotedVariants = [
...new Set(
getCompoundWordVariants(word).map((variant) =>
variant.toLocaleLowerCase()
)
),
].map((variant) => `"${variant.replace(/"/g, '""')}"`);
if (quotedVariants.length <= 1) {
return quotedVariants[0] ?? '';
}
return `(${quotedVariants.join(' OR ')})`;
})
.filter(Boolean)
.join(' AND ');
}
export function buildM3uPayloadTextFieldPatterns(
token: string,
mode: 'contains' | 'prefix'
): string[] {
return buildLikePatterns(token, mode).flatMap((pattern) =>
wrapM3uPayloadTextFieldPattern(pattern)
);
}
/** Compound-word contains patterns scoped to M3U payload name/title fields. */
export function buildM3uPayloadCompoundPatterns(word: string): string[] {
return buildCompoundLikePatterns(word).flatMap((pattern) =>
wrapM3uPayloadTextFieldPattern(pattern)
);
}
function wrapM3uPayloadTextFieldPattern(pattern: string): string[] {
return [
`%"name":"${pattern}"%`,
`%"name": "${pattern}"%`,
`%"title":"${pattern}"%`,
`%"title": "${pattern}"%`,
];
}
export function scoreSearchTextMatch(
value: string,
searchTerm: string
): number | null {
const candidateText = normalizeSearchMatchText(value);
const searchText = normalizeSearchMatchText(searchTerm);
if (!candidateText || !searchText) {
return null;
}
const searchTokens = searchText.split(' ');
const candidateTokens = candidateText.split(' ');
const firstSearchToken = searchTokens[0];
if (
firstSearchToken.length <= 2 &&
!candidateText.startsWith(firstSearchToken)
) {
// Short first tokens stay prefix-anchored to keep one-letter noise
// out, but a multi-token phrase ("a e" from "A&E") may still match as
// a whole word sequence anywhere in the title ("US: A&E").
if (
searchTokens.length > 1 &&
` ${candidateText} `.includes(` ${searchText} `)
) {
return 40;
}
return null;
}
if (candidateText === searchText) {
return 0;
}
if (
candidateText.startsWith(searchText) &&
(searchTokens.length > 1 || firstSearchToken.length <= 2)
) {
return 10;
}
if (candidateTokens.some((token) => token.startsWith(searchText))) {
return 20;
}
if (
searchTokens.every((searchToken) =>
candidateTokens.some((candidateToken) =>
candidateToken.startsWith(searchToken)
)
)
) {
return 30;
}
if (candidateText.includes(searchText)) {
return 40;
}
if (
searchTokens.every((searchToken) => candidateText.includes(searchToken))
) {
return 50;
}
return null;
}
@@ -12,6 +12,10 @@ const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({
left,
values,
}));
const orMock = jest.fn((...conditions: unknown[]) => ({
kind: 'or',
conditions,
}));
const sqlJoinMock = jest.fn((chunks: unknown[], separator?: unknown) => ({
kind: 'sql.join',
chunks,
@@ -34,7 +38,7 @@ jest.mock('drizzle-orm', () => ({
desc: jest.fn(),
eq: (left: unknown, right: unknown) => eqMock(left, right),
inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values),
or: jest.fn(),
or: (...conditions: unknown[]) => orMock(...conditions),
sql: sqlMock,
}));
@@ -47,6 +51,7 @@ import {
getGlobalRecentlyAdded,
saveContent,
scoreSearchTextMatch,
searchContent,
} from './content.operations';
function createDbMock(result: unknown[] = []) {
@@ -111,6 +116,7 @@ describe('content.operations', () => {
andMock.mockClear();
eqMock.mockClear();
inArrayMock.mockClear();
orMock.mockClear();
sqlJoinMock.mockClear();
sqlMock.mockClear();
});
@@ -752,6 +758,299 @@ describe('content.operations', () => {
).toBe(true);
});
it('finds compound-word channels anywhere in M3U channel names (issue #1161)', () => {
const makeChannel = (id: string, name: string) => ({
id,
url: `https://stream.test/${id}.m3u8`,
name,
group: { title: 'Entertainment' },
tvg: {
id,
name: '',
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: '',
});
const results = buildM3uGlobalSearchResults(
[
{
id: 'm3u-1',
name: 'M3U One',
payload: JSON.stringify({
playlist: {
items: [
makeChannel('prefixed', 'US: A&E'),
makeChannel('exact', 'A&E'),
makeChannel('noise', 'Casa e Villa'),
makeChannel('other', 'Bravo Espana'),
],
},
}),
},
],
'A&E',
false
);
expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']);
});
it('finds compound-word titles anywhere via per-playlist search (issue #1161)', async () => {
const makeRow = (id: number, title: string) => ({
id,
category_id: 10,
title,
rating: '',
added: '',
poster_url: '',
epg_channel_id: '',
tv_archive: 0,
tv_archive_duration: 0,
direct_source: '',
xtream_id: id,
type: 'live',
});
const limit = jest
.fn()
.mockResolvedValue([
makeRow(1, 'US: A&E'),
makeRow(2, 'Casa e Villa'),
]);
const where = jest.fn().mockReturnValue({ limit });
const innerJoin = jest.fn().mockReturnValue({ where });
const from = jest.fn().mockReturnValue({ innerJoin });
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
const results = await searchContent(db, 'playlist-1', 'A&E', ['live']);
expect(results.map((item) => item.title)).toEqual(['US: A&E']);
// The intact compound word must reach SQL as a contains pattern and
// be OR-combined with the prefix-anchored token conditions.
const patterns = sqlMock.mock.calls
.flatMap(([, ...values]) => values)
.filter((value): value is string => typeof value === 'string');
expect(patterns).toContain('%a&e%');
expect(where.mock.calls[0][0].conditions).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: 'or' })])
);
});
it('supplements short compound global searches with a trigram FTS substring lookup', async () => {
const makeRow = (id: number, title: string) => ({
id,
category_id: 10,
title,
rating: '',
added: '',
poster_url: '',
epg_channel_id: '',
tv_archive: 0,
tv_archive_duration: 0,
direct_source: '',
xtream_id: id,
type: 'live',
playlist_id: 'playlist-1',
playlist_name: 'Playlist One',
});
const all = jest
.fn()
// Prefix-index arm: titles starting with the short first token.
.mockResolvedValueOnce([makeRow(1, 'A&E')])
// FTS arm: compound substring matches, overlapping with the
// prefix arm to prove candidates are deduplicated.
.mockResolvedValueOnce([
makeRow(2, 'US: A&E'),
makeRow(1, 'A&E'),
]);
const select = jest.fn();
const db = {
all,
select,
} as unknown as AppDatabase;
const results = await globalSearch(
db,
'A&E',
['live'],
false,
['xtream'],
{ limit: 10 }
);
expect(all).toHaveBeenCalledTimes(2);
expect(select).not.toHaveBeenCalled();
const matchSqlCall = sqlMock.mock.calls.find(([strings]) =>
Array.from(strings as TemplateStringsArray)
.join(' ')
.includes('content_title_fts MATCH')
);
expect(matchSqlCall?.[1]).toBe('"a&e"');
expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']);
});
it('keeps non-compound tokens as conditions on the compound FTS supplement', async () => {
const all = jest.fn().mockResolvedValue([]);
const db = {
all,
select: jest.fn(),
} as unknown as AppDatabase;
await globalSearch(db, 'A&E HD', ['live'], false, ['xtream'], {
limit: 10,
});
expect(all).toHaveBeenCalledTimes(2);
const matchSqlCall = sqlMock.mock.calls.find(([strings]) =>
Array.from(strings as TemplateStringsArray)
.join(' ')
.includes('content_title_fts MATCH')
);
expect(matchSqlCall?.[1]).toBe('"a&e"');
// The FTS arm must AND-in the residual "hd" token so plain "A&E"
// titles cannot exhaust the candidate limit before scoring runs.
const residualPatterns = sqlMock.mock.calls
.filter(([strings]) =>
Array.from(strings as TemplateStringsArray)
.join(' ')
.includes('c.title LIKE')
)
.flatMap(([, ...values]) => values)
.filter((value): value is string => typeof value === 'string');
expect(residualPatterns).toContain('%hd%');
});
it('requires every word of a multi-token compound term in per-playlist SQL conditions', async () => {
const limit = jest.fn().mockResolvedValue([]);
const where = jest.fn().mockReturnValue({ limit });
const innerJoin = jest.fn().mockReturnValue({ where });
const from = jest.fn().mockReturnValue({ innerJoin });
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
await searchContent(db, 'playlist-1', 'A&E HD', ['live']);
// One condition per word: the compound "A&E" word (an OR with the
// intact-substring arm) plus the plain "hd" word — both AND-ed, so
// the compound arm cannot bypass the "hd" requirement.
const conditions = where.mock.calls[0][0].conditions as Array<{
kind: string;
}>;
expect(conditions).toHaveLength(4);
expect(conditions[2]).toEqual(expect.objectContaining({ kind: 'or' }));
expect(conditions[3]).toEqual(expect.objectContaining({ kind: 'and' }));
const patterns = sqlMock.mock.calls
.flatMap(([, ...values]) => values)
.filter((value): value is string => typeof value === 'string');
expect(patterns).toContain('%a&e%');
expect(patterns).toContain('%hd%');
});
it('falls back to a content scan when the compound FTS lookup fails', async () => {
const globRow = {
id: 1,
category_id: 10,
title: 'A&E',
rating: '',
added: '',
poster_url: '',
epg_channel_id: '',
tv_archive: 0,
tv_archive_duration: 0,
direct_source: '',
xtream_id: 1,
type: 'live',
playlist_id: 'playlist-1',
playlist_name: 'Playlist One',
};
const scanRow = {
...globRow,
id: 2,
xtream_id: 2,
title: 'US: A&E',
};
const all = jest
.fn()
.mockResolvedValueOnce([globRow])
.mockRejectedValueOnce(new Error('no such table: content_title_fts'));
const limit = jest.fn().mockResolvedValue([globRow, scanRow]);
const orderBy = jest.fn().mockReturnValue({ limit });
const where = jest.fn().mockReturnValue({ orderBy });
const innerJoin2 = jest.fn().mockReturnValue({ where });
const innerJoin1 = jest.fn().mockReturnValue({ innerJoin: innerJoin2 });
const from = jest.fn().mockReturnValue({ innerJoin: innerJoin1 });
const select = jest.fn().mockReturnValue({ from });
const db = {
all,
select,
} as unknown as AppDatabase;
const results = await globalSearch(
db,
'A&E',
['live'],
false,
['xtream'],
{ limit: 10 }
);
expect(all).toHaveBeenCalledTimes(2);
expect(select).toHaveBeenCalledTimes(1);
expect(results.map((item) => item.title)).toEqual(['A&E', 'US: A&E']);
});
it('adds compound contains patterns to the M3U payload prefilter', async () => {
const limit = jest.fn().mockResolvedValue([]);
const orderedQuery = {
limit,
then: (resolve: (value: unknown[]) => void) => resolve([]),
};
const orderBy = jest.fn().mockReturnValue(orderedQuery);
const where = jest.fn().mockReturnValue({ orderBy });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
await globalSearch(db, 'A&E', ['live'], false, ['m3u'], {
limit: 10,
});
const searchPatterns = sqlMock.mock.calls
.flatMap(([, ...values]) => values)
.filter(
(value): value is string =>
typeof value === 'string' &&
value.toLocaleLowerCase().includes('a&e')
);
expect(searchPatterns).toEqual(
expect.arrayContaining([
expect.stringContaining('"name":"%a&e%"'),
expect.stringContaining('"title":"%a&e%"'),
])
);
});
it('keeps accented M3U payload text field variants in SQL prefilters', async () => {
const limit = jest.fn().mockResolvedValue([]);
const orderedQuery = {
@@ -14,6 +14,23 @@ import {
XtreamGlobalSearchResult,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
buildCompoundFtsMatchQuery,
buildCompoundLikePatterns,
buildContentTitleFtsMatchQuery,
buildGlobPrefixPatterns,
buildLikePatterns,
buildM3uPayloadCompoundPatterns,
buildM3uPayloadTextFieldPatterns,
getCompoundResidualTokenGroups,
getSearchWordPlans,
getSqlSearchTokenGroups,
isShortSearchTokenGroup,
normalizeSearchMatchText,
scoreSearchTextMatch,
shouldUseContentTitleFts,
shouldUseContentTitlePrefixIndex,
} from './content-search.util';
import {
checkpointOperation,
chunkValues,
@@ -21,86 +38,7 @@ import {
reportOperationProgress,
} from './operation-control';
function escapeLikePattern(term: string): string {
return term.replace(/[%_\\]/g, '\\$&');
}
function normalizeSearchMatchText(value: unknown): string {
return typeof value === 'string'
? value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ')
: '';
}
function normalizeSqlSearchText(value: unknown): string {
return typeof value === 'string'
? value
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ')
: '';
}
function getSearchTokens(value: unknown): string[] {
const normalized = normalizeSearchMatchText(value);
return normalized ? normalized.split(' ') : [];
}
function getSqlSearchTokenGroups(value: unknown): string[][] {
const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean);
const normalizedTokens = getSearchTokens(value);
const tokenCount = Math.max(rawTokens.length, normalizedTokens.length);
return Array.from({ length: tokenCount }, (_, index) =>
[...new Set([rawTokens[index], normalizedTokens[index]])].filter(
Boolean
)
).filter((group) => group.length > 0);
}
function isShortSearchTokenGroup(tokens: readonly string[]): boolean {
return tokens.some((token) => token.length <= 2);
}
function getSqlSearchTokenVariants(value: unknown): string[] {
return [...new Set(getSqlSearchTokenGroups(value).flat())];
}
function buildLikePatterns(
term: string,
mode: 'contains' | 'prefix' = 'contains'
): string[] {
const variants = new Set<string>();
for (const value of [term, ...getSqlSearchTokenVariants(term)]) {
const trimmedValue = value.trim();
if (!trimmedValue) {
continue;
}
const titleCase =
trimmedValue.length > 0
? trimmedValue.charAt(0).toLocaleUpperCase() +
trimmedValue.slice(1).toLocaleLowerCase()
: trimmedValue;
variants.add(trimmedValue);
variants.add(trimmedValue.toLocaleLowerCase());
variants.add(trimmedValue.toLocaleUpperCase());
variants.add(titleCase);
}
return [...variants].map((value) => {
const escapedValue = escapeLikePattern(value);
return mode === 'prefix' ? `${escapedValue}%` : `%${escapedValue}%`;
});
}
export { scoreSearchTextMatch } from './content-search.util';
const DEFAULT_GLOBAL_SEARCH_LIMIT = 50;
const MAX_GLOBAL_SEARCH_LIMIT = 500;
@@ -174,65 +112,6 @@ function normalizeGlobalSearchPagination(
return { limit, offset };
}
export function scoreSearchTextMatch(
value: string,
searchTerm: string
): number | null {
const candidateText = normalizeSearchMatchText(value);
const searchText = normalizeSearchMatchText(searchTerm);
if (!candidateText || !searchText) {
return null;
}
const searchTokens = searchText.split(' ');
const candidateTokens = candidateText.split(' ');
const firstSearchToken = searchTokens[0];
if (
firstSearchToken.length <= 2 &&
!candidateText.startsWith(firstSearchToken)
) {
return null;
}
if (candidateText === searchText) {
return 0;
}
if (
candidateText.startsWith(searchText) &&
(searchTokens.length > 1 || firstSearchToken.length <= 2)
) {
return 10;
}
if (candidateTokens.some((token) => token.startsWith(searchText))) {
return 20;
}
if (
searchTokens.every((searchToken) =>
candidateTokens.some((candidateToken) =>
candidateToken.startsWith(searchToken)
)
)
) {
return 30;
}
if (candidateText.includes(searchText)) {
return 40;
}
if (
searchTokens.every((searchToken) => candidateText.includes(searchToken))
) {
return 50;
}
return null;
}
function compareScoredGlobalSearchResults(
first: ScoredGlobalSearchResult,
second: ScoredGlobalSearchResult
@@ -278,73 +157,55 @@ function getGlobalSearchCandidateLimit(): number {
return MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT;
}
/**
* Per-word title conditions, AND-ed by the caller. A word's condition is the
* AND of its token-group LIKE conditions; a punctuation-joined word ("A&E")
* additionally matches as an intact contains pattern anywhere in the title
* (issue #1161). Composing per word keeps the other words' constraints on
* the compound arm, so "A&E HD" cannot fill the SQL candidate limit with
* titles that only contain "A&E".
*/
function buildContentTitleSearchConditions(searchTerm: string) {
const tokenGroups = getSqlSearchTokenGroups(searchTerm);
if (tokenGroups.length === 0) {
return [];
}
let groupIndex = 0;
return tokenGroups
.map((tokens, index) => {
const mode =
index === 0 && isShortSearchTokenGroup(tokens)
? 'prefix'
: 'contains';
const likeConditions = tokens.flatMap((token) =>
buildLikePatterns(token, mode).map(
(pattern) =>
sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
)
);
return or(...likeConditions);
})
.filter((condition) => condition !== undefined);
}
return getSearchWordPlans(searchTerm)
.map((plan) => {
const tokenConditions = plan.tokenGroups
.map((tokens) => {
const mode =
groupIndex === 0 && isShortSearchTokenGroup(tokens)
? 'prefix'
: 'contains';
groupIndex += 1;
const likeConditions = tokens.flatMap((token) =>
buildLikePatterns(token, mode).map(
(pattern) =>
sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
)
);
return or(...likeConditions);
})
.filter((condition) => condition !== undefined);
function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean {
const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm);
return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup);
}
function buildContentTitleFtsMatchQuery(searchTerm: string): string {
return getSqlSearchTokenGroups(searchTerm)
.map((tokens) => {
const quotedTokens = tokens
.filter((token) => token.length >= 3)
.map((token) => `"${token.replace(/"/g, '""')}"`);
if (quotedTokens.length <= 1) {
return quotedTokens[0] ?? '';
const tokenArm =
tokenConditions.length > 0
? and(...tokenConditions)
: undefined;
if (plan.compound === null) {
return tokenArm;
}
return `(${quotedTokens.join(' OR ')})`;
const compoundConditions = buildCompoundLikePatterns(
plan.compound
).map(
(pattern) =>
sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'`
);
return tokenArm === undefined
? or(...compoundConditions)
: or(tokenArm, ...compoundConditions);
})
.filter(Boolean)
.join(' AND ');
}
function shouldUseContentTitleFts(searchTerm: string): boolean {
return (
!shouldUseContentTitlePrefixIndex(searchTerm) &&
buildContentTitleFtsMatchQuery(searchTerm).length > 0
);
}
function buildGlobPrefixPatterns(token: string): string[] {
const variants = new Set<string>();
for (const value of [token, ...getSqlSearchTokenVariants(token)]) {
variants.add(value);
variants.add(value.toLocaleLowerCase());
variants.add(value.toLocaleUpperCase());
variants.add(
value.charAt(0).toLocaleUpperCase() +
value.slice(1).toLocaleLowerCase()
);
}
return [...variants].map((value) => `${value}*`);
.filter((condition) => condition !== undefined);
}
function buildRawContentTitleSearchSql(searchTerm: string): SQL[] {
@@ -371,6 +232,40 @@ function buildRawContentTitleSearchSql(searchTerm: string): SQL[] {
});
}
/**
* Contains-mode LIKE conditions for the non-compound words of the term,
* applied on top of the compound FTS lookup so `A&E HD` only surfaces
* candidates that also contain `hd` (the FTS phrase can only express the
* compound word — trigram tokens need >= 3 chars).
*/
function buildCompoundResidualTitleSql(searchTerm: string): SQL[] {
return getCompoundResidualTokenGroups(searchTerm).map(
(tokens) =>
sql`(${sql.join(
tokens.flatMap((token) =>
buildLikePatterns(token).map(
(pattern) => sql`c.title LIKE ${pattern} ESCAPE '\\'`
)
),
sql` OR `
)})`
);
}
function dedupeXtreamCandidatesById(
candidates: XtreamGlobalSearchCandidate[]
): XtreamGlobalSearchCandidate[] {
const seenIds = new Set<number>();
return candidates.filter((candidate) => {
if (seenIds.has(candidate.id)) {
return false;
}
seenIds.add(candidate.id);
return true;
});
}
async function selectXtreamGlobalSearchCandidatesWithTitleIndex(
db: AppDatabase,
searchTerm: string,
@@ -415,12 +310,12 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex(
async function selectXtreamGlobalSearchCandidatesWithFts(
db: AppDatabase,
searchTerm: string,
matchQuery: string,
types: string[],
excludeHidden: boolean,
candidateLimit: number
candidateLimit: number,
residualTitleConditions: SQL[] = []
): Promise<XtreamGlobalSearchCandidate[]> {
const matchQuery = buildContentTitleFtsMatchQuery(searchTerm);
if (!matchQuery || types.length === 0) {
return [];
}
@@ -450,6 +345,11 @@ async function selectXtreamGlobalSearchCandidatesWithFts(
types.map((type) => sql`${type}`),
sql`, `
)})
${
residualTitleConditions.length > 0
? sql`AND ${sql.join(residualTitleConditions, sql` AND `)}`
: sql``
}
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
ORDER BY rank, c.title
LIMIT ${candidateLimit}
@@ -495,38 +395,52 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan(
.limit(candidateLimit);
}
function buildM3uPayloadTextFieldPatterns(
token: string,
mode: 'contains' | 'prefix'
): string[] {
return buildLikePatterns(token, mode).flatMap((pattern) => [
`%"name":"${pattern}"%`,
`%"name": "${pattern}"%`,
`%"title":"${pattern}"%`,
`%"title": "${pattern}"%`,
]);
}
/**
* Per-word M3U payload prefilter conditions, AND-ed by the caller — the same
* compound-word composition as `buildContentTitleSearchConditions`: "A&E"
* must reach channel names like "US: A&E" (issue #1161), while the other
* words of the query keep constraining the candidate playlists.
*/
function buildM3uPayloadSearchConditions(searchTerm: string) {
const tokenGroups = getSqlSearchTokenGroups(searchTerm);
if (tokenGroups.length === 0) {
return [];
}
let groupIndex = 0;
return tokenGroups
.map((tokens, index) => {
const mode =
index === 0 && isShortSearchTokenGroup(tokens)
? 'prefix'
: 'contains';
const patterns = tokens.flatMap((token) =>
buildM3uPayloadTextFieldPatterns(token, mode)
);
const likeConditions = patterns.map(
return getSearchWordPlans(searchTerm)
.map((plan) => {
const tokenConditions = plan.tokenGroups
.map((tokens) => {
const mode =
groupIndex === 0 && isShortSearchTokenGroup(tokens)
? 'prefix'
: 'contains';
groupIndex += 1;
const patterns = tokens.flatMap((token) =>
buildM3uPayloadTextFieldPatterns(token, mode)
);
const likeConditions = patterns.map(
(pattern) =>
sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'`
);
return or(...likeConditions);
})
.filter((condition) => condition !== undefined);
const tokenArm =
tokenConditions.length > 0
? and(...tokenConditions)
: undefined;
if (plan.compound === null) {
return tokenArm;
}
const compoundConditions = buildM3uPayloadCompoundPatterns(
plan.compound
).map(
(pattern) =>
sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'`
);
return or(...likeConditions);
return tokenArm === undefined
? or(...compoundConditions)
: or(tokenArm, ...compoundConditions);
})
.filter((condition) => condition !== undefined);
}
@@ -1207,11 +1121,45 @@ export async function globalSearch(
excludeHidden,
candidateLimit
);
// The prefix arm only sees titles that start with the short first
// token ("A&E" -> GLOB 'a*'), so a compound word like "a&e" is
// additionally looked up as a trigram FTS substring to reach
// titles such as "US: A&E" (issue #1161). The non-compound words
// of the query stay applied as LIKE conditions so this arm cannot
// fill the candidate limit with compound-only matches.
const compoundMatchQuery = buildCompoundFtsMatchQuery(searchTerm);
if (compoundMatchQuery) {
try {
const compoundCandidates =
await selectXtreamGlobalSearchCandidatesWithFts(
db,
compoundMatchQuery,
types,
excludeHidden,
candidateLimit,
buildCompoundResidualTitleSql(searchTerm)
);
candidates = dedupeXtreamCandidatesById([
...candidates,
...compoundCandidates,
]);
} catch {
candidates =
await selectXtreamGlobalSearchCandidatesWithContentScan(
db,
searchTerm,
types,
excludeHidden,
candidateLimit
);
}
}
} else if (shouldUseContentTitleFts(searchTerm)) {
try {
candidates = await selectXtreamGlobalSearchCandidatesWithFts(
db,
searchTerm,
buildContentTitleFtsMatchQuery(searchTerm),
types,
excludeHidden,
candidateLimit
@@ -0,0 +1,223 @@
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({
kind: 'inArray',
left,
values,
}));
const orMock = jest.fn((...conditions: unknown[]) => ({
kind: 'or',
conditions,
}));
const sqlMock = jest.fn(
(strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings: Array.from(strings),
values,
})
);
jest.mock('drizzle-orm', () => ({
and: jest.fn(),
eq: (left: unknown, right: unknown) => eqMock(left, right),
inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values),
or: (...conditions: unknown[]) => orMock(...conditions),
sql: (strings: TemplateStringsArray, ...values: unknown[]) =>
sqlMock(strings, ...values),
}));
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import {
deleteEpgMapping,
getEpgMapping,
getEpgMappingsBatch,
searchEpgChannels,
setEpgMapping,
} from './epg-mapping.operations';
function createSelectChain(result: unknown[]) {
const chain: Record<string, unknown> = {};
chain['from'] = jest.fn().mockReturnValue(chain);
chain['where'] = jest.fn().mockReturnValue(chain);
chain['orderBy'] = jest.fn().mockReturnValue(chain);
chain['limit'] = jest.fn().mockResolvedValue(result);
chain['then'] = (
resolve: (value: unknown) => unknown,
reject: (reason: unknown) => unknown
) => Promise.resolve(result).then(resolve, reject);
return chain;
}
function createDbMock(selectResult: unknown[] = []) {
const chain = createSelectChain(selectResult);
const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined);
const values = jest.fn().mockReturnValue({ onConflictDoUpdate });
const insert = jest.fn().mockReturnValue({ values });
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const deleteFn = jest.fn().mockReturnValue({ where: deleteWhere });
const select = jest.fn().mockReturnValue(chain);
return {
db: {
select,
insert,
delete: deleteFn,
} as unknown as AppDatabase,
chain,
insert,
values,
onConflictDoUpdate,
deleteFn,
deleteWhere,
select,
};
}
describe('epg-mapping.operations', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getEpgMapping', () => {
it('returns the mapping row when present', async () => {
const row = {
id: 1,
channelKey: 'xtream:pl-1:42',
epgChannelId: 'bbc.one.uk',
playlistId: 'pl-1',
};
const { db } = createDbMock([row]);
await expect(getEpgMapping(db, 'xtream:pl-1:42')).resolves.toEqual(
row
);
expect(eqMock).toHaveBeenCalledWith(
schema.epgChannelMappings.channelKey,
'xtream:pl-1:42'
);
});
it('returns null when no mapping exists', async () => {
const { db } = createDbMock([]);
await expect(getEpgMapping(db, 'missing')).resolves.toBeNull();
});
});
describe('getEpgMappingsBatch', () => {
it('returns an empty map without querying for an empty input', async () => {
const { db, select } = createDbMock();
const result = await getEpgMappingsBatch(db, []);
expect(result.size).toBe(0);
expect(select).not.toHaveBeenCalled();
});
it('maps channel keys to their EPG channel ids', async () => {
const { db } = createDbMock([
{ channelKey: 'a', epgChannelId: 'epg-a' },
{ channelKey: 'b', epgChannelId: 'epg-b' },
]);
const result = await getEpgMappingsBatch(db, ['a', 'b', 'c']);
expect(result.get('a')).toBe('epg-a');
expect(result.get('b')).toBe('epg-b');
expect(result.has('c')).toBe(false);
expect(inArrayMock).toHaveBeenCalledWith(
schema.epgChannelMappings.channelKey,
['a', 'b', 'c']
);
});
});
describe('setEpgMapping', () => {
it('upserts on the channel key', async () => {
const { db, insert, values, onConflictDoUpdate } = createDbMock();
await expect(
setEpgMapping(db, 'xtream:pl-1:42', 'bbc.one.uk', 'pl-1')
).resolves.toEqual({ success: true });
expect(insert).toHaveBeenCalledWith(schema.epgChannelMappings);
expect(values).toHaveBeenCalledWith({
channelKey: 'xtream:pl-1:42',
epgChannelId: 'bbc.one.uk',
playlistId: 'pl-1',
});
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: schema.epgChannelMappings.channelKey,
})
);
});
it('stores null when no playlist id is provided', async () => {
const { db, values } = createDbMock();
await setEpgMapping(db, 'bbc.one', 'bbc.one.uk');
expect(values).toHaveBeenCalledWith(
expect.objectContaining({ playlistId: null })
);
});
});
describe('deleteEpgMapping', () => {
it('deletes by channel key', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(deleteEpgMapping(db, 'bbc.one')).resolves.toEqual({
success: true,
});
expect(deleteFn).toHaveBeenCalledWith(schema.epgChannelMappings);
expect(deleteWhere).toHaveBeenCalled();
expect(eqMock).toHaveBeenCalledWith(
schema.epgChannelMappings.channelKey,
'bbc.one'
);
});
});
describe('searchEpgChannels', () => {
it('escapes LIKE wildcards and searches name and id with an ESCAPE clause', async () => {
const { db } = createDbMock([]);
await searchEpgChannels(db, 'HBO%_');
// sql`${column} LIKE ${pattern} ESCAPE '\\'` — the pattern is the
// second interpolated value.
const patterns = sqlMock.mock.calls.map((call) => call[2]);
expect(patterns).toContain('%HBO\\%\\_%');
const templates = sqlMock.mock.calls.map((call) =>
Array.from(call[0] as TemplateStringsArray).join('?')
);
expect(
templates.every((template) => template.includes("ESCAPE '\\'"))
).toBe(true);
});
it('escapes backslashes so a trailing "\\" cannot corrupt the pattern', async () => {
const { db } = createDbMock([]);
await searchEpgChannels(db, 'C\\');
const patterns = sqlMock.mock.calls.map((call) => call[2]);
expect(patterns).toContain('%C\\\\%');
});
it('applies the result limit', async () => {
const { db, chain } = createDbMock([]);
await searchEpgChannels(db, 'news', 5);
expect(chain['limit']).toHaveBeenCalledWith(5);
});
});
});
@@ -0,0 +1,125 @@
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
/**
* Get a single EPG channel mapping by lookup key.
*/
export async function getEpgMapping(
db: AppDatabase,
channelKey: string
): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> {
const rows = await db
.select({
id: schema.epgChannelMappings.id,
channelKey: schema.epgChannelMappings.channelKey,
epgChannelId: schema.epgChannelMappings.epgChannelId,
playlistId: schema.epgChannelMappings.playlistId,
})
.from(schema.epgChannelMappings)
.where(eq(schema.epgChannelMappings.channelKey, channelKey))
.limit(1);
return rows[0] ?? null;
}
/**
* Batch lookup of EPG channel mappings for multiple keys.
* Returns a Map of channelKey → epgChannelId (only for keys that have mappings).
*/
export async function getEpgMappingsBatch(
db: AppDatabase,
channelKeys: string[]
): Promise<Map<string, string>> {
if (channelKeys.length === 0) {
return new Map();
}
const rows = await db
.select({
channelKey: schema.epgChannelMappings.channelKey,
epgChannelId: schema.epgChannelMappings.epgChannelId,
})
.from(schema.epgChannelMappings)
.where(inArray(schema.epgChannelMappings.channelKey, channelKeys));
const mapping = new Map<string, string>();
for (const row of rows) {
mapping.set(row.channelKey, row.epgChannelId);
}
return mapping;
}
/**
* Create or update an EPG channel mapping (upsert).
*/
export async function setEpgMapping(
db: AppDatabase,
channelKey: string,
epgChannelId: string,
playlistId?: string
): Promise<{ success: boolean }> {
await db
.insert(schema.epgChannelMappings)
.values({
channelKey,
epgChannelId,
playlistId: playlistId ?? null,
})
.onConflictDoUpdate({
target: schema.epgChannelMappings.channelKey,
set: {
epgChannelId,
playlistId: playlistId ?? null,
updatedAt: sql`(datetime('now'))`,
},
});
return { success: true };
}
/**
* Remove an EPG channel mapping.
*/
export async function deleteEpgMapping(
db: AppDatabase,
channelKey: string
): Promise<{ success: boolean }> {
await db
.delete(schema.epgChannelMappings)
.where(eq(schema.epgChannelMappings.channelKey, channelKey));
return { success: true };
}
/**
* Search EPG channels by display name for the mapping dialog.
*/
export async function searchEpgChannels(
db: AppDatabase,
searchTerm: string,
limit = 50
): Promise<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
// Escape the escape character itself and the LIKE wildcards so user
// input like "HBO%", "_BC" or a trailing "\" is matched literally —
// an unescaped backslash would pair with the following character (or
// the closing "%") under the ESCAPE clause and corrupt the pattern.
const escaped = searchTerm.trim().replace(/[\\%_]/g, '\\$&');
const pattern = `%${escaped}%`;
return db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.where(
or(
sql`${schema.epgChannels.displayName} LIKE ${pattern} ESCAPE '\\'`,
sql`${schema.epgChannels.id} LIKE ${pattern} ESCAPE '\\'`
)
)
.orderBy(schema.epgChannels.displayName)
.limit(limit);
}
@@ -25,9 +25,12 @@ jest.mock('drizzle-orm', () => ({
),
}));
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { createDbMock } from './operations.test-helpers';
import {
getAllGlobalFavorites,
getFavorites,
getGlobalFavorites,
reorderGlobalFavorites,
} from './favorites.operations';
@@ -93,6 +96,38 @@ describe('favorites.operations', () => {
]);
});
// Regression for issue #1138: archive metadata must survive every
// favorites projection so catch-up stays available outside Live TV.
it('selects archive metadata for playlist favorites', async () => {
const { db, select } = createGlobalFavoritesDbMock([]);
await getFavorites(db, 'playlist-1');
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
})
);
});
it('selects archive metadata for global favorites', async () => {
const { db, select } = createGlobalFavoritesDbMock([]);
await getGlobalFavorites(db);
await getAllGlobalFavorites(db);
expect(select).toHaveBeenCalledTimes(2);
for (const [projection] of select.mock.calls) {
expect(projection).toEqual(
expect.objectContaining({
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
})
);
}
});
describe('reorderGlobalFavorites', () => {
it('short-circuits without touching the db when there are no updates', async () => {
const { db, update, transaction } = createDbMock();
@@ -73,6 +73,8 @@ export async function getFavorites(db: AppDatabase, playlistId: string) {
poster_url: schema.content.posterUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
@@ -117,6 +119,8 @@ function selectGlobalFavoriteRows(
: {}),
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
@@ -35,7 +35,7 @@ describe('recently-viewed.operations', () => {
{ id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' },
{ id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' },
];
const { db, queries } = createDbMock([rows]);
const { db, queries, select } = createDbMock([rows]);
await expect(getRecentlyViewed(db)).resolves.toEqual(rows);
@@ -47,11 +47,19 @@ describe('recently-viewed.operations', () => {
);
expect(queries[0].limit).toHaveBeenCalledWith(100);
expect(queries[0].innerJoin).toHaveBeenCalledTimes(3);
// Regression for issue #1138: archive metadata must survive the
// recent projections so catch-up stays available outside Live TV.
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
})
);
});
it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => {
const rows = [{ id: 5, title: 'Recent Movie' }];
const { db, queries } = createDbMock([rows]);
const { db, queries, select } = createDbMock([rows]);
await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual(
rows
@@ -65,6 +73,12 @@ describe('recently-viewed.operations', () => {
schema.recentlyViewed.viewedAt
);
expect(queries[0].limit).toHaveBeenCalledWith(100);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
})
);
});
});
@@ -15,6 +15,8 @@ export async function getRecentlyViewed(db: AppDatabase) {
backdrop_url: schema.content.backdropUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
playlist_id: schema.categories.playlistId,
playlist_name: schema.playlists.name,
viewed_at: schema.recentlyViewed.viewedAt,
@@ -58,6 +60,8 @@ export async function getRecentItems(
backdrop_url: schema.content.backdropUrl,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
viewed_at: schema.recentlyViewed.viewedAt,
})
.from(schema.recentlyViewed)
@@ -11,6 +11,7 @@ export {
recentlyViewed,
favorites,
epgChannels,
epgChannelMappings,
epgPrograms,
playbackPositions,
downloads,
@@ -0,0 +1,13 @@
import type { BrowserWindow } from 'electron';
let mainWindow: BrowserWindow | null = null;
export function setMainWindow(win: BrowserWindow): void {
mainWindow = win;
}
export function broadcastDownloadUpdate(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
}
}
@@ -1,6 +1,13 @@
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
findAvailableFinalPath,
getPartialDownloadPath,
getPartialDownloadSize,
removePartialDownload,
removePartialDownloadFile,
reserveAvailablePartialDownloadFile,
reserveAvailableDownloadFile,
} from './download-file-path';
@@ -51,6 +58,45 @@ describe('reserveAvailableDownloadFile', () => {
});
});
describe('reserveAvailablePartialDownloadFile', () => {
it('reserves a .part path while keeping the final path free', () => {
const reserveFile = jest.fn();
expect(
reserveAvailablePartialDownloadFile(
'/downloads',
'movie.mp4',
reserveFile,
() => false
)
).toEqual({
filename: 'movie.mp4',
partialPath: join('/downloads', 'movie.mp4.part'),
path: join('/downloads', 'movie.mp4'),
});
expect(reserveFile).toHaveBeenCalledWith(
join('/downloads', 'movie.mp4.part')
);
});
it('skips candidates when the final file already exists', () => {
const reserveFile = jest.fn();
expect(
reserveAvailablePartialDownloadFile(
'/downloads',
'movie.mp4',
reserveFile,
(filePath) => filePath.endsWith('movie.mp4')
)
).toEqual({
filename: 'movie (1).mp4',
partialPath: join('/downloads', 'movie (1).mp4.part'),
path: join('/downloads', 'movie (1).mp4'),
});
});
});
describe('removePartialDownload', () => {
it('removes only the actual partial save path', () => {
const requestedPath = join('/downloads', 'movie.mp4');
@@ -88,3 +134,73 @@ describe('removePartialDownload', () => {
expect(removeFile).not.toHaveBeenCalled();
});
});
describe('partial download helpers', () => {
it('derives, removes, and sizes .part files from the final path', () => {
const finalPath = join('/downloads', 'movie.mp4');
const partialPath = join('/downloads', 'movie.mp4.part');
const removeFile = jest.fn();
expect(getPartialDownloadPath(finalPath)).toBe(partialPath);
expect(
removePartialDownloadFile(
finalPath,
(filePath) => filePath === partialPath,
removeFile
)
).toBe(true);
expect(removeFile).toHaveBeenCalledWith(partialPath);
expect(
getPartialDownloadSize(finalPath, (filePath) => {
expect(filePath).toBe(partialPath);
return { size: 128 };
})
).toBe(128);
});
it('refuses to size a .part that is not a regular file', () => {
const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-'));
const finalPath = join(directory, 'movie.mp4');
const partialPath = `${finalPath}.part`;
try {
writeFileSync(join(directory, 'target.bin'), 'attacker');
symlinkSync(join(directory, 'target.bin'), partialPath);
expect(() => getPartialDownloadSize(finalPath)).toThrow(
'not a regular file'
);
} finally {
rmSync(directory, { force: true, recursive: true });
}
});
it('finds the next numbered destination whose final and partial paths are free', () => {
const occupied = new Set([
join('/downloads', 'movie.mp4'),
join('/downloads', 'movie (1).mp4'),
join('/downloads', 'movie (2).mp4.part'),
]);
expect(
findAvailableFinalPath(join('/downloads', 'movie.mp4'), (path) =>
occupied.has(path)
)
).toEqual({
filename: 'movie (3).mp4',
path: join('/downloads', 'movie (3).mp4'),
});
});
it('reports zero for a missing .part with the default stat reader', () => {
const directory = mkdtempSync(join(tmpdir(), 'iptvnator-part-'));
try {
expect(
getPartialDownloadSize(join(directory, 'missing.mp4'))
).toBe(0);
} finally {
rmSync(directory, { force: true, recursive: true });
}
});
});
@@ -1,16 +1,61 @@
import { closeSync, existsSync, openSync, unlinkSync } from 'node:fs';
import { extname, join } from 'node:path';
import {
closeSync,
existsSync,
lstatSync,
openSync,
unlinkSync,
} from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
export interface ReservedDownloadFile {
filename: string;
path: string;
}
export interface ReservedPartialDownloadFile extends ReservedDownloadFile {
partialPath: string;
}
function createExclusiveFile(filePath: string): void {
const descriptor = openSync(filePath, 'wx');
closeSync(descriptor);
}
function createExistsError(filePath: string): NodeJS.ErrnoException {
const error = new Error(`File already exists: ${filePath}`) as NodeJS.ErrnoException;
error.code = 'EEXIST';
return error;
}
export function getPartialDownloadPath(filePath: string): string {
return `${filePath}.part`;
}
/**
* Next numbered destination whose final and .part paths are both free. Used
* when a retained download's recorded destination got occupied while it was
* paused or failed — the occupying file is never inspected or deleted.
*/
export function findAvailableFinalPath(
filePath: string,
pathExists: (candidate: string) => boolean = existsSync
): { filename: string; path: string } {
const directory = dirname(filePath);
const extension = extname(filePath);
const stem = basename(filePath, extension);
for (let suffix = 1; ; suffix++) {
const filename = `${stem} (${suffix})${extension}`;
const candidate = join(directory, filename);
if (
!pathExists(candidate) &&
!pathExists(getPartialDownloadPath(candidate))
) {
return { filename, path: candidate };
}
}
}
export function reserveAvailableDownloadFile(
directory: string,
requestedFilename: string,
@@ -38,6 +83,38 @@ export function reserveAvailableDownloadFile(
}
}
export function reserveAvailablePartialDownloadFile(
directory: string,
requestedFilename: string,
reserveFile: (filePath: string) => void = createExclusiveFile,
pathExists: (filePath: string) => boolean = existsSync
): ReservedPartialDownloadFile {
const extension = extname(requestedFilename);
const stem = extension
? requestedFilename.slice(0, -extension.length)
: requestedFilename;
let candidate = requestedFilename;
let suffix = 1;
for (;;) {
const filePath = join(directory, candidate);
const partialPath = getPartialDownloadPath(filePath);
try {
if (pathExists(filePath)) {
throw createExistsError(filePath);
}
reserveFile(partialPath);
return { filename: candidate, partialPath, path: filePath };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
candidate = `${stem} (${suffix})${extension}`;
suffix += 1;
}
}
}
export interface DownloadSavePathItem {
getSavePath(): string;
}
@@ -55,3 +132,49 @@ export function removePartialDownload(
removeFile(savePath);
return true;
}
export function removePartialDownloadFile(
filePath: string | null | undefined,
pathExists: (filePath: string) => boolean = existsSync,
removeFile: (filePath: string) => void = unlinkSync
): boolean {
if (!filePath) {
return false;
}
const partialPath = getPartialDownloadPath(filePath);
if (!pathExists(partialPath)) {
return false;
}
removeFile(partialPath);
return true;
}
function getRegularFileStats(filePath: string): { size: number } {
// lstat + isFile so appending to a retained .part can never follow a
// symlink planted in its place while the download was paused.
const stats = lstatSync(filePath);
if (!stats.isFile()) {
throw new Error(`Partial download is not a regular file: ${filePath}`);
}
return stats;
}
export function getPartialDownloadSize(
filePath: string | null | undefined,
getStats: (filePath: string) => { size: number } = getRegularFileStats
): number {
if (!filePath) {
return 0;
}
try {
return getStats(getPartialDownloadPath(filePath)).size;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
return 0;
}
}
@@ -0,0 +1,276 @@
import { PassThrough, Readable } from 'node:stream';
import {
createTask,
waitForCallCount,
waitForStatus,
} from './download.test-helpers';
describe('download finalization', () => {
it('falls back to copying the completed partial when hard links are unsupported', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({ set })),
};
const requestWithValidatedRedirects = jest.fn(
async () =>
({
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4' },
status: 200,
}) as never
);
const link = jest.fn(async () => {
const error = new Error('not supported') as NodeJS.ErrnoException;
error.code = 'EXDEV';
throw error;
});
const copyFile = jest.fn(async () => undefined);
const unlink = jest.fn(async () => undefined);
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('node:fs/promises', () => ({
copyFile,
link,
stat: jest.fn(async () => ({ size: 4 })),
unlink,
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile: jest.fn(),
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
}));
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
await waitForCallCount(requestWithValidatedRedirects, 1);
await waitForStatus(set, 'completed');
expect(link).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
'/downloads/movie.mp4'
);
expect(copyFile).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
'/downloads/movie.mp4',
expect.any(Number)
);
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 4,
filePath: '/downloads/movie.mp4',
status: 'completed',
})
);
});
it('completes when copied output exists but partial cleanup fails', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({ set })),
};
const requestWithValidatedRedirects = jest.fn(
async () =>
({
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4' },
status: 200,
}) as never
);
const link = jest.fn(async () => {
const error = new Error('not supported') as NodeJS.ErrnoException;
error.code = 'EXDEV';
throw error;
});
const copyFile = jest.fn(async () => undefined);
const unlink = jest.fn(async () => {
const error = new Error('cleanup denied') as NodeJS.ErrnoException;
error.code = 'EACCES';
throw error;
});
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('node:fs/promises', () => ({
copyFile,
link,
stat: jest.fn(async () => ({ size: 4 })),
unlink,
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile: jest.fn(),
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
}));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
await waitForCallCount(requestWithValidatedRedirects, 1);
await waitForStatus(set, 'completed');
expect(copyFile).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
'/downloads/movie.mp4',
expect.any(Number)
);
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 4,
errorMessage: null,
fileName: 'movie.mp4',
filePath: '/downloads/movie.mp4',
status: 'completed',
totalBytes: 4,
})
);
} finally {
consoleError.mockRestore();
}
});
it('preserves a completed partial when finalization fails', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({ set })),
};
const requestWithValidatedRedirects = jest.fn(
async () =>
({
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4' },
status: 200,
}) as never
);
const link = jest.fn(async () => {
const error = new Error('not supported') as NodeJS.ErrnoException;
error.code = 'EXDEV';
throw error;
});
const copyFile = jest.fn(async () => {
const error = new Error('disk full') as NodeJS.ErrnoException;
error.code = 'ENOSPC';
throw error;
});
const unlink = jest.fn(async () => undefined);
const removePartialDownloadFile = jest.fn();
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('node:fs/promises', () => ({
copyFile,
link,
stat: jest.fn(async () => ({ size: 4 })),
unlink,
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
}));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
await waitForCallCount(requestWithValidatedRedirects, 1);
await waitForStatus(set, 'failed');
expect(copyFile).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
'/downloads/movie.mp4',
expect.any(Number)
);
expect(unlink).not.toHaveBeenCalled();
expect(removePartialDownloadFile).not.toHaveBeenCalled();
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 4,
errorMessage: 'disk full',
fileName: 'movie.mp4',
filePath: '/downloads/movie.mp4',
status: 'failed',
totalBytes: 4,
})
);
} finally {
consoleError.mockRestore();
}
});
});
@@ -0,0 +1,349 @@
import { eq, sql } from 'drizzle-orm';
import { constants, existsSync } from 'node:fs';
import { copyFile, link, stat, unlink } from 'node:fs/promises';
import * as schema from '../../database/schema';
import {
getPartialDownloadPath,
getPartialDownloadSize,
removePartialDownloadFile,
type ReservedPartialDownloadFile,
} from './download-file-path';
import type {
CompletedPartialProgress,
DownloadsDatabase,
DownloadTask,
TransferProgress,
} from './download-task';
import { describeError, TruncatedTransferError } from './download-transfer';
/**
* Persistence for a failed startDownload() attempt, after its cancel/pause
* checkpoints have been ruled out. Chooses between: retaining a truncated
* transfer for a Range retry, committing an already-finalized file,
* retaining a completed partial, or the generic delete-partial failure.
*/
export async function handleDownloadFailure(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile | undefined,
error: unknown
): Promise<void> {
if (error instanceof TruncatedTransferError && reservation) {
// The short response is retained so a retry can continue the
// transfer via Range instead of starting over.
await persistCompletedPartialFailure(
db,
task,
{
bytesDownloaded: error.progress.bytesDownloaded,
filePath: reservation.path,
totalBytes: error.progress.totalBytes,
},
error
);
return;
}
const existingCompletedFileProgress =
await getExistingCompletedFileProgress(task);
if (existingCompletedFileProgress) {
removePartialFile(existingCompletedFileProgress.filePath);
await persistCompletion(
db,
task,
task.fileName,
existingCompletedFileProgress.filePath,
existingCompletedFileProgress.bytesDownloaded,
existingCompletedFileProgress.totalBytes
);
return;
}
const completedPartialProgress = getCompletedPartialProgress(task);
if (completedPartialProgress) {
await persistCompletedPartialFailure(
db,
task,
completedPartialProgress,
error
);
return;
}
console.error(
`[Downloads] Error downloading ${task.fileName}:`,
describeError(error)
);
removePartialFile(task.filePath);
await db
.update(schema.downloads)
.set({
errorMessage: describeError(error),
filePath: null,
resumeValidator: null,
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
}
export async function completeDownloadFromPartial(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile,
progress: TransferProgress
): Promise<void> {
let fileSize: number;
try {
fileSize = await finalizePartialDownload(
reservation,
progress.bytesDownloaded
);
} catch (error) {
if (task.cancelRequested || task.pauseRequested) {
throw error;
}
await persistFinalizationFailure(
db,
task,
reservation,
progress,
error
);
return;
}
await persistCompletion(
db,
task,
reservation.filename,
reservation.path,
fileSize,
progress.totalBytes
);
}
export async function persistCompletion(
db: DownloadsDatabase,
task: DownloadTask,
fileName: string,
filePath: string,
fileSize: number,
totalBytes: number | null
): Promise<void> {
console.log(`[Downloads] Completed: ${fileName}`);
await db
.update(schema.downloads)
.set({
bytesDownloaded: fileSize,
errorMessage: null,
fileName,
filePath,
resumeValidator: null,
status: 'completed',
totalBytes: totalBytes ?? fileSize,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
}
async function persistFinalizationFailure(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile,
progress: TransferProgress,
error: unknown
): Promise<void> {
await persistRetainedPartialFailure(
db,
task,
reservation.filename,
reservation.path,
progress,
error,
`[Downloads] Error finalizing ${reservation.filename}:`
);
}
export async function persistCompletedPartialFailure(
db: DownloadsDatabase,
task: DownloadTask,
progress: CompletedPartialProgress,
error: unknown
): Promise<void> {
await persistRetainedPartialFailure(
db,
task,
task.fileName,
progress.filePath,
progress,
error,
`[Downloads] Error downloading ${task.fileName}:`
);
}
async function persistRetainedPartialFailure(
db: DownloadsDatabase,
task: DownloadTask,
fileName: string,
filePath: string,
progress: TransferProgress,
error: unknown,
logMessage: string
): Promise<void> {
console.error(logMessage, describeError(error));
const totalBytes = progress.totalBytes ?? progress.bytesDownloaded;
task.totalBytes = totalBytes;
await db
.update(schema.downloads)
.set({
bytesDownloaded: progress.bytesDownloaded,
errorMessage: describeError(error),
fileName,
filePath,
status: 'failed',
totalBytes,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
}
export async function getExistingCompletedFileProgress(
task: DownloadTask
): Promise<CompletedPartialProgress | null> {
if (
!task.filePath ||
task.totalBytes === null ||
task.totalBytes === undefined
) {
return null;
}
try {
const fileStats = await stat(task.filePath);
if (fileStats.size !== task.totalBytes) {
return null;
}
return {
bytesDownloaded: fileStats.size,
filePath: task.filePath,
totalBytes: task.totalBytes,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[Downloads] Failed to inspect target file:', error);
}
return null;
}
}
export function getCompletedPartialProgress(
task: DownloadTask
): CompletedPartialProgress | null {
if (
!task.filePath ||
task.totalBytes === null ||
task.totalBytes === undefined
) {
return null;
}
if (!existsSync(getPartialDownloadPath(task.filePath))) {
return null;
}
try {
const bytesDownloaded = getPartialDownloadSize(task.filePath);
if (bytesDownloaded !== task.totalBytes) {
return null;
}
return {
bytesDownloaded,
filePath: task.filePath,
totalBytes: task.totalBytes,
};
} catch (error) {
console.error('[Downloads] Failed to inspect partial file:', error);
return null;
}
}
export function getPausedByteCount(task: DownloadTask): number {
try {
return getPartialDownloadSize(task.filePath);
} catch (error) {
console.error('[Downloads] Failed to inspect partial file:', error);
return 0;
}
}
async function finalizePartialDownload(
reservation: ReservedPartialDownloadFile,
expectedFileSize: number
): Promise<number> {
try {
await link(reservation.partialPath, reservation.path);
} catch (error) {
if (!canCopyCompletedPartialAfterLinkFailure(error)) {
throw error;
}
await copyFile(
reservation.partialPath,
reservation.path,
constants.COPYFILE_EXCL
);
}
try {
await unlink(reservation.partialPath);
} catch (error) {
const fileSize = await getExpectedFinalFileSize(
reservation.path,
expectedFileSize
);
if (fileSize !== null) {
console.error(
'[Downloads] Failed to delete completed partial file:',
error
);
return fileSize;
}
throw error;
}
const fileStats = await stat(reservation.path);
return fileStats.size;
}
async function getExpectedFinalFileSize(
filePath: string,
expectedFileSize: number
): Promise<number | null> {
try {
const fileStats = await stat(filePath);
return fileStats.size === expectedFileSize ? fileStats.size : null;
} catch {
return null;
}
}
function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
const errorCode = (error as NodeJS.ErrnoException).code;
return (
errorCode === 'EACCES' ||
errorCode === 'ENOSYS' ||
errorCode === 'ENOTSUP' ||
errorCode === 'EOPNOTSUPP' ||
errorCode === 'EPERM' ||
errorCode === 'EXDEV'
);
}
/** @returns false when a .part exists but could not be deleted. */
export function removePartialFile(filePath: string | null | undefined): boolean {
try {
removePartialDownloadFile(filePath);
return true;
} catch (error) {
console.error('[Downloads] Failed to delete partial file:', error);
return false;
}
}
@@ -1,48 +1,397 @@
import { cleanupStaleDownloadFiles } from './stale-download-files';
type StatSyncStub = (path: string) => { isFile: () => boolean; size: number };
let statSyncOverride: StatSyncStub | null = null;
jest.mock('node:fs', () => ({
...jest.requireActual('node:fs'),
statSync: (path: string) =>
statSyncOverride
? statSyncOverride(path)
: jest.requireActual('node:fs').statSync(path),
}));
describe('cleanupStaleDownloadFiles', () => {
it('removes every persisted partial path and ignores empty paths', () => {
const removeFile = jest.fn();
describe('resetStaleDownloads', () => {
it('keeps interrupted partial downloads as paused and pauses queued rows', async () => {
jest.resetModules();
cleanupStaleDownloadFiles(
[
{ filePath: '/downloads/one.mp4' },
{ filePath: null },
{ filePath: '/downloads/two.mp4' },
],
removeFile
const staleDownloads = [
{
filePath: '/downloads/movie.mp4',
id: 1,
status: 'downloading',
totalBytes: 100,
},
{
filePath: null,
id: 2,
status: 'queued',
totalBytes: null,
},
];
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(staleDownloads),
})),
})),
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadSize: jest.fn(() => 64),
removePartialDownloadFile,
}));
const { resetStaleDownloads } = await import('./download-recovery');
await resetStaleDownloads();
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 64,
status: 'paused',
})
);
expect(removeFile).toHaveBeenCalledTimes(2);
expect(removeFile).toHaveBeenNthCalledWith(1, '/downloads/one.mp4');
expect(removeFile).toHaveBeenNthCalledWith(2, '/downloads/two.mp4');
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 0,
status: 'paused',
})
);
expect(set).not.toHaveBeenCalledWith(
expect.objectContaining({ status: 'failed' })
);
expect(removePartialDownloadFile).not.toHaveBeenCalled();
});
it('continues cleaning other stale files after one removal fails', () => {
const removeFile = jest.fn((filePath: string) => {
if (filePath.endsWith('one.mp4')) {
throw new Error('locked');
}
it('commits a finalized download whose completion was interrupted', async () => {
jest.resetModules();
// Crash happened after finalizePartialDownload() (final file on disk,
// .part gone) but before persistCompletion() flipped the row.
const staleDownloads = [
{
filePath: '/downloads/movie.mp4',
id: 9,
status: 'downloading',
totalBytes: 100,
},
];
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(staleDownloads),
})),
})),
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
statSyncOverride = () => ({ isFile: () => true, size: 100 });
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
}));
try {
const recovery = await import('./download-recovery');
await recovery.resetStaleDownloads();
} finally {
statSyncOverride = null;
}
expect(set).toHaveBeenCalledTimes(1);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 100,
errorMessage: null,
status: 'completed',
})
);
// Any leftover .part from the interrupted commit is cleaned up, and
// the final file's path is never cleared or deleted.
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/movie.mp4'
);
expect(set).not.toHaveBeenCalledWith(
expect.objectContaining({ status: 'failed' })
);
});
it('keeps the retained partial of a resumed download that was still queued', async () => {
jest.resetModules();
const staleDownloads = [
{
filePath: '/downloads/resumed.mp4',
id: 7,
status: 'queued',
totalBytes: 1000,
},
];
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(staleDownloads),
})),
})),
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadSize: jest.fn(() => 900),
removePartialDownloadFile,
}));
const { resetStaleDownloads } = await import('./download-recovery');
await resetStaleDownloads();
expect(removePartialDownloadFile).not.toHaveBeenCalled();
expect(set).toHaveBeenCalledTimes(1);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 900,
errorMessage: null,
status: 'paused',
totalBytes: 1000,
})
);
});
it('removes non-recoverable interrupted partial files before clearing the persisted path', async () => {
jest.resetModules();
const staleDownloads = [
{
filePath: '/downloads/empty.mp4',
id: 1,
status: 'downloading',
totalBytes: 100,
},
];
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(staleDownloads),
})),
})),
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
}));
const { resetStaleDownloads } = await import('./download-recovery');
await resetStaleDownloads();
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/empty.mp4'
);
expect(removePartialDownloadFile.mock.invocationCallOrder[0]).toBeLessThan(
set.mock.invocationCallOrder[0]
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
errorMessage: 'Download interrupted by application restart',
filePath: null,
status: 'failed',
})
);
});
it('keeps the persisted path when non-recoverable partial cleanup fails', async () => {
jest.resetModules();
const staleDownloads = [
{
filePath: '/downloads/locked.mp4',
id: 1,
status: 'downloading',
totalBytes: 100,
},
];
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(staleDownloads),
})),
})),
update: jest.fn(() => ({ set })),
};
const deleteError = new Error('permission denied');
const removePartialDownloadFile = jest.fn(() => {
throw deleteError;
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
cleanupStaleDownloadFiles(
[
{ filePath: '/downloads/one.mp4' },
{ filePath: '/downloads/two.mp4' },
],
removeFile
);
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
}));
expect(removeFile).toHaveBeenCalledTimes(2);
expect(consoleError).toHaveBeenCalledWith(
'[Downloads] Failed to delete stale partial file:',
'/downloads/one.mp4',
expect.any(Error)
);
const { resetStaleDownloads } = await import('./download-recovery');
consoleError.mockRestore();
try {
await resetStaleDownloads();
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/locked.mp4'
);
const failedUpdate = (set.mock.calls as unknown[][]).find(
([value]) =>
(value as { status?: string } | undefined)?.status ===
'failed'
)?.[0] as Record<string, unknown> | undefined;
expect(failedUpdate).toEqual(
expect.objectContaining({
errorMessage: 'Download interrupted by application restart',
status: 'failed',
})
);
expect(failedUpdate).not.toHaveProperty('filePath');
expect(consoleError).toHaveBeenCalledWith(
'[Downloads] Failed to delete interrupted partial file:',
'/downloads/locked.mp4',
deleteError
);
} finally {
consoleError.mockRestore();
}
});
it('removes leftover partial files for completed downloads without changing their status', async () => {
jest.resetModules();
const downloads = [
{
filePath: '/downloads/movie.mp4',
id: 1,
status: 'completed',
totalBytes: 100,
},
];
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(downloads),
})),
})),
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadSize: jest.fn(),
removePartialDownloadFile,
}));
const { resetStaleDownloads } = await import('./download-recovery');
await resetStaleDownloads();
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/movie.mp4'
);
expect(set).not.toHaveBeenCalled();
});
it('keeps completed downloads completed when leftover partial cleanup still fails', async () => {
jest.resetModules();
const downloads = [
{
filePath: '/downloads/movie.mp4',
id: 1,
status: 'completed',
totalBytes: 100,
},
];
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(downloads),
})),
})),
update: jest.fn(() => ({ set })),
};
const cleanupError = new Error('locked');
const removePartialDownloadFile = jest.fn(() => {
throw cleanupError;
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadSize: jest.fn(),
removePartialDownloadFile,
}));
const { resetStaleDownloads } = await import('./download-recovery');
try {
await resetStaleDownloads();
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/movie.mp4'
);
expect(set).not.toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledWith(
'[Downloads] Failed to delete completed partial file:',
'/downloads/movie.mp4',
cleanupError
);
} finally {
consoleError.mockRestore();
}
});
});
@@ -1,43 +1,215 @@
import { inArray, sql } from 'drizzle-orm';
import { statSync } from 'node:fs';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { cleanupStaleDownloadFiles } from './stale-download-files';
import {
getPartialDownloadSize,
removePartialDownloadFile,
} from './download-file-path';
interface StaleDownload {
filePath: string | null;
id: number;
status: string;
totalBytes: number | null;
}
function getRecoverablePartialSize(download: StaleDownload): number {
if (
(download.status !== 'downloading' && download.status !== 'queued') ||
!download.filePath
) {
return 0;
}
try {
return getPartialDownloadSize(download.filePath);
} catch (error) {
console.error(
'[Downloads] Failed to inspect interrupted partial file:',
error
);
return 0;
}
}
/**
* A crash between finalizePartialDownload() and persistCompletion() leaves a
* 'downloading' row whose .part is gone but whose final file is fully on
* disk. Recognize that file (size must match the recorded total) so recovery
* commits the completion instead of orphaning the file and re-downloading.
*/
function getFinalizedFileSize(download: StaleDownload): number | null {
if (
download.status !== 'downloading' ||
!download.filePath ||
download.totalBytes === null
) {
return null;
}
try {
const stats = statSync(download.filePath);
return stats.isFile() && stats.size === download.totalBytes
? stats.size
: null;
} catch {
return null;
}
}
function removeFailedPartial(download: StaleDownload): boolean {
if (!download.filePath) {
return true;
}
try {
removePartialDownloadFile(download.filePath);
return true;
} catch (error) {
console.error(
'[Downloads] Failed to delete interrupted partial file:',
download.filePath,
error
);
return false;
}
}
function removeCompletedPartial(download: StaleDownload): void {
if (!download.filePath) {
return;
}
try {
removePartialDownloadFile(download.filePath);
} catch (error) {
console.error(
'[Downloads] Failed to delete completed partial file:',
download.filePath,
error
);
}
}
export async function resetStaleDownloads(): Promise<void> {
try {
const db = await getDatabase();
const staleDownloads = await db
const downloads = await db
.select({
filePath: schema.downloads.filePath,
id: schema.downloads.id,
status: schema.downloads.status,
totalBytes: schema.downloads.totalBytes,
})
.from(schema.downloads)
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
const ownedReservations = staleDownloads.filter(
(item) => item.filePath
.where(
inArray(schema.downloads.status, [
'queued',
'downloading',
'completed',
])
);
const completedDownloads = downloads.filter(
(download) => download.status === 'completed'
);
const staleDownloads = downloads.filter(
(download) => download.status !== 'completed'
);
const finalizedDownloads = staleDownloads
.map((download) => ({
...download,
finalizedSize: getFinalizedFileSize(download),
}))
.filter((download) => download.finalizedSize !== null);
const finalizedIds = new Set(
finalizedDownloads.map((download) => download.id)
);
// Queued rows are recoverable even without partial bytes: a resumed
// download waiting behind an active one is persisted as 'queued' with
// its retained .part, and a never-started queued row loses nothing by
// becoming 'paused' instead of 'failed'.
const recoverableDownloads = staleDownloads
.filter((download) => !finalizedIds.has(download.id))
.map((download) => ({
...download,
bytesDownloaded: getRecoverablePartialSize(download),
}))
.filter(
(download) =>
download.status === 'queued' || download.bytesDownloaded > 0
);
const recoverableIds = new Set(
recoverableDownloads.map((download) => download.id)
);
const failedDownloads = staleDownloads.filter(
(download) =>
!recoverableIds.has(download.id) &&
!finalizedIds.has(download.id)
);
const cleanupResult = failedDownloads.map((download) => ({
...download,
partialRemoved: removeFailedPartial(download),
}));
const failedIdsWithRemovedPartials = cleanupResult
.filter((download) => download.partialRemoved)
.map((download) => download.id);
const failedIdsWithRetainedPartials = cleanupResult
.filter((download) => !download.partialRemoved)
.map((download) => download.id);
cleanupStaleDownloadFiles(ownedReservations);
await db
.update(schema.downloads)
.set({
errorMessage: 'Download interrupted by application restart',
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.status, ['queued', 'downloading']));
completedDownloads.forEach(removeCompletedPartial);
if (ownedReservations.length > 0) {
for (const download of finalizedDownloads) {
// The interrupted commit may also have left the .part behind.
removeCompletedPartial(download);
await db
.update(schema.downloads)
.set({ filePath: null })
.where(
inArray(
schema.downloads.id,
ownedReservations.map((item) => item.id)
)
);
.set({
bytesDownloaded: download.finalizedSize,
errorMessage: null,
status: 'completed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.id, [download.id]));
}
for (const download of recoverableDownloads) {
await db
.update(schema.downloads)
.set({
bytesDownloaded: download.bytesDownloaded,
errorMessage: null,
status: 'paused',
totalBytes: download.totalBytes,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.id, [download.id]));
}
if (failedIdsWithRemovedPartials.length > 0) {
await db
.update(schema.downloads)
.set({
errorMessage: 'Download interrupted by application restart',
filePath: null,
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.id, failedIdsWithRemovedPartials));
}
if (failedIdsWithRetainedPartials.length > 0) {
await db
.update(schema.downloads)
.set({
errorMessage: 'Download interrupted by application restart',
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(inArray(schema.downloads.id, failedIdsWithRetainedPartials));
}
console.log('[Downloads] Reset stale downloads');
} catch (error) {
console.error('[Downloads] Error resetting stale downloads:', error);
@@ -0,0 +1,351 @@
import type { DownloadDirectoryAuthorizer } from './download-directory-authorization';
describe('download requests resume', () => {
it('enqueues a paused download with stored headers and original target path', async () => {
jest.resetModules();
const row = {
filePath: '/downloads/movie.mp4',
id: 42,
requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }),
resumeValidator: '"etag-9"',
status: 'paused',
title: 'Movie',
totalBytes: 100,
url: 'https://example.test/movie.mp4',
};
const limit = jest.fn().mockResolvedValue([row]);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
update: jest.fn(() => ({
set: jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
})),
})),
};
const enqueueDownload = jest.fn();
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
const { resumeDownloadRequest } = await import('./download-requests');
await expect(
resumeDownloadRequest(42, '/unused', authorizer)
).resolves.toEqual({ success: true });
// DB-recorded retained paths stay usable after folder switches.
expect(authorizer.requireAuthorized).not.toHaveBeenCalled();
expect(enqueueDownload).toHaveBeenCalledWith({
directory: '/downloads',
fileName: 'movie.mp4',
filePath: '/downloads/movie.mp4',
headers: { 'User-Agent': 'IPTVnator' },
id: 42,
resumeValidator: '"etag-9"',
totalBytes: 100,
url: 'https://example.test/movie.mp4',
});
});
it('does not enqueue when a concurrent resume already claimed the row', async () => {
jest.resetModules();
const row = {
filePath: '/downloads/movie.mp4',
id: 42,
requestHeaders: null,
resumeValidator: null,
status: 'paused',
title: 'Movie',
totalBytes: 100,
url: 'https://example.test/movie.mp4',
};
const limit = jest.fn().mockResolvedValue([row]);
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
update: jest.fn(() => ({
set: jest.fn(() => ({
// The conditional status='paused' claim matched no rows.
where: jest.fn().mockResolvedValue({ changes: 0 }),
})),
})),
};
const enqueueDownload = jest.fn();
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
const { resumeDownloadRequest } = await import('./download-requests');
await expect(
resumeDownloadRequest(42, '/unused', authorizer)
).resolves.toEqual({
error: 'Can only resume paused downloads',
success: false,
});
expect(enqueueDownload).not.toHaveBeenCalled();
});
it('retries a failed download with a retained partial at the original target path', async () => {
jest.resetModules();
const row = {
filePath: '/downloads/movie.mp4',
id: 42,
requestHeaders: JSON.stringify({ 'User-Agent': 'IPTVnator' }),
resumeValidator: '"etag-9"',
status: 'failed',
title: 'Movie',
totalBytes: 100,
url: 'https://example.test/movie.mp4',
};
const limit = jest.fn().mockResolvedValue([row]);
const set = jest.fn<
{ where: jest.Mock },
[Record<string, unknown>]
>(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
const { retryDownloadRequest } = await import('./download-requests');
await expect(
retryDownloadRequest(42, '/unused', authorizer)
).resolves.toEqual({ success: true });
const update = set.mock.calls[0][0];
expect(authorizer.requireAuthorized).not.toHaveBeenCalled();
expect(update).toEqual(
expect.objectContaining({
errorMessage: null,
fileName: 'movie.mp4',
status: 'queued',
})
);
expect(update).not.toHaveProperty('bytesDownloaded');
expect(update).not.toHaveProperty('filePath');
expect(update).not.toHaveProperty('totalBytes');
expect(enqueueDownload).toHaveBeenCalledWith({
directory: '/downloads',
fileName: 'movie.mp4',
filePath: '/downloads/movie.mp4',
headers: { 'User-Agent': 'IPTVnator' },
id: 42,
resumeValidator: '"etag-9"',
totalBytes: 100,
url: 'https://example.test/movie.mp4',
});
});
it('deletes the retained partial before re-downloading a failed row from scratch', async () => {
jest.resetModules();
const failedRow = {
contentType: 'vod',
filePath: '/downloads/movie.mp4',
id: 42,
playlistId: 'playlist-1',
status: 'failed',
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
};
const limit = jest
.fn()
.mockResolvedValueOnce([{ id: 'playlist-1' }])
.mockResolvedValueOnce([failedRow]);
const set = jest.fn<
{ where: jest.Mock },
[Record<string, unknown>]
>(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const removePartialDownloadFile = jest.fn();
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile,
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
const { startDownloadRequest } = await import('./download-requests');
await expect(
startDownloadRequest(
{
contentType: 'vod',
downloadFolder: '/downloads',
playlistId: 'playlist-1',
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
},
authorizer
)
).resolves.toEqual({ id: 42, success: true });
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/movie.mp4'
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: null,
resumeValidator: null,
status: 'queued',
})
);
});
it('fails the re-download when the retained partial cannot be deleted', async () => {
jest.resetModules();
const failedRow = {
contentType: 'vod',
filePath: '/downloads/movie.mp4',
id: 42,
playlistId: 'playlist-1',
status: 'failed',
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
};
const limit = jest
.fn()
.mockResolvedValueOnce([{ id: 'playlist-1' }])
.mockResolvedValueOnce([failedRow]);
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({ limit })),
})),
})),
update: jest.fn(() => ({ set })),
};
const enqueueDownload = jest.fn();
const removePartialDownloadFile = jest.fn(() => {
throw new Error('EPERM: locked');
});
const authorizer = {
requireAuthorized: jest.fn(async (directory: string) => directory),
} as unknown as DownloadDirectoryAuthorizer;
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../url-safety', () => ({
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile,
}));
jest.doMock('./download-runtime', () => ({
enqueueDownload,
}));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const { startDownloadRequest } = await import(
'./download-requests'
);
await expect(
startDownloadRequest(
{
contentType: 'vod',
downloadFolder: '/downloads',
playlistId: 'playlist-1',
title: 'Movie',
url: 'https://example.test/movie.mp4',
xtreamId: 7,
},
authorizer
)
).resolves.toEqual({
error: 'Could not delete the previous partial file',
id: 42,
success: false,
});
} finally {
consoleError.mockRestore();
}
// The row keeps its filePath ownership and nothing is enqueued.
expect(set).not.toHaveBeenCalled();
expect(enqueueDownload).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,10 @@
import { and, eq, sql } from 'drizzle-orm';
import { extname } from 'node:path';
import { basename, dirname, extname } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { assertRemoteUrlAllowed } from '../url-safety';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import { removePartialDownloadFile } from './download-file-path';
import { enqueueDownload } from './download-runtime';
export interface StartDownloadRequest {
@@ -25,18 +26,21 @@ export interface StartDownloadRequest {
macAddress?: string;
}
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
function getExtensionFromUrl(url: string): string {
try {
return extname(new URL(url).pathname) || '.mp4';
// Sanitize too: URL pathnames may legally contain characters like ':'
// that would create NTFS alternate data streams on Windows.
const extension = sanitizeFilename(extname(new URL(url).pathname));
return extension.startsWith('.') ? extension : '.mp4';
} catch {
return '.mp4';
}
}
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '_').trim();
}
function createFileName(title: string, url: string): string {
return sanitizeFilename(title) + getExtensionFromUrl(url);
}
@@ -44,13 +48,62 @@ function createFileName(title: string, url: string): string {
function createHeaders(
headers: StartDownloadRequest['headers']
): Record<string, string> | undefined {
return headers
? {
'User-Agent': headers.userAgent || '',
Origin: headers.origin || '',
Referer: headers.referer || '',
}
: undefined;
if (!headers) {
return undefined;
}
const result: Record<string, string> = {};
if (headers.userAgent) {
result['User-Agent'] = headers.userAgent;
}
if (headers.origin) {
result.Origin = headers.origin;
}
if (headers.referer) {
result.Referer = headers.referer;
}
return Object.keys(result).length > 0 ? result : undefined;
}
function serializeHeaders(
headers: Record<string, string> | undefined
): string | null {
return headers ? JSON.stringify(headers) : null;
}
const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const;
function parseStoredHeaders(
value: string | null
): Record<string, string> | undefined {
if (!value) {
return undefined;
}
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
// Re-apply the write-time allowlist so a tampered or imported
// database row cannot smuggle arbitrary headers into requests.
const entries = parsed as Record<string, unknown>;
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
(acc, key) => {
const headerValue = entries[key];
if (typeof headerValue === 'string') {
acc[key] = headerValue;
}
return acc;
},
{}
);
return Object.keys(headers).length > 0 ? headers : undefined;
} catch {
return undefined;
}
}
export async function startDownloadRequest(
@@ -98,6 +151,7 @@ export async function startDownloadRequest(
)
.limit(1);
const fileName = createFileName(data.title, data.url);
const headers = createHeaders(data.headers);
if (existing.length > 0) {
const item = existing[0];
@@ -109,6 +163,26 @@ export async function startDownloadRequest(
};
}
if (item.status === 'failed' && item.filePath) {
// A failed row can still reference a retained .part; delete it
// before the restart clears filePath, or the file is orphaned.
// A locked .part must keep its database owner, so fail the
// restart instead of proceeding without the cleanup.
try {
removePartialDownloadFile(item.filePath);
} catch (error) {
console.error(
'[Downloads] Failed to delete retained partial before re-download:',
error
);
return {
error: 'Could not delete the previous partial file',
id: item.id,
success: false,
};
}
}
await db
.update(schema.downloads)
.set({
@@ -116,6 +190,8 @@ export async function startDownloadRequest(
errorMessage: null,
fileName,
filePath: null,
requestHeaders: serializeHeaders(headers),
resumeValidator: null,
status: 'queued',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
@@ -125,7 +201,7 @@ export async function startDownloadRequest(
enqueueDownload({
directory,
fileName,
headers: createHeaders(data.headers),
headers,
id: item.id,
url: data.url,
});
@@ -138,6 +214,7 @@ export async function startDownloadRequest(
fileName,
playlistId: data.playlistId,
posterUrl: data.posterUrl,
requestHeaders: serializeHeaders(headers),
seasonNumber: data.seasonNumber,
seriesXtreamId: data.seriesXtreamId,
status: 'queued',
@@ -149,7 +226,7 @@ export async function startDownloadRequest(
enqueueDownload({
directory,
fileName,
headers: createHeaders(data.headers),
headers,
id: insertedId,
url: data.url,
});
@@ -162,7 +239,6 @@ export async function retryDownloadRequest(
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string }> {
console.log('[Downloads] Retry download:', downloadId);
const directory = await authorizer.requireAuthorized(downloadFolder);
const db = await getDatabase();
const existing = await db
.select()
@@ -183,24 +259,127 @@ export async function retryDownloadRequest(
};
}
const fileName = createFileName(item.title, item.url);
const retainedFilePath =
item.status === 'failed' && item.filePath ? item.filePath : null;
// A retained filePath was written by the main process after its folder
// was authorized; requiring the folder to still be the CURRENT selection
// would strand the retry after the user switches download folders.
const directory = retainedFilePath
? dirname(retainedFilePath)
: await authorizer.requireAuthorized(downloadFolder);
const fileName = retainedFilePath
? basename(retainedFilePath)
: createFileName(item.title, item.url);
const queuedUpdate = retainedFilePath
? {
errorMessage: null,
fileName,
status: 'queued' as const,
updatedAt: sql`CURRENT_TIMESTAMP`,
}
: {
bytesDownloaded: 0,
errorMessage: null,
fileName,
filePath: null,
resumeValidator: null,
status: 'queued' as const,
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
};
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
fileName,
filePath: null,
status: 'queued',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.set(queuedUpdate)
.where(eq(schema.downloads.id, downloadId));
enqueueDownload({
directory,
fileName,
filePath: retainedFilePath,
headers: parseStoredHeaders(item.requestHeaders),
id: item.id,
resumeValidator: retainedFilePath ? item.resumeValidator : null,
totalBytes: retainedFilePath ? item.totalBytes : null,
url: item.url,
});
return { success: true };
}
export async function resumeDownloadRequest(
downloadId: number,
downloadFolder: string,
authorizer: DownloadDirectoryAuthorizer
): Promise<{ success: boolean; error?: string }> {
console.log('[Downloads] Resume download:', downloadId);
const db = await getDatabase();
const existing = await db
.select()
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
if (existing.length === 0) {
return { error: 'Download not found', success: false };
}
const item = existing[0];
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
if (item.status !== 'paused') {
return {
error: 'Can only resume paused downloads',
success: false,
};
}
// See retryDownloadRequest: DB-recorded retained paths stay usable after
// the user switches download folders.
const directory = item.filePath
? dirname(item.filePath)
: await authorizer.requireAuthorized(downloadFolder);
const fileName = item.filePath
? basename(item.filePath)
: createFileName(item.title, item.url);
// Claim the row atomically: a concurrent resume for the same id loses
// this conditional update and must not enqueue a second task.
const claim = await db
.update(schema.downloads)
.set({
errorMessage: null,
fileName,
status: 'queued',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(
eq(schema.downloads.id, downloadId),
eq(schema.downloads.status, 'paused')
)
);
if (hasNoChanges(claim)) {
return {
error: 'Can only resume paused downloads',
success: false,
};
}
enqueueDownload({
directory,
fileName,
filePath: item.filePath,
headers: parseStoredHeaders(item.requestHeaders),
id: item.id,
resumeValidator: item.resumeValidator,
totalBytes: item.totalBytes,
url: item.url,
});
return { success: true };
}
function hasNoChanges(result: unknown): boolean {
return (
typeof result === 'object' &&
result !== null &&
'changes' in result &&
(result as { changes: number }).changes === 0
);
}
@@ -0,0 +1,151 @@
import { PassThrough, Readable } from 'node:stream';
import { createTask, waitForStatus } from './download.test-helpers';
describe('destination collision handling', () => {
it('redirects a retained partial to a numbered destination instead of unlinking', async () => {
jest.resetModules();
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = { update: jest.fn(() => ({ set })) };
const occupied = new Set([
'/downloads/movie.mp4',
'/downloads/movie.mp4.part',
]);
const renameMock = jest.fn(async () => undefined);
const unlinkMock = jest.fn(async () => undefined);
const requestWithValidatedRedirects = jest.fn(
async () =>
({
data: Readable.from([Buffer.alloc(50, 'r')]),
headers: { 'content-range': 'bytes 50-99/100' },
status: 206,
}) as never
);
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn((path: string) => occupied.has(path)),
}));
jest.doMock('node:fs/promises', () => ({
copyFile: jest.fn(async () => undefined),
link: jest.fn(async () => undefined),
rename: renameMock,
stat: jest.fn(async () => ({ size: 100 })),
unlink: unlinkMock,
}));
jest.doMock('./download-file-path', () => ({
findAvailableFinalPath: jest.fn(() => ({
filename: 'movie (1).mp4',
path: '/downloads/movie (1).mp4',
})),
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 50),
removePartialDownloadFile: jest.fn(),
reserveAvailablePartialDownloadFile: jest.fn(),
}));
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload({
...createTask(),
filePath: '/downloads/movie.mp4',
totalBytes: 100,
});
await waitForStatus(set, 'completed');
// The foreign file at the recorded destination is never touched.
expect(unlinkMock).not.toHaveBeenCalledWith('/downloads/movie.mp4');
expect(renameMock).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
'/downloads/movie (1).mp4.part'
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'movie (1).mp4',
filePath: '/downloads/movie (1).mp4',
})
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/downloads/movie (1).mp4',
status: 'completed',
})
);
});
});
describe('queue deduplication', () => {
it('ignores a second enqueue for an id that is already queued', async () => {
jest.resetModules();
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = { update: jest.fn(() => ({ set })) };
const removePartialDownloadFile = jest.fn();
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects: jest.fn(
() => new Promise(() => undefined)
),
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
}));
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
// Occupy the active slot, then enqueue id 43 twice (double resume).
runtime.enqueueDownload(createTask());
runtime.enqueueDownload({ ...createTask(), id: 43 });
runtime.enqueueDownload({ ...createTask(), id: 43 });
// Only one queue entry exists: the first cancel finds it, the second
// finds nothing (and no paused DB row backs id 43 in this harness).
const dbSelect = jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([]),
})),
})),
}));
(db as { select?: jest.Mock }).select = dbSelect;
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
await expect(runtime.cancelDownload(43)).resolves.toBe(false);
});
});
@@ -0,0 +1,337 @@
import { PassThrough, Readable } from 'node:stream';
import type { DownloadTask } from './download-task';
interface ResumeHarness {
createWriteStream: jest.Mock;
removePartialDownloadFile: jest.Mock;
requestWithValidatedRedirects: jest.Mock;
set: jest.Mock;
runtime: typeof import('./download-runtime');
}
interface ResumeHarnessOptions {
partialSize: number;
response: {
data: Readable;
headers: Record<string, string>;
status: number;
};
/** 'enoent' makes stat() report a missing target file. */
finalSize: number | 'enoent';
}
function createTask(overrides: Partial<DownloadTask> = {}): DownloadTask {
return {
directory: '/downloads',
fileName: 'movie.mp4',
id: 42,
url: 'https://example.test/movie.mp4',
...overrides,
};
}
async function waitForStatus(set: jest.Mock, status: string): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
if (set.mock.calls.some(([value]) => value?.status === status)) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(set).toHaveBeenCalledWith(expect.objectContaining({ status }));
}
async function setupResumeHarness(
options: ResumeHarnessOptions
): Promise<ResumeHarness> {
jest.resetModules();
const set = jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
}));
const db = { update: jest.fn(() => ({ set })) };
const requestWithValidatedRedirects = jest.fn(
async () => options.response as never
);
const createWriteStream = jest.fn(() => new PassThrough());
const removePartialDownloadFile = jest.fn();
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream,
existsSync: jest.fn(() => false),
}));
jest.doMock('node:fs/promises', () => ({
copyFile: jest.fn(async () => undefined),
link: jest.fn(async () => undefined),
stat: jest.fn(async () => {
if (options.finalSize === 'enoent') {
const error = new Error('missing') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return { size: options.finalSize };
}),
unlink: jest.fn(async () => undefined),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => options.partialSize),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
}));
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
return {
createWriteStream,
removePartialDownloadFile,
requestWithValidatedRedirects,
set,
runtime,
};
}
describe('download resume validation', () => {
it('sends the stored validator as If-Range alongside the Range header', async () => {
const harness = await setupResumeHarness({
finalSize: 54,
partialSize: 50,
response: {
data: Readable.from([Buffer.from('rest')]),
headers: { 'content-range': 'bytes 50-53/54' },
status: 206,
},
});
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 54,
})
);
await waitForStatus(harness.set, 'completed');
expect(harness.requestWithValidatedRedirects).toHaveBeenCalledWith(
'https://example.test/movie.mp4',
expect.objectContaining({
headers: expect.objectContaining({
'If-Range': '"etag-1"',
Range: 'bytes=50-',
}),
}),
{ allowPrivateNetworks: true }
);
expect(harness.createWriteStream).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
{ flags: 'a' }
);
});
it('restarts from byte zero when a resume request is answered with 200', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
partialSize: 50,
response: {
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4', etag: '"etag-2"' },
status: 200,
},
});
const consoleWarn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
resumeValidator: '"etag-1"',
totalBytes: 54,
})
);
await waitForStatus(harness.set, 'completed');
expect(harness.createWriteStream).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
{ flags: 'w' }
);
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 0,
resumeValidator: '"etag-2"',
totalBytes: 4,
})
);
expect(harness.set).not.toHaveBeenCalledWith(
expect.objectContaining({ status: 'failed' })
);
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
} finally {
consoleWarn.mockRestore();
}
});
it('fails the transfer when the 206 response starts at the wrong offset', async () => {
const body = new PassThrough();
body.write('rest');
const harness = await setupResumeHarness({
finalSize: 'enoent',
partialSize: 50,
response: {
data: body,
headers: { 'content-range': 'bytes 0-53/54' },
status: 206,
},
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
totalBytes: 54,
})
);
await waitForStatus(harness.set, 'failed');
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
errorMessage: 'Server returned an invalid resume range',
status: 'failed',
})
);
expect(body.destroyed).toBe(true);
} finally {
consoleError.mockRestore();
}
});
it('retains the partial when a 206 ends before the advertised size', async () => {
const harness = await setupResumeHarness({
finalSize: 'enoent',
partialSize: 50,
response: {
// Only 20 of the 50 remaining bytes arrive before EOF.
data: Readable.from([Buffer.alloc(20, 'r')]),
headers: { 'content-range': 'bytes 50-99/100' },
status: 206,
},
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
harness.runtime.enqueueDownload(
createTask({
filePath: '/downloads/movie.mp4',
totalBytes: 100,
})
);
await waitForStatus(harness.set, 'failed');
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 70,
errorMessage: 'Transfer ended before the advertised size',
filePath: '/downloads/movie.mp4',
status: 'failed',
})
);
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
} finally {
consoleError.mockRestore();
}
});
it('captures a strong ETag from the first response for later resumes', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
partialSize: 0,
response: {
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4', etag: '"etag-3"' },
status: 200,
},
});
harness.runtime.enqueueDownload(createTask());
await waitForStatus(harness.set, 'completed');
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({ resumeValidator: '"etag-3"' })
);
});
it('pauses before reservation without a network request or file path', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
partialSize: 0,
response: {
data: Readable.from([Buffer.from('full')]),
headers: { 'content-length': '4' },
status: 200,
},
});
harness.runtime.enqueueDownload({
...createTask(),
pauseRequested: true,
});
await waitForStatus(harness.set, 'paused');
expect(harness.requestWithValidatedRedirects).not.toHaveBeenCalled();
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 0,
filePath: null,
status: 'paused',
})
);
});
it('falls back to Last-Modified when the ETag is weak', async () => {
const harness = await setupResumeHarness({
finalSize: 4,
partialSize: 0,
response: {
data: Readable.from([Buffer.from('full')]),
headers: {
'content-length': '4',
etag: 'W/"weak-etag"',
'last-modified': 'Wed, 01 Jul 2026 10:00:00 GMT',
},
status: 200,
},
});
harness.runtime.enqueueDownload(createTask());
await waitForStatus(harness.set, 'completed');
expect(harness.set).toHaveBeenCalledWith(
expect.objectContaining({
resumeValidator: 'Wed, 01 Jul 2026 10:00:00 GMT',
})
);
});
});
@@ -0,0 +1,154 @@
import { PassThrough } from 'node:stream';
import { createTask, waitForStatus } from './download.test-helpers';
describe('retained completed partials', () => {
it('finalizes a retained completed partial without another HTTP request', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({ set })),
};
const requestWithValidatedRedirects = jest.fn();
const link = jest.fn(async () => undefined);
const unlink = jest.fn(async () => undefined);
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn((filePath: string) =>
filePath.endsWith('.part')
),
}));
jest.doMock('node:fs/promises', () => ({
copyFile: jest.fn(async () => undefined),
link,
stat: jest.fn(async () => ({ size: 4 })),
unlink,
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 4),
removePartialDownloadFile: jest.fn(),
reserveAvailablePartialDownloadFile: jest.fn(),
}));
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload({
...createTask(),
filePath: '/downloads/movie.mp4',
totalBytes: 4,
});
await waitForStatus(set, 'completed');
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
expect(link).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
'/downloads/movie.mp4'
);
expect(unlink).toHaveBeenCalledWith('/downloads/movie.mp4.part');
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 4,
filePath: '/downloads/movie.mp4',
status: 'completed',
totalBytes: 4,
})
);
});
it('finalizes a retained completed partial beside an occupied destination without touching it', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({ set })),
};
const requestWithValidatedRedirects = jest.fn();
const link = jest.fn(async () => undefined);
const unlink = jest.fn(async () => undefined);
const occupied = new Set([
'/downloads/movie.mp4',
'/downloads/movie.mp4.part',
]);
const rename = jest.fn(async (from: string, to: string) => {
occupied.delete(from);
occupied.add(to);
});
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn((filePath: string) => occupied.has(filePath)),
}));
jest.doMock('node:fs/promises', () => ({
copyFile: jest.fn(async () => undefined),
link,
rename,
stat: jest.fn(async () => ({ size: 4 })),
unlink,
}));
jest.doMock('./download-file-path', () => ({
findAvailableFinalPath: jest.fn(() => ({
filename: 'movie (1).mp4',
path: '/downloads/movie (1).mp4',
})),
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 4),
removePartialDownloadFile: jest.fn(),
reserveAvailablePartialDownloadFile: jest.fn(),
}));
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload({
...createTask(),
filePath: '/downloads/movie.mp4',
totalBytes: 4,
});
await waitForStatus(set, 'completed');
// The occupied destination is never unlinked or overwritten; the
// retained partial moves aside and finalizes under a numbered name.
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
expect(unlink).not.toHaveBeenCalledWith('/downloads/movie.mp4');
expect(rename).toHaveBeenCalledWith(
'/downloads/movie.mp4.part',
'/downloads/movie (1).mp4.part'
);
expect(link).toHaveBeenCalledWith(
'/downloads/movie (1).mp4.part',
'/downloads/movie (1).mp4'
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 4,
fileName: 'movie (1).mp4',
filePath: '/downloads/movie (1).mp4',
status: 'completed',
totalBytes: 4,
})
);
});
});
@@ -1,124 +1,91 @@
import type { DownloadItem } from 'electron';
import { PassThrough, Readable } from 'node:stream';
import {
attachDownloadItem,
requestDownloadCancellation,
type DownloadTask,
requestDownloadPause,
} from './download-task';
import {
createTask,
waitForCallCount,
waitForStatus,
waitForStatusCount,
} from './download.test-helpers';
function createTask(): DownloadTask {
return {
directory: '/downloads',
fileName: 'movie.mp4',
id: 42,
url: 'https://example.test/movie.mp4',
};
}
async function waitForCallCount(
mock: jest.Mock,
expectedCallCount: number
): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
if (mock.mock.calls.length === expectedCallCount) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(mock).toHaveBeenCalledTimes(expectedCallCount);
}
describe('download runtime cancellation', () => {
it('cancels the item when an earlier cancellation request reaches onStarted', () => {
const task = createTask();
const cancel = jest.fn();
requestDownloadCancellation(task);
attachDownloadItem(task, { cancel } as unknown as DownloadItem);
expect(task.cancelRequested).toBe(true);
expect(cancel).toHaveBeenCalledTimes(1);
});
it('cancels an already-started item immediately', () => {
const cancel = jest.fn();
describe('download task interruption', () => {
it('aborts an active task when cancellation is requested', () => {
const abort = jest.fn();
const task = {
...createTask(),
downloadItem: { cancel } as unknown as DownloadItem,
abortController: { abort } as unknown as AbortController,
};
requestDownloadCancellation(task);
expect(task.cancelRequested).toBe(true);
expect(cancel).toHaveBeenCalledTimes(1);
expect(abort).toHaveBeenCalledTimes(1);
});
it('continues the queue when cancellation persistence fails', async () => {
jest.resetModules();
class TestCancelError extends Error {}
let cancellationCallback: Promise<void> | undefined;
const download = jest
.fn()
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCancel: (item: DownloadItem) => Promise<void>;
}
) => {
cancellationCallback = options.onCancel({
getSavePath: () => '/downloads/movie.mp4',
} as unknown as DownloadItem);
throw new TestCancelError();
}
)
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
await options.onCompleted({
fileSize: 1,
filename: 'second.mp4',
path: '/downloads/second.mp4',
});
}
);
const where = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('database is busy'))
.mockResolvedValue(undefined);
const db = {
update: jest.fn(() => ({
set: jest.fn(() => ({ where })),
})),
it('aborts an active task when pause is requested', () => {
const abort = jest.fn();
const task = {
...createTask(),
abortController: { abort } as unknown as AbortController,
};
jest.doMock('electron-dl', () => ({
CancelError: TestCancelError,
download,
}));
requestDownloadPause(task);
expect(task.pauseRequested).toBe(true);
expect(abort).toHaveBeenCalledTimes(1);
});
});
describe('download runtime pause and resume', () => {
it('persists active pause without deleting the partial file', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
const stream = new PassThrough();
const requestWithValidatedRedirects = jest.fn(
async (
_url: string,
options: { signal?: AbortSignal }
) => {
options.signal?.addEventListener('abort', () => {
stream.destroy(new Error('aborted'));
});
return {
data: stream,
headers: { 'content-length': '100' },
status: 200,
};
}
);
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownload: jest.fn(),
reserveAvailableDownloadFile: jest.fn(
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest
.fn()
.mockReturnValueOnce(0)
.mockReturnValue(25),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
@@ -127,96 +94,134 @@ describe('download runtime cancellation', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
await waitForCallCount(requestWithValidatedRedirects, 1);
await expect(runtime.pauseDownload(42)).resolves.toBe(true);
await waitForStatus(set, 'paused');
expect(removePartialDownloadFile).not.toHaveBeenCalled();
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 25,
status: 'paused',
})
);
} finally {
consoleError.mockRestore();
}
});
it('uses an HTTP Range header when a partial file already exists', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({ set })),
};
const requestWithValidatedRedirects = jest.fn(
async () =>
({
data: Readable.from([Buffer.from('rest')]),
headers: { 'content-range': 'bytes 50-53/54' },
status: 206,
}) as never
);
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('node:fs/promises', () => ({
copyFile: jest.fn(async () => undefined),
link: jest.fn(async () => undefined),
stat: jest.fn(async () => ({ size: 54 })),
unlink: jest.fn(async () => undefined),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 50),
removePartialDownloadFile: jest.fn(),
reserveAvailablePartialDownloadFile: jest.fn(),
}));
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
runtime.enqueueDownload(createTask());
runtime.enqueueDownload({
...createTask(),
fileName: 'second.mp4',
id: 43,
filePath: '/downloads/movie.mp4',
});
await waitForCallCount(requestWithValidatedRedirects, 1);
await waitForCallCount(download, 2);
await expect(cancellationCallback).resolves.toBeUndefined();
consoleError.mockRestore();
expect(requestWithValidatedRedirects).toHaveBeenCalledWith(
'https://example.test/movie.mp4',
expect.objectContaining({
headers: expect.objectContaining({ Range: 'bytes=50-' }),
}),
{ allowPrivateNetworks: true }
);
await waitForStatus(set, 'completed');
});
it('continues the queue when completion persistence fails', async () => {
it('deletes a queued resumed partial file when the queued task is canceled', async () => {
jest.resetModules();
class TestCancelError extends Error {}
let completionCallback: Promise<void> | undefined;
const download = jest
.fn()
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
completionCallback = options.onCompleted({
fileSize: 1,
filename: 'movie.mp4',
path: '/downloads/movie.mp4',
});
}
)
.mockImplementationOnce(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
await options.onCompleted({
fileSize: 1,
filename: 'second.mp4',
path: '/downloads/second.mp4',
});
}
);
const where = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('database is busy'))
.mockResolvedValue(undefined);
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
update: jest.fn(() => ({
set: jest.fn(() => ({ where })),
})),
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn();
const activeStream = new PassThrough();
const requestWithValidatedRedirects = jest.fn(
async (_url: string, options: { signal?: AbortSignal }) => {
options.signal?.addEventListener('abort', () => {
activeStream.destroy(new Error('aborted'));
});
return {
data: activeStream,
headers: { 'content-length': '100' },
status: 200,
};
}
);
jest.doMock('electron-dl', () => ({
CancelError: TestCancelError,
download,
}));
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects,
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('./download-file-path', () => ({
removePartialDownload: jest.fn(),
reserveAvailableDownloadFile: jest.fn(
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
@@ -233,64 +238,66 @@ describe('download runtime cancellation', () => {
} as never);
runtime.enqueueDownload(createTask());
await waitForCallCount(requestWithValidatedRedirects, 1);
runtime.enqueueDownload({
...createTask(),
fileName: 'second.mp4',
fileName: 'resume.mp4',
filePath: '/downloads/resume.mp4',
id: 43,
});
await waitForCallCount(download, 2);
await expect(completionCallback).resolves.toBeUndefined();
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/resume.mp4'
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
bytesDownloaded: 0,
filePath: null,
status: 'canceled',
totalBytes: null,
})
);
await runtime.cancelDownload(42);
await waitForStatusCount(set, 'canceled', 2);
} finally {
consoleError.mockRestore();
}
});
it('continues the queue when initial database access fails', async () => {
it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => {
jest.resetModules();
class TestCancelError extends Error {}
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = { update: jest.fn(() => ({ set })) };
const removePartialDownloadFile = jest.fn(() => {
throw new Error('EPERM: locked');
});
const download = jest.fn(
async (
_window: unknown,
_url: string,
options: {
onCompleted: (file: {
fileSize: number;
filename: string;
path: string;
}) => Promise<void>;
}
) => {
await options.onCompleted({
fileSize: 1,
filename: 'second.mp4',
path: '/downloads/second.mp4',
});
}
);
const where = jest.fn().mockResolvedValue(undefined);
const db = {
update: jest.fn(() => ({
set: jest.fn(() => ({ where })),
})),
};
const getDatabase = jest
.fn()
.mockRejectedValueOnce(new Error('database unavailable'))
.mockResolvedValue(db);
jest.doMock('electron-dl', () => ({
CancelError: TestCancelError,
download,
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('../../util/validated-axios', () => ({
requestWithValidatedRedirects: jest.fn(
() => new Promise(() => undefined)
),
}));
jest.doMock('node:fs', () => ({
...jest.requireActual('node:fs'),
createWriteStream: jest.fn(() => new PassThrough()),
existsSync: jest.fn(() => false),
}));
jest.doMock('../../database/connection', () => ({ getDatabase }));
jest.doMock('./download-file-path', () => ({
removePartialDownload: jest.fn(),
reserveAvailableDownloadFile: jest.fn(
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(
(directory: string, filename: string) => ({
filename,
partialPath: `${directory}/${filename}.part`,
path: `${directory}/${filename}`,
})
),
@@ -306,18 +313,81 @@ describe('download runtime cancellation', () => {
webContents: { send: jest.fn() },
} as never);
// Occupy the active slot so the second task stays queued.
runtime.enqueueDownload(createTask());
runtime.enqueueDownload({
...createTask(),
fileName: 'second.mp4',
fileName: 'resume.mp4',
filePath: '/downloads/resume.mp4',
id: 43,
});
await waitForCallCount(download, 1);
expect(download).toHaveBeenCalledWith(
expect.anything(),
'https://example.test/movie.mp4',
expect.objectContaining({ filename: 'second.mp4' })
await expect(runtime.cancelDownload(43)).resolves.toBe(true);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/downloads/resume.mp4',
status: 'canceled',
})
);
} finally {
consoleError.mockRestore();
}
});
it('retains the partial path when canceling a paused row whose partial cannot be deleted', async () => {
jest.resetModules();
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
const db = {
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([
{
filePath: '/downloads/paused.mp4',
status: 'paused',
},
]),
})),
})),
})),
update: jest.fn(() => ({ set })),
};
const removePartialDownloadFile = jest.fn(() => {
throw new Error('EPERM: locked');
});
jest.doMock('../../database/connection', () => ({
getDatabase: jest.fn().mockResolvedValue(db),
}));
jest.doMock('./download-file-path', () => ({
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
getPartialDownloadSize: jest.fn(() => 0),
removePartialDownloadFile,
reserveAvailablePartialDownloadFile: jest.fn(),
}));
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const runtime = await import('./download-runtime');
runtime.setMainWindow({
isDestroyed: () => false,
webContents: { send: jest.fn() },
} as never);
await expect(runtime.cancelDownload(77)).resolves.toBe(true);
expect(removePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/paused.mp4'
);
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/downloads/paused.mp4',
status: 'canceled',
})
);
} finally {
consoleError.mockRestore();
@@ -1,41 +1,56 @@
import { eq, sql } from 'drizzle-orm';
import type { BrowserWindow } from 'electron';
import { CancelError, download } from 'electron-dl';
import { existsSync } from 'node:fs';
import { rename } from 'node:fs/promises';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { broadcastDownloadUpdate } from './download-broadcast';
import {
removePartialDownload,
reserveAvailableDownloadFile,
findAvailableFinalPath,
getPartialDownloadPath,
reserveAvailablePartialDownloadFile,
type ReservedPartialDownloadFile,
} from './download-file-path';
import {
attachDownloadItem,
completeDownloadFromPartial,
getCompletedPartialProgress,
getPausedByteCount,
handleDownloadFailure,
removePartialFile,
} from './download-finalize';
import {
requestDownloadCancellation,
requestDownloadPause,
type DownloadsDatabase,
type DownloadTask,
} from './download-task';
import { describeError, transferToPartialFile } from './download-transfer';
export {
broadcastDownloadUpdate,
setMainWindow,
} from './download-broadcast';
const downloadQueue: DownloadTask[] = [];
let activeDownload: DownloadTask | null = null;
let mainWindow: BrowserWindow | null = null;
export function setMainWindow(win: BrowserWindow): void {
mainWindow = win;
}
export function broadcastDownloadUpdate(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('DOWNLOADS_UPDATE_EVENT');
}
}
export function enqueueDownload(task: DownloadTask): void {
// A duplicate id (e.g. two rapid Resume clicks racing the status
// refresh) must not produce two transfers for the same row.
if (
activeDownload?.id === task.id ||
downloadQueue.some((queued) => queued.id === task.id)
) {
return;
}
downloadQueue.push(task);
broadcastDownloadUpdate();
void processQueue();
}
export async function cancelDownload(downloadId: number): Promise<boolean> {
export async function pauseDownload(downloadId: number): Promise<boolean> {
if (activeDownload?.id === downloadId) {
requestDownloadCancellation(activeDownload);
requestDownloadPause(activeDownload);
return true;
}
@@ -52,8 +67,7 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
.update(schema.downloads)
.set({
errorMessage: null,
filePath: null,
status: 'canceled',
status: 'paused',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
@@ -61,6 +75,52 @@ export async function cancelDownload(downloadId: number): Promise<boolean> {
return true;
}
export async function cancelDownload(downloadId: number): Promise<boolean> {
if (activeDownload?.id === downloadId) {
requestDownloadCancellation(activeDownload);
return true;
}
const queueIndex = downloadQueue.findIndex(
(task) => task.id === downloadId
);
if (queueIndex !== -1) {
const [queuedTask] = downloadQueue.splice(queueIndex, 1);
const removed = removePartialFile(queuedTask?.filePath);
const db = await getDatabase();
await persistQueuedCancellation(
db,
downloadId,
removed ? null : (queuedTask?.filePath ?? null)
);
broadcastDownloadUpdate();
return true;
}
const db = await getDatabase();
const rows = await db
.select({
filePath: schema.downloads.filePath,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
const item = rows[0];
if (item?.status !== 'paused') {
return false;
}
const removed = removePartialFile(item.filePath);
await persistQueuedCancellation(
db,
downloadId,
removed ? null : item.filePath
);
broadcastDownloadUpdate();
return true;
}
export function removeDownloadFromRuntime(downloadId: number): void {
if (activeDownload?.id === downloadId) {
requestDownloadCancellation(activeDownload);
@@ -90,7 +150,7 @@ async function processQueue(): Promise<void> {
} catch (error) {
console.error(
`[Downloads] Unhandled error for ${task.fileName}:`,
error
describeError(error)
);
finishTask(task);
}
@@ -104,39 +164,25 @@ function finishTask(task: DownloadTask): void {
void processQueue();
}
function createCancellationHandler(
task: DownloadTask,
db: Awaited<ReturnType<typeof getDatabase>>,
reservation: ReturnType<typeof reserveAvailableDownloadFile>
): (item: Parameters<typeof removePartialDownload>[0]) => Promise<void> {
let cancellationPromise: Promise<void> | undefined;
return (item) => {
cancellationPromise ??= (async () => {
console.log(`[Downloads] Canceled: ${reservation.filename}`);
removePartialFile(item);
try {
await db
.update(schema.downloads)
.set({
errorMessage: null,
filePath: null,
status: 'canceled',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error(
'[Downloads] Failed to persist cancellation:',
error
);
} finally {
finishTask(task);
}
})();
return cancellationPromise;
};
async function persistQueuedCancellation(
db: DownloadsDatabase,
downloadId: number,
// Keep the path when the retained .part could not be deleted, so a later
// remove/clear can retry the cleanup instead of orphaning the file.
retainedFilePath: string | null = null
): Promise<void> {
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
filePath: retainedFilePath,
resumeValidator: null,
status: 'canceled',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, downloadId));
}
async function startDownload(task: DownloadTask): Promise<void> {
@@ -145,40 +191,26 @@ async function startDownload(task: DownloadTask): Promise<void> {
.update(schema.downloads)
.set({
errorMessage: null,
filePath: null,
status: 'downloading',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastDownloadUpdate();
if (!mainWindow || mainWindow.isDestroyed()) {
console.error('[Downloads] No main window available');
await db
.update(schema.downloads)
.set({
errorMessage: 'No window available for download',
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
finishTask(task);
return;
}
let lastProgressUpdate = 0;
const progressThrottleMs = 500;
let handleCancellation:
| ReturnType<typeof createCancellationHandler>
| undefined;
let reservation: ReservedPartialDownloadFile | undefined;
try {
const reservation = reserveAvailableDownloadFile(
task.directory,
task.fileName
);
task.reservedPath = reservation.path;
handleCancellation = createCancellationHandler(task, db, reservation);
if (task.cancelRequested) {
await persistCancellation(db, task);
return;
}
if (task.pauseRequested) {
await persistPause(db, task);
return;
}
reservation = await reserveTarget(task);
task.fileName = reservation.filename;
task.filePath = reservation.path;
await db
.update(schema.downloads)
.set({
@@ -189,107 +221,131 @@ async function startDownload(task: DownloadTask): Promise<void> {
})
.where(eq(schema.downloads.id, task.id));
const downloadOptions: Parameters<typeof download>[2] = {
directory: task.directory,
filename: reservation.filename,
onStarted: (item) => {
console.log(`[Downloads] Started: ${reservation.filename}`);
attachDownloadItem(task, item);
},
onProgress: async (progress) => {
const now = Date.now();
if (now - lastProgressUpdate < progressThrottleMs) {
return;
}
lastProgressUpdate = now;
await db
.update(schema.downloads)
.set({
bytesDownloaded: progress.transferredBytes,
totalBytes: progress.totalBytes,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastDownloadUpdate();
},
onCompleted: async (file) => {
console.log(`[Downloads] Completed: ${file.filename}`);
try {
await db
.update(schema.downloads)
.set({
bytesDownloaded: file.fileSize,
errorMessage: null,
fileName: file.filename,
filePath: file.path,
status: 'completed',
totalBytes: file.fileSize,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error(
'[Downloads] Failed to persist completion:',
error
);
} finally {
finishTask(task);
}
},
onCancel: handleCancellation,
};
if (task.headers) {
(
downloadOptions as typeof downloadOptions & {
headers: Record<string, string>;
}
).headers = task.headers;
if (task.cancelRequested) {
await persistCancellation(db, task);
return;
}
await download(mainWindow, task.url, downloadOptions);
} catch (error) {
if (error instanceof CancelError) {
const reservedPath = task.reservedPath;
if (handleCancellation) {
await handleCancellation(
task.downloadItem ??
(reservedPath
? { getSavePath: () => reservedPath }
: undefined)
);
} else {
finishTask(task);
}
if (task.pauseRequested) {
await persistPause(db, task);
return;
}
console.error(`[Downloads] Error downloading ${task.fileName}:`, error);
const reservedPath = task.reservedPath;
removePartialFile(
task.downloadItem ??
(reservedPath ? { getSavePath: () => reservedPath } : undefined)
);
await db
.update(schema.downloads)
.set({
errorMessage:
error instanceof Error ? error.message : String(error),
filePath: null,
status: 'failed',
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
const completedPartialProgress = getCompletedPartialProgress(task);
if (completedPartialProgress) {
await completeDownloadFromPartial(
db,
task,
reservation,
completedPartialProgress
);
return;
}
const progress = await transferToPartialFile(db, task, reservation);
if (task.cancelRequested) {
await persistCancellation(db, task);
return;
}
if (task.pauseRequested) {
await persistPause(db, task);
return;
}
await completeDownloadFromPartial(db, task, reservation, progress);
} catch (error) {
if (task.cancelRequested) {
await persistCancellation(db, task);
return;
}
if (task.pauseRequested) {
await persistPause(db, task);
return;
}
await handleDownloadFailure(db, task, reservation, error);
} finally {
task.abortController = undefined;
finishTask(task);
}
}
function removePartialFile(
item: Parameters<typeof removePartialDownload>[0]
): void {
async function reserveTarget(
task: DownloadTask
): Promise<ReservedPartialDownloadFile> {
if (task.filePath) {
if (!existsSync(task.filePath)) {
return {
filename: task.fileName,
partialPath: getPartialDownloadPath(task.filePath),
path: task.filePath,
};
}
// Something now occupies the recorded destination — possibly a file
// the user created while this download was paused or failed. Never
// inspect or delete it: move the retained .part to the next free
// numbered destination and finalize there instead.
const redirected = findAvailableFinalPath(task.filePath);
const currentPartial = getPartialDownloadPath(task.filePath);
const redirectedPartial = getPartialDownloadPath(redirected.path);
if (existsSync(currentPartial)) {
await rename(currentPartial, redirectedPartial);
}
return {
filename: redirected.filename,
partialPath: redirectedPartial,
path: redirected.path,
};
}
return reserveAvailablePartialDownloadFile(task.directory, task.fileName);
}
async function persistCancellation(
db: DownloadsDatabase,
task: DownloadTask
): Promise<void> {
console.log(`[Downloads] Canceled: ${task.fileName}`);
const removed = removePartialFile(task.filePath);
try {
removePartialDownload(item);
await db
.update(schema.downloads)
.set({
bytesDownloaded: 0,
errorMessage: null,
filePath: removed ? null : (task.filePath ?? null),
resumeValidator: null,
status: 'canceled',
totalBytes: null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error('[Downloads] Failed to delete partial file:', error);
console.error('[Downloads] Failed to persist cancellation:', error);
}
}
async function persistPause(
db: DownloadsDatabase,
task: DownloadTask
): Promise<void> {
console.log(`[Downloads] Paused: ${task.fileName}`);
const bytesDownloaded = getPausedByteCount(task);
try {
await db
.update(schema.downloads)
.set({
bytesDownloaded,
errorMessage: null,
fileName: task.fileName,
filePath: task.filePath ?? null,
status: 'paused',
totalBytes: task.totalBytes ?? null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
} catch (error) {
console.error('[Downloads] Failed to persist pause:', error);
}
}
@@ -1,4 +1,15 @@
import type { DownloadItem } from 'electron';
import type { getDatabase } from '../../database/connection';
export type DownloadsDatabase = Awaited<ReturnType<typeof getDatabase>>;
export interface TransferProgress {
bytesDownloaded: number;
totalBytes: number | null;
}
export interface CompletedPartialProgress extends TransferProgress {
filePath: string;
}
export interface DownloadTask {
id: number;
@@ -7,21 +18,20 @@ export interface DownloadTask {
directory: string;
headers?: Record<string, string>;
cancelRequested?: boolean;
downloadItem?: DownloadItem;
reservedPath?: string;
}
export function attachDownloadItem(
task: DownloadTask,
item: DownloadItem
): void {
task.downloadItem = item;
if (task.cancelRequested) {
item.cancel();
}
pauseRequested?: boolean;
abortController?: AbortController;
filePath?: string | null;
totalBytes?: number | null;
/** ETag/Last-Modified of the entity the partial belongs to (If-Range). */
resumeValidator?: string | null;
}
export function requestDownloadCancellation(task: DownloadTask): void {
task.cancelRequested = true;
task.downloadItem?.cancel();
task.abortController?.abort();
}
export function requestDownloadPause(task: DownloadTask): void {
task.pauseRequested = true;
task.abortController?.abort();
}
@@ -0,0 +1,262 @@
import { eq, sql } from 'drizzle-orm';
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import * as schema from '../../database/schema';
import { requestWithValidatedRedirects } from '../../util/validated-axios';
import { broadcastDownloadUpdate } from './download-broadcast';
import {
getPartialDownloadSize,
type ReservedPartialDownloadFile,
} from './download-file-path';
import type {
DownloadsDatabase,
DownloadTask,
TransferProgress,
} from './download-task';
/**
* Log transfer failures by message only: a raw AxiosError dumps its request
* config, and download URLs can embed portal credentials.
*/
export function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* The response stream ended cleanly before the advertised representation
* size was reached (e.g. a proxy that caps each response). The partial is
* valid — the caller must retain it so a retry can continue via Range.
*/
export class TruncatedTransferError extends Error {
constructor(readonly progress: TransferProgress) {
super('Transfer ended before the advertised size');
}
}
export async function transferToPartialFile(
db: DownloadsDatabase,
task: DownloadTask,
reservation: ReservedPartialDownloadFile
): Promise<TransferProgress> {
const resumeOffset = getResumeOffset(task, reservation);
const headers = {
...(task.headers ?? {}),
};
if (resumeOffset > 0) {
headers.Range = `bytes=${resumeOffset}-`;
if (task.resumeValidator) {
headers['If-Range'] = task.resumeValidator;
}
}
const abortController = new AbortController();
task.abortController = abortController;
if (task.cancelRequested || task.pauseRequested) {
abortController.abort();
}
console.log(`[Downloads] Started: ${reservation.filename}`);
const response = await requestWithValidatedRedirects<Readable>(
task.url,
{
headers,
method: 'GET',
responseType: 'stream',
signal: abortController.signal,
validateStatus: (status) => status >= 200 && status < 300,
},
{ allowPrivateNetworks: true }
);
const readable = response.data;
let effectiveOffset = resumeOffset;
try {
effectiveOffset = validateResumeResponse(
reservation,
response.status,
response.headers,
resumeOffset
);
} catch (error) {
// Abandon the unconsumed response body; swallow its error events so
// destroying a stream nobody is piping cannot crash the process.
readable.on('error', () => undefined);
readable.destroy();
throw error;
}
const totalBytes = getTotalBytes(response.headers, effectiveOffset);
task.totalBytes = totalBytes;
if (effectiveOffset === 0) {
task.resumeValidator = getResponseValidator(response.headers);
}
await persistTransferStart(db, task, effectiveOffset, totalBytes);
let bytesDownloaded = effectiveOffset;
let lastProgressUpdate = 0;
const progressThrottleMs = 500;
const output = createWriteStream(reservation.partialPath, {
flags: effectiveOffset > 0 ? 'a' : 'w',
});
const abortStream = () => {
readable.destroy(new Error('Download aborted'));
};
if (abortController.signal.aborted) {
abortStream();
} else {
abortController.signal.addEventListener('abort', abortStream, {
once: true,
});
}
readable.on('data', (chunk: Buffer | string) => {
bytesDownloaded += Buffer.isBuffer(chunk)
? chunk.length
: Buffer.byteLength(chunk);
const now = Date.now();
if (now - lastProgressUpdate < progressThrottleMs) {
return;
}
lastProgressUpdate = now;
void persistProgress(db, task, {
bytesDownloaded,
totalBytes,
}).catch((error) => {
console.error('[Downloads] Failed to persist progress:', error);
});
});
try {
await pipeline(readable, output);
} finally {
abortController.signal.removeEventListener('abort', abortStream);
}
await persistProgress(db, task, { bytesDownloaded, totalBytes });
if (totalBytes !== null && bytesDownloaded < totalBytes) {
throw new TruncatedTransferError({ bytesDownloaded, totalBytes });
}
return { bytesDownloaded, totalBytes };
}
function getResumeOffset(
task: DownloadTask,
reservation: ReservedPartialDownloadFile
): number {
const resumeOffset = getPartialDownloadSize(reservation.path);
if (
task.totalBytes !== null &&
task.totalBytes !== undefined &&
resumeOffset > task.totalBytes
) {
throw new Error('Partial download is larger than expected');
}
return resumeOffset;
}
function validateResumeResponse(
reservation: ReservedPartialDownloadFile,
status: number,
headers: unknown,
resumeOffset: number
): number {
if (resumeOffset === 0) {
return 0;
}
if (status !== 206) {
// Either the server ignored Range or If-Range detected that the
// remote entity changed. The retained partial is unusable either way,
// so restart from byte zero instead of failing the download.
console.warn(
`[Downloads] Restarting ${reservation.filename} from the beginning (resume request answered with HTTP ${status})`
);
return 0;
}
const contentRange = getHeaderValue(
headers as Record<string, unknown>,
'content-range'
);
const start = contentRange?.match(/^bytes\s+(\d+)-/i)?.[1];
if (start === undefined || Number(start) !== resumeOffset) {
throw new Error('Server returned an invalid resume range');
}
return resumeOffset;
}
function getResponseValidator(headers: unknown): string | null {
const headerMap = headers as Record<string, unknown>;
const etag = getHeaderValue(headerMap, 'etag');
// If-Range only accepts strong validators, so skip weak W/ ETags.
if (etag && !etag.startsWith('W/')) {
return etag;
}
return getHeaderValue(headerMap, 'last-modified') ?? null;
}
async function persistTransferStart(
db: DownloadsDatabase,
task: DownloadTask,
bytesDownloaded: number,
totalBytes: number | null
): Promise<void> {
await db
.update(schema.downloads)
.set({
bytesDownloaded,
resumeValidator: task.resumeValidator ?? null,
totalBytes,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastDownloadUpdate();
}
async function persistProgress(
db: DownloadsDatabase,
task: DownloadTask,
progress: TransferProgress
): Promise<void> {
await db
.update(schema.downloads)
.set({
bytesDownloaded: progress.bytesDownloaded,
totalBytes: progress.totalBytes,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(eq(schema.downloads.id, task.id));
broadcastDownloadUpdate();
}
function getTotalBytes(headers: unknown, resumeOffset: number): number | null {
const headerMap = headers as Record<string, unknown>;
const contentRange = getHeaderValue(headerMap, 'content-range');
if (contentRange) {
const match = contentRange.match(/\/(\d+)$/);
if (match) {
return Number(match[1]);
}
}
const contentLength = getHeaderValue(headerMap, 'content-length');
if (!contentLength) {
return null;
}
const parsed = Number(contentLength);
return Number.isFinite(parsed) ? resumeOffset + parsed : null;
}
function getHeaderValue(
headers: Record<string, unknown>,
name: string
): string | undefined {
const value = headers[name] ?? headers[name.toLowerCase()];
if (Array.isArray(value)) {
return value.length > 0 ? String(value[0]) : undefined;
}
return value === undefined ? undefined : String(value);
}
@@ -0,0 +1,58 @@
import type { DownloadTask } from './download-task';
export function createTask(): DownloadTask {
return {
directory: '/downloads',
fileName: 'movie.mp4',
id: 42,
url: 'https://example.test/movie.mp4',
};
}
export async function waitForCallCount(
mock: jest.Mock,
expectedCallCount: number
): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
if (mock.mock.calls.length === expectedCallCount) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(mock).toHaveBeenCalledTimes(expectedCallCount);
}
export async function waitForStatus(
set: jest.Mock,
status: string
): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
if (set.mock.calls.some(([value]) => value?.status === status)) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(set).toHaveBeenCalledWith(expect.objectContaining({ status }));
}
export async function waitForStatusCount(
set: jest.Mock,
status: string,
expectedCount: number
): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
const statusCount = set.mock.calls.filter(
([value]) => value?.status === status
).length;
if (statusCount >= expectedCount) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(
set.mock.calls.filter(([value]) => value?.status === status)
).toHaveLength(expectedCount);
}
@@ -0,0 +1,331 @@
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
const mockRegisteredHandlers = new Map<string, IpcHandler>();
const mockGetDatabase = jest.fn();
const mockRemoveDownloadFromRuntime = jest.fn();
const mockBroadcastDownloadUpdate = jest.fn();
const mockRemovePartialDownloadFile = jest.fn();
const mockPauseDownload = jest.fn();
const mockResumeDownloadRequest = jest.fn();
function getHandler(channel: string): IpcHandler {
const handler = mockRegisteredHandlers.get(channel);
if (!handler) {
throw new Error(`Expected IPC handler for ${channel}`);
}
return handler;
}
function createDownloadRow(status: string) {
return {
filePath: '/downloads/resume.mp4',
status,
};
}
describe('downloads events', () => {
beforeEach(async () => {
jest.resetModules();
mockRegisteredHandlers.clear();
mockGetDatabase.mockReset();
mockRemoveDownloadFromRuntime.mockReset();
mockBroadcastDownloadUpdate.mockReset();
mockRemovePartialDownloadFile.mockReset();
mockPauseDownload.mockReset();
mockResumeDownloadRequest.mockReset();
jest.doMock('electron', () => ({
app: {
getPath: jest.fn((name: string) =>
name === 'userData' ? '/user-data' : '/downloads'
),
},
dialog: {
showOpenDialog: jest.fn(),
},
ipcMain: {
handle: jest.fn((channel: string, handler: IpcHandler) => {
mockRegisteredHandlers.set(channel, handler);
}),
},
shell: {
openPath: jest.fn(),
showItemInFolder: jest.fn(),
},
}));
jest.doMock('../../database/connection', () => ({
getDatabase: mockGetDatabase,
}));
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile: mockRemovePartialDownloadFile,
}));
jest.doMock('./download-runtime', () => ({
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
cancelDownload: jest.fn(),
pauseDownload: mockPauseDownload,
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
setMainWindow: jest.fn(),
}));
jest.doMock('./download-requests', () => ({
resumeDownloadRequest: mockResumeDownloadRequest,
retryDownloadRequest: jest.fn(),
startDownloadRequest: jest.fn(),
}));
jest.doMock('./download-recovery', () => ({
resetStaleDownloads: jest.fn(),
}));
await import('./downloads.events');
});
function mockDownloadRow(row: { filePath: string | null; status: string }) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const db = {
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([row]),
})),
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, deleteWhere };
}
function mockTerminalRows(
rows: Array<{ filePath: string | null; status: string }>
) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const selectWhere = jest
.fn()
.mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row })));
const db = {
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: selectWhere,
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, deleteWhere, selectWhere };
}
it('removes queued resumed partial files before deleting the row', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
success: true,
});
expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42);
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/resume.mp4'
);
expect(
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]);
expect(
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
});
it('removes completed partial files before deleting the row', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('completed'));
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
success: true,
});
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/resume.mp4'
);
expect(
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
});
it('keeps the queued runtime entry and row when partial cleanup fails', async () => {
const cleanupError = new Error('permission denied');
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
mockRemovePartialDownloadFile.mockImplementation(() => {
throw cleanupError;
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
// The row and its .part must survive, but the renderer gets a
// structured failure it can surface instead of an IPC rejection.
await expect(
getHandler('DOWNLOADS_REMOVE')(null, 42)
).resolves.toEqual({
error: 'Could not delete the partial file',
success: false,
});
} finally {
consoleError.mockRestore();
consoleLog.mockRestore();
}
expect(deleteWhere).not.toHaveBeenCalled();
expect(mockRemoveDownloadFromRuntime).not.toHaveBeenCalled();
});
it('removes completed, failed, and canceled partial files before clearing terminal downloads', async () => {
const { deleteWhere } = mockTerminalRows([
{ filePath: '/downloads/done.mp4', status: 'completed' },
{ filePath: '/downloads/failed.mp4', status: 'failed' },
{ filePath: '/downloads/canceled.mp4', status: 'canceled' },
]);
await expect(
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
).resolves.toEqual({ success: true });
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(3);
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
1,
'/downloads/done.mp4'
);
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
2,
'/downloads/failed.mp4'
);
expect(mockRemovePartialDownloadFile).toHaveBeenNthCalledWith(
3,
'/downloads/canceled.mp4'
);
expect(
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
});
it('retains only downloads whose partial cleanup fails when clearing terminal downloads', async () => {
const cleanupError = new Error('permission denied');
const { deleteWhere } = mockTerminalRows([
{ filePath: '/downloads/done.mp4', status: 'completed' },
{ filePath: '/downloads/failed.mp4', status: 'failed' },
]);
mockRemovePartialDownloadFile.mockImplementation((filePath) => {
if (filePath !== '/downloads/failed.mp4') {
return;
}
throw cleanupError;
});
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_CLEAR_COMPLETED')(null)
).resolves.toEqual({ success: true });
} finally {
consoleError.mockRestore();
}
expect(deleteWhere).toHaveBeenCalledTimes(1);
expect(mockBroadcastDownloadUpdate).toHaveBeenCalledTimes(1);
});
it('removes the row once a previously locked partial becomes deletable', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('paused'));
mockRemovePartialDownloadFile
.mockImplementationOnce(() => {
throw new Error('EPERM: locked');
})
.mockImplementation(() => true);
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_REMOVE')(null, 42)
).resolves.toEqual({
error: 'Could not delete the partial file',
success: false,
});
expect(deleteWhere).not.toHaveBeenCalled();
// Retry after the lock is released: cleanup and delete succeed.
await expect(
getHandler('DOWNLOADS_REMOVE')(null, 42)
).resolves.toEqual({ success: true });
} finally {
consoleError.mockRestore();
consoleLog.mockRestore();
}
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(2);
expect(deleteWhere).toHaveBeenCalledTimes(1);
});
it('maps a successful runtime pause to a success response', async () => {
mockPauseDownload.mockResolvedValue(true);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_PAUSE')(null, 42)
).resolves.toEqual({ success: true });
} finally {
consoleLog.mockRestore();
}
expect(mockPauseDownload).toHaveBeenCalledWith(42);
});
it('maps an unknown pause target to an error response', async () => {
mockPauseDownload.mockResolvedValue(false);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_PAUSE')(null, 42)
).resolves.toEqual({
error: 'Download not found in queue',
success: false,
});
} finally {
consoleLog.mockRestore();
}
});
it('forwards resume requests with the download folder and returns the result', async () => {
mockResumeDownloadRequest.mockResolvedValue({
error: 'Can only resume paused downloads',
success: false,
});
await expect(
getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads')
).resolves.toEqual({
error: 'Can only resume paused downloads',
success: false,
});
expect(mockResumeDownloadRequest).toHaveBeenCalledWith(
42,
'/downloads',
expect.anything()
);
});
});
@@ -6,7 +6,9 @@ import { join } from 'node:path';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
import { removePartialDownloadFile } from './download-file-path';
import {
resumeDownloadRequest,
retryDownloadRequest,
startDownloadRequest,
type StartDownloadRequest,
@@ -15,10 +17,19 @@ import { resetStaleDownloads } from './download-recovery';
import {
broadcastDownloadUpdate,
cancelDownload,
pauseDownload,
removeDownloadFromRuntime,
setMainWindow,
} from './download-runtime';
const removablePartialStatuses = new Set([
'queued',
'paused',
'completed',
'failed',
'canceled',
]);
function getDownloadAuthorizationPath(): string {
return join(
app.getPath('userData'),
@@ -104,6 +115,34 @@ ipcMain.handle('DOWNLOADS_CANCEL', async (_event, downloadId: number) => {
}
});
ipcMain.handle('DOWNLOADS_PAUSE', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Pause download:', downloadId);
return (await pauseDownload(downloadId))
? { success: true }
: { error: 'Download not found in queue', success: false };
} catch (error) {
console.error('[Downloads] Error pausing download:', error);
throw error;
}
});
ipcMain.handle(
'DOWNLOADS_RESUME',
async (_event, downloadId: number, downloadFolder: string) => {
try {
return await resumeDownloadRequest(
downloadId,
downloadFolder,
downloadDirectoryAuthorizer
);
} catch (error) {
console.error('[Downloads] Error resuming download:', error);
throw error;
}
}
);
ipcMain.handle(
'DOWNLOADS_RETRY',
async (_event, downloadId: number, downloadFolder: string) => {
@@ -123,8 +162,36 @@ ipcMain.handle(
ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => {
try {
console.log('[Downloads] Remove download:', downloadId);
removeDownloadFromRuntime(downloadId);
const db = await getDatabase();
const rows = await db
.select({
filePath: schema.downloads.filePath,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(eq(schema.downloads.id, downloadId))
.limit(1);
const row = rows[0];
if (row?.filePath && removablePartialStatuses.has(row.status)) {
try {
removePartialDownloadFile(row.filePath);
} catch (cleanupError) {
// Keep the row (and its runtime entry) so the .part is never
// orphaned, but answer with a structured failure the UI can
// surface instead of an opaque IPC rejection. Retrying the
// remove re-attempts the deletion.
console.error(
'[Downloads] Failed to delete partial file on remove:',
row.filePath,
cleanupError
);
return {
error: 'Could not delete the partial file',
success: false,
};
}
}
removeDownloadFromRuntime(downloadId);
await db
.delete(schema.downloads)
.where(eq(schema.downloads.id, downloadId));
@@ -210,17 +277,43 @@ ipcMain.handle(
'failed',
'canceled',
]);
await db
.delete(schema.downloads)
.where(
playlistId
? and(
eq(schema.downloads.playlistId, playlistId),
terminalStatus
)
: terminalStatus
);
broadcastDownloadUpdate();
const terminalFilter = playlistId
? and(eq(schema.downloads.playlistId, playlistId), terminalStatus)
: terminalStatus;
const rows = await db
.select({
id: schema.downloads.id,
filePath: schema.downloads.filePath,
status: schema.downloads.status,
})
.from(schema.downloads)
.where(terminalFilter);
const downloadIdsToDelete: number[] = [];
for (const row of rows) {
if (row.filePath && removablePartialStatuses.has(row.status)) {
try {
removePartialDownloadFile(row.filePath);
} catch (error) {
console.error(
'[Downloads] Retaining download after partial cleanup failed:',
error
);
continue;
}
}
downloadIdsToDelete.push(row.id);
}
if (downloadIdsToDelete.length > 0) {
await db
.delete(schema.downloads)
.where(
and(
terminalFilter,
inArray(schema.downloads.id, downloadIdsToDelete)
)
);
broadcastDownloadUpdate();
}
return { success: true };
} catch (error) {
console.error('[Downloads] Error clearing completed:', error);
@@ -1,29 +0,0 @@
import { removePartialDownload } from './download-file-path';
interface StaleDownloadFile {
filePath: string | null;
}
function removePersistedPartial(filePath: string): void {
removePartialDownload({ getSavePath: () => filePath });
}
export function cleanupStaleDownloadFiles(
downloads: readonly StaleDownloadFile[],
removeFile: (filePath: string) => void = removePersistedPartial
): void {
for (const download of downloads) {
if (!download.filePath) {
continue;
}
try {
removeFile(download.filePath);
} catch (error) {
console.error(
'[Downloads] Failed to delete stale partial file:',
download.filePath,
error
);
}
}
}
@@ -66,16 +66,35 @@ function createLimitedSelectChain(
limitResult: unknown,
whereCalls: unknown[]
): { from: jest.Mock } {
const limit = jest.fn().mockResolvedValue(limitResult);
const chain: Record<string, jest.Mock> = {
limit: jest.fn().mockResolvedValue(limitResult),
};
// groupBy/orderBy are optional links in the various query shapes; each
// returns the chain so where().groupBy().orderBy().limit() (and any
// subset) resolves to the same data.
chain.groupBy = jest.fn(() => chain);
chain.orderBy = jest.fn(() => chain);
const where = jest.fn((condition: unknown) => {
whereCalls.push(condition);
return { limit };
return chain;
});
return {
from: jest.fn(() => ({ where })),
};
}
/** Program-query chain: from().where().groupBy().orderBy().limit(). */
function createProgramChain(rows: unknown): { from: jest.Mock } {
const chain: Record<string, jest.Mock> = {
limit: jest.fn().mockResolvedValue(rows),
};
chain.groupBy = jest.fn(() => chain);
chain.orderBy = jest.fn(() => chain);
return {
from: jest.fn(() => ({ where: jest.fn(() => chain) })),
};
}
describe('EpgQueryService', () => {
let service: EpgQueryService;
@@ -403,4 +422,103 @@ describe('EpgQueryService', () => {
)
).toBe(true);
});
it('resolves a manual mapping saved under any stream sharing the epg_channel_id via a single join', async () => {
const whereCalls: unknown[] = [];
const joinLimit = jest
.fn()
.mockResolvedValue([{ epgChannelId: 'BBC.MAPPED' }]);
let innerJoinCount = 0;
const joinChain: Record<string, jest.Mock> = {};
joinChain.innerJoin = jest.fn(() => {
innerJoinCount += 1;
return joinChain;
});
joinChain.where = jest.fn((condition: unknown) => {
whereCalls.push(condition);
return { orderBy: jest.fn(() => ({ limit: joinLimit })) };
});
// Program lookup for the resolved (mapped) channel id.
const programRow = {
id: 1,
channelId: 'BBC.MAPPED',
start: '2026-06-21T17:00:00.000Z',
stop: '2026-06-21T18:00:00.000Z',
title: 'Mapped Programme',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: null,
};
const select = jest
.fn()
// getMapping direct lookup — miss.
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
// getMapping Xtream fallback — single join across content /
// categories / mappings, no arbitrary candidate cap.
.mockReturnValueOnce({ from: jest.fn(() => joinChain) })
// selectChannelPrograms for the mapped id.
.mockReturnValueOnce(createProgramChain([programRow]));
getDatabase.mockResolvedValue({ select });
const result = await service.getChannelPrograms('provider.epg.id');
expect(innerJoinCount).toBe(2);
expect(joinLimit).toHaveBeenCalledWith(1);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Mapped Programme');
});
it('collapses duplicate programme slots from multiple sources in an unscoped lookup', async () => {
const whereCalls: unknown[] = [];
const dupRow = {
id: 1,
channelId: 'bbc.one.uk',
start: '2026-06-21T20:00:00.000Z',
stop: '2026-06-21T21:00:00.000Z',
title: 'News',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
};
const select = jest
.fn()
// getMapping direct + Xtream fallback — both miss.
.mockReturnValueOnce(createLimitedSelectChain([], whereCalls))
.mockReturnValueOnce({
from: jest.fn(() => ({
innerJoin: jest.fn(function inner() {
return this;
}),
where: jest.fn(() => ({
orderBy: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([]),
})),
})),
})),
})
// selectChannelPrograms — two sources, same channel/start/title.
// The SQL GROUP BY would collapse these; the mock returns both to
// prove the JS safety-net dedup also holds.
.mockReturnValueOnce(
createProgramChain([
{ ...dupRow, sourceUrl: 'https://a.example/g.xml' },
{ ...dupRow, id: 2, sourceUrl: 'https://b.example/g.xml' },
])
);
getDatabase.mockResolvedValue({ select });
const result = await service.getChannelPrograms('bbc.one.uk');
expect(result).toHaveLength(1);
expect(result[0].title).toBe('News');
});
});
@@ -1,5 +1,8 @@
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
@@ -36,13 +39,21 @@ export class EpgQueryService {
): Promise<EpgProgram[]> {
try {
const db = await getDatabase();
const trimmedChannelId = channelId.trim();
let trimmedChannelId = channelId.trim();
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
if (!trimmedChannelId) {
return [];
}
// Check for manual EPG mapping — if a user has mapped this
// channel key to a different EPG channel ID, use the mapped
// ID for all subsequent lookups.
const mapped = await this.getMapping(db, trimmedChannelId);
if (mapped) {
trimmedChannelId = mapped;
}
let results = await this.selectChannelPrograms(
db,
trimmedChannelId,
@@ -57,9 +68,7 @@ export class EpgQueryService {
}
if (results.length > 0) {
return results
.map(this.transformDbRowToEpgProgram)
.filter(this.isValidEpgProgram);
return this.toEpgPrograms(results);
}
let channel = await this.selectChannelById(
@@ -90,9 +99,7 @@ export class EpgQueryService {
}
if (results.length > 0) {
return results
.map(this.transformDbRowToEpgProgram)
.filter(this.isValidEpgProgram);
return this.toEpgPrograms(results);
}
}
@@ -142,9 +149,7 @@ export class EpgQueryService {
);
}
return results
.map(this.transformDbRowToEpgProgram)
.filter(this.isValidEpgProgram);
return this.toEpgPrograms(results);
}
return [];
@@ -541,6 +546,13 @@ export class EpgQueryService {
sourceUrls
)
)
// Collapse identical slots from multiple sources in SQL so the
// 500-row cap counts distinct programmes, not duplicate rows.
.groupBy(
schema.epgPrograms.channelId,
schema.epgPrograms.start,
schema.epgPrograms.title
)
.orderBy(schema.epgPrograms.start)
.limit(500);
}
@@ -564,6 +576,11 @@ export class EpgQueryService {
{ legacyOnly: true }
)
)
.groupBy(
schema.epgPrograms.channelId,
schema.epgPrograms.start,
schema.epgPrograms.title
)
.orderBy(schema.epgPrograms.start)
.limit(500);
}
@@ -595,6 +612,9 @@ export class EpgQueryService {
options
)
)
// One current row per channel so duplicate cross-source slots
// don't consume the per-channel cap and starve other channels.
.groupBy(schema.epgPrograms.channelId)
.limit(channelIds.length);
}
@@ -883,6 +903,31 @@ export class EpgQueryService {
);
}
/**
* Map program rows to EpgProgram and drop invalid ones, then collapse
* duplicate programme slots. The dedup index is source-aware, so an
* unscoped lookup (e.g. the M3U timeline, which queries without
* `sourceUrls`) can return the same channel/start/title from two EPG
* sources; keep the first so a programme is never shown twice.
*/
private toEpgPrograms(rows: EpgProgramRow[]): EpgProgram[] {
const seen = new Set<string>();
const programs: EpgProgram[] = [];
for (const row of rows) {
const program = this.transformDbRowToEpgProgram(row);
if (!this.isValidEpgProgram(program)) {
continue;
}
const key = `${program.channel}|${program.start}|${program.title}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
programs.push(program);
}
return programs;
}
private transformDbRowToEpgProgram(row: EpgProgramRow): EpgProgram {
return {
start: row.start,
@@ -905,6 +950,75 @@ export class EpgQueryService {
!Number.isNaN(new Date(program.stop).getTime())
);
}
private async getMapping(
db: EpgDatabase,
channelKey: string
): Promise<string | null> {
try {
// Direct lookup by channel key.
const rows = await db
.select({
epgChannelId: schema.epgChannelMappings.epgChannelId,
})
.from(schema.epgChannelMappings)
.where(
eq(schema.epgChannelMappings.channelKey, channelKey)
)
.limit(1);
if (rows.length > 0) {
return rows[0].epgChannelId;
}
// Fallback: the key may be an Xtream provider epg_channel_id
// while the mapping was saved under the playlist-scoped Xtream
// key. Resolve the owning streams (joined through categories for
// the playlist ID) and look up their mapping keys in a single
// join, so a mapping saved under any matching stream is found —
// an earlier "check the first few" cap could silently miss a
// mapping saved under a later stream sharing this epg_channel_id.
//
// The join key is built in SQL to mirror
// buildXtreamEpgMappingKey(playlistId, xtreamId) →
// `xtream:{playlistId}:{xtreamId}`; keep the two in sync.
//
// This layer only receives the provider epg_channel_id, not the
// caller's playlist, so when the same id exists in several
// playlists it cannot scope to the caller's own mapping — the
// renderer resolves the playlist-scoped key directly and this is
// a best-effort fallback. Order deterministically so the chosen
// mapping is at least stable rather than storage-order dependent.
const mapped = await db
.select({
epgChannelId: schema.epgChannelMappings.epgChannelId,
})
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.innerJoin(
schema.epgChannelMappings,
eq(
schema.epgChannelMappings.channelKey,
sql`'xtream:' || ${schema.categories.playlistId} || ':' || ${schema.content.xtreamId}`
)
)
.where(eq(schema.content.epgChannelId, channelKey))
.orderBy(
schema.categories.playlistId,
schema.content.xtreamId
)
.limit(1);
if (mapped.length > 0) {
return mapped[0].epgChannelId;
}
return null;
} catch {
return null;
}
}
}
export const epgQueryService = new EpgQueryService();
@@ -371,46 +371,47 @@ describe('EpgEvents', () => {
it('falls back to case-insensitive channel id lookup for EPG programs', async () => {
const select = jest.fn();
const programLimitExact = jest.fn().mockResolvedValue([]);
const channelLimit = jest
.fn()
.mockResolvedValue([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }]);
const programLimitResolved = jest.fn().mockResolvedValue([
{
id: 1,
channelId: 'BBC.ONE.UK',
start: '2026-04-14T10:00:00Z',
stop: '2026-04-14T11:00:00Z',
title: 'News',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
},
]);
/**
* Build a flexible query-chain mock that every db.select().from().<method>() call
* resolves to the same chain object. Each method (where, orderBy, limit) returns
* the chain itself, and limit() additionally resolves to the given data. This
* handles any query shape our service uses (with or without orderBy) without
* requiring exact call-count ordering — important because our mapping feature
* inserts extra queries that the original test didn't anticipate.
*/
function queryChain<T>(data: T) {
const chain: Record<string, jest.Mock> = {} as Record<string, jest.Mock>;
chain.where = jest.fn().mockReturnValue(chain);
chain.innerJoin = jest.fn().mockReturnValue(chain);
chain.groupBy = jest.fn().mockReturnValue(chain);
chain.orderBy = jest.fn().mockReturnValue(chain);
chain.limit = jest.fn().mockResolvedValue(data);
return chain;
}
// getMapping queries (must come first — they return empty)
const from = jest
.fn()
.mockReturnValueOnce({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockReturnValue({
limit: programLimitExact,
}),
}),
})
.mockReturnValueOnce({
where: jest.fn().mockReturnValue({
limit: channelLimit,
}),
})
.mockReturnValueOnce({
where: jest.fn().mockReturnValue({
orderBy: jest.fn().mockReturnValue({
limit: programLimitResolved,
}),
}),
});
.mockReturnValueOnce(queryChain([])) // 1. getMapping: epgChannelMappings
.mockReturnValueOnce(queryChain([])) // 2. getMapping: content table
// Test expectations below
.mockReturnValueOnce(queryChain([])) // 3. selectChannelPrograms (bbc.one.uk → empty)
.mockReturnValueOnce(queryChain([{ id: 'BBC.ONE.UK', displayName: 'BBC One' }])) // 4. selectChannelById → channel found
.mockReturnValueOnce(queryChain([ // 5. selectChannelPrograms (BBC.ONE.UK → programs)
{
id: 1,
channelId: 'BBC.ONE.UK',
start: '2026-04-14T10:00:00Z',
stop: '2026-04-14T11:00:00Z',
title: 'News',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
},
]));
select.mockImplementation(() => ({ from }));
@@ -470,12 +471,14 @@ describe('EpgEvents', () => {
const select = jest.fn();
const from = jest.fn();
const where = jest.fn();
const groupBy = jest.fn();
const orderBy = jest.fn();
const limit = jest.fn();
select.mockImplementation(() => ({ from }));
from.mockReturnValue({ where });
where.mockReturnValue({ orderBy });
where.mockReturnValue({ groupBy });
groupBy.mockReturnValue({ orderBy });
orderBy.mockReturnValue({ limit });
limit.mockResolvedValue([
{
@@ -9,6 +9,13 @@ import { getDatabase } from '../database/connection';
import * as schema from '../database/schema';
import { epgQueryService } from './epg-query.service';
import { epgWorkerService } from './epg-worker.service';
import {
getEpgMapping,
getEpgMappingsBatch,
setEpgMapping,
deleteEpgMapping,
searchEpgChannels,
} from '../database/operations/epg-mapping.operations';
/**
* EPG Events Handler
@@ -129,6 +136,59 @@ export default class EpgEvents {
}
);
// EPG channel mapping CRUD
ipcMain.handle(
'EPG_MAPPING_GET',
async (_event, args: { channelKey: string }) => {
return this.handleGetEpgMapping(args.channelKey);
}
);
ipcMain.handle(
'EPG_MAPPING_SET',
async (
_event,
args: {
channelKey: string;
epgChannelId: string;
playlistId?: string;
}
) => {
return this.handleSetEpgMapping(
args.channelKey,
args.epgChannelId,
args.playlistId
);
}
);
ipcMain.handle(
'EPG_MAPPING_GET_BATCH',
async (_event, args: { channelKeys: string[] }) => {
return this.handleGetEpgMappingsBatch(args.channelKeys);
}
);
ipcMain.handle(
'EPG_MAPPING_DELETE',
async (_event, args: { channelKey: string }) => {
return this.handleDeleteEpgMapping(args.channelKey);
}
);
ipcMain.handle(
'EPG_CHANNEL_SEARCH',
async (
_event,
args: { searchTerm: string; limit?: number }
) => {
return this.handleSearchEpgChannels(
args.searchTerm,
args.limit
);
}
);
return ipcMain;
}
@@ -294,7 +354,21 @@ export default class EpgEvents {
channelIds: string[],
options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgProgram | null>> {
return epgQueryService.getCurrentProgramsBatch(channelIds, options);
const resolvedMap = await this.resolveChannelIds(channelIds);
const resolvedIds = channelIds.map(
(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<{
@@ -308,7 +382,21 @@ export default class EpgEvents {
channelIds: string[],
options?: { sourceUrls?: string[] }
): Promise<Record<string, EpgChannelMetadata | null>> {
return epgQueryService.getChannelMetadata(channelIds, options);
const resolvedMap = await this.resolveChannelIds(channelIds);
const resolvedIds = channelIds.map(
(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(
@@ -325,6 +413,97 @@ export default class EpgEvents {
return epgQueryService.getChannelsByRange(skip, limit);
}
// ---------------------------------------------------------------------------
// EPG mapping resolution — called at the IPC boundary before queries.
// ---------------------------------------------------------------------------
/**
* Batch-resolve multiple channel IDs through manual mappings.
* Returns a Map of original ID → mapped ID (identity when no mapping).
*/
private static async resolveChannelIds(
channelIds: string[]
): Promise<Map<string, string>> {
try {
const db = await getDatabase();
const mappings = await getEpgMappingsBatch(db, channelIds);
return mappings;
} catch {
return new Map();
}
}
// ---------------------------------------------------------------------------
// EPG mapping CRUD handlers
// ---------------------------------------------------------------------------
private static async handleGetEpgMapping(
channelKey: string
): Promise<{ id: number; channelKey: string; epgChannelId: string; playlistId: string | null } | null> {
try {
const db = await getDatabase();
return getEpgMapping(db, channelKey);
} catch {
return null;
}
}
private static async handleGetEpgMappingsBatch(
channelKeys: string[]
): Promise<Record<string, string>> {
if (!Array.isArray(channelKeys) || channelKeys.length === 0) {
return {};
}
try {
const db = await getDatabase();
const mappings = await getEpgMappingsBatch(db, channelKeys);
return Object.fromEntries(mappings);
} catch {
return {};
}
}
private static async handleSetEpgMapping(
channelKey: string,
epgChannelId: string,
playlistId?: string
): Promise<{ success: boolean }> {
try {
const db = await getDatabase();
return setEpgMapping(db, channelKey, epgChannelId, playlistId);
} catch {
return { success: false };
}
}
private static async handleDeleteEpgMapping(
channelKey: string
): Promise<{ success: boolean }> {
try {
const db = await getDatabase();
return deleteEpgMapping(db, channelKey);
} catch {
return { success: false };
}
}
private static async handleSearchEpgChannels(
searchTerm: string,
limit?: number
): Promise<Array<{ id: string; displayName: string; iconUrl: string | null }>> {
if (!searchTerm?.trim()) {
return [];
}
try {
const db = await getDatabase();
return searchEpgChannels(db, searchTerm, limit);
} catch {
return [];
}
}
static async clearEpgData(): Promise<void> {
return epgWorkerService.clearEpgData();
}
@@ -87,4 +87,37 @@ describe('sanitizePortalDebugEvent', () => {
},
});
});
it('redacts credentials in request, response, errors, and URL query params', () => {
const secrets = {
username: 'main-user-secret',
password: 'main-password-secret',
token: 'main-token-secret',
authorization: 'main-authorization-secret',
mac: 'main-mac-secret',
};
const event = sanitizePortalDebugEvent({
requestId: 'req-redaction',
provider: 'stalker',
operation: 'get_profile',
transport: 'electron-main',
startedAt: new Date().toISOString(),
durationMs: 5,
status: 'error',
request: {
params: secrets,
url: `https://example.com/portal?token=${secrets.token}&action=get_profile`,
},
error: new Error(
`Request failed: https://example.com/portal?authorization=${secrets.authorization}&action=get_profile`
),
});
const output = JSON.stringify(event);
for (const secret of Object.values(secrets)) {
expect(output).not.toContain(secret);
}
expect(output).toContain('get_profile');
expect(output).toContain('req-redaction');
});
});
@@ -1,117 +1,19 @@
import { BrowserWindow } from 'electron';
import { PORTAL_DEBUG_EVENT, PortalDebugEvent } from '@iptvnator/shared/interfaces';
import {
PORTAL_DEBUG_EVENT,
PortalDebugEvent,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { environment } from '../../environments/environment';
const MAX_DEBUG_DEPTH = 6;
function sanitizePortalDebugValue(
value: unknown,
seen = new WeakSet<object>(),
depth = 0
): unknown {
if (
value == null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (depth >= MAX_DEBUG_DEPTH) {
return '[MaxDepth]';
}
if (value instanceof Error) {
const baseError = {
name: value.name,
message: value.message,
stack: value.stack,
} as Record<string, unknown>;
for (const [key, entry] of Object.entries(
value as unknown as Record<string, unknown>
)) {
baseError[key] = sanitizePortalDebugValue(
entry,
seen,
depth + 1
);
}
return baseError;
}
if (value instanceof Date) {
return value.toISOString();
}
if (value instanceof URL) {
return value.toString();
}
if (Array.isArray(value)) {
return value.map((entry) =>
sanitizePortalDebugValue(entry, seen, depth + 1)
);
}
if (value instanceof Map) {
return Object.fromEntries(
[...value.entries()].map(([key, entry]) => [
String(key),
sanitizePortalDebugValue(entry, seen, depth + 1),
])
);
}
if (value instanceof Set) {
return [...value].map((entry) =>
sanitizePortalDebugValue(entry, seen, depth + 1)
);
}
if (typeof value === 'object') {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
const entries = Object.entries(value as Record<string, unknown>).map(
([key, entry]) => [
key,
sanitizePortalDebugValue(entry, seen, depth + 1),
]
);
seen.delete(value);
return Object.fromEntries(entries);
}
return String(value);
}
export function sanitizePortalDebugEvent(
event: PortalDebugEvent
): PortalDebugEvent {
return {
...event,
request: sanitizePortalDebugValue(event.request),
response: sanitizePortalDebugValue(event.response),
error: sanitizePortalDebugValue(event.error),
request: redactSensitiveData(event.request),
response: redactSensitiveData(event.response),
error: redactSensitiveData(event.error),
};
}
@@ -0,0 +1,70 @@
const handlers = new Map<string, (...args: unknown[]) => unknown>();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler);
}
),
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments',
MPV_REUSE_INSTANCE: 'mpvReuseInstance',
VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments',
VLC_REUSE_INSTANCE: 'vlcReuseInstance',
store: { get: jest.fn(), set: jest.fn() },
}));
jest.mock('../server/http-server', () => ({
httpServer: { updateSettings: jest.fn() },
}));
describe('SETTINGS_UPDATE logging', () => {
beforeEach(async () => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
await import('./settings.events');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('does not print a TMDB apiKey while retaining useful fields', () => {
const apiKey = 'tmdb-settings-api-key-secret';
const handler = handlers.get('SETTINGS_UPDATE');
expect(handler).toBeDefined();
handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } });
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
expect(output).not.toContain(apiKey);
expect(output).toContain('language');
expect(output).toContain('de');
expect(output).toContain('enabled');
});
it('does not print credentials embedded in external player arguments', () => {
const authorizationSecret = 'player-authorization-secret';
const cookieSecret = 'player-cookie-secret';
const handler = handlers.get('SETTINGS_UPDATE');
expect(handler).toBeDefined();
handler?.(
{},
{
language: 'de',
mpvPlayerArguments: `--http-header-fields=Authorization: Bearer ${authorizationSecret}`,
vlcPlayerArguments: `--http-referrer=https://example.com --http-cookie=Cookie: ${cookieSecret}`,
}
);
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
expect(output).not.toContain(authorizationSecret);
expect(output).not.toContain(cookieSecret);
expect(output).toContain('language');
expect(output).toContain('de');
});
});
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron';
import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import {
EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS,
@@ -17,7 +18,10 @@ export default class SettingsEvents {
}
ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
console.log('Received SETTINGS_UPDATE with data:', arg);
console.log(
'Received SETTINGS_UPDATE with data:',
redactSensitiveData(arg)
);
if (arg.mpvPlayerArguments !== undefined) {
store.set(
@@ -9,6 +9,7 @@ import {
PortalDebugEvent,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
import { emitPortalDebugEvent } from './portal-debug.events';
import { buildStalkerIdentityRequestContext } from './stalker-identity';
@@ -186,7 +187,10 @@ ipcMain.handle(
emitPortalDebugEvent(debugEvent);
}
console.error('[StalkerEvents] Request error:', error);
console.error(
'[StalkerEvents] Request error:',
redactSensitiveData(error)
);
// Format error response
if (axios.isAxiosError(error)) {
@@ -10,6 +10,7 @@ import {
XTREAM_CANCEL_SESSION,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { emitPortalDebugEvent } from './portal-debug.events';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
@@ -212,10 +213,12 @@ ipcMain.handle(
if (!payload.suppressErrorLog) {
console.error(
'[XTREAM_REQUEST] Failed',
formatXtreamError(
error,
requestUrlForLog,
payload.params?.action
redactSensitiveData(
formatXtreamError(
error,
requestUrlForLog,
payload.params?.action
)
)
);
}
@@ -0,0 +1,47 @@
import { trace } from './debug-trace';
describe('debug trace redaction', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('does not serialize credentials from nested payloads or URLs', () => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const secrets = {
password: 'trace-password-secret',
token: 'trace-token-secret',
authorization: 'trace-authorization-secret',
mac: 'trace-mac-secret',
};
trace('portal', 'request', {
params: secrets,
url: `https://example.com/portal?token=${secrets.token}&action=get_profile`,
requestId: 'diagnostic-request-id',
});
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
for (const secret of Object.values(secrets)) {
expect(output).not.toContain(secret);
}
expect(output).toContain('diagnostic-request-id');
expect(output).toContain('get_profile');
});
it('redacts serialized credentials before truncating trace strings', () => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
const secret = 'long-trace-json-password-secret';
const diagnostic = JSON.stringify({
password: secret,
operation: 'get_profile',
padding: 'x'.repeat(300),
});
trace('portal', 'request', { diagnostic });
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
expect(output).not.toContain(secret);
expect(output).toContain('[Redacted]');
expect(output).toContain('get_profile');
});
});
@@ -1,3 +1,5 @@
import { redactSensitiveData } from '@iptvnator/shared/logging';
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
const TRACE_PREFIX = '[IPTVnator Trace]';
const MAX_TRACE_ARRAY_ITEMS = 5;
@@ -146,10 +148,10 @@ export function summarizeForTrace(value: unknown, depth = 0): unknown {
export function safeStringifyForTrace(payload: unknown): string {
try {
return JSON.stringify(payload);
return JSON.stringify(redactSensitiveData(payload));
} catch (error) {
return JSON.stringify({
fallback: summarizeForTrace(payload),
fallback: summarizeForTrace(redactSensitiveData(payload)),
stringifyError:
error instanceof Error
? truncateString(error.message)
@@ -166,7 +168,7 @@ export function trace(scope: string, message: string, payload?: unknown): void {
console.log(
`${TRACE_PREFIX}[${scope}] ${message} ${safeStringifyForTrace(
summarizeForTrace(payload)
summarizeForTrace(redactSensitiveData(payload))
)}`
);
}
@@ -0,0 +1,133 @@
import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
import {
NativeViewBoundsContext,
toNativeViewBounds,
} from './embedded-mpv-bounds.util';
const CSS_BOUNDS: EmbeddedMpvBounds = { x: 372, y: 60, width: 578, height: 330 };
function context(
overrides: Partial<NativeViewBoundsContext> = {}
): NativeViewBoundsContext {
return {
platform: 'linux',
zoomFactor: 1,
displayScaleFactor: 1,
...overrides,
};
}
describe('toNativeViewBounds', () => {
it('returns the input untouched at 100% zoom and 100% display scale', () => {
const result = toNativeViewBounds(CSS_BOUNDS, context());
expect(result).toBe(CSS_BOUNDS);
});
// Regression for #1145: CSS bounds were handed to XMoveResizeWindow as-is,
// so on a 140%-scaled Linux Mint desktop the mpv window landed at ~71% of
// the expected position and size, toward the window's top-left corner.
it('scales linux bounds by the display scale factor', () => {
const result = toNativeViewBounds(
CSS_BOUNDS,
context({ platform: 'linux', displayScaleFactor: 1.4 })
);
expect(result).toEqual({ x: 521, y: 84, width: 809, height: 462 });
});
it('scales win32 bounds by the display scale factor', () => {
const result = toNativeViewBounds(
{ x: 100, y: 50, width: 640, height: 360 },
context({ platform: 'win32', displayScaleFactor: 1.25 })
);
expect(result).toEqual({ x: 125, y: 63, width: 800, height: 450 });
});
it('combines page zoom with the display scale factor', () => {
const result = toNativeViewBounds(
{ x: 100, y: 50, width: 640, height: 360 },
context({
platform: 'win32',
zoomFactor: 1.2,
displayScaleFactor: 1.5,
})
);
expect(result).toEqual({ x: 180, y: 90, width: 1152, height: 648 });
});
it('ignores the display scale on macOS (NSView frames are in points)', () => {
const result = toNativeViewBounds(
CSS_BOUNDS,
context({ platform: 'darwin', displayScaleFactor: 2 })
);
expect(result).toBe(CSS_BOUNDS);
});
it('applies page zoom on macOS', () => {
const result = toNativeViewBounds(
{ x: 100, y: 50, width: 640, height: 360 },
context({
platform: 'darwin',
zoomFactor: 1.5,
displayScaleFactor: 2,
})
);
expect(result).toEqual({ x: 150, y: 75, width: 960, height: 540 });
});
it('rounds fractional CSS edges only after scaling', () => {
// A 10.49px CSS edge at 200% renders at 21 physical pixels; edges
// rounded before scaling would send 20 and shift the video by 1px.
const result = toNativeViewBounds(
{ x: 10.49, y: 0.5, width: 100.02, height: 50 },
context({ platform: 'win32', displayScaleFactor: 2 })
);
expect(result).toEqual({ x: 21, y: 1, width: 200, height: 100 });
});
it('keeps vertically adjacent rects seamless under fractional scales', () => {
// 42 × 1.25 and 153 × 1.25 both land on .5/.25 fractions: rounding
// x/y/width/height independently would misplace the shared edge by
// 1px, while edge-based rounding keeps the rects flush.
const scale = context({ displayScaleFactor: 1.25 });
const upper = toNativeViewBounds(
{ x: 0, y: 42, width: 500, height: 111 },
scale
);
const lower = toNativeViewBounds(
{ x: 0, y: 153, width: 500, height: 90 },
scale
);
expect(upper.y + upper.height).toBe(lower.y);
});
it('keeps hidden bounds offscreen and at least 1x1', () => {
const result = toNativeViewBounds(
{ x: -100000, y: -100000, width: 1, height: 1 },
context({ displayScaleFactor: 1.5 })
);
expect(result.x).toBeLessThanOrEqual(-100000);
expect(result.y).toBeLessThanOrEqual(-100000);
expect(result.width).toBeGreaterThanOrEqual(1);
expect(result.height).toBeGreaterThanOrEqual(1);
});
it('treats non-finite or non-positive factors as 100%', () => {
for (const zoomFactor of [Number.NaN, 0, -1, Number.POSITIVE_INFINITY]) {
expect(
toNativeViewBounds(
CSS_BOUNDS,
context({ zoomFactor, displayScaleFactor: zoomFactor })
)
).toBe(CSS_BOUNDS);
}
});
});
@@ -0,0 +1,60 @@
import { EmbeddedMpvBounds } from '@iptvnator/shared/interfaces';
export interface NativeViewBoundsContext {
platform: NodeJS.Platform;
/** Page zoom factor of the main window's webContents (1 = 100%). */
zoomFactor: number;
/** Scale factor of the display hosting the main window (1 = 96 dpi). */
displayScaleFactor: number;
}
/**
* Converts renderer-measured bounds (CSS pixels from
* getBoundingClientRect()) into the coordinate space the native-view
* engines position their OS windows in: physical pixels for the win32
* child HWND (SetWindowPos) and the linux child X11 window
* (XMoveResizeWindow), points — device-independent pixels — for the macOS
* NSView (setFrame). CSS pixels match points only at 100% page zoom and
* match physical pixels only at 100% page zoom AND 100% display scale, so
* every platform scales by the zoom factor and win32/linux additionally by
* the display scale factor (#1145).
*
* Bounds arrive with unrounded CSS edges and are rounded exactly once here,
* after scaling: edges first, then width/height derived from them. Rounding
* any earlier (or per-field) lets fractional CSS layouts drift by a pixel
* per scale factor and open 1px seams between the native video window and
* the surrounding DOM UI.
*/
export function toNativeViewBounds(
bounds: EmbeddedMpvBounds,
context: NativeViewBoundsContext
): EmbeddedMpvBounds {
const scale = resolveNativeViewScale(context);
if (scale === 1) {
return bounds;
}
const left = Math.round(bounds.x * scale);
const top = Math.round(bounds.y * scale);
const right = Math.round((bounds.x + bounds.width) * scale);
const bottom = Math.round((bounds.y + bounds.height) * scale);
return {
x: left,
y: top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
};
}
function resolveNativeViewScale(context: NativeViewBoundsContext): number {
const zoomFactor = sanitizeFactor(context.zoomFactor);
if (context.platform === 'darwin') {
return zoomFactor;
}
return zoomFactor * sanitizeFactor(context.displayScaleFactor);
}
function sanitizeFactor(value: number): number {
return Number.isFinite(value) && value > 0 ? value : 1;
}
@@ -10,10 +10,17 @@ const mockElectronApp = {
jest.mock('electron', () => ({ app: mockElectronApp }));
import {
getEmbeddedMpvAddonCandidatePaths,
getFrameCopyRuntimeAvailability,
isFrameCopyPlatformSupported,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
shouldPromotePersistedFrameCopyOptIn,
} from './embedded-mpv-frame-copy-platform.util';
import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeResult,
} from './embedded-mpv-frame-copy-runtime';
describe('embedded-mpv-frame-copy-platform.util', () => {
describe('isFrameCopyPlatformSupported', () => {
@@ -31,7 +38,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['linux', 'arm64', false],
['linux', 'arm', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])('%s/%s -> %s', (platform, arch, expected) => {
@@ -61,8 +69,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
process.platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper';
const helperPath = () =>
path.join(releaseDir(), helperFileName());
const helperPath = () => path.join(releaseDir(), helperFileName());
const readerPath = () =>
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
@@ -152,24 +159,184 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
expect(resolveFrameCopyHelperPath()).toBe(packagedHelper);
});
it('limits packaged native-view addon discovery to package-owned paths', () => {
mockElectronApp.isPackaged = true;
const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources');
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: resourcesPath,
});
mockElectronApp.getAppPath.mockReturnValue(
path.join(resourcesPath, 'app.asar')
);
expect(getEmbeddedMpvAddonCandidatePaths()).toEqual([
path.join(
resourcesPath,
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
),
]);
});
});
it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => {
const shouldPromote = (
frameCopyPlatform as typeof frameCopyPlatform & {
shouldPromotePersistedFrameCopyOptIn?: (
storedEnabled: boolean,
explicitEnv: string | undefined,
runtimeUsable: boolean
) => boolean;
}
).shouldPromotePersistedFrameCopyOptIn;
describe('isFrameCopyRuntimeUsable', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
expect(shouldPromote).toBeDefined();
expect(shouldPromote?.(true, undefined, true)).toBe(true);
expect(shouldPromote?.(true, undefined, false)).toBe(false);
expect(shouldPromote?.(true, '0', true)).toBe(false);
expect(shouldPromote?.(true, '1', true)).toBe(false);
expect(shouldPromote?.(false, undefined, true)).toBe(false);
beforeEach(() => {
mockElectronApp.isPackaged = false;
});
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', { value: originalArch });
mockElectronApp.isPackaged = false;
});
it('requires a successful Linux x64 runtime probe', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn<
EmbeddedMpvFrameCopyRuntimeResult,
[string, EmbeddedMpvFrameCopyManifestContract]
>(() => ({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
}));
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
true
);
expect(probeRuntime).toHaveBeenCalledWith(
'/native/iptvnator_mpv_helper',
'development'
);
probeRuntime.mockReturnValueOnce({
usable: false,
reason: 'helper-probe-failed',
});
expect(
getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime)
).toEqual({
usable: false,
reason: 'helper-probe-failed',
});
});
it('selects the packaged manifest contract only from app.isPackaged', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
mockElectronApp.isPackaged = true;
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn<
EmbeddedMpvFrameCopyRuntimeResult,
[string, EmbeddedMpvFrameCopyManifestContract]
>(() => ({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
}));
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
true
);
expect(probeRuntime).toHaveBeenCalledWith(
'/native/iptvnator_mpv_helper',
'packaged'
);
});
it.each<[NodeJS.Platform, string]>([
['darwin', 'arm64'],
['win32', 'x64'],
])(
'keeps the existing helper-presence gate on %s',
(platform, arch) => {
Object.defineProperty(process, 'platform', {
value: platform,
});
Object.defineProperty(process, 'arch', { value: arch });
const resolveHelper = jest.fn(
() => '/native/iptvnator_mpv_helper'
);
const probeRuntime = jest.fn();
expect(
isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)
).toBe(true);
expect(probeRuntime).not.toHaveBeenCalled();
}
);
it('rejects Linux ARM before helper discovery or probing', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'arm64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn();
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
false
);
expect(resolveHelper).not.toHaveBeenCalled();
expect(probeRuntime).not.toHaveBeenCalled();
});
it('reports unsupported architecture for Intel macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn();
expect(
getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime)
).toEqual({
usable: false,
reason: 'unsupported-architecture',
});
expect(resolveHelper).not.toHaveBeenCalled();
expect(probeRuntime).not.toHaveBeenCalled();
});
});
it('lazily probes only a stored opt-in without an explicit env override', () => {
const runtimeUsable = jest.fn(() => true);
expect(
shouldPromotePersistedFrameCopyOptIn(
false,
undefined,
runtimeUsable
)
).toBe(false);
expect(
shouldPromotePersistedFrameCopyOptIn(true, '0', runtimeUsable)
).toBe(false);
expect(
shouldPromotePersistedFrameCopyOptIn(true, '1', runtimeUsable)
).toBe(false);
expect(runtimeUsable).not.toHaveBeenCalled();
expect(
shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable)
).toBe(true);
expect(runtimeUsable).toHaveBeenCalledTimes(1);
runtimeUsable.mockReturnValue(false);
expect(
shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable)
).toBe(false);
expect(runtimeUsable).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,11 @@
import { app } from 'electron';
import { accessSync, constants as fsConstants, statSync } from 'fs';
import path from 'path';
import { probeEmbeddedMpvFrameCopyRuntime } from './embedded-mpv-frame-copy-runtime';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeResult,
} from './embedded-mpv-frame-copy-runtime';
/**
* Platform gate + helper discovery for the embedded MPV frame-copy engine,
@@ -9,15 +14,15 @@ import path from 'path';
* callable before app.whenReady().
*
* macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep
* the docked native engine. Linux: any arch — the helper renders offscreen
* through headless EGL and links libmpv out of process, so neither window
* embedding nor the in-process-libmpv ban constrains it. Windows: any arch
* with a helper binary (WGL offscreen render; in practice x64, the only
* vendored runtime) — the helper-presence check below is the real gate.
* the docked native engine. Linux: x64 only — official packages validate a
* profile manifest and run the helper's bounded EGL/libmpv capability probe;
* ARM packages remain honestly unavailable. Windows: any arch with a helper
* binary (WGL offscreen render; in practice x64, the only vendored runtime)
* — the helper-presence check below is the real gate.
*/
export function isFrameCopyPlatformSupported(): boolean {
return (
process.platform === 'linux' ||
(process.platform === 'linux' && process.arch === 'x64') ||
process.platform === 'win32' ||
(process.platform === 'darwin' && process.arch === 'arm64')
);
@@ -77,7 +82,7 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] {
return dedupeDefinedPaths(
app.isPackaged
? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath]
? packagedAddonPaths
: [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths]
);
}
@@ -104,10 +109,7 @@ export function resolveFrameCopyHelperPath(): string | null {
.map((candidatePath) => path.dirname(candidatePath))
.map((nativeDir) => ({
helper: path.join(nativeDir, helperFileName),
reader: path.join(
nativeDir,
'embedded_mpv_frame_reader.node'
),
reader: path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
}))
.find(({ helper, reader }) => {
try {
@@ -127,16 +129,80 @@ export function resolveFrameCopyHelperPath(): string | null {
);
}
type FrameCopyRuntimeProbe = (
helperPath: string,
manifestContract: EmbeddedMpvFrameCopyManifestContract
) => EmbeddedMpvFrameCopyRuntimeResult;
export type FrameCopyRuntimeAvailability =
| EmbeddedMpvFrameCopyRuntimeResult
| { usable: true };
let cachedDefaultRuntimeAvailability: FrameCopyRuntimeAvailability | undefined;
export function getFrameCopyRuntimeAvailability(
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
): FrameCopyRuntimeAvailability {
const usesProcessDecision =
resolveHelper === resolveFrameCopyHelperPath &&
probeRuntime === probeEmbeddedMpvFrameCopyRuntime;
if (usesProcessDecision && cachedDefaultRuntimeAvailability !== undefined) {
return cachedDefaultRuntimeAvailability;
}
let availability: FrameCopyRuntimeAvailability;
if (!isFrameCopyPlatformSupported()) {
availability = {
usable: false,
reason:
process.platform === 'linux' || process.platform === 'darwin'
? 'unsupported-architecture'
: 'unsupported-platform',
};
} else {
const helperPath = resolveHelper();
if (!helperPath) {
availability = {
usable: false,
reason: 'runtime-artifact-missing',
};
} else if (process.platform !== 'linux') {
availability = { usable: true };
} else {
try {
// app.isPackaged is the trusted boundary. Environment flags
// can request the feature, but cannot weaken its manifest
// contract or make development artifacts package-trusted.
availability = probeRuntime(
helperPath,
app.isPackaged ? 'packaged' : 'development'
);
} catch {
availability = {
usable: false,
reason: 'runtime-probe-internal-error',
};
}
}
}
if (usesProcessDecision) {
cachedDefaultRuntimeAvailability = availability;
}
return availability;
}
export function isFrameCopyRuntimeUsable(
resolveHelper: () => string | null = resolveFrameCopyHelperPath
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
): boolean {
return isFrameCopyPlatformSupported() && resolveHelper() !== null;
return getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime).usable;
}
export function shouldPromotePersistedFrameCopyOptIn(
storedEnabled: boolean,
explicitEnv: string | undefined,
runtimeUsable = isFrameCopyRuntimeUsable()
runtimeUsable: () => boolean = isFrameCopyRuntimeUsable
): boolean {
return explicitEnv === undefined && storedEnabled && runtimeUsable;
return explicitEnv === undefined && storedEnabled && runtimeUsable();
}
@@ -0,0 +1,16 @@
export { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime/helper-environment';
export { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime/helper-launch';
export {
createEmbeddedMpvFrameCopyRuntimeProbe,
probeEmbeddedMpvFrameCopyRuntime,
} from './embedded-mpv-frame-copy-runtime/probe';
export type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeDependencies,
EmbeddedMpvFrameCopyRuntimeFailureReason,
EmbeddedMpvFrameCopyRuntimeFileSystem,
EmbeddedMpvFrameCopyRuntimeMode,
EmbeddedMpvFrameCopyRuntimeResult,
EmbeddedMpvHelperRuntimeProbeFailureReason,
} from './embedded-mpv-frame-copy-runtime/types';
export type { LinuxFrameCopyHelperLaunchFileSystem } from './embedded-mpv-frame-copy-runtime/helper-launch';
@@ -0,0 +1,330 @@
import runtimeProbeContract = require('../../../../../../tools/embedded-mpv/runtime-probe-contract.cjs');
import sourceArchiveContract = require('../../../../../../tools/embedded-mpv/linux-source-archive-contract.cjs');
import type { EmbeddedMpvFrameCopyRuntimeMode, RuntimeProfile } from './types';
interface RuntimeProfileContract {
origin: string;
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
targets: ReadonlySet<string>;
}
export const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json';
export const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node';
export const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node';
export const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper';
export const RUNTIME_PROBE_PROTOCOL = 1;
export const { RUNTIME_PROBE_MAX_BUFFER_BYTES, RUNTIME_PROBE_TIMEOUT_MS } =
runtimeProbeContract;
export const {
SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION,
SOURCE_ARCHIVE_NAME,
validateLinuxSourceArchiveBinding,
} = sourceArchiveContract;
export const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
export const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/;
export const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/;
export const PINNED_LIBPLACEBO_SOURCE_SUBMODULES = [
'450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers',
'97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float',
'73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad',
'15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja',
'297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe',
'242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear',
] as const;
export const SHA256_PATTERN = /^[a-f0-9]{64}$/;
export const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/;
export const SUBMODULE_RECORD_PATTERN =
/^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)$/;
export const VERSION_PATTERN = /^\d+(?:\.\d+)+$/;
export const GLIBC_TOOLCHAIN_ALLOWLIST = [
'ld-linux-x86-64.so.2',
'libc.so.6',
'libdl.so.2',
'libgcc_s.so.1',
'libm.so.6',
'libpthread.so.0',
'librt.so.1',
'libstdc++.so.6',
] as const;
export const EXPECTED_EXTERNAL_SYSTEM_LIBRARIES = [
{
name: 'libEGL.so.1',
interface: 'EGL',
reason: 'System graphics-driver interface used by the frame-copy helper.',
},
{
name: 'libGL.so.1',
interface: 'OpenGL',
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
},
{
name: 'libGLX.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
},
{
name: 'libOpenGL.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
},
{
name: 'libasound.so.2',
interface: 'ALSA',
reason: 'Linux system audio interface intentionally used by libmpv.',
},
{
name: 'libdrm.so.2',
interface: 'DRM',
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
},
{
name: 'libgbm.so.1',
interface: 'GBM',
reason: 'System graphics-buffer interface used by headless EGL rendering.',
},
{
name: 'libpulse.so.0',
interface: 'PulseAudio',
reason: 'Linux desktop audio interface intentionally used by libmpv.',
},
{
name: 'libva-drm.so.2',
interface: 'VA-API DRM',
reason: 'System VA-API DRM interface used for hardware decoding.',
},
{
name: 'libva.so.2',
interface: 'VA-API',
reason: 'System video-acceleration interface used for hardware decoding.',
},
] as const;
export const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set<string>([
...GLIBC_TOOLCHAIN_ALLOWLIST,
...EXPECTED_EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
]);
export const PORTABLE_ABI_BASELINE = {
distribution: 'Ubuntu 22.04',
glibcMaximum: '2.35',
glibcxxMaximum: '3.4.30',
} as const;
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
freetype: {
version: '2.13.3',
sourceUrl:
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
sourceSha256:
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
license: 'FreeType License (FTL)',
},
fribidi: {
version: '1.0.16',
sourceUrl:
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
sourceSha256:
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
license: 'LGPL-2.1-or-later',
},
harfbuzz: {
version: '8.5.0',
sourceUrl:
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
sourceSha256:
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
license: 'MIT',
},
expat: {
version: '2.8.2',
sourceUrl:
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
sourceSha256:
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
license: 'MIT',
},
fontconfig: {
version: '2.16.0',
sourceUrl:
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
sourceSha256:
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
license: 'MIT',
},
libass: {
version: '0.17.3',
sourceUrl:
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
sourceSha256:
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
license: 'ISC',
},
openssl: {
version: '3.5.7',
sourceUrl:
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
sourceSha256:
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
license: 'Apache-2.0',
},
ffmpeg: {
version: '8.1',
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
sourceSha256:
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
license: 'LGPL-2.1-or-later',
},
libplacebo: {
version: '7.360.1',
sourceUrl: 'https://github.com/haasn/libplacebo.git',
sourceTag: 'v7.360.1',
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
sourceSubmodules: PINNED_LIBPLACEBO_SOURCE_SUBMODULES,
license: 'LGPL-2.1-or-later',
},
hwdata: {
version: '0.409',
sourceUrl:
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
sourceSha256:
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
buildInput: {
consumer: 'libdisplay-info',
relativePath: 'pnp.ids',
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
},
license: 'GPL-2.0-or-later OR XFree86-1.0',
},
'libdisplay-info': {
version: '0.1.1',
sourceUrl:
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
sourceSha256:
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
license: 'MIT',
},
mpv: {
version: '0.41.0',
sourceUrl:
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
sourceSha256:
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
license: 'LGPL-2.1-or-later with -Dgpl=false',
},
} as const;
export const EXPECTED_ARTIFACTS = {
addon: {
name: FRAME_COPY_ADDON_NAME,
regularFile: true,
readable: true,
},
frameReader: {
name: FRAME_COPY_READER_NAME,
regularFile: true,
readable: true,
},
helper: {
name: FRAME_COPY_HELPER_NAME,
regularFile: true,
readable: true,
executable: true,
},
};
export const EXPECTED_PROCESS_ISOLATION = {
addonLoadsLibmpv: false,
readerLoadsLibmpv: false,
electronLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
};
export const EXPECTED_DEVELOPMENT_ARTIFACTS = {
addon: FRAME_COPY_ADDON_NAME,
frameReader: FRAME_COPY_READER_NAME,
helper: FRAME_COPY_HELPER_NAME,
};
export const EXPECTED_DEVELOPMENT_PROCESS_ISOLATION = {
addonLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
};
export const DEVELOPMENT_MANIFEST_FIELDS = [
'allowedPackageRuntimeModes',
'arch',
'artifacts',
'buildInputMode',
'generatedAt',
'libmpvSoname',
'nativeViewFallback',
'origin',
'packageRuntimeAvailability',
'platform',
'processIsolation',
'runtimeFiles',
'runtimeTotalBytes',
'schemaVersion',
'sourceArchive',
'sourceRuntime',
'sourceRuntimeValidated',
] as const;
export const SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({
deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']),
rpm: Object.freeze([
'mpv-libs',
'libglvnd-egl',
'libglvnd-glx',
'mesa-libgbm',
]),
pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']),
});
export const PROFILE_CONTRACTS = {
system: {
origin: 'system-libmpv-frame-copy',
runtimeMode: 'system',
targets: new Set(['deb', 'rpm', 'pacman']),
},
portable: {
origin: 'bundled-lgpl-frame-copy',
runtimeMode: 'bundled',
targets: new Set(['appimage', 'snap']),
},
flatpak: {
origin: 'bundled-lgpl-frame-copy',
runtimeMode: 'bundled',
targets: new Set(['flatpak']),
},
} as const satisfies Record<RuntimeProfile, RuntimeProfileContract>;
export const BASE_MANIFEST_FIELDS = [
'arch',
'artifacts',
'generatedAt',
'libmpvSoname',
'nativeViewFallback',
'origin',
'packageDependencies',
'platform',
'processIsolation',
'profile',
'runtimeFiles',
'runtimeMode',
'runtimeTotalBytes',
'schemaVersion',
'targets',
] as const;
export const BUNDLED_MANIFEST_FIELDS = [
...BASE_MANIFEST_FIELDS,
'externalSystemLibraries',
'runtimeDependencyClosure',
'sourceArchive',
'sourceRuntime',
] as const;
@@ -0,0 +1,172 @@
import { mkdirSync } from 'fs';
import path from 'path';
import {
cloneManifest,
createDevelopmentFixture,
probeDevelopmentRuntime,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy development manifest', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it.each(['system-dev', 'system-build-inputs', 'bundled-runtime'] as const)(
'accepts an exact unpackaged %s build manifest and still runs the helper probe',
(buildInputMode) => {
const fixture = createDevelopmentFixture(
context.rootDir,
buildInputMode
);
expect(
probeDevelopmentRuntime(
context.createProbe(),
fixture.helperPath
)
).toEqual(
expect.objectContaining({
usable: true,
runtimeMode:
buildInputMode === 'bundled-runtime'
? 'bundled'
: 'system',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
expect.objectContaining({
env:
buildInputMode === 'bundled-runtime'
? {
PATH: '/usr/bin',
LD_LIBRARY_PATH: path.join(
fixture.nativeDir,
'lib'
),
}
: {
PATH: '/usr/bin',
},
})
);
}
);
it('keeps the packaged manifest contract strict when a development manifest is present', () => {
const fixture = createDevelopmentFixture(
context.rootDir,
'bundled-runtime'
);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('accepts a local bundled runtime before a release source archive is assembled', () => {
const fixture = createDevelopmentFixture(
context.rootDir,
'bundled-runtime'
);
const manifest = cloneManifest(fixture.manifest);
manifest.sourceArchive = null;
writeManifest(fixture.manifestPath, manifest);
expect(
probeDevelopmentRuntime(context.createProbe(), fixture.helperPath)
).toEqual(
expect.objectContaining({
usable: true,
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalled();
});
it.each([
{
label: 'origin',
mutate(manifest: Record<string, unknown>) {
manifest.origin = 'system-libmpv-frame-copy';
},
},
{
label: 'architecture',
mutate(manifest: Record<string, unknown>) {
manifest.arch = 'arm64';
},
},
{
label: 'artifact set',
mutate(manifest: Record<string, unknown>) {
const artifacts = manifest.artifacts as Record<string, unknown>;
artifacts.helper = 'other-helper';
},
},
{
label: 'package availability',
mutate(manifest: Record<string, unknown>) {
manifest.packageRuntimeAvailability = {
system: true,
bundled: false,
};
},
},
{
label: 'unexpected field',
mutate(manifest: Record<string, unknown>) {
manifest.manifestContract = 'packaged';
},
},
])(
'rejects a development manifest with an invalid $label',
({ mutate }) => {
const fixture = createDevelopmentFixture(
context.rootDir,
'system-dev'
);
const manifest = cloneManifest(fixture.manifest);
mutate(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(
probeDevelopmentRuntime(
context.createProbe(),
fixture.helperPath
)
).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('rejects a system development manifest with a private runtime directory', () => {
const fixture = createDevelopmentFixture(context.rootDir, 'system-dev');
mkdirSync(path.join(fixture.nativeDir, 'lib'));
expect(
probeDevelopmentRuntime(context.createProbe(), fixture.helperPath)
).toEqual({
usable: false,
reason: 'runtime-library-directory-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,148 @@
import { accessSync, lstatSync, readFileSync, readdirSync } from 'fs';
import path from 'path';
import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime';
import { createFixture } from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
const FLATPAK_NATIVE_DIR =
'/app/iptvnator/resources/app.asar.unpacked/electron-backend/native';
const FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [
'/etc/egl/egl_external_platform.d',
'/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d',
'/usr/share/egl/egl_external_platform.d',
].join(':');
describe('Flatpak embedded MPV frame-copy runtime', () => {
it('reconstructs the immutable Freedesktop GL metadata inside the packaged app', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
'/tmp/hostile-egl-platform.json',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
VK_LAYER_PATH: '/tmp/hostile-vulkan',
},
FLATPAK_NATIVE_DIR,
'bundled'
)
).toEqual({
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS,
LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'),
});
});
it.each([
['wrong app id', 'com.example.other', '/app/iptvnator/native'],
[
'helper outside /app',
'com.fourgray.iptvnator',
'/opt/iptvnator/native',
],
])(
'does not reconstruct Flatpak GL metadata for %s',
(_label, flatpakId, nativeDir) => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
FLATPAK_ID: flatpakId,
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
},
nativeDir,
'bundled'
)
).toEqual({
FLATPAK_ID: flatpakId,
LD_LIBRARY_PATH: path.join(nativeDir, 'lib'),
});
}
);
describe('packaged capability probe', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('uses reconstructed Freedesktop GL metadata through the application gate', () => {
const fixture = createFixture(context.rootDir, 'flatpak');
const virtualHelperPath = path.join(
FLATPAK_NATIVE_DIR,
'iptvnator_mpv_helper'
);
const translatePath = (candidatePath: string): string => {
if (
candidatePath === FLATPAK_NATIVE_DIR ||
candidatePath.startsWith(`${FLATPAK_NATIVE_DIR}${path.sep}`)
) {
return path.join(
fixture.nativeDir,
path.relative(FLATPAK_NATIVE_DIR, candidatePath)
);
}
return candidatePath;
};
const probeRuntime = context.createProbe({
env: {
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
'/tmp/hostile-egl-platform.json',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
},
fileSystem: {
accessSync: (candidatePath, mode) =>
accessSync(translatePath(candidatePath), mode),
lstatSync: (candidatePath) =>
lstatSync(translatePath(candidatePath)),
readFileSync: (candidatePath) =>
readFileSync(translatePath(candidatePath)),
readdirSync: (candidatePath) =>
readdirSync(translatePath(candidatePath)),
},
});
expect(probeRuntime(virtualHelperPath)).toEqual(
expect.objectContaining({
usable: true,
profile: 'flatpak',
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
virtualHelperPath,
['--runtime-probe'],
expect.objectContaining({
env: {
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS,
LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'),
},
})
);
});
});
});
@@ -0,0 +1,369 @@
import path from 'path';
import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime';
const HOSTILE_LOADER_ENVIRONMENT = {
BASH_ENV: '/tmp/hostile-bash-env',
ENV: '/tmp/hostile-shell-env',
BASHOPTS: 'extdebug',
SHELLOPTS: 'xtrace',
PS4: '$(/tmp/hostile-trace-hook)',
BASH_XTRACEFD: '9',
CDPATH: '/tmp/hostile-cdpath',
'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
LD_ORIGIN_PATH: '/tmp/hostile-origin',
LD_PRELOAD: '/tmp/inject.so',
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json',
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers',
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
} as const;
const GRAPHICS_SELECTOR_ENVIRONMENT = {
LIBGL_ALWAYS_SOFTWARE: '1',
EGL_PLATFORM: 'x11',
DRI_PRIME: '1',
GALLIUM_DRIVER: 'llvmpipe',
VDPAU_DRIVER: 'mesa',
__GLX_VENDOR_LIBRARY_NAME: 'mesa',
__GLX_FORCE_VENDOR_LIBRARY_0: 'mesa',
VK_INSTANCE_LAYERS: 'VK_LAYER_MESA_overlay',
VK_LOADER_DRIVERS_SELECT: '*mesa*',
} as const;
describe('createLinuxFrameCopyHelperEnvironment', () => {
it('removes ambient loader overrides for system packages', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/usr/bin',
HOME: '/home/user',
...HOSTILE_LOADER_ENVIRONMENT,
},
'/opt/iptvnator/native',
'system'
)
).toEqual({
PATH: '/usr/bin',
HOME: '/home/user',
});
});
it('preserves graphics feature and debug selectors', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
GRAPHICS_SELECTOR_ENVIRONMENT,
'/opt/iptvnator/native',
'system'
)
).toEqual(GRAPHICS_SELECTOR_ENVIRONMENT);
});
it('keeps trusted Snap GL and core22 roots ahead of older and generic libraries', () => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/snap/bin:/usr/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: [
'/var/lib/snapd/lib/gl',
'/tmp/hostile-gl',
'/var/lib/snapd/lib/gl/nvidia',
'/var/lib/snapd/lib/gl-evil',
].join(':'),
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,
},
nativeDir,
'bundled'
)
).toEqual({
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: [
'/var/lib/snapd/lib/gl',
'/var/lib/snapd/lib/gl/nvidia',
].join(':'),
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',
'/var/lib/snapd/lib/gl/nvidia',
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.each([
'/tmp/gnome-platform',
'/snap/iptvnator/42/gnome-platform-evil',
'gnome-platform',
])(
'ignores an untrusted Snap desktop runtime declaration: %s',
(declaredDesktopRuntime) => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const helperEnvironment = createLinuxFrameCopyHelperEnvironment(
{
SNAP: snapRoot,
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
SNAP_DESKTOP_RUNTIME: declaredDesktopRuntime,
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',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
},
nativeDir,
'bundled'
);
expect(helperEnvironment.LD_LIBRARY_PATH?.split(':')).toEqual([
path.join(nativeDir, 'lib'),
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, 'lib'),
path.join(snapRoot, 'usr', 'lib'),
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
]);
expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain(
declaredDesktopRuntime
);
expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain(
'hostile-linux-gnu'
);
expect(helperEnvironment.SNAP_DESKTOP_RUNTIME).toBeUndefined();
expect(helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET).toBe(
'x86_64-linux-gnu'
);
expect(helperEnvironment.SNAP_ARCH).toBe('amd64');
}
);
it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/usr/bin',
SNAP: '/snap/iptvnator/42',
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
LD_PRELOAD: '/tmp/inject.so',
},
'/opt/iptvnator/native',
'bundled'
)
).toEqual({
PATH: '/usr/bin',
SNAP: '/snap/iptvnator/42',
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
LD_LIBRARY_PATH: '/opt/iptvnator/native/lib',
});
});
});
@@ -0,0 +1,281 @@
import path from 'path';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './types';
import { resolveTrustedSnapRoot } from './trusted-snap-root';
const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl';
const TRUSTED_SNAP_EGL_VENDOR_ROOT = '/var/lib/snapd/lib/glvnd/egl_vendor.d';
const SNAP_DESKTOP_RUNTIME_DIRECTORY = 'gnome-platform';
const SNAP_GRAPHICS_RUNTIME_DIRECTORY = 'graphics';
const SNAP_X64_LIBRARY_TRIPLET = 'x86_64-linux-gnu';
const TRUSTED_SNAP_BASE_LIBRARY_ROOT = '/usr/lib/x86_64-linux-gnu';
const TRUSTED_SNAP_HELPER_PATH = '/usr/sbin:/usr/bin:/sbin:/bin';
const TRUSTED_FLATPAK_APP_ID = 'com.fourgray.iptvnator';
const TRUSTED_FLATPAK_APP_ROOT = '/app';
const TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [
'/etc/egl/egl_external_platform.d',
'/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d',
'/usr/share/egl/egl_external_platform.d',
].join(':');
const UNSAFE_HELPER_ENVIRONMENT_VARIABLES = [
'BASH_ENV',
'ENV',
'BASHOPTS',
'SHELLOPTS',
'PS4',
'BASH_XTRACEFD',
'CDPATH',
'LD_AUDIT',
'LD_LIBRARY_PATH',
'LD_ORIGIN_PATH',
'LD_PRELOAD',
'__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS',
'__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES',
'__EGL_VENDOR_LIBRARY_DIRS',
'__EGL_VENDOR_LIBRARY_FILENAMES',
'GBM_BACKEND',
'GBM_BACKENDS_PATH',
'LIBGL_DRIVERS_PATH',
'MESA_LOADER_DRIVER_OVERRIDE',
'LIBVA_DRIVER_NAME',
'LIBVA_DRIVERS_PATH',
'VDPAU_DRIVER_PATH',
'VK_DRIVER_FILES',
'VK_ICD_FILENAMES',
'VK_ADD_DRIVER_FILES',
'VK_ADD_LAYER_PATH',
'VK_IMPLICIT_LAYER_PATH',
'VK_ADD_IMPLICIT_LAYER_PATH',
'VK_LAYER_PATH',
] as const;
function isPathInside(
parentPath: string,
candidatePath: string,
allowEqual: boolean
): boolean {
const relativePath = path.relative(parentPath, candidatePath);
if (relativePath === '') {
return allowEqual;
}
return (
relativePath !== '..' &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
function getTrustedSnapLibraryPaths(
environment: NodeJS.ProcessEnv,
snapRoot: string
): string[] {
const snapLibraryPaths = getTrustedSnapHostGlLibraryPaths(environment);
const graphicsLibraryRoot = path.join(
snapRoot,
SNAP_GRAPHICS_RUNTIME_DIRECTORY,
'usr',
'lib',
SNAP_X64_LIBRARY_TRIPLET
);
const desktopLibraryPaths = getTrustedSnapDesktopLibraryPaths(
environment,
snapRoot
);
return [
...snapLibraryPaths,
graphicsLibraryRoot,
path.join(graphicsLibraryRoot, 'vdpau'),
// The core22 libedit ABI must win over gnome-3-28's libtinfo5 build.
TRUSTED_SNAP_BASE_LIBRARY_ROOT,
...desktopLibraryPaths,
path.join(snapRoot, 'lib'),
path.join(snapRoot, 'usr', 'lib'),
path.join(snapRoot, 'lib', SNAP_X64_LIBRARY_TRIPLET),
path.join(snapRoot, 'usr', 'lib', SNAP_X64_LIBRARY_TRIPLET),
];
}
function getTrustedSnapHostGlLibraryPaths(
environment: NodeJS.ProcessEnv
): string[] {
return (environment.SNAP_LIBRARY_PATH ?? '')
.split(':')
.filter(Boolean)
.filter((libraryPath) => path.isAbsolute(libraryPath))
.map((libraryPath) => path.resolve(libraryPath))
.filter((libraryPath) =>
isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true)
);
}
function getTrustedSnapDesktopLibraryPaths(
environment: NodeJS.ProcessEnv,
snapRoot: string
): string[] {
const desktopRuntime = resolveTrustedSnapDesktopRuntime(
environment,
snapRoot
);
if (!desktopRuntime) {
return [];
}
const desktopLibraryRoot = path.join(
desktopRuntime,
'usr',
'lib',
SNAP_X64_LIBRARY_TRIPLET
);
return [
path.join(desktopRuntime, 'lib', SNAP_X64_LIBRARY_TRIPLET),
desktopLibraryRoot,
path.join(desktopLibraryRoot, 'mesa'),
path.join(desktopLibraryRoot, 'mesa-egl'),
path.join(desktopLibraryRoot, 'dri'),
path.join(desktopLibraryRoot, 'pulseaudio'),
];
}
function resolveTrustedSnapDesktopRuntime(
environment: NodeJS.ProcessEnv,
snapRoot: string
): string | null {
const expectedDesktopRuntime = path.join(
snapRoot,
SNAP_DESKTOP_RUNTIME_DIRECTORY
);
const declaredDesktopRuntime = environment.SNAP_DESKTOP_RUNTIME;
if (
!declaredDesktopRuntime ||
!path.isAbsolute(declaredDesktopRuntime) ||
path.resolve(declaredDesktopRuntime) !== expectedDesktopRuntime
) {
return null;
}
return expectedDesktopRuntime;
}
function isTrustedFlatpakRuntime(
environment: NodeJS.ProcessEnv,
nativeDir: string
): boolean {
return (
environment.FLATPAK_ID === TRUSTED_FLATPAK_APP_ID &&
path.isAbsolute(nativeDir) &&
isPathInside(TRUSTED_FLATPAK_APP_ROOT, path.resolve(nativeDir), false)
);
}
function applyTrustedSnapGraphicsEnvironment(
helperEnvironment: NodeJS.ProcessEnv,
sourceEnvironment: NodeJS.ProcessEnv,
snapRoot: string
): void {
const graphicsRuntime = path.join(
snapRoot,
SNAP_GRAPHICS_RUNTIME_DIRECTORY,
'usr'
);
const graphicsLibraryRoot = path.join(
graphicsRuntime,
'lib',
SNAP_X64_LIBRARY_TRIPLET
);
const graphicsDriRoot = path.join(graphicsLibraryRoot, 'dri');
helperEnvironment.GBM_BACKENDS_PATH = [
path.join(graphicsLibraryRoot, 'gbm'),
path.join(TRUSTED_SNAP_GL_ROOT, 'gbm'),
].join(':');
helperEnvironment.LIBGL_DRIVERS_PATH = graphicsDriRoot;
helperEnvironment.LIBVA_DRIVERS_PATH = graphicsDriRoot;
helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = path.join(
graphicsRuntime,
'share',
'egl',
'egl_external_platform.d'
);
helperEnvironment.__EGL_VENDOR_LIBRARY_DIRS = [
TRUSTED_SNAP_EGL_VENDOR_ROOT,
path.join(graphicsRuntime, 'share', 'glvnd', 'egl_vendor.d'),
].join(':');
helperEnvironment.VK_LAYER_PATH = [
path.join(graphicsRuntime, 'share', 'vulkan', 'implicit_layer.d'),
path.join(graphicsRuntime, 'share', 'vulkan', 'explicit_layer.d'),
].join(':');
const snapConfigRoot = path.join(snapRoot, 'etc', 'xdg');
const snapDataRoot = path.join(snapRoot, 'usr', 'share');
const desktopRuntime = resolveTrustedSnapDesktopRuntime(
sourceEnvironment,
snapRoot
);
const snapLibraryPaths =
getTrustedSnapHostGlLibraryPaths(sourceEnvironment);
if (snapLibraryPaths.length > 0) {
helperEnvironment.SNAP_LIBRARY_PATH = snapLibraryPaths.join(':');
} else {
delete helperEnvironment.SNAP_LIBRARY_PATH;
}
helperEnvironment.SNAP_ARCH = 'amd64';
helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET = SNAP_X64_LIBRARY_TRIPLET;
if (desktopRuntime) {
helperEnvironment.SNAP_DESKTOP_RUNTIME = desktopRuntime;
} else {
delete helperEnvironment.SNAP_DESKTOP_RUNTIME;
}
helperEnvironment.XDG_CONFIG_HOME = snapConfigRoot;
helperEnvironment.XDG_CONFIG_DIRS = [snapConfigRoot, '/etc/xdg'].join(':');
helperEnvironment.XDG_DATA_HOME = snapDataRoot;
helperEnvironment.XDG_DATA_DIRS = [
path.join(graphicsRuntime, 'share'),
...(desktopRuntime ? [path.join(desktopRuntime, 'usr', 'share')] : []),
snapDataRoot,
'/usr/share',
].join(':');
}
/**
* Builds the loader environment shared by the bounded startup probe and each
* real Linux helper session. The validated package profile is authoritative:
* system packages use the system loader, while bundled packages start at
* native/lib and add only fixed trusted Snap or Flatpak graphics roots.
*/
export function createLinuxFrameCopyHelperEnvironment(
environment: NodeJS.ProcessEnv,
nativeDir: string,
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode
): NodeJS.ProcessEnv {
const helperEnvironment = { ...environment };
for (const variableName of UNSAFE_HELPER_ENVIRONMENT_VARIABLES) {
delete helperEnvironment[variableName];
}
for (const variableName of Object.keys(helperEnvironment)) {
if (variableName.startsWith('BASH_FUNC_')) {
delete helperEnvironment[variableName];
}
}
if (runtimeMode === 'system') {
return helperEnvironment;
}
const libraryPaths = [path.join(nativeDir, 'lib')];
const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir);
if (trustedSnapRoot) {
helperEnvironment.PATH = TRUSTED_SNAP_HELPER_PATH;
libraryPaths.push(
...getTrustedSnapLibraryPaths(environment, trustedSnapRoot)
);
applyTrustedSnapGraphicsEnvironment(
helperEnvironment,
environment,
trustedSnapRoot
);
} else if (isTrustedFlatpakRuntime(environment, nativeDir)) {
helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS =
TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS;
}
helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':');
return helperEnvironment;
}
@@ -0,0 +1,338 @@
import { SUCCESS_OUTPUT } from './runtime.spec-data';
import { createFixture } from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy helper failures', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('converts a thrown spawn failure into a stable result', () => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockImplementation(() => {
throw new Error('spawn exploded');
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-spawn-error',
});
});
it.each([
{
label: 'timeout',
spawnResult: {
status: null,
signal: 'SIGTERM',
stdout: '',
stderr: '',
error: Object.assign(new Error('timed out'), {
code: 'ETIMEDOUT',
}),
},
reason: 'helper-probe-timeout',
},
{
label: 'spawn error',
spawnResult: {
status: null,
signal: null,
stdout: '',
stderr: '',
error: Object.assign(new Error('spawn failed'), {
code: 'EACCES',
}),
},
reason: 'helper-probe-spawn-error',
},
{
label: 'nonzero exit',
spawnResult: {
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
stderr: '',
},
reason: 'helper-probe-failed',
},
{
label: 'signal',
spawnResult: {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
},
reason: 'helper-probe-signaled',
},
{
label: 'invalid JSON',
spawnResult: {
status: 0,
signal: null,
stdout: 'not-json\n',
stderr: '',
},
reason: 'helper-probe-invalid-output',
},
{
label: 'multiple lines',
spawnResult: {
status: 0,
signal: null,
stdout: `${SUCCESS_OUTPUT}${SUCCESS_OUTPUT}`,
stderr: '',
},
reason: 'helper-probe-invalid-output',
},
{
label: 'wrong protocol',
spawnResult: {
status: 0,
signal: null,
stdout: '{"protocol":2,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n',
stderr: '',
},
reason: 'helper-probe-protocol-mismatch',
},
{
label: 'unusable success',
spawnResult: {
status: 0,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
stderr: '',
},
reason: 'helper-probe-unusable',
},
])('fails closed on helper $label', ({ spawnResult, reason }) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue(spawnResult);
expect(context.createProbe()(fixture.helperPath)).toEqual(
expect.objectContaining({ usable: false, reason })
);
});
it.each([
'mpv-create-failed',
'mpv-initialize-failed',
'gl-context-create-failed',
'gl-context-bind-failed',
'mpv-render-context-failed',
'shared-memory-create-failed',
'shared-memory-initialize-failed',
])('preserves the allowlisted helper reason %s', (helperReason) => {
const fixture = createFixture(context.rootDir);
const helperDetail =
helperReason === 'mpv-create-failed'
? undefined
: 'EGL initialization failed: display unavailable';
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: `${JSON.stringify({
protocol: 1,
usable: false,
reason: helperReason,
...(helperDetail ? { detail: helperDetail } : {}),
})}\n`,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason,
...(helperDetail ? { helperDetail } : {}),
});
});
it.each([
['one printable ASCII character', 'x'],
['1024 printable ASCII characters', 'x'.repeat(1024)],
])('preserves %s as helper detail', (_label, helperDetail) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: `${JSON.stringify({
protocol: 1,
usable: false,
reason: 'gl-context-create-failed',
detail: helperDetail,
})}\n`,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
helperDetail,
});
});
it.each([
['malformed JSON', 'not-json\n'],
[
'multiple lines',
'{"protocol":1,"usable":false,"reason":"mpv-create-failed"}\nignored\n',
],
[
'wrong protocol',
'{"protocol":2,"usable":false,"reason":"mpv-create-failed"}\n',
],
[
'wrong usable value',
'{"protocol":1,"usable":true,"reason":"mpv-create-failed"}\n',
],
[
'non-allowlisted reason',
'{"protocol":1,"usable":false,"reason":"loader-injected"}\n',
],
[
'unexpected field',
'{"protocol":1,"usable":false,"reason":"mpv-create-failed","extra":true}\n',
],
[
'invalid detail',
'{"protocol":1,"usable":false,"reason":"mpv-create-failed","detail":1}\n',
],
])('does not propagate a helper reason from %s', (_label, stdout) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
});
});
it.each([
['empty', ''],
['control character', 'EGL\tfailure'],
['trailing line feed', 'EGL failure\n'],
['DEL character', `EGL${String.fromCharCode(0x7f)}failure`],
['non-ASCII character', 'EGL échoué'],
['1025 characters', 'x'.repeat(1025)],
])(
'does not propagate any helper fields from %s detail',
(_label, detail) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: `${JSON.stringify({
protocol: 1,
usable: false,
reason: 'gl-context-create-failed',
detail,
})}\n`,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
});
}
);
it('does not trace helper stderr without the exact player trace flag', () => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
stderr: 'libEGL debug output\n',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
});
expect(context.writeRuntimeProbeStderr).not.toHaveBeenCalled();
});
it('traces helper stderr as one JSON-escaped line when player tracing is enabled', () => {
const fixture = createFixture(context.rootDir);
const helperStderr =
'libEGL warning: vendor "mesa"\nfailed path: C:\\driver';
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
stderr: helperStderr,
});
expect(
context.createProbe({
env: {
PATH: '/usr/bin',
IPTVNATOR_TRACE_PLAYER: '1',
},
})(fixture.helperPath)
).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
});
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledWith(
`${JSON.stringify({
event: 'embedded-mpv-helper-runtime-probe-stderr',
stderr: helperStderr,
truncated: false,
})}\n`
);
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1);
expect(
context.writeRuntimeProbeStderr.mock.calls[0][0].split('\n')
).toHaveLength(2);
});
it('bounds traced helper stderr to 16384 characters and reports truncation', () => {
const fixture = createFixture(context.rootDir);
const helperStderr = `${'x'.repeat(16_384)}discarded`;
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
stderr: helperStderr,
});
context.createProbe({
env: {
PATH: '/usr/bin',
IPTVNATOR_TRACE_PLAYER: '1',
},
})(fixture.helperPath);
const trace = JSON.parse(
context.writeRuntimeProbeStderr.mock.calls[0][0]
);
expect(trace).toEqual({
event: 'embedded-mpv-helper-runtime-probe-stderr',
stderr: 'x'.repeat(16_384),
truncated: true,
});
expect(trace.stderr).toHaveLength(16_384);
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,203 @@
import type { Stats } from 'fs';
import path from 'path';
import { createLinuxFrameCopyHelperLaunch } from '../embedded-mpv-frame-copy-runtime';
function fakeStat(
kind: 'directory' | 'file' | 'symlink'
): Pick<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
return {
isDirectory: () => kind === 'directory',
isFile: () => kind === 'file',
isSymbolicLink: () => kind === 'symlink',
};
}
describe('createLinuxFrameCopyHelperLaunch', () => {
const helperArgs = ['--runtime-probe'];
it.each([
['system', '/opt/iptvnator/native/iptvnator_mpv_helper'],
[
'bundled',
'/tmp/.mount-IPTVnator/resources/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper',
],
] as const)(
'launches a non-Snap %s helper directly',
(runtimeMode, helperPath) => {
expect(
createLinuxFrameCopyHelperLaunch({
environment: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: '/tmp/hostile',
},
helperPath,
helperArgs,
runtimeMode,
})
).toEqual({
usable: true,
command: helperPath,
args: helperArgs,
env:
runtimeMode === 'system'
? { PATH: '/usr/bin' }
: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: path.join(
path.dirname(helperPath),
'lib'
),
},
});
}
);
it('launches a trusted Snap helper through the connected provider wrapper', () => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
const graphicsRoot = path.join(snapRoot, 'graphics');
const wrapperPath = path.join(
graphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
const lstatSync = jest.fn((candidatePath: string) => {
if (candidatePath === graphicsRoot) {
return fakeStat('directory') as Stats;
}
if (candidatePath === wrapperPath) {
return fakeStat('file') as Stats;
}
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
});
const accessSync = jest.fn();
expect(
createLinuxFrameCopyHelperLaunch({
environment: {
PATH: '/snap/bin:/usr/bin',
SNAP: snapRoot,
},
helperPath,
helperArgs,
runtimeMode: 'bundled',
fileSystem: { lstatSync, accessSync },
})
).toEqual({
usable: true,
command: wrapperPath,
args: [helperPath, ...helperArgs],
env: expect.objectContaining({
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
SNAP: snapRoot,
LD_LIBRARY_PATH: expect.stringContaining(
path.join(nativeDir, 'lib')
),
}),
});
expect(lstatSync).toHaveBeenCalledWith(graphicsRoot);
expect(lstatSync).toHaveBeenCalledWith(wrapperPath);
expect(accessSync).toHaveBeenCalledWith(
wrapperPath,
expect.any(Number)
);
});
it.each([
['missing mount', 'missing', 'file'],
['symlink mount', 'symlink', 'file'],
['missing wrapper', 'directory', 'missing'],
['symlink wrapper', 'directory', 'symlink'],
] as const)(
'fails closed for a trusted Snap with a %s',
(_label, mountKind, wrapperKind) => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
const graphicsRoot = path.join(snapRoot, 'graphics');
const wrapperPath = path.join(
graphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
expect(
createLinuxFrameCopyHelperLaunch({
environment: { SNAP: snapRoot },
helperPath,
helperArgs,
runtimeMode: 'bundled',
fileSystem: {
lstatSync: (candidatePath) => {
const kind =
candidatePath === graphicsRoot
? mountKind
: wrapperKind;
if (kind === 'missing') {
throw Object.assign(new Error('missing'), {
code: 'ENOENT',
});
}
return fakeStat(kind) as Stats;
},
accessSync: () => undefined,
},
})
).toEqual({
usable: false,
reason: 'snap-graphics-provider-unavailable',
});
expect(wrapperPath).toContain('/graphics/bin/');
}
);
it('fails closed when the provider wrapper is not executable', () => {
const snapRoot = '/var/lib/snapd/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
expect(
createLinuxFrameCopyHelperLaunch({
environment: { SNAP: snapRoot },
helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'),
helperArgs,
runtimeMode: 'bundled',
fileSystem: {
lstatSync: (candidatePath) =>
fakeStat(
candidatePath.endsWith('/graphics')
? 'directory'
: 'file'
) as Stats,
accessSync: () => {
throw Object.assign(new Error('denied'), {
code: 'EACCES',
});
},
},
})
).toEqual({
usable: false,
reason: 'snap-graphics-provider-unavailable',
});
});
});
@@ -0,0 +1,103 @@
import {
accessSync as nodeAccessSync,
constants as fileSystemConstants,
lstatSync as nodeLstatSync,
} from 'fs';
import type * as nodeFileSystem from 'fs';
import path from 'path';
import { createLinuxFrameCopyHelperEnvironment } from './helper-environment';
import { resolveTrustedSnapRoot } from './trusted-snap-root';
import type {
EmbeddedMpvFrameCopyRuntimeFailureReason,
EmbeddedMpvFrameCopyRuntimeMode,
} from './types';
export interface LinuxFrameCopyHelperLaunchFileSystem {
lstatSync(filePath: string): nodeFileSystem.Stats;
accessSync(filePath: string, mode: number): void;
}
interface CreateLinuxFrameCopyHelperLaunchOptions {
environment: NodeJS.ProcessEnv;
helperPath: string;
helperArgs: string[];
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
fileSystem?: LinuxFrameCopyHelperLaunchFileSystem;
}
export type LinuxFrameCopyHelperLaunch =
| {
usable: true;
command: string;
args: string[];
env: NodeJS.ProcessEnv;
}
| {
usable: false;
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
};
export function createLinuxFrameCopyHelperLaunch(
options: CreateLinuxFrameCopyHelperLaunchOptions
): LinuxFrameCopyHelperLaunch {
const nativeDir = path.dirname(options.helperPath);
const env = createLinuxFrameCopyHelperEnvironment(
options.environment,
nativeDir,
options.runtimeMode
);
const trustedSnapRoot =
options.runtimeMode === 'bundled'
? resolveTrustedSnapRoot(options.environment, nativeDir)
: null;
if (!trustedSnapRoot) {
return {
usable: true,
command: options.helperPath,
args: options.helperArgs,
env,
};
}
const graphicsRoot = path.join(trustedSnapRoot, 'graphics');
const providerWrapperPath = path.join(
graphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
const fileSystem = options.fileSystem ?? {
lstatSync: nodeLstatSync,
accessSync: nodeAccessSync,
};
try {
const graphicsRootStat = fileSystem.lstatSync(graphicsRoot);
const providerWrapperStat = fileSystem.lstatSync(providerWrapperPath);
if (
!graphicsRootStat.isDirectory() ||
graphicsRootStat.isSymbolicLink() ||
!providerWrapperStat.isFile() ||
providerWrapperStat.isSymbolicLink()
) {
return {
usable: false,
reason: 'snap-graphics-provider-unavailable',
};
}
fileSystem.accessSync(
providerWrapperPath,
fileSystemConstants.R_OK | fileSystemConstants.X_OK
);
} catch {
return {
usable: false,
reason: 'snap-graphics-provider-unavailable',
};
}
return {
usable: true,
command: providerWrapperPath,
args: [options.helperPath, ...options.helperArgs],
env,
};
}
@@ -0,0 +1,149 @@
import { unlinkSync, writeFileSync } from 'fs';
import {
cloneManifest,
createFixture,
mirrorBundledManifestFields,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy packaged manifest policy', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('rejects a missing or malformed manifest without throwing', () => {
const missing = createFixture(context.rootDir);
unlinkSync(missing.manifestPath);
expect(context.createProbe()(missing.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-missing',
});
const malformed = createFixture(context.rootDir, 'portable');
writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 });
expect(context.createProbe()(malformed.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
['origin', 'system-libmpv-frame-copy'],
['arch', 'arm64'],
['runtimeMode', 'system'],
['targets', ['deb']],
['sourceArchive', null],
['unexpectedField', true],
])('rejects a bundled profile mismatch in %s', (field, value) => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
manifest[field] = value;
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
['system', ['deb', 'pacman']],
['portable', ['appimage']],
] as const)(
'rejects an allowed subset of the exact %s profile targets',
(profile, targets) => {
const fixture = createFixture(context.rootDir, profile);
const manifest = cloneManifest(fixture.manifest);
manifest.targets = targets;
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('rejects a bundled closure dependency outside the deterministic system allowlist', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const closure = manifest.runtimeDependencyClosure as {
entries: Array<{ needed: string[] }>;
externalDependencies: string[];
};
closure.entries[0].needed = ['libambient-only.so.1'];
closure.externalDependencies = ['libambient-only.so.1'];
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('requires externalDependencies to exactly equal the sorted external closure', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const closure = manifest.runtimeDependencyClosure as {
entries: Array<{ needed: string[] }>;
externalDependencies: string[];
};
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
closure.externalDependencies = [];
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('accepts only the exact deterministic external-system library declaration', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
(manifest.externalSystemLibraries as unknown[]).pop();
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('accepts bundled dependencies on declared system interfaces and the glibc toolchain', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const closure = manifest.runtimeDependencyClosure as {
entries: Array<{ needed: string[] }>;
externalDependencies: string[];
};
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
closure.externalDependencies = ['libEGL.so.1', 'libc.so.6'];
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual(
expect.objectContaining({ usable: true, runtimeMode: 'bundled' })
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,267 @@
import { isDeepStrictEqual } from 'util';
import {
BASE_MANIFEST_FIELDS,
BUNDLED_MANIFEST_FIELDS,
DEVELOPMENT_MANIFEST_FIELDS,
EXPECTED_ARTIFACTS,
EXPECTED_DEVELOPMENT_ARTIFACTS,
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION,
EXPECTED_PROCESS_ISOLATION,
PROFILE_CONTRACTS,
validateLinuxSourceArchiveBinding,
SYSTEM_PACKAGE_DEPENDENCIES,
VERSIONED_LIBMPV_PATTERN,
} from './contracts';
import { validateRuntimeClosure } from './runtime-closure-validator';
import { validateSourceRuntimePolicy } from './source-runtime-validator';
import type { RuntimeProfile, ValidManifest, ValidationResult } from './types';
import {
hasExactFields,
isObject,
validateRuntimeFiles,
validateTargets,
validationFailure,
} from './validation-primitives';
export function validatePackagedManifest(
manifest: Record<string, unknown>
): ValidationResult<ValidManifest> {
if (
manifest.schemaVersion !== 1 ||
manifest.platform !== 'linux' ||
manifest.arch !== 'x64' ||
typeof manifest.profile !== 'string' ||
!Object.prototype.hasOwnProperty.call(
PROFILE_CONTRACTS,
manifest.profile
)
) {
return validationFailure('runtime-manifest-invalid');
}
const profile = manifest.profile as RuntimeProfile;
const contract = PROFILE_CONTRACTS[profile];
if (
manifest.origin !== contract.origin ||
manifest.runtimeMode !== contract.runtimeMode ||
!hasExactFields(
manifest,
contract.runtimeMode === 'bundled'
? BUNDLED_MANIFEST_FIELDS
: BASE_MANIFEST_FIELDS
) ||
typeof manifest.generatedAt !== 'string' ||
manifest.generatedAt.trim() === '' ||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
!validateTargets(manifest.targets, contract.targets) ||
!isDeepStrictEqual(manifest.artifacts, EXPECTED_ARTIFACTS) ||
!isDeepStrictEqual(
manifest.processIsolation,
EXPECTED_PROCESS_ISOLATION
) ||
manifest.nativeViewFallback !== 'process-isolated mpv --wid' ||
typeof manifest.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname)
) {
return validationFailure('runtime-manifest-invalid');
}
if (contract.runtimeMode === 'system') {
if (
!isDeepStrictEqual(
manifest.packageDependencies,
SYSTEM_PACKAGE_DEPENDENCIES
) ||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
manifest.runtimeTotalBytes !== 0
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile,
runtimeMode: 'system',
runtimeFiles: [],
},
};
}
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
if (
!runtimeFiles ||
!isDeepStrictEqual(manifest.packageDependencies, {}) ||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
manifest.runtimeTotalBytes !==
runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
) ||
!validateRuntimeClosure(
manifest.runtimeDependencyClosure,
runtimeFiles,
manifest.libmpvSoname,
manifest.externalSystemLibraries
) ||
validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !==
0 ||
!validateSourceRuntimePolicy(
manifest.sourceRuntime,
runtimeFiles,
manifest.runtimeDependencyClosure,
manifest.externalSystemLibraries
)
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile,
runtimeMode: 'bundled',
runtimeFiles,
},
};
}
function validateSystemDevelopmentSource(
value: unknown,
buildInputMode: 'system-dev' | 'system-build-inputs'
): boolean {
if (buildInputMode === 'system-dev') {
return isDeepStrictEqual(value, {
linuxBackend: 'process-isolated mpv --wid',
warning: 'Development-only unmanaged system libmpv toolchain.',
});
}
if (
!isObject(value) ||
!hasExactFields(value, [
'buildInputs',
'linuxBackend',
'sourceDistribution',
]) ||
value.linuxBackend !== 'process-isolated mpv --wid' ||
typeof value.sourceDistribution !== 'string' ||
value.sourceDistribution.trim() === '' ||
!isObject(value.buildInputs) ||
!hasExactFields(value.buildInputs, ['libmpvDevPackage', 'mpvPackage'])
) {
return false;
}
return ['libmpvDevPackage', 'mpvPackage'].every((packageField) => {
const packageName = value.buildInputs[packageField];
return typeof packageName === 'string' && packageName.trim().length > 0;
});
}
export function validateDevelopmentManifest(
manifest: Record<string, unknown>
): ValidationResult<ValidManifest> {
const buildInputMode = manifest.buildInputMode;
if (
!hasExactFields(manifest, DEVELOPMENT_MANIFEST_FIELDS) ||
manifest.schemaVersion !== 1 ||
manifest.origin !== 'linux-frame-copy-build' ||
manifest.platform !== 'linux' ||
manifest.arch !== 'x64' ||
typeof manifest.generatedAt !== 'string' ||
manifest.generatedAt.trim() === '' ||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
!['system-dev', 'system-build-inputs', 'bundled-runtime'].includes(
String(buildInputMode)
) ||
!isDeepStrictEqual(manifest.allowedPackageRuntimeModes, [
'system',
'bundled',
]) ||
!isDeepStrictEqual(
manifest.artifacts,
EXPECTED_DEVELOPMENT_ARTIFACTS
) ||
!isDeepStrictEqual(
manifest.processIsolation,
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION
) ||
manifest.nativeViewFallback !== 'process-isolated mpv --wid'
) {
return validationFailure('runtime-manifest-invalid');
}
if (
buildInputMode === 'system-dev' ||
buildInputMode === 'system-build-inputs'
) {
if (
manifest.sourceRuntimeValidated !== false ||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
system: false,
bundled: false,
}) ||
manifest.libmpvSoname !== null ||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
manifest.runtimeTotalBytes !== 0 ||
manifest.sourceArchive !== null ||
!validateSystemDevelopmentSource(
manifest.sourceRuntime,
buildInputMode
)
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile: 'development',
runtimeMode: 'system',
runtimeFiles: [],
},
};
}
const sourceRuntime = manifest.sourceRuntime;
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
if (
buildInputMode !== 'bundled-runtime' ||
manifest.sourceRuntimeValidated !== true ||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
system: true,
bundled: true,
}) ||
typeof manifest.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) ||
!runtimeFiles ||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
manifest.runtimeTotalBytes !==
runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
) ||
!isObject(sourceRuntime) ||
(manifest.sourceArchive !== null &&
validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !==
0) ||
!validateRuntimeClosure(
sourceRuntime.runtimeDependencyClosure,
runtimeFiles,
manifest.libmpvSoname,
sourceRuntime.externalSystemLibraries
) ||
!validateSourceRuntimePolicy(
sourceRuntime,
runtimeFiles,
sourceRuntime.runtimeDependencyClosure,
sourceRuntime.externalSystemLibraries
)
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile: 'development',
runtimeMode: 'bundled',
runtimeFiles,
},
};
}
@@ -0,0 +1,246 @@
import {
chmodSync,
constants as fsConstants,
mkdirSync,
readFileSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'fs';
import path from 'path';
import type { RuntimeFile, RuntimeFixture } from './runtime.spec-data';
import {
cloneManifest,
createFixture,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy package integrity', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('rejects system manifests with a private library directory', () => {
const fixture = createFixture(context.rootDir);
mkdirSync(path.join(fixture.nativeDir, 'lib'));
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-library-directory-invalid',
});
});
it.each([
{
label: 'missing addon',
mutate(fixture: RuntimeFixture) {
unlinkSync(path.join(fixture.nativeDir, 'embedded_mpv.node'));
},
reason: 'runtime-artifact-missing',
},
{
label: 'non-executable helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o644);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'setuid helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o4755);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'setgid helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o2755);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'sticky helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o1755);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'wrong reader mode',
mutate(fixture: RuntimeFixture) {
chmodSync(
path.join(
fixture.nativeDir,
'embedded_mpv_frame_reader.node'
),
0o600
);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'symlinked helper',
mutate(fixture: RuntimeFixture) {
const target = `${fixture.helperPath}.real`;
writeFileSync(target, '#!/bin/sh\n', { mode: 0o755 });
unlinkSync(fixture.helperPath);
symlinkSync(target, fixture.helperPath);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'reader directory',
mutate(fixture: RuntimeFixture) {
const readerPath = path.join(
fixture.nativeDir,
'embedded_mpv_frame_reader.node'
);
unlinkSync(readerPath);
mkdirSync(readerPath);
},
reason: 'runtime-artifact-invalid',
},
])('rejects a $label', ({ mutate, reason }) => {
const fixture = createFixture(context.rootDir);
mutate(fixture);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason,
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
{
label: 'missing declared library',
mutate(fixture: RuntimeFixture) {
unlinkSync(path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'));
},
reason: 'runtime-library-missing',
},
{
label: 'undeclared library',
mutate(fixture: RuntimeFixture) {
writeFileSync(
path.join(fixture.nativeDir, 'lib', 'libextra.so'),
'extra'
);
},
reason: 'runtime-library-undeclared',
},
{
label: 'library size mismatch',
mutate(fixture: RuntimeFixture) {
writeFileSync(
path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'),
'different-length'
);
},
reason: 'runtime-library-size-mismatch',
},
{
label: 'library hash mismatch',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
const original = readFileSync(runtimePath);
writeFileSync(
runtimePath,
Buffer.from(original.map((value) => value ^ 0xff))
);
},
reason: 'runtime-library-hash-mismatch',
},
{
label: 'symlinked library',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
const targetPath = path.join(
fixture.nativeDir,
'libmpv-real.so.2'
);
writeFileSync(
targetPath,
fixture.runtimeContents['libmpv.so.2']
);
unlinkSync(runtimePath);
symlinkSync(targetPath, runtimePath);
},
reason: 'runtime-library-invalid',
},
{
label: 'library directory',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
unlinkSync(runtimePath);
mkdirSync(runtimePath);
},
reason: 'runtime-library-invalid',
},
])('rejects a $label', ({ mutate, reason }) => {
const fixture = createFixture(context.rootDir, 'portable');
mutate(fixture);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason,
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each(['../libmpv.so.2', '/tmp/libmpv.so.2', 'sub/libmpv.so.2'])(
'rejects unsafe runtime path %s',
(unsafeName) => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const runtimeFiles = manifest.runtimeFiles as RuntimeFile[];
runtimeFiles[0].name = unsafeName;
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('uses read and execute access checks for declared artifacts', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const accessMock = context.fileSystem.accessSync as jest.Mock;
expect(accessMock).toHaveBeenCalledWith(
fixture.helperPath,
fsConstants.R_OK | fsConstants.X_OK
);
expect(accessMock).toHaveBeenCalledWith(
path.join(fixture.nativeDir, 'embedded_mpv_frame_reader.node'),
fsConstants.R_OK
);
});
});
@@ -0,0 +1,238 @@
import { createHash } from 'crypto';
import * as nodeFileSystem from 'fs';
import path from 'path';
import {
FRAME_COPY_ADDON_NAME,
FRAME_COPY_HELPER_NAME,
FRAME_COPY_READER_NAME,
} from './contracts';
import {
validateDevelopmentManifest,
validatePackagedManifest,
} from './manifest-validator';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeFileSystem,
RuntimeFile,
ValidatedPackage,
ValidationResult,
} from './types';
import {
isMissingFileError,
isValidationFailure,
readManifest,
validationFailure,
} from './validation-primitives';
function validateRegularArtifact(
filePath: string,
accessMode: number,
expectedMode: number | null,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<nodeFileSystem.Stats> {
let stat: nodeFileSystem.Stats;
try {
stat = fileSystem.lstatSync(filePath);
} catch (error) {
return validationFailure(
isMissingFileError(error)
? 'runtime-artifact-missing'
: 'runtime-artifact-invalid'
);
}
if (
stat.isSymbolicLink() ||
!stat.isFile() ||
(expectedMode !== null && (stat.mode & 0o7777) !== expectedMode)
) {
return validationFailure('runtime-artifact-invalid');
}
try {
fileSystem.accessSync(filePath, accessMode);
} catch {
return validationFailure('runtime-artifact-invalid');
}
return { value: stat };
}
function pathExistsByLstat(
filePath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): boolean {
try {
fileSystem.lstatSync(filePath);
return true;
} catch (error) {
if (isMissingFileError(error)) {
return false;
}
throw error;
}
}
function validateBundledRuntimeFiles(
nativeDir: string,
runtimeFiles: RuntimeFile[],
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<true> {
const libDir = path.join(nativeDir, 'lib');
let libStat: nodeFileSystem.Stats;
try {
libStat = fileSystem.lstatSync(libDir);
} catch {
return validationFailure('runtime-library-directory-invalid');
}
if (libStat.isSymbolicLink() || !libStat.isDirectory()) {
return validationFailure('runtime-library-directory-invalid');
}
let packagedNames: string[];
try {
packagedNames = fileSystem.readdirSync(libDir) as string[];
} catch {
return validationFailure('runtime-library-directory-invalid');
}
const declaredNames = new Set(runtimeFiles.map(({ name }) => name));
if (packagedNames.some((name) => !declaredNames.has(name))) {
return validationFailure('runtime-library-undeclared');
}
for (const runtimeFile of runtimeFiles) {
const runtimePath = path.join(libDir, runtimeFile.name);
let stat: nodeFileSystem.Stats;
try {
stat = fileSystem.lstatSync(runtimePath);
} catch (error) {
return validationFailure(
isMissingFileError(error)
? 'runtime-library-missing'
: 'runtime-library-invalid'
);
}
if (stat.isSymbolicLink() || !stat.isFile()) {
return validationFailure('runtime-library-invalid');
}
try {
fileSystem.accessSync(runtimePath, nodeFileSystem.constants.R_OK);
} catch {
return validationFailure('runtime-library-invalid');
}
if (stat.size !== runtimeFile.size) {
return validationFailure('runtime-library-size-mismatch');
}
let contents: Buffer;
try {
contents = fileSystem.readFileSync(runtimePath);
} catch {
return validationFailure('runtime-library-invalid');
}
if (contents.length !== runtimeFile.size) {
return validationFailure('runtime-library-size-mismatch');
}
const actualSha256 = createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
return validationFailure('runtime-library-hash-mismatch');
}
}
return { value: true };
}
export function validatePackage(
helperPath: string,
manifestPath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem,
manifestContract: EmbeddedMpvFrameCopyManifestContract
): ValidationResult<ValidatedPackage> {
const nativeDir = path.dirname(helperPath);
if (
path.basename(helperPath) !== FRAME_COPY_HELPER_NAME ||
path.dirname(manifestPath) !== nativeDir
) {
return validationFailure('runtime-artifact-invalid');
}
const manifestArtifact = validateRegularArtifact(
manifestPath,
nodeFileSystem.constants.R_OK,
0o644,
fileSystem
);
if (isValidationFailure(manifestArtifact)) {
return validationFailure(
manifestArtifact.reason === 'runtime-artifact-missing'
? 'runtime-manifest-missing'
: 'runtime-manifest-invalid'
);
}
const manifestResult = readManifest(manifestPath, fileSystem);
if (isValidationFailure(manifestResult)) {
return manifestResult;
}
const validManifest =
manifestContract === 'packaged'
? validatePackagedManifest(manifestResult.value)
: validateDevelopmentManifest(manifestResult.value);
if (isValidationFailure(validManifest)) {
return validManifest;
}
for (const [artifactPath, accessMode, expectedMode] of [
[
path.join(nativeDir, FRAME_COPY_ADDON_NAME),
nodeFileSystem.constants.R_OK,
null,
],
[
path.join(nativeDir, FRAME_COPY_READER_NAME),
nodeFileSystem.constants.R_OK,
0o644,
],
[
helperPath,
nodeFileSystem.constants.R_OK | nodeFileSystem.constants.X_OK,
0o755,
],
] as const) {
const artifact = validateRegularArtifact(
artifactPath,
accessMode,
expectedMode,
fileSystem
);
if (isValidationFailure(artifact)) {
return artifact;
}
}
const libDir = path.join(nativeDir, 'lib');
if (validManifest.value.runtimeMode === 'system') {
try {
if (pathExistsByLstat(libDir, fileSystem)) {
return validationFailure('runtime-library-directory-invalid');
}
} catch {
return validationFailure('runtime-library-directory-invalid');
}
} else {
const bundledRuntime = validateBundledRuntimeFiles(
nativeDir,
validManifest.value.runtimeFiles,
fileSystem
);
if (isValidationFailure(bundledRuntime)) {
return bundledRuntime;
}
}
return {
value: {
manifest: validManifest.value,
helperPath,
nativeDir,
},
};
}
@@ -0,0 +1,391 @@
import {
accessSync,
chmodSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from 'fs';
import path from 'path';
import {
cloneManifest,
createFixture,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy runtime probe orchestration', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe({
env: {
PATH: '/usr/bin',
LIBGL_ALWAYS_SOFTWARE: '1',
GALLIUM_DRIVER: 'llvmpipe',
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: '/ambient/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',
},
});
expect(probeRuntime(fixture.helperPath)).toEqual({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
});
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
{
encoding: 'utf8',
timeout: 3000,
killSignal: 'SIGKILL',
windowsHide: true,
maxBuffer: 16 * 1024 * 1024,
env: {
PATH: '/usr/bin',
LIBGL_ALWAYS_SOFTWARE: '1',
GALLIUM_DRIVER: 'llvmpipe',
},
}
);
expect(context.fileSystem.readFileSync).toHaveBeenCalled();
});
it('invalidates a cached result when helper or manifest bytes change under identical stats', () => {
const fixture = createFixture(context.rootDir);
const fixedStats = new Map([
[fixture.helperPath, lstatSync(fixture.helperPath)],
[fixture.manifestPath, lstatSync(fixture.manifestPath)],
]);
context.fileSystem = {
...context.fileSystem,
lstatSync: jest.fn(
(filePath: string) =>
fixedStats.get(filePath) ?? lstatSync(filePath)
),
};
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const changedHelper = readFileSync(fixture.helperPath);
changedHelper[0] ^= 0xff;
writeFileSync(fixture.helperPath, changedHelper);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
fixture.manifest.generatedAt = '2026-07-18T00:00:00.000Z';
writeManifest(fixture.manifestPath, fixture.manifest);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(3);
});
it.each(['portable', 'flatpak'] as const)(
'validates the exact %s bundled closure and uses only its private library directory',
(profile) => {
const fixture = createFixture(context.rootDir, profile);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath)).toEqual(
expect.objectContaining({
usable: true,
profile,
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
expect.objectContaining({
env: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: path.join(fixture.nativeDir, 'lib'),
},
})
);
}
);
it('runs a packaged Snap probe through the connected graphics provider wrapper', () => {
const actualSnapRoot = path.join(context.rootDir, 'snap-root');
const actualFixtureRoot = path.join(actualSnapRoot, 'fixture');
const fixture = createFixture(actualFixtureRoot, 'portable');
const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics');
const actualProviderWrapper = path.join(
actualGraphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
mkdirSync(path.dirname(actualProviderWrapper), { recursive: true });
writeFileSync(actualProviderWrapper, '#!/bin/sh\nexec "$@"\n', {
mode: 0o755,
});
const virtualSnapRoot = '/snap/iptvnator/42';
const linuxTriplet = 'x86_64-linux-gnu';
const snapLibraries = (...relativePaths: string[]): string[] =>
relativePaths.map((relativePath) =>
path.join(virtualSnapRoot, relativePath)
);
const virtualNativeDir = path.join(
virtualSnapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const virtualHelperPath = path.join(
virtualNativeDir,
'iptvnator_mpv_helper'
);
const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics');
const virtualProviderWrapper = path.join(
virtualGraphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
const translatePath = (candidatePath: string): string => {
if (
candidatePath === virtualNativeDir ||
candidatePath.startsWith(`${virtualNativeDir}${path.sep}`)
) {
return path.join(
fixture.nativeDir,
path.relative(virtualNativeDir, candidatePath)
);
}
if (
candidatePath === virtualGraphicsRoot ||
candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`)
) {
return path.join(
actualGraphicsRoot,
path.relative(virtualGraphicsRoot, candidatePath)
);
}
return candidatePath;
};
const virtualFileSystem = {
accessSync: jest.fn((candidatePath: string, mode: number) =>
accessSync(translatePath(candidatePath), mode)
),
lstatSync: jest.fn((candidatePath: string) =>
lstatSync(translatePath(candidatePath))
),
readFileSync: jest.fn((candidatePath: string) =>
readFileSync(translatePath(candidatePath))
),
readdirSync: jest.fn((candidatePath: string) =>
readdirSync(translatePath(candidatePath))
),
};
const probeRuntime = context.createProbe({
env: {
PATH: '/snap/bin:/usr/bin',
SNAP: virtualSnapRoot,
SNAP_DESKTOP_RUNTIME: path.join(
virtualSnapRoot,
'gnome-platform'
),
SNAP_LIBRARY_PATH:
'/var/lib/snapd/lib/gl:/var/lib/snapd/lib/gl/nvidia',
},
fileSystem: virtualFileSystem,
});
expect(probeRuntime(virtualHelperPath)).toEqual(
expect.objectContaining({
usable: true,
profile: 'portable',
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
virtualProviderWrapper,
[virtualHelperPath, '--runtime-probe'],
expect.objectContaining({
env: expect.objectContaining({
SNAP: virtualSnapRoot,
LD_LIBRARY_PATH: [
path.join(virtualNativeDir, 'lib'),
'/var/lib/snapd/lib/gl',
'/var/lib/snapd/lib/gl/nvidia',
...snapLibraries(
`graphics/usr/lib/${linuxTriplet}`,
`graphics/usr/lib/${linuxTriplet}/vdpau`
),
'/usr/lib/x86_64-linux-gnu',
...snapLibraries(
`gnome-platform/lib/${linuxTriplet}`,
`gnome-platform/usr/lib/${linuxTriplet}`,
`gnome-platform/usr/lib/${linuxTriplet}/mesa`,
`gnome-platform/usr/lib/${linuxTriplet}/mesa-egl`,
`gnome-platform/usr/lib/${linuxTriplet}/dri`,
`gnome-platform/usr/lib/${linuxTriplet}/pulseaudio`,
'lib',
'usr/lib',
`lib/${linuxTriplet}`,
`usr/lib/${linuxTriplet}`
),
].join(':'),
}),
})
);
});
it('reports a stable unavailable reason when the Snap graphics provider is disconnected', () => {
const actualSnapRoot = path.join(context.rootDir, 'snap-root');
const fixture = createFixture(
path.join(actualSnapRoot, 'fixture'),
'portable'
);
const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics');
mkdirSync(actualGraphicsRoot, { recursive: true });
const virtualSnapRoot = '/snap/iptvnator/42';
const virtualNativeDir = path.join(
virtualSnapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const translatePath = (candidatePath: string): string => {
if (
candidatePath === virtualNativeDir ||
candidatePath.startsWith(`${virtualNativeDir}${path.sep}`)
) {
return path.join(
fixture.nativeDir,
path.relative(virtualNativeDir, candidatePath)
);
}
const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics');
if (
candidatePath === virtualGraphicsRoot ||
candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`)
) {
return path.join(
actualGraphicsRoot,
path.relative(virtualGraphicsRoot, candidatePath)
);
}
return candidatePath;
};
const probeRuntime = context.createProbe({
env: { SNAP: virtualSnapRoot },
fileSystem: {
accessSync: (candidatePath, mode) =>
accessSync(translatePath(candidatePath), mode),
lstatSync: (candidatePath) =>
lstatSync(translatePath(candidatePath)),
readFileSync: (candidatePath) =>
readFileSync(translatePath(candidatePath)),
readdirSync: (candidatePath) =>
readdirSync(translatePath(candidatePath)),
},
});
expect(
probeRuntime(path.join(virtualNativeDir, 'iptvnator_mpv_helper'))
).toEqual({
usable: false,
reason: 'snap-graphics-provider-unavailable',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('reprobes when the helper identity changes', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
writeFileSync(fixture.helperPath, '#!/bin/sh\n# changed\n');
chmodSync(fixture.helperPath, 0o755);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
});
it('reprobes when the manifest identity changes', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const manifest = cloneManifest(fixture.manifest);
manifest.generatedAt = '2026-07-17T00:01:00.000Z';
writeManifest(fixture.manifestPath, manifest);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
});
it.each([
['linux', 'arm64', 'unsupported-architecture'],
['linux', 'arm', 'unsupported-architecture'],
['darwin', 'x64', 'unsupported-platform'],
] as const)(
'does not probe unsupported %s/%s runtimes',
(platform, arch, reason) => {
const fixture = createFixture(context.rootDir);
expect(
context.createProbe({ platform, arch })(fixture.helperPath)
).toEqual({
usable: false,
reason,
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
});
@@ -0,0 +1,334 @@
import { spawnSync as nodeSpawnSync } from 'child_process';
import * as nodeFileSystem from 'fs';
import path from 'path';
import {
RUNTIME_MANIFEST_NAME,
RUNTIME_PROBE_MAX_BUFFER_BYTES,
RUNTIME_PROBE_PROTOCOL,
RUNTIME_PROBE_TIMEOUT_MS,
} from './contracts';
import { createLinuxFrameCopyHelperLaunch } from './helper-launch';
import { validatePackage } from './package-validator';
import { EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS } from './types';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeDependencies,
EmbeddedMpvFrameCopyRuntimeFileSystem,
EmbeddedMpvFrameCopyRuntimeResult,
EmbeddedMpvHelperRuntimeProbeFailureReason,
ValidManifest,
ValidatedPackage,
} from './types';
import {
failure,
fileIdentity,
hasExactFields,
isMissingFileError,
isObject,
isValidationFailure,
} from './validation-primitives';
const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set<string>(
Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)
);
const HELPER_RUNTIME_PROBE_STDERR_LIMIT = 16_384;
const HELPER_RUNTIME_PROBE_STDERR_EVENT =
'embedded-mpv-helper-runtime-probe-stderr';
interface ParsedFailedProbe {
helperReason: EmbeddedMpvHelperRuntimeProbeFailureReason;
helperDetail?: string;
}
function isSafeHelperDetail(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 1024 &&
!/[^\x20-\x7e]/.test(value)
);
}
function parseFailedProbe(stdout: unknown): ParsedFailedProbe | null {
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(stdout.slice(0, -1));
} catch {
return null;
}
if (!isObject(parsed)) {
return null;
}
const hasDetail = Object.prototype.hasOwnProperty.call(parsed, 'detail');
const fields = hasDetail
? ['detail', 'protocol', 'reason', 'usable']
: ['protocol', 'reason', 'usable'];
if (
!hasExactFields(parsed, fields) ||
parsed.protocol !== RUNTIME_PROBE_PROTOCOL ||
parsed.usable !== false ||
typeof parsed.reason !== 'string' ||
!HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST.has(parsed.reason) ||
(hasDetail && !isSafeHelperDetail(parsed.detail))
) {
return null;
}
return {
helperReason:
parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason,
...(hasDetail ? { helperDetail: parsed.detail as string } : {}),
};
}
function parseSuccessfulProbe(
stdout: unknown,
manifest: ValidManifest
): EmbeddedMpvFrameCopyRuntimeResult {
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
return failure('helper-probe-invalid-output');
}
let parsed: unknown;
try {
parsed = JSON.parse(stdout.slice(0, -1));
} catch {
return failure('helper-probe-invalid-output');
}
if (!isObject(parsed)) {
return failure('helper-probe-invalid-output');
}
if (parsed.protocol !== RUNTIME_PROBE_PROTOCOL) {
return failure('helper-probe-protocol-mismatch');
}
if (parsed.usable !== true) {
return failure(
parsed.usable === false
? 'helper-probe-unusable'
: 'helper-probe-invalid-output'
);
}
if (
!hasExactFields(parsed, [
'libmpv',
'protocol',
'renderApi',
'usable',
]) ||
typeof parsed.libmpv !== 'string' ||
parsed.libmpv.trim() === '' ||
parsed.renderApi !== 'egl'
) {
return failure('helper-probe-invalid-output');
}
return {
usable: true,
profile: manifest.profile,
runtimeMode: manifest.runtimeMode,
libmpv: parsed.libmpv,
renderApi: parsed.renderApi,
};
}
function traceHelperProbeStderr(
stderr: unknown,
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
): void {
if (
dependencies.env.IPTVNATOR_TRACE_PLAYER !== '1' ||
typeof stderr !== 'string' ||
stderr.length === 0
) {
return;
}
const boundedStderr = stderr.slice(0, HELPER_RUNTIME_PROBE_STDERR_LIMIT);
const output = `${JSON.stringify({
event: HELPER_RUNTIME_PROBE_STDERR_EVENT,
stderr: boundedStderr,
truncated: stderr.length > boundedStderr.length,
})}\n`;
try {
dependencies.writeStderr(output);
} catch {
// Opt-in diagnostics must never change the fail-closed probe result.
}
}
function runHelperProbe(
runtimePackage: ValidatedPackage,
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
): EmbeddedMpvFrameCopyRuntimeResult {
const launch = createLinuxFrameCopyHelperLaunch({
environment: dependencies.env,
helperPath: runtimePackage.helperPath,
helperArgs: ['--runtime-probe'],
runtimeMode: runtimePackage.manifest.runtimeMode,
fileSystem: dependencies.fileSystem,
});
if (launch.usable === false) {
return failure(launch.reason);
}
let result: ReturnType<typeof nodeSpawnSync>;
try {
result = dependencies.spawnSync(launch.command, launch.args, {
encoding: 'utf8',
timeout: RUNTIME_PROBE_TIMEOUT_MS,
killSignal: 'SIGKILL',
windowsHide: true,
maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES,
env: launch.env,
});
} catch {
return failure('helper-probe-spawn-error');
}
traceHelperProbeStderr(result.stderr, dependencies);
if (
result.error &&
'code' in result.error &&
result.error.code === 'ETIMEDOUT'
) {
return failure('helper-probe-timeout');
}
if (result.error) {
return failure('helper-probe-spawn-error');
}
if (result.signal) {
return failure('helper-probe-signaled');
}
if (result.status !== 0) {
const helperFailure = parseFailedProbe(result.stdout);
return helperFailure
? {
usable: false,
reason: 'helper-probe-failed',
...helperFailure,
}
: failure('helper-probe-failed');
}
return parseSuccessfulProbe(result.stdout, runtimePackage.manifest);
}
/**
* Creates an isolated probe/cache. Production uses the singleton below;
* tests inject filesystem and spawn collaborators through this factory.
*/
export function createEmbeddedMpvFrameCopyRuntimeProbe(
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
): (
helperPath: string,
manifestContract?: EmbeddedMpvFrameCopyManifestContract
) => EmbeddedMpvFrameCopyRuntimeResult {
const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = {
accessSync: (filePath, mode) =>
nodeFileSystem.accessSync(filePath, mode),
lstatSync: (filePath) => nodeFileSystem.lstatSync(filePath),
readFileSync: (filePath) => nodeFileSystem.readFileSync(filePath),
readdirSync: (filePath) => nodeFileSystem.readdirSync(filePath),
};
const dependencies: EmbeddedMpvFrameCopyRuntimeDependencies = {
platform: process.platform,
arch: process.arch,
env: process.env,
fileSystem: defaultFileSystem,
spawnSync: nodeSpawnSync,
writeStderr: (output) => {
nodeFileSystem.writeSync(process.stderr.fd, output);
},
...overrides,
};
const resultCache = new Map<string, EmbeddedMpvFrameCopyRuntimeResult>();
return (
helperPath: string,
manifestContract: EmbeddedMpvFrameCopyManifestContract = 'packaged'
): EmbeddedMpvFrameCopyRuntimeResult => {
if (dependencies.platform !== 'linux') {
return failure('unsupported-platform');
}
if (dependencies.arch !== 'x64') {
return failure('unsupported-architecture');
}
const manifestPath = path.join(
path.dirname(helperPath),
RUNTIME_MANIFEST_NAME
);
let helperStat: nodeFileSystem.Stats;
let manifestStat: nodeFileSystem.Stats;
try {
helperStat = dependencies.fileSystem.lstatSync(helperPath);
} catch {
return failure('runtime-artifact-missing');
}
try {
manifestStat = dependencies.fileSystem.lstatSync(manifestPath);
} catch (error) {
return failure(
isMissingFileError(error)
? 'runtime-manifest-missing'
: 'runtime-manifest-invalid'
);
}
let helperContents: Buffer;
let manifestContents: Buffer;
try {
helperContents = dependencies.fileSystem.readFileSync(helperPath);
} catch {
return failure('runtime-artifact-invalid');
}
try {
manifestContents =
dependencies.fileSystem.readFileSync(manifestPath);
} catch {
return failure('runtime-manifest-invalid');
}
const cacheKey = `${manifestContract}\0${fileIdentity(
helperPath,
helperStat,
helperContents
)}\0${fileIdentity(manifestPath, manifestStat, manifestContents)}`;
const cached = resultCache.get(cacheKey);
if (cached) {
return cached;
}
let result: EmbeddedMpvFrameCopyRuntimeResult;
try {
const runtimePackage = validatePackage(
helperPath,
manifestPath,
dependencies.fileSystem,
manifestContract
);
result = isValidationFailure(runtimePackage)
? failure(runtimePackage.reason)
: runHelperProbe(runtimePackage.value, dependencies);
} catch {
result = failure('runtime-probe-internal-error');
}
resultCache.set(cacheKey, result);
return result;
};
}
const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe();
/**
* Fail-closed, process-lifetime Linux runtime decision shared by startup and
* the service gate. The helper and manifest identities scope cached results.
*/
export function probeEmbeddedMpvFrameCopyRuntime(
helperPath: string,
manifestContract: EmbeddedMpvFrameCopyManifestContract
): EmbeddedMpvFrameCopyRuntimeResult {
return processRuntimeProbe(helperPath, manifestContract);
}
@@ -0,0 +1,95 @@
import { isDeepStrictEqual } from 'util';
import {
ALLOWED_EXTERNAL_LIBRARY_NAMES,
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES,
SAFE_RUNTIME_NAME_PATTERN,
SHARED_LIBRARY_PATTERN,
} from './contracts';
import type { RuntimeFile } from './types';
import {
hasExactFields,
isObject,
isSafeRuntimeName,
} from './validation-primitives';
export function validateRuntimeClosure(
value: unknown,
runtimeFiles: RuntimeFile[],
libmpvSoname: string,
externalSystemLibraries: unknown
): boolean {
if (
!isDeepStrictEqual(
externalSystemLibraries,
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES
) ||
!isObject(value) ||
!hasExactFields(value, ['entries', 'externalDependencies']) ||
!Array.isArray(value.entries) ||
!Array.isArray(value.externalDependencies) ||
value.externalDependencies.some(
(dependency) =>
typeof dependency !== 'string' ||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
!SHARED_LIBRARY_PATTERN.test(dependency)
)
) {
return false;
}
const runtimeNames = runtimeFiles.map(({ name }) => name);
const runtimeNameSet = new Set(runtimeNames);
const closureNames: string[] = [];
const computedExternalDependencies = new Set<string>();
for (const entry of value.entries) {
if (
!isObject(entry) ||
!hasExactFields(entry, [
'name',
'needed',
'rpath',
'runpath',
'soname',
]) ||
!isSafeRuntimeName(entry.name) ||
(entry.soname !== null && !isSafeRuntimeName(entry.soname)) ||
!Array.isArray(entry.needed) ||
entry.needed.some(
(dependency) =>
typeof dependency !== 'string' ||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
!SHARED_LIBRARY_PATTERN.test(dependency)
) ||
!isDeepStrictEqual(entry.rpath, []) ||
!isDeepStrictEqual(entry.runpath, ['$ORIGIN']) ||
new Set(entry.needed).size !== entry.needed.length ||
!isDeepStrictEqual([...entry.needed].sort(), entry.needed)
) {
return false;
}
closureNames.push(entry.name);
for (const dependency of entry.needed) {
if (runtimeNameSet.has(dependency)) {
continue;
}
if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependency)) {
return false;
}
computedExternalDependencies.add(dependency);
}
}
const linkerAlias = value.entries.find(
(entry) => isObject(entry) && entry.name === 'libmpv.so'
);
return (
isDeepStrictEqual(closureNames, runtimeNames) &&
new Set(closureNames).size === closureNames.length &&
isDeepStrictEqual(
value.externalDependencies,
[...computedExternalDependencies].sort()
) &&
isObject(linkerAlias) &&
linkerAlias.soname === libmpvSoname
);
}
@@ -0,0 +1,306 @@
import { createHash } from 'crypto';
import { chmodSync, mkdirSync, writeFileSync } from 'fs';
import path from 'path';
import type { createEmbeddedMpvFrameCopyRuntimeProbe } from '../embedded-mpv-frame-copy-runtime';
import {
EXTERNAL_SYSTEM_LIBRARIES,
PINNED_SOURCE_PACKAGE_IDENTITIES,
type RuntimeFile,
type RuntimeFixture,
} from './runtime.spec-data';
function sha256(contents: Buffer): string {
return createHash('sha256').update(contents).digest('hex');
}
function createRuntimeFiles(
runtimeContents: Record<string, Buffer>
): RuntimeFile[] {
return Object.entries(runtimeContents)
.map(([name, contents]) => ({
name,
size: contents.length,
sha256: sha256(contents),
}))
.sort((left, right) => left.name.localeCompare(right.name));
}
function createSourceRuntime(
runtimeFiles: RuntimeFile[],
runtimeDependencyClosure: Record<string, unknown>
): Record<string, unknown> {
const packages = cloneManifest(PINNED_SOURCE_PACKAGE_IDENTITIES);
return {
schemaVersion: 1,
origin: 'vendored-lgpl-source-build',
platform: 'linux',
arch: 'x64',
packages,
ffmpeg: {
...(packages.ffmpeg as Record<string, unknown>),
configureFlags: ['--disable-gpl', '--disable-nonfree'],
},
mpv: {
...(packages.mpv as Record<string, unknown>),
mesonFlags: ['-Dgpl=false', '-Dlibmpv=true'],
},
sourceDistribution:
'Publish the exact hwdata archive and pnp.ids with the libdisplay-info source.',
runtimeFiles,
runtimeTotalBytes: runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
runtimeAbi: {
baseline: {
distribution: 'Ubuntu 22.04',
glibcMaximum: '2.35',
glibcxxMaximum: '3.4.30',
},
files: runtimeFiles.map(({ name }) => ({
name,
requiredGlibc: '2.34',
requiredGlibcxx: null,
})),
},
runtimeDependencyClosure,
externalSystemLibraries: cloneManifest(EXTERNAL_SYSTEM_LIBRARIES),
};
}
export function createFixture(
rootDir: string,
profile: 'system' | 'portable' | 'flatpak' = 'system'
): RuntimeFixture {
const nativeDir = path.join(rootDir, profile, 'native');
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json');
mkdirSync(nativeDir, { recursive: true });
writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', {
mode: 0o644,
});
writeFileSync(
path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
'reader',
{ mode: 0o644 }
);
writeFileSync(helperPath, '#!/bin/sh\n', { mode: 0o755 });
const bundled = profile !== 'system';
const runtimeContents = bundled
? {
'libmpv.so': Buffer.from('libmpv-linker-alias'),
'libmpv.so.2': Buffer.from('libmpv-soname'),
}
: {};
const runtimeFiles = createRuntimeFiles(runtimeContents);
const runtimeDependencyClosure = {
entries: runtimeFiles.map(({ name }) => ({
name,
soname: name === 'libmpv.so' ? 'libmpv.so.2' : name,
needed: [],
rpath: [],
runpath: ['$ORIGIN'],
})),
externalDependencies: [],
};
const externalSystemLibraries = cloneManifest(EXTERNAL_SYSTEM_LIBRARIES);
const sourceRuntime = createSourceRuntime(
runtimeFiles,
runtimeDependencyClosure
);
const sourceArchive = {
schemaVersion: 1,
name: 'linux-frame-copy-runtime-sources.tar.xz',
sha256: '7'.repeat(64),
repositoryRevision: '8'.repeat(40),
};
const manifest: Record<string, unknown> = {
schemaVersion: 1,
origin: bundled
? 'bundled-lgpl-frame-copy'
: 'system-libmpv-frame-copy',
generatedAt: '2026-07-17T00:00:00.000Z',
platform: 'linux',
arch: 'x64',
profile,
runtimeMode: bundled ? 'bundled' : 'system',
targets:
profile === 'system'
? ['deb', 'pacman', 'rpm']
: profile === 'portable'
? ['appimage', 'snap']
: ['flatpak'],
artifacts: {
addon: {
name: 'embedded_mpv.node',
regularFile: true,
readable: true,
},
frameReader: {
name: 'embedded_mpv_frame_reader.node',
regularFile: true,
readable: true,
},
helper: {
name: 'iptvnator_mpv_helper',
regularFile: true,
readable: true,
executable: true,
},
},
processIsolation: {
addonLoadsLibmpv: false,
readerLoadsLibmpv: false,
electronLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
},
nativeViewFallback: 'process-isolated mpv --wid',
libmpvSoname: 'libmpv.so.2',
packageDependencies: bundled
? {}
: {
deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'],
rpm: [
'mpv-libs',
'libglvnd-egl',
'libglvnd-glx',
'mesa-libgbm',
],
pacman: ['mpv', 'libglvnd', 'mesa'],
},
runtimeFiles,
runtimeTotalBytes: runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
...(bundled
? {
runtimeDependencyClosure,
externalSystemLibraries,
sourceArchive,
sourceRuntime,
}
: {}),
};
writeManifest(manifestPath, manifest);
if (bundled) {
const libDir = path.join(nativeDir, 'lib');
mkdirSync(libDir);
for (const [name, contents] of Object.entries(runtimeContents)) {
writeFileSync(path.join(libDir, name), contents, {
mode: 0o644,
});
}
}
return {
nativeDir,
helperPath,
manifestPath,
manifest,
runtimeContents,
};
}
export function createDevelopmentFixture(
rootDir: string,
buildInputMode: 'system-dev' | 'system-build-inputs' | 'bundled-runtime'
): RuntimeFixture {
const bundled = buildInputMode === 'bundled-runtime';
const fixture = createFixture(rootDir, bundled ? 'portable' : 'system');
const packagedSourceRuntime = fixture.manifest.sourceRuntime;
const sourceRuntime =
buildInputMode === 'system-dev'
? {
linuxBackend: 'process-isolated mpv --wid',
warning:
'Development-only unmanaged system libmpv toolchain.',
}
: buildInputMode === 'system-build-inputs'
? {
linuxBackend: 'process-isolated mpv --wid',
buildInputs: {
libmpvDevPackage: 'libmpv-dev',
mpvPackage: 'mpv',
},
sourceDistribution:
'Linux development inputs are supplied by the host package manager.',
}
: packagedSourceRuntime;
const manifest: Record<string, unknown> = {
schemaVersion: 1,
origin: 'linux-frame-copy-build',
generatedAt: '2026-07-17T00:00:00.000Z',
platform: 'linux',
arch: 'x64',
buildInputMode,
sourceRuntimeValidated: bundled,
allowedPackageRuntimeModes: ['system', 'bundled'],
packageRuntimeAvailability: {
system: bundled,
bundled,
},
artifacts: {
addon: 'embedded_mpv.node',
frameReader: 'embedded_mpv_frame_reader.node',
helper: 'iptvnator_mpv_helper',
},
processIsolation: {
addonLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
},
nativeViewFallback: 'process-isolated mpv --wid',
libmpvSoname: bundled ? 'libmpv.so.2' : null,
runtimeFiles: fixture.manifest.runtimeFiles,
runtimeTotalBytes: fixture.manifest.runtimeTotalBytes,
sourceArchive: bundled
? cloneManifest(fixture.manifest.sourceArchive)
: null,
sourceRuntime,
};
fixture.manifest = manifest;
writeManifest(fixture.manifestPath, manifest);
return fixture;
}
export function probeDevelopmentRuntime(
probeRuntime: ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>,
helperPath: string
) {
return (
probeRuntime as unknown as (
path: string,
contract: 'development'
) => ReturnType<typeof probeRuntime>
)(helperPath, 'development');
}
export function mirrorBundledManifestFields(
manifest: Record<string, unknown>
): void {
const sourceRuntime = manifest.sourceRuntime as Record<string, unknown>;
sourceRuntime.runtimeFiles = cloneManifest(manifest.runtimeFiles);
sourceRuntime.runtimeTotalBytes = manifest.runtimeTotalBytes;
sourceRuntime.runtimeDependencyClosure = cloneManifest(
manifest.runtimeDependencyClosure
);
sourceRuntime.externalSystemLibraries = cloneManifest(
manifest.externalSystemLibraries
);
}
export function writeManifest(
manifestPath: string,
manifest: Record<string, unknown>
): void {
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, {
mode: 0o644,
});
chmodSync(manifestPath, 0o644);
}
export function cloneManifest<T>(manifest: T): T {
return JSON.parse(JSON.stringify(manifest)) as T;
}
@@ -0,0 +1,74 @@
import { spawnSync } from 'child_process';
import {
accessSync,
lstatSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
} from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import {
createEmbeddedMpvFrameCopyRuntimeProbe,
type EmbeddedMpvFrameCopyRuntimeDependencies,
} from '../embedded-mpv-frame-copy-runtime';
import { SUCCESS_OUTPUT } from './runtime.spec-data';
export interface RuntimeTestContext {
rootDir: string;
spawnRuntimeProbe: jest.Mock;
writeRuntimeProbeStderr: jest.Mock<void, [string]>;
fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'];
createProbe(
overrides?: Partial<EmbeddedMpvFrameCopyRuntimeDependencies>
): ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>;
dispose(): void;
}
export function createRuntimeTestContext(): RuntimeTestContext {
const rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-'));
const spawnRuntimeProbe = jest.fn(() => ({
status: 0,
signal: null,
stdout: SUCCESS_OUTPUT,
stderr: '',
}));
const writeRuntimeProbeStderr = jest.fn<void, [string]>();
const fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'] = {
accessSync: jest.fn((filePath: string, mode: number) =>
accessSync(filePath, mode)
),
lstatSync: jest.fn((filePath: string) => lstatSync(filePath)),
readFileSync: jest.fn((filePath: string) => readFileSync(filePath)),
readdirSync: jest.fn((filePath: string) => readdirSync(filePath)),
};
const context: RuntimeTestContext = {
rootDir,
spawnRuntimeProbe,
writeRuntimeProbeStderr,
fileSystem,
createProbe(
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
) {
return createEmbeddedMpvFrameCopyRuntimeProbe({
platform: 'linux',
arch: 'x64',
env: {
PATH: '/usr/bin',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/ambient/libs',
LD_PRELOAD: '/tmp/inject.so',
},
fileSystem: context.fileSystem,
spawnSync: context.spawnRuntimeProbe as typeof spawnSync,
writeStderr: context.writeRuntimeProbeStderr,
...overrides,
});
},
dispose() {
rmSync(context.rootDir, { recursive: true, force: true });
},
};
return context;
}
@@ -0,0 +1,179 @@
export const SUCCESS_OUTPUT =
'{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n';
export const EXTERNAL_SYSTEM_LIBRARIES = [
{
name: 'libEGL.so.1',
interface: 'EGL',
reason: 'System graphics-driver interface used by the frame-copy helper.',
},
{
name: 'libGL.so.1',
interface: 'OpenGL',
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
},
{
name: 'libGLX.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
},
{
name: 'libOpenGL.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
},
{
name: 'libasound.so.2',
interface: 'ALSA',
reason: 'Linux system audio interface intentionally used by libmpv.',
},
{
name: 'libdrm.so.2',
interface: 'DRM',
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
},
{
name: 'libgbm.so.1',
interface: 'GBM',
reason: 'System graphics-buffer interface used by headless EGL rendering.',
},
{
name: 'libpulse.so.0',
interface: 'PulseAudio',
reason: 'Linux desktop audio interface intentionally used by libmpv.',
},
{
name: 'libva-drm.so.2',
interface: 'VA-API DRM',
reason: 'System VA-API DRM interface used for hardware decoding.',
},
{
name: 'libva.so.2',
interface: 'VA-API',
reason: 'System video-acceleration interface used for hardware decoding.',
},
];
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
freetype: {
version: '2.13.3',
sourceUrl:
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
sourceSha256:
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
license: 'FreeType License (FTL)',
},
fribidi: {
version: '1.0.16',
sourceUrl:
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
sourceSha256:
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
license: 'LGPL-2.1-or-later',
},
harfbuzz: {
version: '8.5.0',
sourceUrl:
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
sourceSha256:
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
license: 'MIT',
},
expat: {
version: '2.8.2',
sourceUrl:
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
sourceSha256:
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
license: 'MIT',
},
fontconfig: {
version: '2.16.0',
sourceUrl:
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
sourceSha256:
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
license: 'MIT',
},
libass: {
version: '0.17.3',
sourceUrl:
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
sourceSha256:
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
license: 'ISC',
},
openssl: {
version: '3.5.7',
sourceUrl:
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
sourceSha256:
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
license: 'Apache-2.0',
},
ffmpeg: {
version: '8.1',
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
sourceSha256:
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
license: 'LGPL-2.1-or-later',
},
libplacebo: {
version: '7.360.1',
sourceUrl: 'https://github.com/haasn/libplacebo.git',
sourceTag: 'v7.360.1',
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
sourceSubmodules: [
'450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers',
'97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float',
'73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad',
'15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja',
'297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe',
'242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear',
],
license: 'LGPL-2.1-or-later',
},
hwdata: {
version: '0.409',
sourceUrl:
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
sourceSha256:
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
buildInput: {
consumer: 'libdisplay-info',
relativePath: 'pnp.ids',
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
},
license: 'GPL-2.0-or-later OR XFree86-1.0',
},
'libdisplay-info': {
version: '0.1.1',
sourceUrl:
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
sourceSha256:
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
license: 'MIT',
},
mpv: {
version: '0.41.0',
sourceUrl:
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
sourceSha256:
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
license: 'LGPL-2.1-or-later with -Dgpl=false',
},
};
export interface RuntimeFile {
name: string;
size: number;
sha256: string;
}
export interface RuntimeFixture {
nativeDir: string;
helperPath: string;
manifestPath: string;
manifest: Record<string, unknown>;
runtimeContents: Record<string, Buffer>;
}
@@ -0,0 +1,150 @@
import {
cloneManifest,
createFixture,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy source runtime policy', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it.each([
{
label: 'pinned source identity',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.mpv.sourceSha256 = '0'.repeat(64);
},
},
{
label: 'pinned source URL',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.freetype.sourceUrl =
'https://example.invalid/freetype.tar.xz';
},
},
{
label: 'pinned git tag',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.libplacebo.sourceTag = 'main';
},
},
{
label: 'pinned hwdata build input',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.hwdata.buildInput = {
consumer: 'libdisplay-info',
relativePath: '../pnp.ids',
purpose:
'PNP vendor lookup table compiled into libdisplay-info.',
};
},
},
{
label: 'git submodule record',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.libplacebo.sourceSubmodules = [
`${'a'.repeat(40)} ../outside`,
];
},
},
{
label: 'duplicate git submodule records',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
const record = `${'a'.repeat(40)} 3rdparty/example`;
packages.libplacebo.sourceSubmodules = [record, record];
},
},
{
label: 'unpinned git submodule commit',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.libplacebo.sourceSubmodules = [
`${'f'.repeat(40)} 3rdparty/Vulkan-Headers`,
`${'e'.repeat(40)} 3rdparty/fast_float`,
];
},
},
{
label: 'portable ABI baseline',
mutate(sourceRuntime: Record<string, unknown>) {
const runtimeAbi = sourceRuntime.runtimeAbi as {
baseline: Record<string, unknown>;
};
runtimeAbi.baseline.glibcMaximum = '9.99';
},
},
{
label: 'source-distribution obligation',
mutate(sourceRuntime: Record<string, unknown>) {
sourceRuntime.sourceDistribution = 'Sources available.';
},
},
{
label: 'FFmpeg LGPL flags',
mutate(sourceRuntime: Record<string, unknown>) {
const ffmpeg = sourceRuntime.ffmpeg as {
configureFlags: string[];
};
ffmpeg.configureFlags = ['--disable-nonfree', '--enable-gpl'];
},
},
{
label: 'mpv LGPL flags',
mutate(sourceRuntime: Record<string, unknown>) {
const mpv = sourceRuntime.mpv as {
mesonFlags: string[];
};
mpv.mesonFlags = ['-Dgpl=true', '-Dlibmpv=true'];
},
},
])('rejects an invalid $label', ({ mutate }) => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
mutate(manifest.sourceRuntime as Record<string, unknown>);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,247 @@
import path from 'path';
import { isDeepStrictEqual } from 'util';
import {
GIT_COMMIT_PATTERN,
PINNED_SOURCE_PACKAGE_IDENTITIES,
PORTABLE_ABI_BASELINE,
SUBMODULE_RECORD_PATTERN,
VERSION_PATTERN,
} from './contracts';
import type { RuntimeFile } from './types';
import {
hasExactFields,
isObject,
isSafeRuntimeName,
} from './validation-primitives';
function compareDottedVersions(left: string, right: string): number {
const leftParts = left.split('.').map(Number);
const rightParts = right.split('.').map(Number);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (difference !== 0) {
return difference;
}
}
return 0;
}
function validatesPinnedSourceIdentity(
candidate: unknown,
expected: (typeof PINNED_SOURCE_PACKAGE_IDENTITIES)[keyof typeof PINNED_SOURCE_PACKAGE_IDENTITIES]
): boolean {
if (
!isObject(candidate) ||
candidate.version !== expected.version ||
candidate.sourceUrl !== expected.sourceUrl ||
candidate.license !== expected.license
) {
return false;
}
if (
('sourceTag' in expected
? candidate.sourceTag !== expected.sourceTag
: candidate.sourceTag !== undefined) ||
('buildInput' in expected
? !isDeepStrictEqual(candidate.buildInput, expected.buildInput)
: candidate.buildInput !== undefined)
) {
return false;
}
if ('sourceSha256' in expected) {
return (
candidate.sourceSha256 === expected.sourceSha256 &&
candidate.sourceGitCommit === undefined &&
candidate.sourceSubmodules === undefined
);
}
return (
'sourceSubmodules' in expected &&
candidate.sourceGitCommit === expected.sourceGitCommit &&
GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) &&
candidate.sourceSha256 === undefined &&
validatesGitSubmoduleRecords(candidate.sourceSubmodules) &&
isDeepStrictEqual(candidate.sourceSubmodules, expected.sourceSubmodules)
);
}
function validatesGitSubmoduleRecords(value: unknown): boolean {
if (
!Array.isArray(value) ||
value.length === 0 ||
new Set(value).size !== value.length
) {
return false;
}
return value.every((record) => {
if (typeof record !== 'string') {
return false;
}
const match = record.match(SUBMODULE_RECORD_PATTERN);
if (!match) {
return false;
}
const submodulePath = match[1];
return (
!path.posix.isAbsolute(submodulePath) &&
!submodulePath
.split('/')
.some((segment) => segment === '.' || segment === '..')
);
});
}
function validateStringFlags(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every(
(flag) =>
typeof flag === 'string' &&
flag.length > 0 &&
flag.trim() === flag
) &&
new Set(value).size === value.length
);
}
function validateRuntimeAbi(
value: unknown,
runtimeFiles: RuntimeFile[]
): boolean {
if (
!isObject(value) ||
!hasExactFields(value, ['baseline', 'files']) ||
!isDeepStrictEqual(value.baseline, PORTABLE_ABI_BASELINE) ||
!Array.isArray(value.files)
) {
return false;
}
const abiFileNames: string[] = [];
for (const record of value.files) {
if (
!isObject(record) ||
!hasExactFields(record, [
'name',
'requiredGlibc',
'requiredGlibcxx',
]) ||
!isSafeRuntimeName(record.name)
) {
return false;
}
for (const [field, maximum] of [
['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum],
['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum],
] as const) {
const version = record[field];
if (
version !== null &&
(typeof version !== 'string' ||
!VERSION_PATTERN.test(version) ||
compareDottedVersions(version, maximum) > 0)
) {
return false;
}
}
abiFileNames.push(record.name);
}
return isDeepStrictEqual(
abiFileNames,
runtimeFiles.map(({ name }) => name)
);
}
/**
* The source builder and package verifier own exhaustive build-host, recipe,
* tool-version, URL and external-configuration validation. Startup repeats
* only the immutable policy boundary needed before sandbox relaxation:
* pinned source identities/licenses, LGPL flags, portable ABI, display-data
* distribution, and the exact runtime closure mirrored by the package.
*/
export function validateSourceRuntimePolicy(
value: unknown,
runtimeFiles: RuntimeFile[],
runtimeDependencyClosure: unknown,
externalSystemLibraries: unknown
): boolean {
if (
!isObject(value) ||
value.schemaVersion !== 1 ||
value.origin !== 'vendored-lgpl-source-build' ||
value.platform !== 'linux' ||
value.arch !== 'x64' ||
!isObject(value.packages) ||
!isObject(value.ffmpeg) ||
!isObject(value.mpv) ||
!isDeepStrictEqual(
Object.keys(value.packages).sort(),
Object.keys(PINNED_SOURCE_PACKAGE_IDENTITIES).sort()
)
) {
return false;
}
for (const [packageName, expectedIdentity] of Object.entries(
PINNED_SOURCE_PACKAGE_IDENTITIES
)) {
if (
!validatesPinnedSourceIdentity(
value.packages[packageName],
expectedIdentity
)
) {
return false;
}
}
if (
!validatesPinnedSourceIdentity(
value.ffmpeg,
PINNED_SOURCE_PACKAGE_IDENTITIES.ffmpeg
) ||
!validateStringFlags(value.ffmpeg.configureFlags) ||
!value.ffmpeg.configureFlags.includes('--disable-gpl') ||
!value.ffmpeg.configureFlags.includes('--disable-nonfree') ||
value.ffmpeg.configureFlags.includes('--enable-gpl') ||
value.ffmpeg.configureFlags.includes('--enable-nonfree') ||
!validatesPinnedSourceIdentity(
value.mpv,
PINNED_SOURCE_PACKAGE_IDENTITIES.mpv
) ||
!validateStringFlags(value.mpv.mesonFlags) ||
!value.mpv.mesonFlags.includes('-Dgpl=false') ||
!value.mpv.mesonFlags.includes('-Dlibmpv=true') ||
value.mpv.mesonFlags.includes('-Dgpl=true')
) {
return false;
}
if (
typeof value.sourceDistribution !== 'string' ||
!/\bhwdata\b/i.test(value.sourceDistribution) ||
!/\bpnp\.ids\b/i.test(value.sourceDistribution) ||
!/\blibdisplay-info\b/i.test(value.sourceDistribution) ||
value.runtimeTotalBytes !==
runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
) ||
!isDeepStrictEqual(value.runtimeFiles, runtimeFiles) ||
!isDeepStrictEqual(
value.runtimeDependencyClosure,
runtimeDependencyClosure
) ||
!isDeepStrictEqual(
value.externalSystemLibraries,
externalSystemLibraries
) ||
!validateRuntimeAbi(value.runtimeAbi, runtimeFiles)
) {
return false;
}
return true;
}
@@ -0,0 +1,51 @@
import path from 'path';
const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const;
function isPathInside(
parentPath: string,
candidatePath: string,
allowEqual: boolean
): boolean {
const relativePath = path.relative(parentPath, candidatePath);
if (relativePath === '') {
return allowEqual;
}
return (
relativePath !== '..' &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
export function resolveTrustedSnapRoot(
environment: NodeJS.ProcessEnv,
nativeDir: string
): string | null {
const declaredSnapRoot = environment.SNAP;
if (
!declaredSnapRoot ||
!path.isAbsolute(declaredSnapRoot) ||
!path.isAbsolute(nativeDir)
) {
return null;
}
const normalizedSnapRoot = path.resolve(declaredSnapRoot);
const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some(
(mountRoot) => {
const relativePath = path.relative(mountRoot, normalizedSnapRoot);
return (
isPathInside(mountRoot, normalizedSnapRoot, false) &&
relativePath.split(path.sep).filter(Boolean).length >= 2
);
}
);
if (
!resemblesReadOnlySnapMount ||
!isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false)
) {
return null;
}
return normalizedSnapRoot;
}
@@ -0,0 +1,103 @@
import type { spawnSync as nodeSpawnSync } from 'child_process';
import type * as nodeFileSystem from 'fs';
export const EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS = {
MPV_CREATE_FAILED: 'mpv-create-failed',
MPV_INITIALIZE_FAILED: 'mpv-initialize-failed',
GL_CONTEXT_CREATE_FAILED: 'gl-context-create-failed',
GL_CONTEXT_BIND_FAILED: 'gl-context-bind-failed',
MPV_RENDER_CONTEXT_FAILED: 'mpv-render-context-failed',
SHARED_MEMORY_CREATE_FAILED: 'shared-memory-create-failed',
SHARED_MEMORY_INITIALIZE_FAILED: 'shared-memory-initialize-failed',
} as const;
export type EmbeddedMpvHelperRuntimeProbeFailureReason =
(typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)[keyof typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS];
export type EmbeddedMpvFrameCopyRuntimeFailureReason =
| 'unsupported-platform'
| 'unsupported-architecture'
| 'runtime-manifest-missing'
| 'runtime-manifest-invalid'
| 'runtime-artifact-missing'
| 'runtime-artifact-invalid'
| 'runtime-library-directory-invalid'
| 'runtime-library-missing'
| 'runtime-library-undeclared'
| 'runtime-library-invalid'
| 'runtime-library-size-mismatch'
| 'runtime-library-hash-mismatch'
| 'snap-graphics-provider-unavailable'
| 'helper-probe-timeout'
| 'helper-probe-spawn-error'
| 'helper-probe-signaled'
| 'helper-probe-failed'
| 'helper-probe-invalid-output'
| 'helper-probe-protocol-mismatch'
| 'helper-probe-unusable'
| 'runtime-probe-internal-error';
export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development';
export type EmbeddedMpvFrameCopyRuntimeMode = 'system' | 'bundled';
export type RuntimeProfile = 'system' | 'portable' | 'flatpak';
export type RuntimeProbeProfile = RuntimeProfile | 'development';
export type EmbeddedMpvFrameCopyRuntimeResult =
| {
usable: true;
profile: RuntimeProbeProfile;
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
libmpv: string;
renderApi: 'egl';
}
| {
usable: false;
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
helperReason?: EmbeddedMpvHelperRuntimeProbeFailureReason;
helperDetail?: string;
};
export interface EmbeddedMpvFrameCopyRuntimeFileSystem {
accessSync(filePath: string, mode: number): void;
lstatSync(filePath: string): nodeFileSystem.Stats;
readFileSync(filePath: string): Buffer;
readdirSync(filePath: string): string[];
}
export interface EmbeddedMpvFrameCopyRuntimeDependencies {
platform: NodeJS.Platform;
arch: string;
env: NodeJS.ProcessEnv;
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem;
spawnSync: typeof nodeSpawnSync;
writeStderr(output: string): void;
}
export interface RuntimeFile {
name: string;
size: number;
sha256: string;
}
export interface ValidManifest {
profile: RuntimeProbeProfile;
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
runtimeFiles: RuntimeFile[];
}
export interface ValidatedPackage {
manifest: ValidManifest;
helperPath: string;
nativeDir: string;
}
export interface ValidationSuccess<T> {
value: T;
}
export interface ValidationFailure {
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
}
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
@@ -0,0 +1,162 @@
import { createHash } from 'crypto';
import type * as nodeFileSystem from 'fs';
import path from 'path';
import { isDeepStrictEqual } from 'util';
import {
SAFE_RUNTIME_NAME_PATTERN,
SHA256_PATTERN,
SHARED_LIBRARY_PATTERN,
} from './contracts';
import type {
EmbeddedMpvFrameCopyRuntimeFailureReason,
EmbeddedMpvFrameCopyRuntimeFileSystem,
EmbeddedMpvFrameCopyRuntimeResult,
RuntimeFile,
ValidationFailure,
ValidationResult,
} from './types';
export function failure(
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
): EmbeddedMpvFrameCopyRuntimeResult {
return { usable: false, reason };
}
export function validationFailure(
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
): ValidationFailure {
return { reason };
}
export function isValidationFailure<T>(
result: ValidationResult<T>
): result is ValidationFailure {
return 'reason' in result;
}
export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function hasExactFields(
value: Record<string, unknown>,
fields: readonly string[]
): boolean {
return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort());
}
export function isSafeRuntimeName(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value !== '.' &&
value !== '..' &&
path.basename(value) === value &&
!value.includes('/') &&
!value.includes('\\') &&
SAFE_RUNTIME_NAME_PATTERN.test(value) &&
SHARED_LIBRARY_PATTERN.test(value)
);
}
export function isMissingFileError(error: unknown): boolean {
return (
isObject(error) &&
typeof error.code === 'string' &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
);
}
export function fileIdentity(
filePath: string,
stat: nodeFileSystem.Stats,
contents: Buffer
): string {
return [
path.resolve(filePath),
stat.dev,
stat.ino,
stat.mode,
stat.size,
stat.mtimeMs,
stat.ctimeMs,
createHash('sha256').update(contents).digest('hex'),
].join(':');
}
export function readManifest(
manifestPath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<Record<string, unknown>> {
let contents: Buffer;
try {
contents = fileSystem.readFileSync(manifestPath);
} catch {
return validationFailure('runtime-manifest-invalid');
}
try {
const parsed: unknown = JSON.parse(contents.toString('utf8'));
return isObject(parsed)
? { value: parsed }
: validationFailure('runtime-manifest-invalid');
} catch {
return validationFailure('runtime-manifest-invalid');
}
}
export function validateTargets(
targets: unknown,
allowedTargets: ReadonlySet<string>
): boolean {
const expectedTargets = [...allowedTargets].sort();
if (
!Array.isArray(targets) ||
targets.length !== expectedTargets.length ||
targets.some(
(target) =>
typeof target !== 'string' ||
target.trim() !== target ||
target.toLowerCase() !== target ||
!allowedTargets.has(target)
)
) {
return false;
}
return isDeepStrictEqual(targets, expectedTargets);
}
export function validateRuntimeFiles(value: unknown): RuntimeFile[] | null {
if (!Array.isArray(value) || value.length === 0) {
return null;
}
const runtimeFiles: RuntimeFile[] = [];
const names = new Set<string>();
for (const candidate of value) {
if (
!isObject(candidate) ||
!hasExactFields(candidate, ['name', 'sha256', 'size']) ||
!isSafeRuntimeName(candidate.name) ||
!Number.isSafeInteger(candidate.size) ||
(candidate.size as number) <= 0 ||
typeof candidate.sha256 !== 'string' ||
!SHA256_PATTERN.test(candidate.sha256) ||
names.has(candidate.name)
) {
return null;
}
names.add(candidate.name);
runtimeFiles.push({
name: candidate.name,
size: candidate.size as number,
sha256: candidate.sha256,
});
}
return isDeepStrictEqual(
runtimeFiles.map(({ name }) => name).sort(),
runtimeFiles.map(({ name }) => name)
)
? runtimeFiles
: null;
}
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import type { Stats } from 'fs';
import path from 'path';
const spawnMock = jest.fn();
@@ -7,6 +8,55 @@ jest.mock('child_process', () => ({
}));
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
const HOSTILE_LOADER_ENVIRONMENT = {
BASH_ENV: '/tmp/hostile-bash-env',
ENV: '/tmp/hostile-shell-env',
BASHOPTS: 'extdebug',
SHELLOPTS: 'xtrace',
PS4: '$(/tmp/hostile-trace-hook)',
BASH_XTRACEFD: '9',
CDPATH: '/tmp/hostile-cdpath',
'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
LD_ORIGIN_PATH: '/tmp/hostile-origin',
LD_PRELOAD: '/tmp/inject.so',
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json',
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers',
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
} as const;
const GRAPHICS_SELECTOR_ENVIRONMENT = {
LIBGL_ALWAYS_SOFTWARE: '1',
GALLIUM_DRIVER: 'llvmpipe',
} as const;
function fakeStat(
kind: 'directory' | 'file'
): Pick<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
return {
isDirectory: () => kind === 'directory',
isFile: () => kind === 'file',
isSymbolicLink: () => false,
};
}
class FakeHelperProcess extends EventEmitter {
exitCode: number | null = null;
@@ -36,14 +86,34 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
let frameSourceChanges: Array<{ sessionId: string; shmName: string }>;
let adapter: EmbeddedMpvFrameCopyAdapter;
const createAdapter = (helperPath: string | null = '/native/helper') => {
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 }),
});
frameSourceChanges.push({
sessionId,
shmName: source.shmName,
}),
} as ConstructorParameters<typeof EmbeddedMpvFrameCopyAdapter>[0]);
};
beforeEach(() => {
@@ -83,6 +153,269 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
]);
});
describe('Linux loader environment', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', { value: originalArch });
});
it('uses a sanitized system environment for the real helper session', () => {
adapter = createAdapter('/opt/iptvnator/native/helper', {
runtimeMode: 'system',
environment: {
PATH: '/usr/bin',
HOME: '/home/user',
...HOSTILE_LOADER_ENVIRONMENT,
...GRAPHICS_SELECTOR_ENVIRONMENT,
},
});
createSession();
expect(spawnMock.mock.calls[0][2]).toEqual({
stdio: ['pipe', 'pipe', 'pipe'],
env: {
PATH: '/usr/bin',
HOME: '/home/user',
...GRAPHICS_SELECTOR_ENVIRONMENT,
},
});
});
it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
adapter = createAdapter(path.join(nativeDir, 'helper'), {
runtimeMode: 'bundled',
helperLaunchFileSystem: {
lstatSync: (candidatePath) =>
fakeStat(
candidatePath.endsWith('/graphics')
? 'directory'
: 'file'
) as Stats,
accessSync: () => undefined,
},
environment: {
PATH: '/snap/bin:/usr/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
VK_LAYER_PATH: '/tmp/hostile-vulkan',
XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home',
XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs',
XDG_DATA_HOME: '/tmp/hostile-xdg-data-home',
XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs',
...HOSTILE_LOADER_ENVIRONMENT,
...GRAPHICS_SELECTOR_ENVIRONMENT,
},
});
createSession();
expect(spawnMock.mock.calls[0][0]).toBe(
path.join(
snapRoot,
'graphics',
'bin',
'graphics-core22-provider-wrapper'
)
);
expect(spawnMock.mock.calls[0][1][0]).toBe(
path.join(nativeDir, 'helper')
);
expect(spawnMock.mock.calls[0][2]).toEqual({
stdio: ['pipe', 'pipe', 'pipe'],
env: {
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl',
SNAP_ARCH: 'amd64',
SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu',
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
...GRAPHICS_SELECTOR_ENVIRONMENT,
GBM_BACKENDS_PATH: [
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'gbm'
),
'/var/lib/snapd/lib/gl/gbm',
].join(':'),
LIBGL_DRIVERS_PATH: path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
LIBVA_DRIVERS_PATH: path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join(
snapRoot,
'graphics',
'usr',
'share',
'egl',
'egl_external_platform.d'
),
__EGL_VENDOR_LIBRARY_DIRS: [
'/var/lib/snapd/lib/glvnd/egl_vendor.d',
path.join(
snapRoot,
'graphics',
'usr',
'share',
'glvnd',
'egl_vendor.d'
),
].join(':'),
VK_LAYER_PATH: [
path.join(
snapRoot,
'graphics',
'usr',
'share',
'vulkan',
'implicit_layer.d'
),
path.join(
snapRoot,
'graphics',
'usr',
'share',
'vulkan',
'explicit_layer.d'
),
].join(':'),
XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'),
XDG_CONFIG_DIRS: [
path.join(snapRoot, 'etc', 'xdg'),
'/etc/xdg',
].join(':'),
XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'),
XDG_DATA_DIRS: [
path.join(snapRoot, 'graphics', 'usr', 'share'),
path.join(snapRoot, 'gnome-platform', 'usr', 'share'),
path.join(snapRoot, 'usr', 'share'),
'/usr/share',
].join(':'),
LD_LIBRARY_PATH: [
path.join(nativeDir, 'lib'),
'/var/lib/snapd/lib/gl',
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'vdpau'
),
'/usr/lib/x86_64-linux-gnu',
path.join(
snapRoot,
'gnome-platform',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'mesa'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'mesa-egl'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'pulseaudio'
),
path.join(snapRoot, 'lib'),
path.join(snapRoot, 'usr', 'lib'),
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
].join(':'),
},
});
});
it('refuses a Linux session without a validated runtime mode', () => {
adapter = createAdapter('/native/helper', { runtimeMode: null });
expect(() => createSession()).toThrow(
'validated Linux frame-copy runtime'
);
expect(spawnMock).not.toHaveBeenCalled();
});
});
it('caches helper snapshot events for getSessionSnapshot', () => {
const sessionId = createSession();
child.emitStdout({
@@ -149,7 +482,12 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
'size\twidth=1600\theight=900\n'
);
const writesBefore = child.stdin.written.length;
adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 });
adapter.setBounds(sessionId, {
x: -10000,
y: -10000,
width: 1,
height: 1,
});
expect(child.stdin.written.length).toBe(writesBefore);
});
@@ -192,7 +530,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['linux', 'arm64', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])(
@@ -7,6 +7,11 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util';
import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime';
import type {
EmbeddedMpvFrameCopyRuntimeMode,
LinuxFrameCopyHelperLaunchFileSystem,
} from './embedded-mpv-frame-copy-runtime';
import type {
NativeEmbeddedMpvAddon,
NativeEmbeddedMpvSessionSnapshot,
@@ -28,6 +33,9 @@ import type {
export interface EmbeddedMpvFrameCopyAdapterOptions {
resolveHelperPath: () => string | null;
resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null;
environment?: NodeJS.ProcessEnv;
helperLaunchFileSystem?: LinuxFrameCopyHelperLaunchFileSystem;
getScaleFactor: () => number;
onFrameSourceChanged: (
sessionId: string,
@@ -76,14 +84,18 @@ function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot {
export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
private readonly sessions = new Map<string, FrameCopyRuntimeSession>();
constructor(
private readonly options: EmbeddedMpvFrameCopyAdapterOptions
) {}
constructor(private readonly options: EmbeddedMpvFrameCopyAdapterOptions) {}
isSupported(): boolean {
if (
!isFrameCopyPlatformSupported() ||
this.options.resolveHelperPath() === null
) {
return false;
}
return (
isFrameCopyPlatformSupported() &&
this.options.resolveHelperPath() !== null
process.platform !== 'linux' ||
this.options.resolveRuntimeMode() !== null
);
}
@@ -104,30 +116,56 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
const scale = this.options.getScaleFactor();
const width = Math.max(16, Math.round(bounds.width * scale));
const height = Math.max(16, Math.round(bounds.height * scale));
const helperArgs = [
'--shm-base',
`/${sessionId}`,
'--width',
String(width),
'--height',
String(height),
'--volume',
String(Math.min(Math.max(initialVolume ?? 1, 0), 1)),
// Lip-sync compensation for the video path's added latency
// (~10 ms measured on M1 Pro); tunable until calibration
// lands, see the architecture doc.
...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY
? [
'--audio-delay',
process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY,
]
: []),
];
let helperCommand = helperPath;
let resolvedHelperArgs = helperArgs;
let helperEnvironment: NodeJS.ProcessEnv | undefined;
if (process.platform === 'linux') {
const runtimeMode = this.options.resolveRuntimeMode();
if (!runtimeMode) {
throw new Error(
'A validated Linux frame-copy runtime is not available.'
);
}
const launch = createLinuxFrameCopyHelperLaunch({
environment: this.options.environment ?? process.env,
helperPath,
helperArgs,
runtimeMode,
fileSystem: this.options.helperLaunchFileSystem,
});
if (!launch.usable) {
throw new Error(
'The connected Snap graphics provider is not available.'
);
}
helperCommand = launch.command;
resolvedHelperArgs = launch.args;
helperEnvironment = launch.env;
}
const child = spawn(
helperPath,
[
'--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,
]
: []),
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
const child = spawn(helperCommand, resolvedHelperArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
...(helperEnvironment ? { env: helperEnvironment } : {}),
});
console.log(
`[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)`
@@ -177,7 +215,9 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
}
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`];
const fields: string[] = [
`url=${encodeProtocolValue(playback.streamUrl)}`,
];
if (playback.title) {
fields.push(
`opt.force-media-title=${encodeProtocolValue(playback.title)}`
@@ -0,0 +1,539 @@
import {
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';
const linkageModulePath = path.resolve(
__dirname,
'../../../embedded-mpv-linux-linkage.cjs'
);
const requireBuildHelper = createRequire(__filename);
interface RuntimeFileRecord {
name: string;
size: number;
sha256: string;
}
interface SonameFixture {
exactPath: string;
outputLibDir: string;
runtimeDependencyClosure: {
entries: Array<{
name: string;
needed: string[];
rpath: string[];
runpath: string[];
soname: string | null;
}>;
};
runtimeFiles: RuntimeFileRecord[];
}
function loadLinkageModule(): {
parseReadelfDynamic: (output: string) => {
needed: string[];
rpath: string[];
runpath: string[];
soname: string[];
};
resolveVerifiedLinuxLibMpvSoname: (options: {
outputLibDir: string;
readDynamicSection: (filePath: string) => string;
runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure'];
runtimeFiles: RuntimeFileRecord[];
}) => string;
resolveLinuxFrameCopyLinkageInputs: (options: {
buildInputMode: string;
outputLibDir: string;
packagedLibmpvSoname: string | null;
readDynamicSection: (filePath: string) => string;
runtimeLibDir: string;
}) => {
expectedLibmpvSoname: string | null;
linkerLibraryDir: string;
};
runWithCleanup: <T>(operation: () => T, cleanup: () => void) => T;
validateLinuxFrameCopyLinkage: (options: {
expectedLibmpvSoname: string;
outputDir: string;
readDynamicSection: (filePath: string) => string;
}) => void;
} {
expect(existsSync(linkageModulePath)).toBe(true);
return requireBuildHelper(linkageModulePath);
}
function sha256(contents: Buffer): string {
return createHash('sha256').update(contents).digest('hex');
}
function runtimeFile(name: string, contents: Buffer): RuntimeFileRecord {
return {
name,
size: contents.byteLength,
sha256: sha256(contents),
};
}
function readelfDynamic(
entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]>
): string {
return entries
.map(
([tag, value], index) =>
` 0x${index
.toString(16)
.padStart(16, '0')} (${tag}) Library value: [${value}]`
)
.join('\n');
}
describe('Linux Embedded MPV linkage verification', () => {
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
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', () => {
const { parseReadelfDynamic } = loadLinkageModule();
expect(
parseReadelfDynamic(
readelfDynamic([
['NEEDED', 'libmpv.so.2'],
['RPATH', '/forbidden'],
['RUNPATH', '$ORIGIN/lib'],
['SONAME', 'libmpv.so.2'],
['SONAME', 'libmpv.so.3'],
])
)
).toEqual({
needed: ['libmpv.so.2'],
rpath: ['/forbidden'],
runpath: ['$ORIGIN/lib'],
soname: ['libmpv.so.2', 'libmpv.so.3'],
});
});
it('resolves the exact libmpv SONAME from closure metadata and verified copied files', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const fixture = createSonameFixture();
expect(
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toBe('libmpv.so.2');
});
it('rejects missing and ambiguous closure SONAME metadata', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const missingFixture = createSonameFixture();
for (const entry of missingFixture.runtimeDependencyClosure.entries) {
entry.soname = null;
}
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...missingFixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/exactly one versioned libmpv SONAME/i);
const ambiguousFixture = createSonameFixture();
ambiguousFixture.runtimeDependencyClosure.entries.push({
name: 'libmpv.so.3',
needed: [],
rpath: [],
runpath: ['$ORIGIN'],
soname: 'libmpv.so.3',
});
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...ambiguousFixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/exactly one versioned libmpv SONAME/i);
});
it('rejects missing, ambiguous, and mismatched DT_SONAME values', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const fixture = createSonameFixture();
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () => readelfDynamic([]),
})
).toThrow(/exactly one DT_SONAME/i);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([
['SONAME', 'libmpv.so.2'],
['SONAME', 'libmpv.so.3'],
]),
})
).toThrow(/exactly one DT_SONAME/i);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.3']]),
})
).toThrow(/does not match validated closure SONAME/i);
});
(process.platform === 'win32' ? it.skip : it)(
'rejects a symlinked copied linker input',
() => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const fixture = createSonameFixture();
const aliasPath = path.join(fixture.outputLibDir, 'libmpv.so');
unlinkSync(aliasPath);
symlinkSync(path.basename(fixture.exactPath), aliasPath);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/must be a regular non-symbolic-link file/i);
}
);
it('rejects a missing exact runtime record and a mismatched exact file hash', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const missingRecordFixture = createSonameFixture();
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...missingRecordFixture,
runtimeFiles: missingRecordFixture.runtimeFiles.filter(
({ name }) => name !== 'libmpv.so.2'
),
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/exact runtimeFiles record/i);
const mismatchedFileFixture = createSonameFixture();
writeFileSync(
mismatchedFileFixture.exactPath,
Buffer.from('tampered exact SONAME file')
);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...mismatchedFileFixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/size|SHA-256/i);
});
it('uses and identifies the unmanaged system libmpv only for system development', () => {
const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule();
const readDynamicSection = jest.fn((filePath: string) => {
expect(filePath).toBe('/opt/libmpv/lib/libmpv.so');
return readelfDynamic([['SONAME', 'libmpv.so.2']]);
});
expect(
resolveLinuxFrameCopyLinkageInputs({
buildInputMode: 'system-dev',
outputLibDir: '/native/build/Release/lib',
packagedLibmpvSoname: null,
readDynamicSection,
runtimeLibDir: '/opt/libmpv/lib',
})
).toEqual({
expectedLibmpvSoname: 'libmpv.so.2',
linkerLibraryDir: '/opt/libmpv/lib',
});
expect(readDynamicSection).toHaveBeenCalledTimes(1);
});
it('keeps bundled and untrusted build modes on the copied runtime directory', () => {
const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule();
const readDynamicSection = jest.fn(() => {
throw new Error('must not inspect the ambient system runtime');
});
for (const buildInputMode of [
'bundled-runtime',
'system-build-inputs',
'unexpected-mode',
]) {
expect(
resolveLinuxFrameCopyLinkageInputs({
buildInputMode,
outputLibDir: '/native/build/Release/lib',
packagedLibmpvSoname:
buildInputMode === 'bundled-runtime'
? 'libmpv.so.2'
: null,
readDynamicSection,
runtimeLibDir: '/usr/lib/x86_64-linux-gnu',
})
).toEqual({
expectedLibmpvSoname:
buildInputMode === 'bundled-runtime' ? 'libmpv.so.2' : null,
linkerLibraryDir: '/native/build/Release/lib',
});
}
expect(readDynamicSection).not.toHaveBeenCalled();
});
it('rejects ambiguous or unversioned system-development libmpv identities', () => {
const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule();
const options = {
buildInputMode: 'system-dev',
outputLibDir: '/native/build/Release/lib',
packagedLibmpvSoname: null,
runtimeLibDir: '/usr/lib/x86_64-linux-gnu',
};
expect(() =>
resolveLinuxFrameCopyLinkageInputs({
...options,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so']]),
})
).toThrow(/system-development.*exactly one versioned libmpv SONAME/i);
expect(() =>
resolveLinuxFrameCopyLinkageInputs({
...options,
readDynamicSection: () => readelfDynamic([]),
})
).toThrow(/system-development.*exactly one versioned libmpv SONAME/i);
expect(() =>
resolveLinuxFrameCopyLinkageInputs({
...options,
readDynamicSection: () =>
readelfDynamic([
['SONAME', 'libmpv.so.1'],
['SONAME', 'libmpv.so.2'],
]),
})
).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']);
});
});
@@ -32,6 +32,16 @@ describe('Embedded MPV native source recording invariants', () => {
),
'utf8'
);
const electronBuilderConfig = JSON.parse(
readFileSync(
path.resolve(__dirname, '../../../../../electron-builder.json'),
'utf8'
)
) as {
snap?: {
plugs?: unknown;
};
};
const stageRuntimeSource = readFileSync(
path.resolve(
__dirname,
@@ -43,6 +53,10 @@ describe('Embedded MPV native source recording invariants', () => {
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
'utf8'
);
const frameHelperSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/mpv_frame_helper.cpp'),
'utf8'
);
const frameHelperGlSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
'utf8'
@@ -570,11 +584,120 @@ describe('Embedded MPV native source recording invariants', () => {
expect(buildScriptSource).toContain('cleanNativeBuildIntermediates();');
});
it('does not stage Linux libmpv runtime libraries', () => {
expect(stageRuntimeSource).toContain(
"if (platform !== 'linux') {\n" +
' copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);\n' +
' }'
it('validates and copies only the staged Linux shared-library closure', () => {
expect(buildScriptSource).toContain(
"require('../../tools/embedded-mpv/linux-runtime-manifest.cjs')"
);
expect(buildScriptSource).toContain(
'validateLinuxRuntimeManifest(sourceRuntimeManifest)'
);
expect(buildScriptSource).toContain(
'validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)'
);
expect(buildScriptSource).toContain(
'copyLinuxRuntimeClosureToNativeBuild(runtime)'
);
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeManifest.runtimeFiles'
);
expect(buildScriptSource).toContain(
'SHA-256 mismatch for staged Linux runtime file'
);
expect(buildScriptSource).toContain(
'resolveLinuxFrameCopyLinkageInputs({'
);
expect(buildScriptSource).toContain(
'LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:\n' +
' linuxLinkageInputs.linkerLibraryDir'
);
expect(buildScriptSource).toContain(
'expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname'
);
expect(buildScriptSource).not.toContain(
'process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir'
);
expect(buildScriptSource).not.toContain(
'LINUX_NATIVE_LIBRARY_DIR: runtime.libDir'
);
});
it('writes a profile-neutral Linux frame-copy build manifest', () => {
expect(buildScriptSource).toContain(
"const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);"
);
expect(buildScriptSource).toContain("origin: 'linux-frame-copy-build'");
expect(buildScriptSource).toContain(
'allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES]'
);
expect(buildScriptSource).toContain(
'buildInputMode: runtime.buildInputMode'
);
expect(buildScriptSource).toContain(
'sourceRuntime: runtime.sourceRuntimeManifest'
);
expect(buildScriptSource).toContain('runtimeFiles: copiedRuntimeFiles');
expect(buildScriptSource).not.toContain(
'writeLinuxProcessRuntimeManifest'
);
});
it('requires a validated bundled source runtime for required Linux builds', () => {
expect(buildScriptSource).toContain(
"sourceRuntimeValidated: buildInputMode === 'bundled-runtime'"
);
expect(buildScriptSource).toContain(
'assertRequiredLinuxFrameCopyRuntime(runtime);'
);
expect(buildScriptSource).toContain(
"runtime.buildInputMode !== 'bundled-runtime' ||"
);
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeValidated !== true'
);
expect(buildScriptSource).toContain("runtimeFile.name === 'libmpv.so'");
expect(buildScriptSource).toContain(
'Required Linux builds must use the validated bundled source runtime containing staged libmpv.'
);
});
it('derives packaged Linux libmpv identity from validated closure SONAME metadata', () => {
expect(buildScriptSource).toContain('resolveVerifiedLinuxLibMpvSoname');
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeManifest.runtimeDependencyClosure'
);
expect(buildScriptSource).toContain('libmpvSoname,');
expect(buildScriptSource).not.toContain(
'VERSIONED_LINUX_LIBMPV_PATTERN'
);
expect(buildScriptSource).not.toContain("libmpvSoname: 'libmpv.so.2'");
});
it('marks both package modes available only for a validated bundled build', () => {
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeValidated === true &&\n' +
" runtime.buildInputMode === 'bundled-runtime' &&\n" +
' copiedRuntimeFiles.length > 0 &&\n' +
' libmpvSoname !== null'
);
expect(buildScriptSource).toContain('system: packageRuntimeAvailable');
expect(buildScriptSource).toContain('bundled: packageRuntimeAvailable');
});
it('validates Linux post-link isolation before marking the build available', () => {
const postLinkValidation = buildScriptSource.indexOf(
'validateLinuxFrameCopyLinkage({'
);
const availabilityMarkerRemoval = buildScriptSource.lastIndexOf(
'fs.rmSync(unavailableMarkerFile'
);
expect(buildScriptSource).toContain(
"spawnSync('readelf', ['-d', filePath]"
);
expect(postLinkValidation).toBeGreaterThanOrEqual(0);
expect(availabilityMarkerRemoval).toBeGreaterThan(postLinkValidation);
expect(buildScriptSource).toContain(
'runWithCleanup(buildNativeArtifacts, cleanOutput)'
);
});
@@ -597,17 +720,33 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => {
it('requires the pinned Linux source runtime artifact and validates process isolation in CI', () => {
expect(buildAndMakeWorkflowSource).toContain(
'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev'
'Build and stage pinned LGPL Linux runtime'
);
expect(buildAndMakeWorkflowSource).toContain(
'node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}"'
);
expect(buildAndMakeWorkflowSource).toContain(
'node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}"'
);
expect(buildAndMakeWorkflowSource).toContain(
'name: linux-embedded-mpv-runtime'
);
expect(buildAndMakeWorkflowSource).toContain(
'path: vendor/embedded-mpv/linux-x64'
);
expect(buildAndMakeWorkflowSource).toContain(
'Download pinned Linux Embedded MPV runtime'
);
expect(buildAndMakeWorkflowSource).not.toContain('libopengl-dev');
expect(buildAndMakeWorkflowSource).not.toContain(
'Stage Linux embedded MPV build inputs'
);
expect(buildAndMakeWorkflowSource).toContain(
"linuxBackend: 'process-isolated mpv --wid'"
);
expect(buildAndMakeWorkflowSource).toContain("matrix.os == 'linux'");
expect(buildAndMakeWorkflowSource).toContain(
"manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true"
);
expect(buildAndMakeWorkflowSource).toContain(
'Linux embedded MPV addon must not link directly to libmpv'
);
@@ -615,14 +754,11 @@ describe('Embedded MPV native source recording invariants', () => {
'test -f dist/apps/electron-backend/native/iptvnator_mpv_helper'
);
expect(buildAndMakeWorkflowSource).toContain(
'Linux frame-copy helper must link libmpv'
'Linux frame-copy helper must need libmpv.so.2'
);
expect(buildScriptSource).toContain("origin: 'external-mpv-process'");
expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest');
expect(buildScriptSource).toContain('runtimeFiles: []');
});
it('supports Linux system-development inputs without leaving stale frame-copy artifacts', () => {
it('keeps optional Linux system development separate from required staged inputs', () => {
expect(buildScriptSource).toContain(
"const systemIncludeDir =\n process.env.LIBMPV_INCLUDE_DIR || '/usr/include';"
);
@@ -635,6 +771,9 @@ describe('Embedded MPV native source recording invariants', () => {
expect(buildScriptSource).toContain(
"if (!embeddedMpvRequired && runtime.origin === 'system-dev')"
);
expect(buildScriptSource).toContain(
'Required Linux builds must use the validated bundled source runtime containing staged libmpv.'
);
expect(buildScriptSource).toContain(
'removeStaleFrameCopyArtifacts(outputDir);'
);
@@ -654,6 +793,101 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('keeps Electron Builder defaults and exact Snap runtime plugs', () => {
expect(electronBuilderConfig.snap?.plugs).toEqual([
'default',
{
'graphics-core22': {
interface: 'content',
target: '$SNAP/graphics',
'default-provider': 'mesa-core22',
},
},
{
'shared-memory': {
interface: 'shared-memory',
private: true,
},
},
]);
});
it('runs the helper runtime probe through shared memory without media or command loops', () => {
const runtimeProbe = sourceFunctionBody(
frameHelperSource,
'int runRuntimeProbe(',
'runRuntimeProbe'
);
const runtimeProbeShmName = sourceFunctionBody(
frameHelperSource,
'std::string runtimeProbeShmName(',
'runtimeProbeShmName'
);
const main = sourceFunctionBody(frameHelperSource, 'int main(', 'main');
const runtimeProbeFailure = sourceFunctionBody(
frameHelperSource,
'int runtimeProbeFailure(',
'runtimeProbeFailure'
);
expect(main).toContain('if (args.runtimeProbe) {');
expect(main).toContain('return runRuntimeProbe();');
expect(runtimeProbe).toContain('mpv_create()');
expect(runtimeProbe).toContain('"idle", "yes"');
expect(runtimeProbe).toContain('"vo", "libmpv"');
expect(runtimeProbe).toContain('mpv_initialize(mpv)');
expect(runtimeProbe).toContain('GlContext gl;');
expect(runtimeProbe).toContain('gl.create(error)');
expect(runtimeProbe).toContain('gl.makeCurrent(error)');
expect(runtimeProbe).toContain('mpv_render_context_create(');
expect(runtimeProbe).toContain('mpv_render_context_free(');
expect(runtimeProbe).toContain('gl.destroy()');
expect(runtimeProbe).toContain('mpv_terminate_destroy(mpv)');
expect(runtimeProbe).toContain(
'frame_helper::ShmRing runtimeProbeRing;'
);
expect(runtimeProbe).toContain(
'const std::string shmName = runtimeProbeShmName();'
);
expect(runtimeProbe).toContain(
'runtimeProbeRing.create(shmName, 16, 16, 1)'
);
expect(runtimeProbe).toContain(
'runtimeProbeRing.header->magic == FRAME_SHM_MAGIC'
);
expect(runtimeProbe).toContain('runtimeProbeRing.destroy();');
expect(runtimeProbe).toContain('"shared-memory-create-failed"');
expect(runtimeProbe).toContain('"shared-memory-initialize-failed"');
expect(runtimeProbeShmName).toContain('"/impv-fc-runtime-probe-"');
expect(runtimeProbeShmName).toContain('getpid()');
expect(runtimeProbeShmName).toContain('GetCurrentProcessId()');
expect(runtimeProbeShmName).toContain('std::to_string(processId)');
expect(runtimeProbe).toContain('.num("protocol", 1)');
expect(runtimeProbe).toContain('.boolean("usable", true)');
expect(runtimeProbe).toContain('.str("libmpv",');
expect(runtimeProbe).toContain('.str("renderApi", gl.renderApiName())');
expect(runtimeProbe).not.toContain('pipeline');
expect(runtimeProbe).not.toContain('runStdinLoop');
expect(runtimeProbe).not.toContain('runMpvEventLoop');
expect(runtimeProbe).not.toContain('loadfile');
expect(runtimeProbe.match(/emitLine\(/g)).toHaveLength(1);
expect(runtimeProbeFailure).toContain('.num("protocol", 1)');
expect(runtimeProbeFailure).toContain('.boolean("usable", false)');
expect(runtimeProbeFailure).toContain('.str("reason", reason)');
expect(runtimeProbeFailure.match(/emitLine\(/g)).toHaveLength(1);
expect(runtimeProbeFailure).toContain('return 1;');
});
it('identifies the Linux helper runtime probe render API as EGL', () => {
const renderApiName = sourceFunctionBody(
linuxFrameHelperGlSource,
'const char* renderApiName() const',
'GlContext::renderApiName'
);
expect(renderApiName).toContain('return "egl";');
});
it('validates complete EGL candidates and keeps software rendering as the final fallback', () => {
const tryCandidate = sourceFunctionBody(
linuxFrameHelperGlSource,
@@ -817,14 +1051,33 @@ describe('Embedded MPV native build configuration', () => {
)?.[1];
expect(linuxAddonConfig?.libraries).not.toContain('-lmpv');
expect(JSON.stringify(linuxAddonConfig)).not.toContain('-lmpv');
expect(linuxHelperConfig?.libraries).toEqual(
expect.arrayContaining([
'-lmpv',
'-lEGL',
'-lOpenGL',
'-lgbm',
'-ldl',
])
expect.arrayContaining(['-lEGL', '-lGL', '-lgbm', '-ldl'])
);
expect(linuxHelperConfig?.libraries).not.toContain('-lOpenGL');
expect(linuxHelperConfig?.libraries).not.toContain('-lmpv');
expect(
linuxHelperConfig?.libraries.find((library: string) =>
library.includes("path.join(dir, 'libmpv.so')")
)
).toContain('LINUX_VERIFIED_RUNTIME_LIBRARY_DIR');
expect(JSON.stringify(linuxHelperConfig)).not.toContain(
"LINUX_VERIFIED_RUNTIME_LIBRARY_DIR || '/usr/lib'"
);
expect(JSON.stringify(linuxHelperConfig)).toContain(
'Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'
);
const runtimeLinkerFlags = linuxHelperConfig?.ldflags.filter(
(flag: string) => flag.startsWith('-Wl,')
);
expect(runtimeLinkerFlags).toEqual([
'-Wl,--enable-new-dtags',
"-Wl,-rpath,'$$ORIGIN/lib'",
]);
expect(JSON.stringify(runtimeLinkerFlags)).not.toContain(
'LINUX_NATIVE_LIBRARY_DIR'
);
});
});
@@ -3,24 +3,30 @@ import type {
EmbeddedMpvSessionStatus,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'fs';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
const mockSpawnSync = jest.fn();
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
const mockGetFrameCopyRuntimeAvailability = jest.fn();
jest.mock('child_process', () => ({
spawnSync: mockSpawnSync,
}));
jest.mock('./embedded-mpv-frame-copy-platform.util', () => {
const actual = jest.requireActual(
'./embedded-mpv-frame-copy-platform.util'
);
return {
...actual,
getFrameCopyRuntimeAvailability: mockGetFrameCopyRuntimeAvailability,
isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable,
};
});
const powerSaveBlockerMock = {
start: jest.fn<number, [string]>(),
stop: jest.fn<void, [number]>(),
@@ -35,20 +41,29 @@ const appMock = {
commandLine: commandLineMock,
};
const screenGetDisplayMatchingMock = jest.fn();
jest.mock('electron', () => ({
app: appMock,
powerSaveBlocker: powerSaveBlockerMock,
screen: { getDisplayMatching: screenGetDisplayMatchingMock },
}));
const mainWindowSendMock = jest.fn();
const mainWindowWebContentsOnMock = jest.fn();
const mainWindowGetZoomFactorMock = jest.fn<number, []>();
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
Buffer.alloc(8)
);
const mainWindowMock = {
isDestroyed: () => false,
getNativeWindowHandle: mainWindowGetNativeWindowHandleMock,
webContents: { send: mainWindowSendMock, on: mainWindowWebContentsOnMock },
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
webContents: {
send: mainWindowSendMock,
on: mainWindowWebContentsOnMock,
getZoomFactor: mainWindowGetZoomFactorMock,
},
};
jest.mock('../app', () => ({
@@ -138,10 +153,21 @@ describe('EmbeddedMpvNativeService power blocker', () => {
mockSpawnSync.mockReturnValue({
status: 0,
});
mockIsFrameCopyRuntimeUsable.mockReset();
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
mockGetFrameCopyRuntimeAvailability.mockReset();
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: false,
reason: 'helper-probe-failed',
});
mainWindowGetNativeWindowHandleMock.mockReset();
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
mainWindowSendMock.mockReset();
mainWindowWebContentsOnMock.mockReset();
mainWindowGetZoomFactorMock.mockReset();
mainWindowGetZoomFactorMock.mockReturnValue(1);
screenGetDisplayMatchingMock.mockReset();
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1 });
appMock.isPackaged = true;
tempDirs = [];
@@ -230,14 +256,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
// no helper on disk => the engine env flag is ignored, native keeps
// working, and support does not advertise frame-copy.
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue(null);
try {
expect(service.getActiveEngine()).toBe('native');
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith();
const support = service.getSupport();
expect(support.engine).not.toBe('frame-copy');
startSession('s-fallback', snapshot('loading'));
@@ -262,37 +283,23 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
(process.platform === 'win32' ? it.skip : it)(
'falls back to the native engine when the frame-copy helper is not executable',
'falls back to the native engine when the frame-copy runtime probe fails',
() => {
const tempDir = createTempDir();
const releaseDir = path.join(
tempDir,
'apps',
'electron-backend',
'native',
'build',
'Release'
);
const helperPath = path.join(
releaseDir,
'iptvnator_mpv_helper'
);
mkdirSync(releaseDir, { recursive: true });
writeFileSync(helperPath, '#!/bin/sh\n');
chmodSync(helperPath, 0o644);
writeFileSync(
path.join(releaseDir, 'embedded_mpv_frame_reader.node'),
'reader'
);
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir);
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
try {
expect(service.getActiveEngine()).toBe('native');
expect(service.isFrameCopyAvailable()).toBe(false);
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith();
expect(service.getSupport()).toEqual(
expect.objectContaining({
engine: 'native',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'helper-probe-failed',
})
);
} finally {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
cwdSpy.mockRestore();
}
}
);
@@ -300,13 +307,11 @@ describe('EmbeddedMpvNativeService power blocker', () => {
describe('frame-copy platform gate', () => {
const originalArch = process.arch;
function mockHelperPresent(): void {
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue('/native/iptvnator_mpv_helper');
function mockRuntimeUsable(): void {
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: true,
});
}
afterEach(() => {
@@ -322,7 +327,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
@@ -342,7 +347,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
mockHelperPresent();
mockRuntimeUsable();
const support = service.getSupport();
expect(support.supported).toBe(false);
@@ -355,7 +360,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
mockRuntimeUsable();
const support = service.getSupport();
expect(support.supported).toBe(false);
@@ -370,7 +375,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
delete process.env.WAYLAND_DISPLAY;
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
mockRuntimeUsable();
expect(service.getSupport()).toEqual(
expect.objectContaining({
@@ -383,16 +388,35 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it('activates the frame-copy engine on macOS arm64', () => {
Object.defineProperty(process, 'arch', { value: 'arm64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
});
it('supplies the adapter with the runtime mode from the validated Linux capability', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: true,
profile: 'portable',
runtimeMode: 'bundled',
libmpv: '2.3',
renderApi: 'egl',
});
expect(
(
service as unknown as {
resolveFrameCopyRuntimeMode(): string | null;
}
).resolveFrameCopyRuntimeMode()
).toBe('bundled');
});
it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => {
Object.defineProperty(process, 'arch', { value: 'x64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
expect(service.getActiveEngine()).toBe('native');
expect(service.isFrameCopyAvailable()).toBe(false);
@@ -401,7 +425,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it('skips the native window handle when creating a frame-copy session', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
const frameCopyAddon = createMockAddon();
frameCopyAddon.createSession.mockReturnValueOnce('s-fc');
frameCopyAddon.getSessionSnapshot.mockReturnValueOnce(
@@ -425,9 +449,97 @@ describe('EmbeddedMpvNativeService power blocker', () => {
// dispatches to the adapter that owns the session, not the
// native addon the outer afterEach shutdown would pick.
service.disposeSession('s-fc');
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith(
's-fc'
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith('s-fc');
});
});
describe('native view bounds scaling', () => {
afterEach(() => {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
});
it('converts CSS bounds to physical pixels for the native engine on scaled displays', () => {
// Regression for #1145: the win32/linux engines position their
// child window in physical pixels, so renderer CSS bounds must
// be multiplied by the display scale before reaching the addon.
Object.defineProperty(process, 'platform', { value: 'linux' });
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 });
addon.createSession.mockReturnValueOnce('s-scaled');
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
const cssBounds = { x: 100, y: 40, width: 640, height: 360 };
service.createSession(cssBounds, '', 1);
service.setBounds('s-scaled', cssBounds);
const physicalBounds = { x: 150, y: 60, width: 960, height: 540 };
expect(addon.createSession).toHaveBeenCalledWith(
expect.any(Buffer),
physicalBounds,
'',
1
);
expect(addon.setBounds).toHaveBeenCalledWith(
's-scaled',
physicalBounds
);
});
it('applies page zoom but not the display scale on macOS', () => {
// NSView frames are in points (device-independent pixels): only
// the webContents zoom factor separates them from CSS pixels.
Object.defineProperty(process, 'platform', { value: 'darwin' });
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 2 });
mainWindowGetZoomFactorMock.mockReturnValue(1.25);
addon.createSession.mockReturnValueOnce('s-zoom');
addon.getSessionSnapshot.mockReturnValue(snapshot('loading'));
service.createSession({ x: 0, y: 0, width: 100, height: 100 }, '', 1);
expect(addon.createSession).toHaveBeenCalledWith(
expect.any(Buffer),
{ x: 0, y: 0, width: 125, height: 125 },
'',
1
);
});
it('passes frame-copy bounds through unscaled', () => {
// The frame-copy engine paints into a DOM canvas laid out in CSS
// pixels; its adapter applies the display scale to the render
// size itself, so a second scaling pass here would double it.
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: true,
});
screenGetDisplayMatchingMock.mockReturnValue({ scaleFactor: 1.5 });
const frameCopyAddon = createMockAddon();
frameCopyAddon.createSession.mockReturnValueOnce('s-fc-bounds');
frameCopyAddon.getSessionSnapshot.mockReturnValue(
snapshot('loading')
);
(
service as unknown as { frameCopyAdapter: MockAddon }
).frameCopyAdapter = frameCopyAddon;
service.createSession(BOUNDS, '', 1);
service.setBounds('s-fc-bounds', BOUNDS);
expect(frameCopyAddon.createSession).toHaveBeenCalledWith(
Buffer.alloc(0),
BOUNDS,
'',
1
);
expect(frameCopyAddon.setBounds).toHaveBeenCalledWith(
's-fc-bounds',
BOUNDS
);
// Dispose while the frame-copy env is still set so teardown
// dispatches to the adapter that owns the session.
service.disposeSession('s-fc-bounds');
});
});
@@ -455,9 +567,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect.arrayContaining(['render-process-gone', 'did-navigate'])
);
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
handlers.get('did-navigate')?.();
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
@@ -27,12 +27,15 @@ import {
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getFrameCopyRuntimeAvailability,
getEmbeddedMpvAddonCandidatePaths,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
} from './embedded-mpv-frame-copy-platform.util';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
import {
EMBEDDED_MPV_EXPERIMENT_ENV,
isEmbeddedMpvFeatureEnabled,
@@ -112,8 +115,9 @@ export class EmbeddedMpvNativeService {
/**
* Frame-copy engine: helper process + shm ring + renderer canvas.
* Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux
* and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1
* on top of the regular embedded MPV experiment flag.
* x64 and Windows, opted into with
* IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the regular
* embedded MPV experiment flag.
*/
private isFrameCopyEngineRequested(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
@@ -127,10 +131,7 @@ export class EmbeddedMpvNativeService {
// Requires the helper binary too: a stale opt-in (cleaned native
// build, bad install) must fall back to the native engine instead
// of leaving embedded MPV unsupported with no way to recover.
return (
this.isFrameCopyEngineRequested() &&
isFrameCopyRuntimeUsable(() => this.resolveFrameCopyHelperPath())
);
return this.isFrameCopyEngineRequested() && isFrameCopyRuntimeUsable();
}
getActiveEngine(): EmbeddedMpvEngine {
@@ -138,9 +139,31 @@ export class EmbeddedMpvNativeService {
}
isFrameCopyAvailable(): boolean {
return isFrameCopyRuntimeUsable(() =>
this.resolveFrameCopyHelperPath()
);
return isFrameCopyRuntimeUsable();
}
private getFrameCopySupportDetails(): Pick<
EmbeddedMpvSupport,
'frameCopyAvailable' | 'frameCopyUnavailableReason'
> {
const availability = getFrameCopyRuntimeAvailability();
if (!('reason' in availability)) {
return { frameCopyAvailable: true };
}
return {
frameCopyAvailable: false,
frameCopyUnavailableReason: availability.reason,
};
}
private resolveFrameCopyRuntimeMode(): EmbeddedMpvFrameCopyRuntimeMode | null {
if (process.platform !== 'linux') {
return null;
}
const availability = getFrameCopyRuntimeAvailability();
return availability.usable && 'runtimeMode' in availability
? availability.runtimeMode
: null;
}
getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null {
@@ -150,7 +173,8 @@ export class EmbeddedMpvNativeService {
private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter {
if (!this.frameCopyAdapter) {
this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({
resolveHelperPath: () => this.resolveFrameCopyHelperPath(),
resolveHelperPath: resolveFrameCopyHelperPath,
resolveRuntimeMode: () => this.resolveFrameCopyRuntimeMode(),
getScaleFactor: () => this.getMainWindowScaleFactor(),
onFrameSourceChanged: (sessionId, source) => {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
@@ -166,12 +190,6 @@ export class EmbeddedMpvNativeService {
return this.frameCopyAdapter;
}
private resolveFrameCopyHelperPath(): string | null {
// Shared with the startup sandbox gate; kept as an instance method so
// service tests can stub helper discovery per scenario.
return resolveFrameCopyHelperPath();
}
private getMainWindowScaleFactor(): number {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
@@ -184,6 +202,36 @@ export class EmbeddedMpvNativeService {
}
}
private getMainWindowZoomFactor(): number {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return 1;
}
return App.mainWindow.webContents.getZoomFactor();
} catch {
return 1;
}
}
/**
* Renderer bounds arrive in CSS pixels; the native-view engines position
* OS windows in physical pixels (win32/linux) or points (macOS), so at
* page zoom or display scale ≠ 100% the raw values land the video toward
* the window's top-left corner at a fraction of its size (#1145). The
* frame-copy engine must bypass this: it paints into a DOM canvas laid
* out in CSS pixels, and its adapter already applies the display scale
* to the render size itself.
*/
private scaleBoundsForNativeView(
bounds: EmbeddedMpvBounds
): EmbeddedMpvBounds {
return toNativeViewBounds(bounds, {
platform: process.platform,
zoomFactor: this.getMainWindowZoomFactor(),
displayScaleFactor: this.getMainWindowScaleFactor(),
});
}
private detectCapabilities(): EmbeddedMpvCapabilities {
if (this.isFrameCopyEngineActive()) {
return {
@@ -229,7 +277,7 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
};
}
@@ -249,7 +297,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: 'frame-copy',
frameCopyAvailable: true,
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
}
@@ -261,7 +309,7 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
};
}
@@ -279,7 +327,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -345,7 +393,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -377,7 +425,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -402,14 +450,15 @@ export class EmbeddedMpvNativeService {
// embed into the window at all. Derive the skip from the dispatched
// addon rather than re-evaluating the engine gate, so the two
// decisions cannot disagree.
const windowHandle =
this.frameCopyAdapter && addon === this.frameCopyAdapter
? Buffer.alloc(0)
: this.getMainWindowHandle();
const usesFrameCopyAddon =
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
const windowHandle = usesFrameCopyAddon
? Buffer.alloc(0)
: this.getMainWindowHandle();
const startedAt = new Date().toISOString();
const sessionId = addon.createSession(
windowHandle,
bounds,
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds),
title,
initialVolume
);
@@ -461,7 +510,13 @@ export class EmbeddedMpvNativeService {
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
this.assertEmbeddedMpvEnabled();
this.getAddon().setBounds(sessionId, bounds);
const addon = this.getAddon();
const usesFrameCopyAddon =
this.frameCopyAdapter !== null && addon === this.frameCopyAdapter;
addon.setBounds(
sessionId,
usesFrameCopyAddon ? bounds : this.scaleBoundsForNativeView(bounds)
);
}
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
@@ -709,8 +764,9 @@ export class EmbeddedMpvNativeService {
});
};
App.mainWindow.webContents.on('render-process-gone', (_event, details) =>
disposeAll(`process gone (${details.reason})`)
App.mainWindow.webContents.on(
'render-process-gone',
(_event, details) => disposeAll(`process gone (${details.reason})`)
);
// Full navigations/reloads only — in-app Angular routing emits
// did-navigate-in-page and must not kill the active session.
@@ -0,0 +1,112 @@
const mockElectronApp = {
isPackaged: true,
};
jest.mock('electron', () => ({ app: mockElectronApp }));
import {
EMBEDDED_MPV_RUNTIME_PROBE_SWITCH,
runEmbeddedMpvRuntimeDiagnosticOrContinue,
} from './embedded-mpv-runtime-diagnostic';
import type { FrameCopyRuntimeAvailability } from './embedded-mpv-frame-copy-platform.util';
interface DiagnosticHarness {
continueStartup: jest.Mock<void, []>;
exit: jest.Mock<void, [number]>;
getRuntimeAvailability: jest.Mock<FrameCopyRuntimeAvailability, []>;
writeStdout: jest.Mock<void, [string]>;
}
function createHarness(
availability: FrameCopyRuntimeAvailability = {
usable: false,
reason: 'runtime-artifact-missing',
}
): DiagnosticHarness {
return {
continueStartup: jest.fn(),
exit: jest.fn(),
getRuntimeAvailability: jest.fn(() => availability),
writeStdout: jest.fn(),
};
}
function runDiagnostic(
argv: readonly string[],
harness: DiagnosticHarness
): void {
runEmbeddedMpvRuntimeDiagnosticOrContinue(argv, harness.continueStartup, {
exit: harness.exit,
getRuntimeAvailability: harness.getRuntimeAvailability,
writeStdout: harness.writeStdout,
});
}
describe('embedded MPV runtime diagnostic', () => {
it.each([
[['electron', 'main.js']],
[['electron', 'main.js', `${EMBEDDED_MPV_RUNTIME_PROBE_SWITCH}=1`]],
[['electron', 'main.js', 'embedded-mpv-runtime-probe']],
])('continues normal startup for argv %j', (argv) => {
const harness = createHarness();
runDiagnostic(argv, harness);
expect(harness.continueStartup).toHaveBeenCalledTimes(1);
expect(harness.getRuntimeAvailability).not.toHaveBeenCalled();
expect(harness.writeStdout).not.toHaveBeenCalled();
expect(harness.exit).not.toHaveBeenCalled();
});
it('prints the usable availability as one JSON line, exits zero, and skips startup', () => {
const availability: FrameCopyRuntimeAvailability = {
usable: true,
profile: 'portable',
runtimeMode: 'bundled',
libmpv: '2.3',
renderApi: 'egl',
};
const harness = createHarness(availability);
runDiagnostic(
['electron', 'main.js', EMBEDDED_MPV_RUNTIME_PROBE_SWITCH],
harness
);
expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1);
expect(harness.writeStdout).toHaveBeenCalledWith(
`${JSON.stringify(availability)}\n`
);
expect(harness.writeStdout).toHaveBeenCalledTimes(1);
expect(harness.exit).toHaveBeenCalledWith(0);
expect(harness.exit).toHaveBeenCalledTimes(1);
expect(harness.writeStdout.mock.invocationCallOrder[0]).toBeLessThan(
harness.exit.mock.invocationCallOrder[0]
);
expect(harness.continueStartup).not.toHaveBeenCalled();
});
it('prints the unavailable helper reason as one JSON line, exits nonzero, and skips startup', () => {
const availability: FrameCopyRuntimeAvailability = {
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
helperDetail: 'EGL initialization failed: display unavailable',
};
const harness = createHarness(availability);
runDiagnostic(
[EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, 'electron', 'main.js'],
harness
);
expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1);
expect(harness.writeStdout).toHaveBeenCalledWith(
'{"usable":false,"reason":"helper-probe-failed","helperReason":"gl-context-create-failed","helperDetail":"EGL initialization failed: display unavailable"}\n'
);
expect(harness.writeStdout).toHaveBeenCalledTimes(1);
expect(harness.exit).toHaveBeenCalledWith(1);
expect(harness.exit).toHaveBeenCalledTimes(1);
expect(harness.continueStartup).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import { writeSync } from 'fs';
import {
getFrameCopyRuntimeAvailability,
type FrameCopyRuntimeAvailability,
} from './embedded-mpv-frame-copy-platform.util';
export const EMBEDDED_MPV_RUNTIME_PROBE_SWITCH = '--embedded-mpv-runtime-probe';
interface EmbeddedMpvRuntimeDiagnosticDependencies {
exit(code: number): void;
getRuntimeAvailability(): FrameCopyRuntimeAvailability;
writeStdout(output: string): void;
}
const defaultDependencies: EmbeddedMpvRuntimeDiagnosticDependencies = {
exit: (code) => process.exit(code),
getRuntimeAvailability: getFrameCopyRuntimeAvailability,
writeStdout: (output) => {
writeSync(process.stdout.fd, output);
},
};
export function runEmbeddedMpvRuntimeDiagnosticOrContinue(
argv: readonly string[],
continueStartup: () => void,
dependencies: EmbeddedMpvRuntimeDiagnosticDependencies = defaultDependencies
): void {
if (!argv.includes(EMBEDDED_MPV_RUNTIME_PROBE_SWITCH)) {
continueStartup();
return;
}
const availability = dependencies.getRuntimeAvailability();
dependencies.writeStdout(`${JSON.stringify(availability)}\n`);
dependencies.exit(availability.usable ? 0 : 1);
}
@@ -9,10 +9,10 @@ export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS';
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
/**
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux). Lives in the main
* process config file because it must be readable synchronously before the
* BrowserWindow is created — the engine relaxes the window sandbox for its
* preload frame pump, which cannot change after window creation.
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux x64). Lives in the
* main process config file because it must be readable synchronously before
* the BrowserWindow is created — the engine relaxes the window sandbox for
* its preload frame pump, which cannot change after window creation.
*/
export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
@@ -21,12 +21,30 @@ function normalizeSql(sql: unknown): string {
return String(sql).replace(/\s+/g, ' ').trim();
}
function createEpgDatabaseMock() {
function createEpgDatabaseMock(
options: {
/** Result of the sqlite_master index-existence probe. */
dedupIndexExists?: boolean;
/** Make the CREATE UNIQUE INDEX statement throw on run(). */
failIndexCreation?: boolean;
} = {}
) {
const statements = new Map<string, jest.Mock>();
const prepare = jest.fn((statement: string) => {
const run = jest.fn();
statements.set(normalizeSql(statement), run);
return { run };
const normalized = normalizeSql(statement);
const run = jest.fn(() => {
if (
options.failIndexCreation &&
normalized.startsWith('CREATE UNIQUE INDEX')
) {
throw new Error('UNIQUE constraint failed');
}
});
const get = jest.fn(() =>
options.dedupIndexExists ? { 1: 1 } : undefined
);
statements.set(normalized, run);
return { run, get };
});
const transaction = jest.fn((callback: (rows: unknown[]) => void) => {
return (rows: unknown[]) => callback(rows);
@@ -43,7 +61,7 @@ function createEpgDatabaseMock() {
}
describe('EpgDatabase', () => {
it('refreshes a source without cascading shared channel programs from other sources', () => {
it('refreshes a source selectively, keeping the recent past-programme archive', () => {
const sourceUrl = 'https://example.com/playlist-guide.xml';
const { Database, database, statements } = createEpgDatabaseMock();
const epgDb = new EpgDatabase(Database);
@@ -62,8 +80,11 @@ describe('EpgDatabase', () => {
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const deleteProgramsSql =
'DELETE FROM epg_programs WHERE source_url = ?';
const deleteTodayAndFutureSql = normalizeSql(`
DELETE FROM epg_programs
WHERE source_url = ?
AND (start >= date('now') OR start < date('now', '-7 days'))
`);
const deleteOrphanChannelsSql = normalizeSql(`
DELETE FROM epg_channels
WHERE source_url = ?
@@ -74,12 +95,18 @@ describe('EpgDatabase', () => {
)
`);
expect(preparedSql).toContain(deleteProgramsSql);
expect(preparedSql).toContain(deleteTodayAndFutureSql);
expect(preparedSql).toContain(deleteOrphanChannelsSql);
// A refresh must not wipe the whole source: the last 7 days of
// programmes stay behind so catch-up remains browsable (#1138),
// and other sources' shared channels must not cascade away.
expect(preparedSql).not.toContain(
'DELETE FROM epg_programs WHERE source_url = ?'
);
expect(preparedSql).not.toContain(
'DELETE FROM epg_channels WHERE source_url = ?'
);
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
expect(statements.get(deleteTodayAndFutureSql)).toHaveBeenCalledWith(
sourceUrl
);
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
@@ -101,6 +128,95 @@ describe('EpgDatabase', () => {
'source_url = excluded.source_url'
);
});
it('removes pre-existing duplicate programs before creating the dedup index', () => {
const { Database, database, statements } = createEpgDatabaseMock();
new EpgDatabase(Database);
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const dedupDeleteSql = preparedSql.find((sql) =>
sql.startsWith('DELETE FROM epg_programs WHERE id NOT IN')
);
const createIndexSql = preparedSql.find((sql) =>
sql.startsWith('CREATE UNIQUE INDEX idx_epg_programs_dedup_v2')
);
const dropOldIndexSql = preparedSql.find((sql) =>
sql.startsWith('DROP INDEX IF EXISTS idx_epg_programs_dedup')
);
expect(dedupDeleteSql).toBeDefined();
expect(createIndexSql).toBeDefined();
expect(dropOldIndexSql).toBeDefined();
expect(statements.get(dedupDeleteSql!)).toHaveBeenCalled();
expect(statements.get(createIndexSql!)).toHaveBeenCalled();
expect(statements.get(dropOldIndexSql!)).toHaveBeenCalled();
// The dedup key and index are source-aware so programmes imported
// from different EPG sources are not collapsed.
expect(dedupDeleteSql).toContain(
'GROUP BY channel_id, start, title, source_url'
);
expect(createIndexSql).toContain(
'epg_programs(channel_id, start, title, source_url)'
);
const insertProgramSql = preparedSql.find((sql) =>
sql.startsWith('INSERT INTO epg_programs')
);
expect(insertProgramSql).toContain(
'ON CONFLICT(channel_id, start, title, source_url) DO UPDATE SET'
);
expect(insertProgramSql).not.toContain(
'source_url = excluded.source_url'
);
});
it('skips the dedup pass when the index already exists', () => {
const { Database, database } = createEpgDatabaseMock({
dedupIndexExists: true,
});
new EpgDatabase(Database);
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
expect(
preparedSql.some((sql) =>
sql.startsWith('DELETE FROM epg_programs WHERE id NOT IN')
)
).toBe(false);
expect(
preparedSql.some((sql) => sql.startsWith('CREATE UNIQUE INDEX'))
).toBe(false);
const insertProgramSql = preparedSql.find((sql) =>
sql.startsWith('INSERT INTO epg_programs')
);
expect(insertProgramSql).toContain(
'ON CONFLICT(channel_id, start, title, source_url) DO UPDATE SET'
);
});
it('falls back to plain inserts when index creation fails', () => {
const { Database, database } = createEpgDatabaseMock({
failIndexCreation: true,
});
expect(() => new EpgDatabase(Database)).not.toThrow();
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const insertProgramSql = preparedSql.find((sql) =>
sql.startsWith('INSERT INTO epg_programs')
);
expect(insertProgramSql).toBeDefined();
expect(insertProgramSql).not.toContain('ON CONFLICT');
});
});
describe('EpgDatabaseClearOperation', () => {
@@ -11,8 +11,8 @@ export class EpgDatabase {
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
@@ -30,14 +30,33 @@ export class EpgDatabase {
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertProgramStmt = this.db.prepare(`
INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// Guard against duplicate entries when the clearFirst logic misses old
// rows. The same channel + start + title + source is treated as the
// same programme — a later import with a corrected stop time simply
// updates the earlier row. `source_url` is part of the key so that
// programmes imported from different EPG sources that happen to share
// channel_id/start/title stay isolated: the query layer scopes by
// source_url, and source-scoped deletes must not clobber another
// source's rows.
// The upsert (instead of INSERT OR REPLACE) keeps the epg_programs_fts
// triggers consistent: REPLACE deletes rows without firing the delete
// trigger unless recursive_triggers is enabled, leaving ghost FTS rows.
const dedupIndexReady = this.ensureProgramDedupIndex();
this.deleteProgramsForSourceStmt = this.db.prepare(`
DELETE FROM epg_programs WHERE source_url = ?
`);
this.insertProgramStmt = this.db.prepare(
dedupIndexReady
? `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(channel_id, start, title, source_url) DO UPDATE SET
stop = excluded.stop,
description = excluded.description,
category = excluded.category,
icon_url = excluded.icon_url,
rating = excluded.rating,
episode_num = excluded.episode_num`
: `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
@@ -48,21 +67,28 @@ export class EpgDatabase {
WHERE epg_programs.channel_id = epg_channels.id
)
`);
this.deleteTodayAndFutureStmt = this.db.prepare(`
DELETE FROM epg_programs
WHERE source_url = ?
AND (start >= date('now') OR start < date('now', '-7 days'))
`);
}
/**
* Insert a batch of channels. When `clearFirst` is true, the existing rows
* for `sourceUrl` are deleted inside the same transaction as the insert so
* old data is preserved if the fetch/parse never produces any channels.
* Insert a batch of channels. When `clearTodayAndFuture` is true, the
* selective delete (today+future and older-than-7-days) runs inside the
* same transaction so a parse failure after the delete atomically rolls
* back both — no gap left in the schedule.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearFirst = false
clearTodayAndFuture = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteProgramsForSourceStmt.run(sourceUrl);
if (clearTodayAndFuture) {
this.deleteTodayAndFutureStmt.run(sourceUrl);
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
@@ -131,6 +157,57 @@ export class EpgDatabase {
close(): void {
this.db.close();
}
/**
* Create the unique (channel_id, start, title, source_url) dedup index.
*
* Databases that predate the index may already contain duplicate rows —
* exactly the situation the index is meant to prevent — so those are
* removed first; a plain `CREATE UNIQUE INDEX` would otherwise throw in
* this constructor and permanently break EPG imports for upgrading users.
*
* The `_v2` name migrates users off the earlier source-blind index
* (`idx_epg_programs_dedup` on `channel_id, start, title`), which
* collapsed programmes imported from different EPG sources that shared
* those three columns. The old index is dropped so it can no longer
* enforce the source-blind uniqueness.
*
* Returns false when the index could not be created, in which case the
* insert statement falls back to the previous plain-INSERT behaviour.
*/
private ensureProgramDedupIndex(): boolean {
try {
const exists = this.db
.prepare(
`SELECT 1 FROM sqlite_master
WHERE type = 'index' AND name = 'idx_epg_programs_dedup_v2'`
)
.get();
if (!exists) {
this.db
.prepare(`DROP INDEX IF EXISTS idx_epg_programs_dedup`)
.run();
this.db
.prepare(
`DELETE FROM epg_programs
WHERE id NOT IN (
SELECT MIN(id) FROM epg_programs
GROUP BY channel_id, start, title, source_url
)`
)
.run();
this.db
.prepare(
`CREATE UNIQUE INDEX idx_epg_programs_dedup_v2
ON epg_programs(channel_id, start, title, source_url)`
)
.run();
}
return true;
} catch {
return false;
}
}
}
export class EpgDatabaseClearOperation {
Loaded 100 of 425 files, more files were not shown because too many files have changed in this diff. Show more