Files
iptvnator/tools/packaging/embedded-mpv-frame-copy-files.cjs
T
4gray 8fdac824fd feat(packaging): ship Linux embedded MPV frame-copy runtime (#1200)
* docs: design Linux frame-copy packaging

* docs: plan Linux frame-copy packaging

* feat(packaging): define Linux frame-copy profiles

* fix(packaging): reject inherited profile names

* feat(embedded-mpv): validate staged Linux runtime

* fix(embedded-mpv): require Linux source packages

* fix(embedded-mpv): harden Linux runtime staging

* feat(embedded-mpv): build LGPL Linux runtime

* fix(embedded-mpv): pin Linux runtime inputs

* feat(embedded-mpv): build relocatable Linux helper

* fix(embedded-mpv): require bundled Linux runtime

* fix(embedded-mpv): make Linux runtime portable

* feat(packaging): ship Linux frame-copy artifacts

* fix(embedded-mpv): verify Linux helper linkage

* fix(packaging): enforce Linux frame-copy isolation

* fix(embedded-mpv): pin Linux display data

* docs(embedded-mpv): document Linux frame-copy packaging

* feat(embedded-mpv): probe Linux frame-copy runtime

* test(embedded-mpv): smoke packaged Linux frame-copy

* docs(embedded-mpv): clarify Linux system runtime baseline

* fix(embedded-mpv): harden Linux runtime capability gate

* ci: verify Linux frame-copy packages

* test(embedded-mpv): harden packaged Linux smoke

* test(embedded-mpv): preserve packaged GL mode

* test(packaging): harden Linux package probes

* fix(embedded-mpv): enable private Snap shared memory

* fix(embedded-mpv): sanitize Linux helper environment

* fix(packaging): enforce private Snap memory semantics

* fix(packaging): reject ambiguous Snap memory metadata

* fix(embedded-mpv): prioritize trusted Snap GL

* fix(packaging): reject advanced Snap YAML semantics

* fix(packaging): reject arbitrary Snap YAML aliases

* feat(packaging): ship Linux runtime license notices

* docs(embedded-mpv): document Linux runtime distribution

* fix(packaging): parse Snap trailing comments safely

* fix(release): gate Snap publish on public source release

* fix(packaging): strip VCS metadata from source bundle

* docs(packaging): clarify Linux source release gate

* test(embedded-mpv): smoke missing bundled libmpv

* style(embedded-mpv): format final validation inputs

* fix(e2e): satisfy fixture index signature typing

* fix(ci): declare fontconfig gperf generator

* fix(embedded-mpv): hash runtime cache identities

* fix(packaging): harden Linux frame-copy delivery

* fix(packaging): tighten runtime delivery gates

* fix(ci): decouple Linux runtime matrix

* fix(packaging): harden Linux frame-copy delivery

* fix(packaging): validate Linux frame-copy runtimes

* fix(packaging): scope Snap Electron library checks

* feat(packaging): ship Linux frame-copy runtimes

* fix(packaging): improve Linux runtime smoke diagnostics

* fix(packaging): expose bounded helper probe details

* test(packaging): trace Snap EGL probe failures

* fix(packaging): prefer core22 ABI in Snap helper

* fix(packaging): bound helper probe capture

* fix(packaging): harden Linux frame-copy releases

* fix(packaging): canonicalize libplacebo submodule identity

* fix(packaging): make source archive inspection portable

* fix(packaging): harden Snap release verification
2026-07-18 17:28:22 +02:00

579 lines
19 KiB
JavaScript

'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { isDeepStrictEqual } = require('node:util');
const {
validateLinuxRuntimeManifest,
} = require('../embedded-mpv/linux-runtime-manifest.cjs');
const {
validateLinuxSourceArchiveBinding,
} = require('../embedded-mpv/linux-source-archive-contract.cjs');
const {
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
validateLinuxRuntimeNotices,
} = require('../embedded-mpv/generate-linux-runtime-notices.cjs');
const {
LINUX_SYSTEM_PACKAGE_DEPENDENCIES,
resolveLinuxFrameCopyProfile,
validateLinuxProfileTargets,
} = require('./linux-frame-copy-profile.cjs');
const FRAME_COPY_HELPER = 'iptvnator_mpv_helper';
const WINDOWS_FRAME_COPY_HELPER = 'iptvnator_mpv_helper.exe';
const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node';
const EMBEDDED_MPV_ADDON = 'embedded_mpv.node';
const RUNTIME_MANIFEST = 'embedded-mpv-runtime.json';
const UNAVAILABLE_MARKER = 'embedded-mpv-unavailable.txt';
const LICENSES_DIRECTORY = 'licenses';
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
const EXPECTED_ARTIFACTS = Object.freeze({
addon: EMBEDDED_MPV_ADDON,
frameReader: FRAME_COPY_READER,
helper: FRAME_COPY_HELPER,
});
const EXPECTED_PROCESS_ISOLATION = Object.freeze({
addonLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: Object.freeze(['$ORIGIN/lib']),
});
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function readJsonFile(filePath, label) {
let contents;
try {
contents = fs.readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(
`Unable to read ${label} at ${filePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
try {
return JSON.parse(contents);
} catch (error) {
throw new Error(
`Invalid JSON in ${label} at ${filePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
function assertRegularReadableFile(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 file: ${filePath}`);
}
try {
fs.accessSync(filePath, fs.constants.R_OK);
} catch {
throw new Error(`${label} must be readable: ${filePath}`);
}
return stat;
}
function sameJson(left, right) {
return isDeepStrictEqual(left, right);
}
function normalizeTargetNames(targetNames) {
if (!Array.isArray(targetNames) || targetNames.length === 0) {
throw new Error(
'Linux frame-copy packaging requires at least one target.'
);
}
const normalized = [];
for (const targetName of targetNames) {
const name = String(targetName ?? '')
.trim()
.toLowerCase();
if (!name) {
throw new Error(
'Linux frame-copy target names must be non-empty strings.'
);
}
if (normalized.includes(name)) {
throw new Error(`Linux frame-copy target "${name}" is duplicated.`);
}
normalized.push(name);
}
return normalized.sort();
}
function validateLinuxFrameCopyBuildManifest(manifest) {
const errors = [];
if (!isObject(manifest)) {
return ['Linux frame-copy build manifest must be an object.'];
}
for (const [field, expected] of [
['schemaVersion', 1],
['origin', 'linux-frame-copy-build'],
['platform', 'linux'],
['arch', 'x64'],
['buildInputMode', 'bundled-runtime'],
['sourceRuntimeValidated', true],
]) {
if (manifest[field] !== expected) {
errors.push(
`Linux frame-copy build manifest ${field} must equal ${JSON.stringify(
expected
)}.`
);
}
}
if (!sameJson(manifest.allowedPackageRuntimeModes, ['system', 'bundled'])) {
errors.push(
'Linux frame-copy build manifest allowedPackageRuntimeModes must contain exactly system and bundled.'
);
}
if (
!sameJson(manifest.packageRuntimeAvailability, {
system: true,
bundled: true,
})
) {
errors.push(
'Linux frame-copy build manifest packageRuntimeAvailability must mark exactly system and bundled as available.'
);
}
if (!sameJson(manifest.artifacts, EXPECTED_ARTIFACTS)) {
errors.push(
'Linux frame-copy build manifest artifacts must name the addon, frame reader, and helper exactly.'
);
}
if (!sameJson(manifest.processIsolation, EXPECTED_PROCESS_ISOLATION)) {
errors.push(
'Linux frame-copy build manifest processIsolation contract is invalid.'
);
}
if (manifest.nativeViewFallback !== 'process-isolated mpv --wid') {
errors.push(
'Linux frame-copy build manifest nativeViewFallback contract is invalid.'
);
}
if (
typeof manifest.generatedAt !== 'string' ||
Number.isNaN(Date.parse(manifest.generatedAt))
) {
errors.push(
'Linux frame-copy build manifest generatedAt must be a valid timestamp.'
);
}
if (
typeof manifest.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname)
) {
errors.push(
'Linux frame-copy build manifest libmpvSoname must be a versioned libmpv SONAME.'
);
}
const sourceLinkerAlias =
manifest.sourceRuntime?.runtimeDependencyClosure?.entries?.find(
(entry) => entry?.name === 'libmpv.so'
);
if (
typeof manifest.libmpvSoname === 'string' &&
sourceLinkerAlias?.soname !== manifest.libmpvSoname
) {
errors.push(
'Linux frame-copy build manifest libmpvSoname must be derived from the validated source runtime libmpv.so SONAME.'
);
}
const sourceErrors = validateLinuxRuntimeManifest(manifest.sourceRuntime);
errors.push(
...sourceErrors.map(
(error) => `Invalid bundled source runtime: ${error}`
)
);
errors.push(
...validateLinuxSourceArchiveBinding(manifest.sourceArchive).map(
(error) => `Invalid Linux source archive binding: ${error}`
)
);
if (
Array.isArray(manifest.runtimeFiles) &&
Array.isArray(manifest.sourceRuntime?.runtimeFiles) &&
!sameJson(manifest.runtimeFiles, manifest.sourceRuntime.runtimeFiles)
) {
errors.push(
'Linux frame-copy build manifest runtimeFiles must exactly match sourceRuntime.runtimeFiles.'
);
}
const expectedTotal = Array.isArray(manifest.runtimeFiles)
? manifest.runtimeFiles.reduce(
(total, runtimeFile) =>
total +
(isObject(runtimeFile) &&
Number.isInteger(runtimeFile.size) &&
runtimeFile.size > 0
? runtimeFile.size
: 0),
0
)
: 0;
if (manifest.runtimeTotalBytes !== expectedTotal) {
errors.push(
`Linux frame-copy build manifest runtimeTotalBytes must equal ${expectedTotal}.`
);
}
if (
Array.isArray(manifest.runtimeFiles) &&
typeof manifest.libmpvSoname === 'string' &&
!manifest.runtimeFiles.some(
(runtimeFile) => runtimeFile.name === manifest.libmpvSoname
)
) {
errors.push(
'Linux frame-copy build manifest runtimeFiles must include libmpvSoname.'
);
}
return errors;
}
function verifyBundledRuntimeFiles(nativeDir, manifest) {
const libDir = path.join(nativeDir, 'lib');
let libDirStat;
try {
libDirStat = fs.lstatSync(libDir);
} catch {
throw new Error(`Missing bundled runtime directory: ${libDir}`);
}
if (!libDirStat.isDirectory() || libDirStat.isSymbolicLink()) {
throw new Error(
`Bundled runtime directory must be a regular directory: ${libDir}`
);
}
const declaredFiles = new Map(
manifest.runtimeFiles.map((runtimeFile) => [
runtimeFile.name,
runtimeFile,
])
);
for (const entry of fs.readdirSync(libDir, { withFileTypes: true })) {
if (!declaredFiles.has(entry.name)) {
throw new Error(
`Found undeclared bundled runtime file ${entry.name} in ${libDir}.`
);
}
}
for (const runtimeFile of manifest.runtimeFiles) {
const runtimePath = path.join(libDir, runtimeFile.name);
let stat;
try {
stat = fs.lstatSync(runtimePath);
} catch {
throw new Error(`Missing bundled runtime file: ${runtimePath}`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(
`Bundled runtime file must be a materialized regular file: ${runtimePath}`
);
}
try {
fs.accessSync(runtimePath, fs.constants.R_OK);
} catch {
throw new Error(
`Bundled runtime file must be readable: ${runtimePath}`
);
}
const contents = fs.readFileSync(runtimePath);
if (contents.length !== runtimeFile.size) {
throw new Error(
`Bundled runtime file size mismatch for ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.length}.`
);
}
const actualSha256 = crypto
.createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
throw new Error(
`Bundled runtime file SHA-256 mismatch for ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
);
}
}
}
function writeManifest(nativeDir, manifest) {
const manifestPath = path.join(nativeDir, RUNTIME_MANIFEST);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
mode: 0o644,
});
return manifest;
}
function removeLinuxRuntimeNotices(nativeDir) {
for (const relativePath of [
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
LICENSES_DIRECTORY,
'notices',
]) {
fs.rmSync(path.join(nativeDir, relativePath), {
recursive: true,
force: true,
});
}
}
function prepareBundledLinuxRuntimeNotices(
nativeDir,
sourceRuntime,
noticeSourceDir
) {
if (noticeSourceDir) {
const sourceErrors = validateLinuxRuntimeNotices(
noticeSourceDir,
sourceRuntime
);
if (sourceErrors.length > 0) {
throw new Error(
[
`Invalid Linux runtime notice source at ${noticeSourceDir}.`,
...sourceErrors.map((error) => `- ${error}`),
].join('\n')
);
}
removeLinuxRuntimeNotices(nativeDir);
for (const relativePath of [
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
LICENSES_DIRECTORY,
]) {
fs.cpSync(
path.join(noticeSourceDir, relativePath),
path.join(nativeDir, relativePath),
{
recursive: true,
force: true,
}
);
}
}
const packagedErrors = validateLinuxRuntimeNotices(
nativeDir,
sourceRuntime,
{ allowUnrelatedFiles: true }
);
if (packagedErrors.length > 0) {
throw new Error(
[
`Invalid packaged Linux runtime notices at ${nativeDir}.`,
...packagedErrors.map((error) => `- ${error}`),
].join('\n')
);
}
}
function createPackagedManifest(buildManifest, profile, targetNames) {
const bundled = profile.runtimeMode === 'bundled';
const runtimeFiles = bundled
? buildManifest.runtimeFiles.map((runtimeFile) => ({ ...runtimeFile }))
: [];
return {
schemaVersion: 1,
origin: profile.manifestOrigin,
generatedAt: buildManifest.generatedAt,
platform: 'linux',
arch: 'x64',
profile: profile.name,
runtimeMode: profile.runtimeMode,
targets: [...new Set(targetNames)].sort(),
artifacts: {
addon: {
name: EMBEDDED_MPV_ADDON,
regularFile: true,
readable: true,
},
frameReader: {
name: FRAME_COPY_READER,
regularFile: true,
readable: true,
},
helper: {
name: FRAME_COPY_HELPER,
regularFile: true,
readable: true,
executable: true,
},
},
processIsolation: {
addonLoadsLibmpv: false,
readerLoadsLibmpv: false,
electronLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
},
nativeViewFallback: buildManifest.nativeViewFallback,
libmpvSoname: buildManifest.libmpvSoname,
packageDependencies:
profile.runtimeMode === 'system'
? { ...LINUX_SYSTEM_PACKAGE_DEPENDENCIES }
: {},
runtimeFiles,
runtimeTotalBytes: runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
...(bundled
? {
runtimeDependencyClosure: {
entries:
buildManifest.sourceRuntime.runtimeDependencyClosure.entries.map(
(entry) => ({
name: entry.name,
soname: entry.soname ?? null,
needed: [...entry.needed],
rpath: [...entry.rpath],
runpath: [...entry.runpath],
})
),
externalDependencies: [
...buildManifest.sourceRuntime
.runtimeDependencyClosure.externalDependencies,
],
},
externalSystemLibraries:
buildManifest.sourceRuntime.externalSystemLibraries.map(
(entry) => ({ ...entry })
),
sourceArchive: structuredClone(buildManifest.sourceArchive),
sourceRuntime: structuredClone(buildManifest.sourceRuntime),
}
: {}),
};
}
function prepareNativeViewOnlyLinuxArtifacts(nativeDir) {
for (const fileName of [
FRAME_COPY_HELPER,
WINDOWS_FRAME_COPY_HELPER,
FRAME_COPY_READER,
UNAVAILABLE_MARKER,
]) {
fs.rmSync(path.join(nativeDir, fileName), { force: true });
}
fs.rmSync(path.join(nativeDir, 'lib'), { recursive: true, force: true });
removeLinuxRuntimeNotices(nativeDir);
return writeManifest(nativeDir, {
schemaVersion: 1,
origin: 'external-mpv-process',
platform: 'linux',
arch: 'x64',
runtimeMode: 'native-view-only',
frameCopyAvailable: false,
artifacts: {
addon: EMBEDDED_MPV_ADDON,
},
nativeViewFallback: 'process-isolated mpv --wid',
});
}
function prepareLinuxFrameCopyArtifacts(nativeDir, options = {}) {
if (!options.profile) {
return prepareNativeViewOnlyLinuxArtifacts(nativeDir);
}
const profile = resolveLinuxFrameCopyProfile(options.profile);
const targetNames = normalizeTargetNames(options.targetNames);
const targetErrors = validateLinuxProfileTargets(profile.name, targetNames);
if (targetErrors.length > 0) {
throw new Error(targetErrors.join('\n'));
}
const addonPath = path.join(nativeDir, EMBEDDED_MPV_ADDON);
const readerPath = path.join(nativeDir, FRAME_COPY_READER);
const helperPath = path.join(nativeDir, FRAME_COPY_HELPER);
const manifestPath = path.join(nativeDir, RUNTIME_MANIFEST);
assertRegularReadableFile(addonPath, 'embedded MPV addon');
assertRegularReadableFile(readerPath, 'embedded MPV frame reader');
assertRegularReadableFile(helperPath, 'embedded MPV frame-copy helper');
assertRegularReadableFile(manifestPath, 'embedded MPV runtime manifest');
const buildManifest = readJsonFile(
manifestPath,
'embedded MPV runtime manifest'
);
const manifestErrors = validateLinuxFrameCopyBuildManifest(buildManifest);
if (manifestErrors.length > 0) {
throw new Error(
['Invalid Linux frame-copy build manifest.', ...manifestErrors]
.map((error) => `- ${error}`)
.join('\n')
);
}
verifyBundledRuntimeFiles(nativeDir, buildManifest);
fs.chmodSync(helperPath, 0o755);
fs.chmodSync(readerPath, 0o644);
fs.rmSync(path.join(nativeDir, WINDOWS_FRAME_COPY_HELPER), { force: true });
fs.rmSync(path.join(nativeDir, UNAVAILABLE_MARKER), { force: true });
if (profile.runtimeMode === 'system') {
fs.rmSync(path.join(nativeDir, 'lib'), {
recursive: true,
force: true,
});
removeLinuxRuntimeNotices(nativeDir);
} else {
prepareBundledLinuxRuntimeNotices(
nativeDir,
buildManifest.sourceRuntime,
options.noticeSourceDir
);
}
return writeManifest(
nativeDir,
createPackagedManifest(buildManifest, profile, targetNames)
);
}
function preparePackagedFrameCopyArtifacts(nativeDir, platform, options = {}) {
if (platform === 'linux') {
return prepareLinuxFrameCopyArtifacts(nativeDir, options);
}
const helperPath = path.join(
nativeDir,
platform === 'win32' ? WINDOWS_FRAME_COPY_HELPER : FRAME_COPY_HELPER
);
if (platform !== 'win32' && fs.existsSync(helperPath)) {
// Asset copying drops POSIX modes; restore spawn permission.
fs.chmodSync(helperPath, 0o755);
}
return undefined;
}
function removeStaleFrameCopyArtifacts(nativeDir) {
for (const fileName of [
FRAME_COPY_HELPER,
WINDOWS_FRAME_COPY_HELPER,
FRAME_COPY_READER,
]) {
fs.rmSync(path.join(nativeDir, fileName), { force: true });
}
removeLinuxRuntimeNotices(nativeDir);
}
module.exports = {
preparePackagedFrameCopyArtifacts,
removeStaleFrameCopyArtifacts,
validateLinuxFrameCopyBuildManifest,
};