mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
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: [],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+16
-2
@@ -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;
|
||||
}
|
||||
+187
-20
@@ -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;
|
||||
+172
@@ -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();
|
||||
});
|
||||
});
|
||||
+148
@@ -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'),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+369
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+281
@@ -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;
|
||||
}
|
||||
+338
@@ -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);
|
||||
});
|
||||
});
|
||||
+203
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+103
@@ -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,
|
||||
};
|
||||
}
|
||||
+149
@@ -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);
|
||||
});
|
||||
});
|
||||
+267
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+246
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
+238
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+391
@@ -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);
|
||||
}
|
||||
+95
@@ -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
|
||||
);
|
||||
}
|
||||
+306
@@ -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;
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
+179
@@ -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>;
|
||||
}
|
||||
+150
@@ -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();
|
||||
});
|
||||
});
|
||||
+247
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
+162
@@ -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
Reference in new issue
Block a user