feat(embedded-mpv): build LGPL Linux runtime

This commit is contained in:
4gray committed 2026-07-17 22:42:36 +02:00
1 parent d3a4d4dab8
commit e51fdc5286
5 files changed
+2227 -1

No files matched your search

+1
View File
@@ -46,6 +46,7 @@
"verify:package-layout": "node tools/packaging/verify-electron-package-layout.mjs",
"embedded-mpv:build-native:homebrew": "IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 nx run electron-backend:build-embedded-mpv",
"embedded-mpv:build-runtime": "node tools/embedded-mpv/build-macos-runtime.mjs",
"embedded-mpv:build-runtime:linux": "node tools/embedded-mpv/build-linux-runtime.mjs",
"embedded-mpv:stage-runtime": "node tools/embedded-mpv/stage-runtime.mjs",
"embedded-mpv:stage-runtime:macos": "node tools/embedded-mpv/stage-macos-runtime.mjs",
"embedded-mpv:stage-runtime:windows-archive": "node tools/embedded-mpv/stage-windows-runtime-archive.mjs",
+835
View File
@@ -0,0 +1,835 @@
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const SOURCE_PACKAGES = Object.freeze(
[
{
id: 'freetype',
version: '2.13.3',
sourceKind: 'archive',
sourceUrl:
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
license: 'FreeType License (FTL)',
},
{
id: 'fribidi',
version: '1.0.16',
sourceKind: 'archive',
sourceUrl:
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
license: 'LGPL-2.1-or-later',
},
{
id: 'harfbuzz',
version: '8.5.0',
sourceKind: 'archive',
sourceUrl:
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
license: 'MIT',
},
{
id: 'expat',
version: '2.8.2',
sourceKind: 'archive',
sourceUrl:
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
license: 'MIT',
},
{
id: 'fontconfig',
version: '2.16.0',
sourceKind: 'archive',
sourceUrl:
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
license: 'MIT',
},
{
id: 'libass',
version: '0.17.3',
sourceKind: 'archive',
sourceUrl:
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
license: 'ISC',
},
{
id: 'openssl',
version: '3.5.7',
sourceKind: 'archive',
sourceUrl:
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
license: 'Apache-2.0',
},
{
id: 'ffmpeg',
version: '8.1',
sourceKind: 'archive',
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
license: 'LGPL-2.1-or-later',
},
{
id: 'libplacebo',
version: '7.360.1',
sourceKind: 'git',
sourceUrl: 'https://github.com/haasn/libplacebo.git',
sourceTag: 'v7.360.1',
license: 'LGPL-2.1-or-later',
},
{
id: 'mpv',
version: '0.41.0',
sourceKind: 'archive',
sourceUrl:
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
license: 'LGPL-2.1-or-later with -Dgpl=false',
},
].map((sourcePackage) => Object.freeze(sourcePackage))
);
const BUILD_ORDER = Object.freeze(
SOURCE_PACKAGES.map((sourcePackage) => sourcePackage.id)
);
const FFMPEG_CONFIGURE_FLAGS = Object.freeze([
'--enable-shared',
'--disable-static',
'--disable-programs',
'--disable-doc',
'--disable-debug',
'--disable-autodetect',
'--disable-gpl',
'--disable-nonfree',
'--disable-version3',
'--enable-pic',
'--enable-pthreads',
'--enable-openssl',
'--disable-gnutls',
'--disable-mbedtls',
'--disable-libtls',
'--enable-network',
'--disable-protocols',
'--enable-protocol=file',
'--enable-protocol=http',
'--enable-protocol=https',
'--enable-protocol=httpproxy',
'--enable-protocol=tcp',
'--enable-protocol=tls',
'--enable-protocol=udp',
'--enable-protocol=crypto',
'--enable-protocol=data',
'--enable-demuxer=hls',
'--enable-vaapi',
'--disable-vdpau',
'--disable-vulkan',
'--disable-libdrm',
'--disable-cuda-llvm',
'--disable-cuvid',
'--disable-nvdec',
'--disable-nvenc',
'--disable-xlib',
'--disable-sdl2',
'--disable-openal',
]);
const MPV_MESON_FLAGS = Object.freeze([
'-Dgpl=false',
'-Dlibmpv=true',
'-Dcplayer=false',
'-Dbuild-date=false',
'-Dtests=false',
'-Dfuzzers=false',
'-Ddisable-packet-pool=false',
'-Dcdda=disabled',
'-Dcplugins=disabled',
'-Ddvbin=disabled',
'-Ddvdnav=disabled',
'-Diconv=enabled',
'-Djavascript=disabled',
'-Djpeg=disabled',
'-Dlcms2=disabled',
'-Dlibarchive=disabled',
'-Dlibavdevice=disabled',
'-Dlibbluray=disabled',
'-Dlua=disabled',
'-Dpthread-debug=disabled',
'-Drubberband=disabled',
'-Dsdl2-gamepad=disabled',
'-Duchardet=disabled',
'-Duwp=disabled',
'-Dvapoursynth=disabled',
'-Dvector=enabled',
'-Dwin32-threads=disabled',
'-Dx11-clipboard=disabled',
'-Dzimg=disabled',
'-Dzlib=disabled',
'-Dalsa=enabled',
'-Daudiounit=disabled',
'-Dcoreaudio=disabled',
'-Davfoundation=disabled',
'-Djack=disabled',
'-Dopenal=disabled',
'-Daudiotrack=disabled',
'-Daaudio=disabled',
'-Dopensles=disabled',
'-Doss-audio=disabled',
'-Dpipewire=disabled',
'-Dpulse=enabled',
'-Dsdl2-audio=disabled',
'-Dsndio=disabled',
'-Dwasapi=disabled',
'-Dcaca=disabled',
'-Dcocoa=disabled',
'-Dd3d11=disabled',
'-Ddirect3d=disabled',
'-Ddmabuf-wayland=disabled',
'-Ddrm=disabled',
'-Degl=enabled',
'-Degl-android=disabled',
'-Degl-angle=disabled',
'-Degl-angle-lib=disabled',
'-Degl-angle-win32=disabled',
'-Degl-drm=disabled',
'-Degl-wayland=disabled',
'-Degl-x11=disabled',
'-Dgbm=enabled',
'-Dgl=enabled',
'-Dgl-cocoa=disabled',
'-Dgl-dxinterop=disabled',
'-Dgl-win32=disabled',
'-Dgl-x11=disabled',
'-Dsdl2-video=disabled',
'-Dshaderc=disabled',
'-Dsixel=disabled',
'-Dspirv-cross=disabled',
'-Dplain-gl=enabled',
'-Dvdpau=disabled',
'-Dvdpau-gl-x11=disabled',
'-Dvaapi=enabled',
'-Dvaapi-drm=enabled',
'-Dvaapi-wayland=disabled',
'-Dvaapi-win32=disabled',
'-Dvaapi-x11=disabled',
'-Dvulkan=disabled',
'-Dwayland=disabled',
'-Dx11=disabled',
'-Dxv=disabled',
'-Dandroid-media-ndk=disabled',
'-Dcuda-hwaccel=disabled',
'-Dcuda-interop=disabled',
'-Dd3d-hwaccel=disabled',
'-Dd3d9-hwaccel=disabled',
'-Dgl-dxinterop-d3d9=disabled',
'-Dios-gl=disabled',
'-Dvideotoolbox-gl=disabled',
'-Dvideotoolbox-pl=disabled',
'-Dmacos-10-15-4-features=disabled',
'-Dmacos-11-features=disabled',
'-Dmacos-11-3-features=disabled',
'-Dmacos-12-features=disabled',
'-Dmacos-cocoa-cb=disabled',
'-Dmacos-media-player=disabled',
'-Dmacos-touchbar=disabled',
'-Dswift-build=disabled',
'-Dwin32-smtc=disabled',
'-Dhtml-build=disabled',
'-Dmanpage-build=disabled',
'-Dpdf-build=disabled',
]);
const BUILD_RECIPES = Object.freeze({
freetype: Object.freeze({
buildSystem: 'configure',
sharedOnly: true,
args: Object.freeze([
'--enable-shared',
'--disable-static',
'--without-brotli',
'--without-bzip2',
'--without-harfbuzz',
'--without-png',
'--without-zlib',
]),
}),
fribidi: Object.freeze({
buildSystem: 'configure',
sharedOnly: true,
args: Object.freeze([
'--enable-shared',
'--disable-static',
'--disable-docs',
'--disable-bin',
]),
}),
harfbuzz: Object.freeze({
buildSystem: 'meson',
sharedOnly: true,
args: Object.freeze([
'-Dglib=disabled',
'-Dgobject=disabled',
'-Dcairo=disabled',
'-Dchafa=disabled',
'-Dicu=disabled',
'-Dfreetype=enabled',
'-Dtests=disabled',
'-Dintrospection=disabled',
'-Ddocs=disabled',
'-Dutilities=disabled',
'-Dbenchmark=disabled',
]),
}),
expat: Object.freeze({
buildSystem: 'cmake',
sharedOnly: true,
args: Object.freeze([
'-DEXPAT_SHARED_LIBS=ON',
'-DEXPAT_BUILD_TOOLS=OFF',
'-DEXPAT_BUILD_EXAMPLES=OFF',
'-DEXPAT_BUILD_TESTS=OFF',
'-DEXPAT_BUILD_DOCS=OFF',
]),
}),
fontconfig: Object.freeze({
buildSystem: 'meson',
sharedOnly: true,
args: Object.freeze([
'-Ddoc=disabled',
'-Dtests=disabled',
'-Dtools=disabled',
'-Dcache-build=disabled',
'-Dnls=disabled',
]),
}),
libass: Object.freeze({
buildSystem: 'configure',
sharedOnly: true,
args: Object.freeze([
'--enable-shared',
'--disable-static',
'--enable-fontconfig',
'--disable-coretext',
'--disable-directwrite',
'--disable-libunibreak',
]),
}),
openssl: Object.freeze({
buildSystem: 'openssl',
sharedOnly: true,
args: Object.freeze([
'shared',
'no-apps',
'no-docs',
'no-tests',
'no-engine',
'no-legacy',
'no-module',
'no-weak-ssl-ciphers',
]),
}),
ffmpeg: Object.freeze({
buildSystem: 'ffmpeg',
sharedOnly: true,
args: FFMPEG_CONFIGURE_FLAGS,
}),
libplacebo: Object.freeze({
buildSystem: 'meson',
sharedOnly: true,
args: Object.freeze([
'-Dopengl=enabled',
'-Dvulkan=disabled',
'-Dvk-proc-addr=disabled',
'-Dglslang=disabled',
'-Dshaderc=disabled',
'-Dlcms=disabled',
'-Ddovi=disabled',
'-Dlibdovi=disabled',
'-Ddemos=false',
'-Dtests=false',
'-Dbench=false',
'-Dfuzz=false',
'-Dunwind=disabled',
'-Dxxhash=disabled',
]),
}),
mpv: Object.freeze({
buildSystem: 'meson',
sharedOnly: true,
args: MPV_MESON_FLAGS,
}),
});
const REQUIRED_TOOLS = Object.freeze([
'cc',
'cmake',
'curl',
'git',
'make',
'meson',
'ninja',
'patchelf',
'perl',
'pkg-config',
'python3',
'readelf',
'tar',
]);
const GLIBC_TOOLCHAIN_ALLOWLIST = Object.freeze([
'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',
]);
const EXTERNAL_SYSTEM_LIBRARIES = Object.freeze(
[
{
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.',
},
].map((externalLibrary) => Object.freeze(externalLibrary))
);
const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const externalSystemLibraryNames = new Set(
EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name)
);
const allowedExternalLibraryNames = new Set([
...GLIBC_TOOLCHAIN_ALLOWLIST,
...externalSystemLibraryNames,
]);
function joinEnvironmentParts(parts, separator = ' ') {
return parts.filter((value) => value && value.trim()).join(separator);
}
function createBuildEnvironment({
prefix,
baseEnv = process.env,
systemPkgConfigDirs = [],
}) {
const prefixPkgConfigDirs = [
path.join(prefix, 'lib', 'pkgconfig'),
path.join(prefix, 'share', 'pkgconfig'),
];
const pkgConfigLibDirs = [
...new Set([
...prefixPkgConfigDirs,
...systemPkgConfigDirs.filter(Boolean),
]),
];
const prefixLibDir = path.join(prefix, 'lib');
return {
...baseEnv,
PATH: joinEnvironmentParts(
[path.join(prefix, 'bin'), baseEnv.PATH],
path.delimiter
),
PKG_CONFIG_PATH: prefixPkgConfigDirs.join(path.delimiter),
PKG_CONFIG_LIBDIR: pkgConfigLibDirs.join(path.delimiter),
CMAKE_PREFIX_PATH: prefix,
CPPFLAGS: joinEnvironmentParts([
`-I${path.join(prefix, 'include')}`,
baseEnv.CPPFLAGS,
]),
CFLAGS: joinEnvironmentParts([
'-fPIC',
`-I${path.join(prefix, 'include')}`,
baseEnv.CFLAGS,
]),
CXXFLAGS: joinEnvironmentParts([
'-fPIC',
`-I${path.join(prefix, 'include')}`,
baseEnv.CXXFLAGS,
]),
LDFLAGS: joinEnvironmentParts([
`-L${prefixLibDir}`,
`-Wl,-rpath-link,${prefixLibDir}`,
baseEnv.LDFLAGS,
]),
LD_LIBRARY_PATH: joinEnvironmentParts(
[prefixLibDir, baseEnv.LD_LIBRARY_PATH],
path.delimiter
),
FONTCONFIG_PATH: path.join(prefix, 'etc', 'fonts'),
OPENSSL_MODULES: path.join(prefixLibDir, 'ossl-modules'),
};
}
function sha256Buffer(contents) {
return crypto.createHash('sha256').update(contents).digest('hex');
}
function runtimeLibraryNames(libDir) {
return fs
.readdirSync(libDir, { withFileTypes: true })
.filter(
(entry) =>
(entry.isFile() || entry.isSymbolicLink()) &&
SHARED_LIBRARY_PATTERN.test(entry.name)
)
.map((entry) => entry.name)
.sort();
}
function assertPathInside(parentPath, candidatePath, label) {
const relativePath = path.relative(parentPath, candidatePath);
if (
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw new Error(`${label} resolves outside ${parentPath}.`);
}
}
function materializeLibrarySymlinks(libDir) {
const realLibDir = fs.realpathSync(libDir);
for (const name of runtimeLibraryNames(libDir)) {
const libraryPath = path.join(libDir, name);
const stat = fs.lstatSync(libraryPath);
if (!stat.isSymbolicLink()) {
continue;
}
const realLibraryPath = fs.realpathSync(libraryPath);
assertPathInside(
realLibDir,
realLibraryPath,
`Runtime library alias ${name}`
);
const targetStat = fs.statSync(realLibraryPath);
if (!targetStat.isFile()) {
throw new Error(
`Runtime library alias ${name} does not resolve to a regular file.`
);
}
const contents = fs.readFileSync(realLibraryPath);
fs.unlinkSync(libraryPath);
fs.writeFileSync(libraryPath, contents, {
mode: targetStat.mode & 0o777,
});
}
}
function createRuntimeFileRecords(libDir) {
return runtimeLibraryNames(libDir).map((name) => {
const libraryPath = path.join(libDir, name);
const stat = fs.lstatSync(libraryPath);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(
`Runtime library ${name} must be a materialized regular file.`
);
}
const contents = fs.readFileSync(libraryPath);
return {
name,
size: contents.length,
sha256: sha256Buffer(contents),
};
});
}
function parseReadelfDynamic(output) {
const dynamic = {
needed: [],
rpath: [],
runpath: [],
};
const dynamicEntryPattern = /\((NEEDED|RPATH|RUNPATH)\)[^[]*\[([^\]]*)\]/g;
for (const match of output.matchAll(dynamicEntryPattern)) {
const [, tag, value] = match;
if (tag === 'NEEDED') {
dynamic.needed.push(value);
continue;
}
const field = tag.toLowerCase();
dynamic[field].push(
...value.split(':').filter((pathEntry) => pathEntry.length > 0)
);
}
for (const field of Object.keys(dynamic)) {
dynamic[field] = [...new Set(dynamic[field])].sort();
}
return dynamic;
}
function validateRuntimeDependencyClosure({
entries,
runtimeFileNames,
buildPrefix,
}) {
if (!Array.isArray(entries) || !Array.isArray(runtimeFileNames)) {
throw new TypeError(
'Runtime closure entries and runtime file names must be arrays.'
);
}
const bundledNames = new Set(runtimeFileNames);
const entryNames = new Set();
const externalDependencies = new Set();
const normalizedEntries = [...entries]
.map((entry) => ({
name: entry.name,
needed: [...new Set(entry.needed ?? [])].sort(),
rpath: [...new Set(entry.rpath ?? [])].sort(),
runpath: [...new Set(entry.runpath ?? [])].sort(),
}))
.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of normalizedEntries) {
if (!bundledNames.has(entry.name)) {
throw new Error(
`Dynamic closure contains undeclared runtime file ${entry.name}.`
);
}
if (entryNames.has(entry.name)) {
throw new Error(
`Dynamic closure contains duplicate runtime file ${entry.name}.`
);
}
entryNames.add(entry.name);
if (entry.rpath.length > 0) {
throw new Error(
`${entry.name} has forbidden RPATH ${entry.rpath.join(':')}.`
);
}
if (entry.runpath.length !== 1 || entry.runpath[0] !== '$ORIGIN') {
const renderedRunpath =
entry.runpath.length > 0 ? entry.runpath.join(':') : '<empty>';
throw new Error(
`${entry.name} RUNPATH must be exactly $ORIGIN; got ${renderedRunpath}.`
);
}
if (
buildPrefix &&
[...entry.rpath, ...entry.runpath].some((value) =>
value.includes(buildPrefix)
)
) {
throw new Error(
`${entry.name} RPATH/RUNPATH contains build prefix ${buildPrefix}.`
);
}
for (const dependencyName of entry.needed) {
if (bundledNames.has(dependencyName)) {
continue;
}
if (!allowedExternalLibraryNames.has(dependencyName)) {
throw new Error(
`Runtime dependency is not bundled or allowlisted: ${entry.name} -> ${dependencyName}.`
);
}
externalDependencies.add(dependencyName);
}
}
for (const runtimeFileName of bundledNames) {
if (!entryNames.has(runtimeFileName)) {
throw new Error(
`Runtime library ${runtimeFileName} is missing from the dynamic closure.`
);
}
}
return {
entries: normalizedEntries,
externalDependencies: [...externalDependencies].sort(),
};
}
function parseCliInvocation({ platform, arch, argv, cwd }) {
if (platform !== 'linux' || arch !== 'x64') {
throw new Error(
`Embedded MPV runtime source builds are supported on Linux x64 only; received ${platform}/${arch}.`
);
}
const args = argv[0] === '--' ? argv.slice(1) : argv;
if (args.length !== 1 || !args[0]) {
throw new Error(
[
'Usage: node tools/embedded-mpv/build-linux-runtime.mjs <output-prefix>',
'',
'Builds the pinned LGPL-compatible Linux x64 libmpv runtime from source.',
].join('\n')
);
}
return { prefix: path.resolve(cwd, args[0]) };
}
function sourceManifestMetadata(sourceRecord) {
const metadata = {
version: sourceRecord.version,
sourceUrl: sourceRecord.sourceUrl,
...(sourceRecord.sourceTag
? { sourceTag: sourceRecord.sourceTag }
: {}),
...(sourceRecord.sourceSha256
? { sourceSha256: sourceRecord.sourceSha256 }
: {}),
...(sourceRecord.sourceGitCommit
? { sourceGitCommit: sourceRecord.sourceGitCommit }
: {}),
...(sourceRecord.sourceSubmodules
? { sourceSubmodules: [...sourceRecord.sourceSubmodules] }
: {}),
license: sourceRecord.license,
};
if (
sourceRecord.sourceKind === 'archive' &&
!SHA256_PATTERN.test(sourceRecord.sourceSha256 ?? '')
) {
throw new Error(
`Archive source ${sourceRecord.id} is missing its downloaded SHA-256 digest.`
);
}
if (
sourceRecord.sourceKind === 'git' &&
!/^[a-f0-9]{40,64}$/.test(sourceRecord.sourceGitCommit ?? '')
) {
throw new Error(
`Git source ${sourceRecord.id} is missing its exact commit digest.`
);
}
return metadata;
}
function createLinuxRuntimeManifest({
sourceRecords,
runtimeFiles,
dependencyClosure,
buildHost,
generatedAt = new Date().toISOString(),
ffmpegConfigureFlags = FFMPEG_CONFIGURE_FLAGS,
mpvMesonFlags = MPV_MESON_FLAGS,
}) {
const packages = {};
for (const sourcePackage of SOURCE_PACKAGES) {
const sourceRecord = sourceRecords[sourcePackage.id];
if (!sourceRecord) {
throw new Error(`Missing source metadata for ${sourcePackage.id}.`);
}
packages[sourcePackage.id] = sourceManifestMetadata(sourceRecord);
}
const runtimeTotalBytes = runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
);
return {
schemaVersion: 1,
origin: 'vendored-lgpl-source-build',
platform: 'linux',
arch: 'x64',
generatedAt,
packages,
ffmpeg: {
...packages.ffmpeg,
licensePolicy:
'LGPL, built with OpenSSL and without GPL, nonfree, or version-3-only components.',
configureFlags: [...ffmpegConfigureFlags],
},
mpv: {
...packages.mpv,
licensePolicy:
'LGPL-compatible libmpv built with -Dgpl=false and without the CLI player.',
mesonFlags: [...mpvMesonFlags],
},
runtimeFiles: runtimeFiles.map((runtimeFile) => ({ ...runtimeFile })),
runtimeTotalBytes,
runtimeDependencyClosure: {
entries: dependencyClosure.entries.map((entry) => ({
name: entry.name,
needed: [...entry.needed],
rpath: [...entry.rpath],
runpath: [...entry.runpath],
})),
externalDependencies: [...dependencyClosure.externalDependencies],
},
externalSystemLibraries: EXTERNAL_SYSTEM_LIBRARIES.map(
(externalLibrary) => ({ ...externalLibrary })
),
buildHost,
sourceDistribution:
'Attach a source archive to the corresponding Linux binary release containing the exact downloaded source archives, a checkout or git bundle of the recorded libplacebo commit and submodules, tools/embedded-mpv/build-linux-runtime.mjs, tools/embedded-mpv/build-linux-runtime.cjs, this runtime manifest, and any local patches.',
};
}
module.exports = {
BUILD_RECIPES,
BUILD_ORDER,
EXTERNAL_SYSTEM_LIBRARIES,
FFMPEG_CONFIGURE_FLAGS,
GLIBC_TOOLCHAIN_ALLOWLIST,
MPV_MESON_FLAGS,
REQUIRED_TOOLS,
SOURCE_PACKAGES,
createBuildEnvironment,
createLinuxRuntimeManifest,
createRuntimeFileRecords,
materializeLibrarySymlinks,
parseCliInvocation,
parseReadelfDynamic,
runtimeLibraryNames,
sha256Buffer,
validateRuntimeDependencyClosure,
};
+677
View File
@@ -0,0 +1,677 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const {
BUILD_RECIPES,
BUILD_ORDER,
MPV_MESON_FLAGS,
REQUIRED_TOOLS,
SOURCE_PACKAGES,
createBuildEnvironment,
createLinuxRuntimeManifest,
createRuntimeFileRecords,
materializeLibrarySymlinks,
parseCliInvocation,
parseReadelfDynamic,
runtimeLibraryNames,
sha256Buffer,
validateRuntimeDependencyClosure,
} = require('./build-linux-runtime.cjs');
const {
validateLinuxRuntimeManifest,
} = require('./linux-runtime-manifest.cjs');
const scriptPath = fileURLToPath(import.meta.url);
const workspaceRoot = path.resolve(path.dirname(scriptPath), '..', '..');
const sourcePackageById = new Map(
SOURCE_PACKAGES.map((sourcePackage) => [sourcePackage.id, sourcePackage])
);
function log(message) {
process.stdout.write(`[embedded-mpv-linux-runtime] ${message}\n`);
}
function commandLine(command, args) {
return [command, ...args]
.map((value) =>
/^[A-Za-z0-9_./:=+,-]+$/.test(value) ? value : JSON.stringify(value)
)
.join(' ');
}
function spawn(command, args, options, capture) {
log(commandLine(command, args));
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
encoding: capture ? 'utf8' : undefined,
stdio: capture ? 'pipe' : 'inherit',
});
if (result.error) {
throw new Error(
`Unable to run ${commandLine(command, args)}: ${result.error.message}`
);
}
if (result.status !== 0) {
const details = capture
? [result.stdout, result.stderr].filter(Boolean).join('\n').trim()
: '';
throw new Error(
`${commandLine(command, args)} failed with status ${
result.status ?? 1
}.${details ? `\n${details}` : ''}`
);
}
return result;
}
function createCommandRunner({ buildEnvironment }) {
return {
run(command, args, options = {}) {
spawn(
command,
args,
{
cwd: options.cwd ?? workspaceRoot,
env: options.env ?? buildEnvironment,
},
false
);
},
runCapture(command, args, options = {}) {
const result = spawn(
command,
args,
{
cwd: options.cwd ?? workspaceRoot,
env: options.env ?? buildEnvironment,
},
true
);
return [result.stdout, result.stderr]
.filter(Boolean)
.join('\n')
.trim();
},
};
}
function commandExists(command) {
const result = spawnSync(
'sh',
['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command],
{ stdio: 'ignore' }
);
return result.status === 0;
}
function ensureTools() {
const missingTools = REQUIRED_TOOLS.filter(
(command) => !commandExists(command)
);
if (missingTools.length > 0) {
throw new Error(
`Missing required Linux runtime build tools: ${missingTools.join(
', '
)}.`
);
}
}
function resolveParallelism(environment) {
const explicitJobs = environment.IPTVNATOR_EMBEDDED_MPV_JOBS;
const makeJobs = environment.MAKEFLAGS?.match(
/(?:^|\s)-j\s*(\d+)(?:\s|$)/
)?.[1];
const value =
explicitJobs ??
makeJobs ??
String(os.availableParallelism?.() ?? os.cpus().length);
if (!/^[1-9]\d*$/.test(value)) {
throw new Error(
`IPTVNATOR_EMBEDDED_MPV_JOBS must be a positive integer; received ${value}.`
);
}
return value;
}
function containsPath(parentPath, candidatePath) {
const relativePath = path.relative(parentPath, candidatePath);
return (
relativePath === '' ||
(!relativePath.startsWith(`..${path.sep}`) &&
relativePath !== '..' &&
!path.isAbsolute(relativePath))
);
}
function assertSafeOutputPrefix(prefix, buildRoot) {
const filesystemRoot = path.parse(prefix).root;
if (
prefix === filesystemRoot ||
containsPath(prefix, workspaceRoot) ||
containsPath(prefix, buildRoot)
) {
throw new Error(
`Refusing unsafe output prefix ${prefix}; choose a dedicated directory that does not contain the repository or build cache.`
);
}
}
function resolveSystemPkgConfigDirs(environment) {
const explicitDirectories =
environment.IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS;
if (explicitDirectories) {
return [
...new Set(
explicitDirectories
.split(path.delimiter)
.map((directory) => directory.trim())
.filter(Boolean)
.map((directory) => path.resolve(directory))
),
];
}
const result = spawnSync(
'pkg-config',
['--variable', 'pc_path', 'pkg-config'],
{
env: environment,
encoding: 'utf8',
stdio: 'pipe',
}
);
if (result.status !== 0 || !result.stdout.trim()) {
throw new Error(
'Unable to discover system pkg-config directories. Set IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS explicitly.'
);
}
return [
...new Set(
result.stdout
.trim()
.split(path.delimiter)
.filter(Boolean)
.map((directory) => path.resolve(directory))
),
];
}
function archiveExtension(sourceUrl) {
for (const extension of ['.tar.xz', '.tar.gz', '.tar.bz2', '.tgz']) {
if (new URL(sourceUrl).pathname.endsWith(extension)) {
return extension;
}
}
throw new Error(`Unsupported source archive URL: ${sourceUrl}`);
}
function archivePathFor(sourcePackage, archiveRoot) {
return path.join(
archiveRoot,
`${sourcePackage.id}-${sourcePackage.version}${archiveExtension(
sourcePackage.sourceUrl
)}`
);
}
function sourcePathFor(packageId, sourceRoot) {
return path.join(sourceRoot, packageId);
}
function sha256File(filePath) {
return sha256Buffer(fs.readFileSync(filePath));
}
function downloadArchive(sourcePackage, context) {
const archivePath = archivePathFor(sourcePackage, context.archiveRoot);
if (!fs.existsSync(archivePath)) {
const temporaryArchivePath = `${archivePath}.partial`;
fs.rmSync(temporaryArchivePath, { force: true });
context.run('curl', [
'--fail',
'--location',
'--retry',
'3',
'--retry-all-errors',
'--connect-timeout',
'30',
'--proto',
'=https',
'--tlsv1.2',
'--output',
temporaryArchivePath,
sourcePackage.sourceUrl,
]);
fs.renameSync(temporaryArchivePath, archivePath);
}
const packageSourcePath = sourcePathFor(
sourcePackage.id,
context.sourceRoot
);
fs.rmSync(packageSourcePath, { recursive: true, force: true });
fs.mkdirSync(packageSourcePath, { recursive: true });
context.run('tar', [
'--extract',
'--file',
archivePath,
'--directory',
packageSourcePath,
'--strip-components',
'1',
'--no-same-owner',
]);
return {
...sourcePackage,
sourceSha256: sha256File(archivePath),
};
}
function cloneGitSource(sourcePackage, context) {
const packageSourcePath = sourcePathFor(
sourcePackage.id,
context.sourceRoot
);
fs.rmSync(packageSourcePath, { recursive: true, force: true });
context.run('git', [
'clone',
'--depth',
'1',
'--branch',
sourcePackage.sourceTag,
sourcePackage.sourceUrl,
packageSourcePath,
]);
context.run(
'git',
['submodule', 'update', '--init', '--recursive', '--depth', '1'],
{ cwd: packageSourcePath }
);
const sourceGitCommit = context.runCapture('git', ['rev-parse', 'HEAD'], {
cwd: packageSourcePath,
});
const submoduleOutput = context.runCapture(
'git',
['submodule', 'status', '--recursive'],
{ cwd: packageSourcePath }
);
return {
...sourcePackage,
sourceGitCommit,
sourceSubmodules: submoduleOutput
? submoduleOutput
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
: [],
};
}
function acquireSources(context) {
fs.mkdirSync(context.archiveRoot, { recursive: true });
fs.mkdirSync(context.sourceRoot, { recursive: true });
const sourceRecords = {};
for (const packageId of BUILD_ORDER) {
const sourcePackage = sourcePackageById.get(packageId);
log(
`Acquiring ${sourcePackage.id} ${sourcePackage.version} from ${sourcePackage.sourceUrl}`
);
sourceRecords[packageId] =
sourcePackage.sourceKind === 'git'
? cloneGitSource(sourcePackage, context)
: downloadArchive(sourcePackage, context);
}
return sourceRecords;
}
function configureInstall(packageId, recipe, context) {
const sourcePath = sourcePathFor(packageId, context.sourceRoot);
context.run('./configure', [`--prefix=${context.prefix}`, ...recipe.args], {
cwd: sourcePath,
});
context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath });
context.run('make', ['install'], { cwd: sourcePath });
}
function mesonSetupArgs(prefix, recipeArgs) {
return [
`--prefix=${prefix}`,
'--libdir=lib',
'--buildtype=release',
'--default-library=shared',
'--wrap-mode=nodownload',
'--auto-features=disabled',
'-Db_ndebug=true',
...recipeArgs,
];
}
function mesonInstall(packageId, recipe, context) {
const sourcePath = sourcePathFor(packageId, context.sourceRoot);
const buildPath = path.join(sourcePath, 'build-iptvnator');
fs.rmSync(buildPath, { recursive: true, force: true });
context.run(
'meson',
['setup', buildPath, ...mesonSetupArgs(context.prefix, recipe.args)],
{ cwd: sourcePath }
);
context.run(
'meson',
['compile', '--jobs', context.parallelism, '-C', buildPath],
{ cwd: sourcePath }
);
context.run('meson', ['install', '-C', buildPath], { cwd: sourcePath });
}
function cmakeInstall(packageId, recipe, context) {
const sourcePath = sourcePathFor(packageId, context.sourceRoot);
const buildPath = path.join(sourcePath, 'build-iptvnator');
fs.rmSync(buildPath, { recursive: true, force: true });
context.run('cmake', [
'-S',
sourcePath,
'-B',
buildPath,
'-G',
'Ninja',
`-DCMAKE_INSTALL_PREFIX=${context.prefix}`,
'-DCMAKE_INSTALL_LIBDIR=lib',
'-DCMAKE_BUILD_TYPE=Release',
'-DBUILD_SHARED_LIBS=ON',
...recipe.args,
]);
context.run('cmake', [
'--build',
buildPath,
'--parallel',
context.parallelism,
]);
context.run('cmake', ['--install', buildPath]);
}
function opensslInstall(recipe, context) {
const sourcePath = sourcePathFor('openssl', context.sourceRoot);
context.run(
'perl',
[
'./Configure',
'linux-x86_64',
`--prefix=${context.prefix}`,
`--openssldir=${path.join(context.prefix, 'etc', 'ssl')}`,
'--libdir=lib',
...recipe.args,
],
{ cwd: sourcePath }
);
context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath });
context.run('make', ['install_sw'], { cwd: sourcePath });
}
function ffmpegInstall(recipe, context) {
const sourcePath = sourcePathFor('ffmpeg', context.sourceRoot);
const configureFlags = [`--prefix=${context.prefix}`, ...recipe.args];
context.run('./configure', configureFlags, { cwd: sourcePath });
context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath });
context.run('make', ['install'], { cwd: sourcePath });
context.ffmpegConfigureFlags = configureFlags;
}
function buildRuntime(context) {
for (const packageId of BUILD_ORDER) {
const recipe = BUILD_RECIPES[packageId];
log(`Building ${packageId} as shared libraries`);
switch (recipe.buildSystem) {
case 'configure':
configureInstall(packageId, recipe, context);
break;
case 'meson':
mesonInstall(packageId, recipe, context);
break;
case 'cmake':
cmakeInstall(packageId, recipe, context);
break;
case 'openssl':
opensslInstall(recipe, context);
break;
case 'ffmpeg':
ffmpegInstall(recipe, context);
break;
default:
throw new Error(
`Unsupported build system ${recipe.buildSystem} for ${packageId}.`
);
}
}
}
function removeFilesMatching(root, pattern) {
if (!fs.existsSync(root)) {
return;
}
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
removeFilesMatching(entryPath, pattern);
} else if (entry.isFile() && pattern.test(entry.name)) {
fs.rmSync(entryPath);
}
}
}
function removeNonRuntimeBuildOutputs(prefix) {
removeFilesMatching(path.join(prefix, 'lib'), /\.(?:a|la)$/);
for (const relativePath of [
'bin',
path.join('share', 'doc'),
path.join('share', 'gtk-doc'),
path.join('share', 'man'),
]) {
fs.rmSync(path.join(prefix, relativePath), {
recursive: true,
force: true,
});
}
}
function assertElfLibrary(libraryPath) {
const descriptor = fs.openSync(libraryPath, 'r');
try {
const header = Buffer.alloc(4);
const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0);
if (
bytesRead !== 4 ||
!header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
) {
throw new Error(
`Runtime shared library is not an ELF file: ${libraryPath}.`
);
}
} finally {
fs.closeSync(descriptor);
}
}
function postProcessRuntime(context) {
const libDir = path.join(context.prefix, 'lib');
if (!fs.existsSync(libDir)) {
throw new Error(`Runtime library directory was not built: ${libDir}.`);
}
materializeLibrarySymlinks(libDir);
const dynamicEntries = [];
for (const libraryName of runtimeLibraryNames(libDir)) {
const libraryPath = path.join(libDir, libraryName);
assertElfLibrary(libraryPath);
context.run('patchelf', ['--set-rpath', '$ORIGIN', libraryPath]);
const dynamic = parseReadelfDynamic(
context.runCapture('readelf', ['-d', libraryPath])
);
dynamicEntries.push({ name: libraryName, ...dynamic });
}
const runtimeFiles = createRuntimeFileRecords(libDir);
if (runtimeFiles.length === 0) {
throw new Error(
'The Linux runtime build produced no shared libraries.'
);
}
const dependencyClosure = validateRuntimeDependencyClosure({
entries: dynamicEntries,
runtimeFileNames: runtimeFiles.map(({ name }) => name),
buildPrefix: context.prefix,
});
return { dependencyClosure, runtimeFiles };
}
function firstLine(value) {
return (
value
.split(/\r?\n/)
.find((line) => line.trim())
?.trim() ?? ''
);
}
function collectBuildHost(context) {
const versionArgs = {
cc: ['--version'],
cmake: ['--version'],
curl: ['--version'],
git: ['--version'],
make: ['--version'],
meson: ['--version'],
ninja: ['--version'],
patchelf: ['--version'],
perl: ['--version'],
'pkg-config': ['--version'],
python3: ['--version'],
readelf: ['--version'],
tar: ['--version'],
};
const tools = {};
for (const tool of REQUIRED_TOOLS) {
tools[tool] = firstLine(
context.runCapture(tool, versionArgs[tool] ?? ['--version'])
);
}
return {
platform: process.platform,
arch: process.arch,
release: os.release(),
systemPkgConfigDirs: [...context.systemPkgConfigDirs],
tools,
};
}
function writeManifest(context, sourceRecords, runtimeMetadata) {
const mpvMesonFlags = mesonSetupArgs(context.prefix, MPV_MESON_FLAGS);
const manifest = createLinuxRuntimeManifest({
sourceRecords,
runtimeFiles: runtimeMetadata.runtimeFiles,
dependencyClosure: runtimeMetadata.dependencyClosure,
buildHost: collectBuildHost(context),
ffmpegConfigureFlags: context.ffmpegConfigureFlags,
mpvMesonFlags,
});
const manifestErrors = validateLinuxRuntimeManifest(manifest);
if (manifestErrors.length > 0) {
throw new Error(
[
'Generated Linux runtime manifest is invalid:',
...manifestErrors.map((error) => `- ${error}`),
].join('\n')
);
}
const manifestPath = path.join(context.prefix, 'runtime-manifest.json');
const temporaryManifestPath = `${manifestPath}.tmp`;
fs.writeFileSync(
temporaryManifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
{ mode: 0o644 }
);
fs.renameSync(temporaryManifestPath, manifestPath);
return manifest;
}
function createBuildContext(prefix, environment) {
const buildRoot = path.resolve(
environment.IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT ??
path.join(
os.tmpdir(),
'iptvnator-embedded-mpv-runtime',
'linux-x64'
)
);
assertSafeOutputPrefix(prefix, buildRoot);
const systemPkgConfigDirs = resolveSystemPkgConfigDirs(environment);
const buildEnvironment = createBuildEnvironment({
prefix,
baseEnv: environment,
systemPkgConfigDirs,
});
const runner = createCommandRunner({ buildEnvironment });
return {
...runner,
prefix,
buildRoot,
archiveRoot: path.join(buildRoot, 'archives'),
sourceRoot: path.join(buildRoot, 'sources'),
parallelism: resolveParallelism(environment),
systemPkgConfigDirs,
ffmpegConfigureFlags: null,
};
}
export function main({
argv = process.argv.slice(2),
platform = process.platform,
arch = process.arch,
cwd = process.cwd(),
environment = process.env,
} = {}) {
const { prefix } = parseCliInvocation({ platform, arch, argv, cwd });
ensureTools();
const context = createBuildContext(prefix, environment);
fs.mkdirSync(context.buildRoot, { recursive: true });
fs.rmSync(context.prefix, { recursive: true, force: true });
fs.mkdirSync(context.prefix, { recursive: true });
const sourceRecords = acquireSources(context);
buildRuntime(context);
removeNonRuntimeBuildOutputs(prefix);
const runtimeMetadata = postProcessRuntime(context);
const manifest = writeManifest(context, sourceRecords, runtimeMetadata);
log(
`Built ${manifest.runtimeFiles.length} LGPL-compatible runtime libraries (${manifest.runtimeTotalBytes} bytes) at ${prefix}`
);
}
const invokedScriptPath = process.argv[1]
? path.resolve(process.argv[1])
: undefined;
if (invokedScriptPath === scriptPath) {
try {
main();
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`
);
process.exitCode = 1;
}
}
@@ -0,0 +1,710 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const builderScript = path.join(currentDir, 'build-linux-runtime.mjs');
const builderHelpers = path.join(currentDir, 'build-linux-runtime.cjs');
const workspaceRoot = path.resolve(currentDir, '..', '..');
const require = createRequire(import.meta.url);
const {
BUILD_RECIPES,
BUILD_ORDER,
EXTERNAL_SYSTEM_LIBRARIES,
FFMPEG_CONFIGURE_FLAGS,
GLIBC_TOOLCHAIN_ALLOWLIST,
MPV_MESON_FLAGS,
REQUIRED_TOOLS,
SOURCE_PACKAGES,
createBuildEnvironment,
createLinuxRuntimeManifest,
createRuntimeFileRecords,
materializeLibrarySymlinks,
parseCliInvocation,
parseReadelfDynamic,
validateRuntimeDependencyClosure,
} = require('./build-linux-runtime.cjs');
const {
validateLinuxRuntimeManifest,
} = require('./linux-runtime-manifest.cjs');
test('provides the Linux source runtime builder entrypoint and helpers', () => {
assert.equal(fs.existsSync(builderScript), true);
assert.equal(fs.existsSync(builderHelpers), true);
});
test('pins the complete source stack and preserves dependency build order', () => {
assert.deepEqual(
SOURCE_PACKAGES.map(({ id, version }) => ({ id, version })),
[
{ id: 'freetype', version: '2.13.3' },
{ id: 'fribidi', version: '1.0.16' },
{ id: 'harfbuzz', version: '8.5.0' },
{ id: 'expat', version: '2.8.2' },
{ id: 'fontconfig', version: '2.16.0' },
{ id: 'libass', version: '0.17.3' },
{ id: 'openssl', version: '3.5.7' },
{ id: 'ffmpeg', version: '8.1' },
{ id: 'libplacebo', version: '7.360.1' },
{ id: 'mpv', version: '0.41.0' },
]
);
assert.deepEqual(
BUILD_ORDER,
SOURCE_PACKAGES.map(({ id }) => id)
);
for (const sourcePackage of SOURCE_PACKAGES) {
assert.match(sourcePackage.sourceUrl, /^https:\/\//);
assert.ok(sourcePackage.license);
if (sourcePackage.id === 'libplacebo') {
assert.equal(sourcePackage.sourceTag, 'v7.360.1');
assert.equal(sourcePackage.sourceKind, 'git');
} else {
assert.equal(sourcePackage.sourceKind, 'archive');
}
}
const byId = new Map(
SOURCE_PACKAGES.map((sourcePackage) => [
sourcePackage.id,
sourcePackage,
])
);
assert.equal(
byId.get('expat').sourceUrl,
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz'
);
assert.equal(
byId.get('fontconfig').sourceUrl,
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz'
);
assert.equal(
byId.get('openssl').sourceUrl,
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz'
);
});
test('defines shared-only source recipes with font discovery before playback', () => {
assert.deepEqual(Object.keys(BUILD_RECIPES), BUILD_ORDER);
assert.ok(BUILD_RECIPES.freetype.args.includes('--enable-shared'));
assert.ok(BUILD_RECIPES.freetype.args.includes('--disable-static'));
assert.ok(BUILD_RECIPES.fribidi.args.includes('--disable-docs'));
assert.ok(BUILD_RECIPES.fribidi.args.includes('--disable-bin'));
for (const flag of [
'-Dtests=disabled',
'-Ddocs=disabled',
'-Dutilities=disabled',
]) {
assert.ok(BUILD_RECIPES.harfbuzz.args.includes(flag), flag);
}
for (const flag of [
'-DEXPAT_SHARED_LIBS=ON',
'-DEXPAT_BUILD_TOOLS=OFF',
'-DEXPAT_BUILD_EXAMPLES=OFF',
'-DEXPAT_BUILD_TESTS=OFF',
'-DEXPAT_BUILD_DOCS=OFF',
]) {
assert.ok(BUILD_RECIPES.expat.args.includes(flag), flag);
}
for (const flag of [
'-Ddoc=disabled',
'-Dtests=disabled',
'-Dtools=disabled',
'-Dcache-build=disabled',
]) {
assert.ok(BUILD_RECIPES.fontconfig.args.includes(flag), flag);
}
assert.ok(BUILD_RECIPES.libass.args.includes('--enable-fontconfig'));
assert.equal(
BUILD_RECIPES.libass.args.includes('--disable-fontconfig'),
false
);
for (const flag of ['shared', 'no-apps', 'no-docs', 'no-tests']) {
assert.ok(BUILD_RECIPES.openssl.args.includes(flag), flag);
}
assert.ok(
BUILD_ORDER.indexOf('fontconfig') < BUILD_ORDER.indexOf('libass')
);
assert.ok(BUILD_ORDER.indexOf('openssl') < BUILD_ORDER.indexOf('ffmpeg'));
for (const packageId of BUILD_ORDER) {
assert.equal(BUILD_RECIPES[packageId].sharedOnly, true, packageId);
}
});
test('constructs a prefix-first build environment with only declared system interfaces', () => {
const environment = createBuildEnvironment({
prefix: '/opt/runtime',
baseEnv: {
PATH: '/usr/bin',
CFLAGS: '-O2',
LDFLAGS: '-Wl,--as-needed',
},
systemPkgConfigDirs: [
'/usr/lib/x86_64-linux-gnu/pkgconfig',
'/usr/share/pkgconfig',
],
});
assert.equal(environment.PATH, '/opt/runtime/bin:/usr/bin');
assert.equal(
environment.PKG_CONFIG_PATH,
'/opt/runtime/lib/pkgconfig:/opt/runtime/share/pkgconfig'
);
assert.equal(
environment.PKG_CONFIG_LIBDIR,
[
'/opt/runtime/lib/pkgconfig',
'/opt/runtime/share/pkgconfig',
'/usr/lib/x86_64-linux-gnu/pkgconfig',
'/usr/share/pkgconfig',
].join(':')
);
assert.match(environment.CPPFLAGS, /-I\/opt\/runtime\/include/);
assert.match(environment.CFLAGS, /-fPIC/);
assert.match(environment.CFLAGS, /-O2/);
assert.match(environment.LDFLAGS, /-L\/opt\/runtime\/lib/);
assert.match(environment.LDFLAGS, /-Wl,--as-needed/);
assert.equal(environment.LD_LIBRARY_PATH, '/opt/runtime/lib');
});
test('requires ELF patching and inspection tools in addition to the build toolchain', () => {
for (const tool of [
'cc',
'cmake',
'curl',
'git',
'make',
'meson',
'ninja',
'patchelf',
'pkg-config',
'readelf',
'tar',
]) {
assert.ok(REQUIRED_TOOLS.includes(tool), tool);
}
});
test('uses an explicit LGPL FFmpeg HTTPS and HLS protocol baseline', () => {
const required = [
'--enable-shared',
'--disable-static',
'--disable-programs',
'--disable-doc',
'--disable-debug',
'--disable-autodetect',
'--disable-gpl',
'--disable-nonfree',
'--enable-pic',
'--enable-pthreads',
'--enable-openssl',
'--enable-network',
'--enable-protocol=file',
'--enable-protocol=http',
'--enable-protocol=https',
'--enable-protocol=tcp',
'--enable-protocol=tls',
'--enable-protocol=udp',
'--enable-protocol=crypto',
'--enable-protocol=data',
'--enable-demuxer=hls',
'--enable-vaapi',
];
for (const flag of required) {
assert.ok(FFMPEG_CONFIGURE_FLAGS.includes(flag), `missing ${flag}`);
}
for (const forbidden of [
'--enable-gpl',
'--enable-nonfree',
'--enable-version3',
]) {
assert.equal(FFMPEG_CONFIGURE_FLAGS.includes(forbidden), false);
}
});
test('uses valid mpv v0.41 Meson option names and pins the Linux backends', () => {
const upstreamMpv041Options = new Set([
'aaudio',
'alsa',
'android-media-ndk',
'audiotrack',
'audiounit',
'avfoundation',
'build-date',
'caca',
'cdda',
'coreaudio',
'cocoa',
'cplayer',
'cplugins',
'cuda-hwaccel',
'cuda-interop',
'd3d-hwaccel',
'd3d11',
'd3d9-hwaccel',
'direct3d',
'disable-packet-pool',
'dmabuf-wayland',
'drm',
'dvbin',
'dvdnav',
'egl',
'egl-android',
'egl-angle',
'egl-angle-lib',
'egl-angle-win32',
'egl-drm',
'egl-wayland',
'egl-x11',
'fuzzers',
'gbm',
'gl',
'gl-cocoa',
'gl-dxinterop',
'gl-dxinterop-d3d9',
'gl-win32',
'gl-x11',
'gpl',
'html-build',
'iconv',
'ios-gl',
'jack',
'javascript',
'jpeg',
'lcms2',
'libarchive',
'libavdevice',
'libbluray',
'libmpv',
'lua',
'macos-10-15-4-features',
'macos-11-3-features',
'macos-11-features',
'macos-12-features',
'macos-cocoa-cb',
'macos-media-player',
'macos-touchbar',
'manpage-build',
'openal',
'opensles',
'oss-audio',
'pdf-build',
'pipewire',
'plain-gl',
'pthread-debug',
'pulse',
'rubberband',
'sdl2-audio',
'sdl2-gamepad',
'sdl2-video',
'shaderc',
'sixel',
'sndio',
'spirv-cross',
'swift-build',
'tests',
'uchardet',
'uwp',
'vaapi',
'vaapi-drm',
'vaapi-wayland',
'vaapi-win32',
'vaapi-x11',
'vapoursynth',
'vdpau',
'vdpau-gl-x11',
'vector',
'videotoolbox-gl',
'videotoolbox-pl',
'vulkan',
'wasapi',
'wayland',
'win32-smtc',
'win32-threads',
'x11',
'x11-clipboard',
'xv',
'zimg',
'zlib',
]);
for (const flag of MPV_MESON_FLAGS) {
const optionName = flag.slice(2).split('=')[0];
assert.ok(
upstreamMpv041Options.has(optionName),
`unknown mpv v0.41 option ${optionName}`
);
assert.doesNotMatch(flag, /=auto$/);
}
for (const required of [
'-Dgpl=false',
'-Dlibmpv=true',
'-Dcplayer=false',
'-Dtests=false',
'-Dlua=disabled',
'-Djavascript=disabled',
'-Dcplugins=disabled',
'-Dmanpage-build=disabled',
'-Dhtml-build=disabled',
'-Dpdf-build=disabled',
'-Dlibarchive=disabled',
'-Dlibbluray=disabled',
'-Ddvdnav=disabled',
'-Dcdda=disabled',
'-Ddvbin=disabled',
'-Dvulkan=disabled',
'-Dshaderc=disabled',
'-Dspirv-cross=disabled',
'-Dgl=enabled',
'-Dplain-gl=enabled',
'-Degl=enabled',
'-Dgbm=enabled',
'-Dpulse=enabled',
'-Dalsa=enabled',
'-Dvaapi=enabled',
'-Dvaapi-drm=enabled',
]) {
assert.ok(MPV_MESON_FLAGS.includes(required), `missing ${required}`);
}
});
test('keeps the external dependency allowlists explicit and deterministic', () => {
assert.deepEqual(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',
]);
assert.deepEqual(
EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
[
'libEGL.so.1',
'libGL.so.1',
'libGLX.so.0',
'libOpenGL.so.0',
'libasound.so.2',
'libdrm.so.2',
'libgbm.so.1',
'libpulse.so.0',
'libva-drm.so.2',
'libva.so.2',
]
);
for (const externalLibrary of EXTERNAL_SYSTEM_LIBRARIES) {
assert.ok(externalLibrary.interface);
assert.ok(externalLibrary.reason);
}
});
test('parses readelf dynamic sections and validates an ORIGIN-only closure', () => {
const mpvDynamic = parseReadelfDynamic(`
0x0000000000000001 (NEEDED) Shared library: [libavcodec.so.62]
0x0000000000000001 (NEEDED) Shared library: [libEGL.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
`);
assert.deepEqual(mpvDynamic, {
needed: ['libEGL.so.1', 'libavcodec.so.62', 'libc.so.6'],
rpath: [],
runpath: ['$ORIGIN'],
});
const closure = validateRuntimeDependencyClosure({
entries: [
{
name: 'libavcodec.so.62',
...parseReadelfDynamic(`
0x0000000000000001 (NEEDED) Shared library: [libm.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
`),
},
{ name: 'libmpv.so.2', ...mpvDynamic },
],
runtimeFileNames: ['libavcodec.so.62', 'libmpv.so.2'],
buildPrefix: '/tmp/iptvnator-prefix',
});
assert.deepEqual(closure.externalDependencies, [
'libEGL.so.1',
'libc.so.6',
'libm.so.6',
]);
assert.deepEqual(closure.entries[1].needed, [
'libEGL.so.1',
'libavcodec.so.62',
'libc.so.6',
]);
assert.throws(
() =>
validateRuntimeDependencyClosure({
entries: [
{
name: 'libmpv.so.2',
needed: ['libsurprise.so.1'],
rpath: [],
runpath: ['$ORIGIN'],
},
],
runtimeFileNames: ['libmpv.so.2'],
buildPrefix: '/tmp/iptvnator-prefix',
}),
/not bundled or allowlisted.*libsurprise\.so\.1/
);
for (const forbiddenRunpath of [
'/tmp/iptvnator-prefix/lib',
'/usr/local/lib',
'$ORIGIN:/tmp/host-lib',
]) {
assert.throws(
() =>
validateRuntimeDependencyClosure({
entries: [
{
name: 'libmpv.so.2',
needed: ['libc.so.6'],
rpath: [],
runpath: [forbiddenRunpath],
},
],
runtimeFileNames: ['libmpv.so.2'],
buildPrefix: '/tmp/iptvnator-prefix',
}),
/RUNPATH/
);
}
});
test('materializes symlink aliases and hashes every runtime library', (t) => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'iptvnator-linux-builder-')
);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const libDir = path.join(root, 'lib');
fs.mkdirSync(libDir);
const contents = Buffer.from('fake-elf-runtime');
fs.writeFileSync(path.join(libDir, 'libmpv.so.2.0.0'), contents);
fs.symlinkSync('libmpv.so.2.0.0', path.join(libDir, 'libmpv.so.2'));
fs.symlinkSync('libmpv.so.2', path.join(libDir, 'libmpv.so'));
materializeLibrarySymlinks(libDir);
for (const name of ['libmpv.so', 'libmpv.so.2', 'libmpv.so.2.0.0']) {
const filePath = path.join(libDir, name);
assert.equal(fs.lstatSync(filePath).isFile(), true);
assert.deepEqual(fs.readFileSync(filePath), contents);
}
assert.deepEqual(createRuntimeFileRecords(libDir), [
{
name: 'libmpv.so',
size: contents.length,
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
},
{
name: 'libmpv.so.2',
size: contents.length,
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
},
{
name: 'libmpv.so.2.0.0',
size: contents.length,
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
},
]);
});
test('generates a hash-complete manifest accepted by the Linux validator', (t) => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'iptvnator-linux-manifest-')
);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const libDir = path.join(root, 'lib');
fs.mkdirSync(libDir);
fs.writeFileSync(path.join(libDir, 'libavcodec.so.62'), 'avcodec');
fs.writeFileSync(path.join(libDir, 'libmpv.so.2'), 'mpv');
const runtimeFiles = createRuntimeFileRecords(libDir);
const sourceRecords = Object.fromEntries(
SOURCE_PACKAGES.map((sourcePackage, index) => [
sourcePackage.id,
{
...sourcePackage,
...(sourcePackage.sourceKind === 'git'
? {
sourceGitCommit: String(index + 1)
.repeat(40)
.slice(0, 40),
sourceSubmodules: [
`${String(index + 2)
.repeat(40)
.slice(0, 40)} 3rdparty/example`,
],
}
: { sourceSha256: String(index).repeat(64).slice(0, 64) }),
},
])
);
const manifest = createLinuxRuntimeManifest({
sourceRecords,
runtimeFiles,
dependencyClosure: {
entries: [
{
name: 'libavcodec.so.62',
needed: ['libm.so.6'],
rpath: [],
runpath: ['$ORIGIN'],
},
{
name: 'libmpv.so.2',
needed: ['libavcodec.so.62', 'libEGL.so.1'],
rpath: [],
runpath: ['$ORIGIN'],
},
],
externalDependencies: ['libEGL.so.1', 'libm.so.6'],
},
buildHost: {
platform: 'linux',
arch: 'x64',
release: 'fixture-kernel',
tools: {
cc: 'cc fixture',
meson: '1.7.0',
ninja: '1.12.1',
patchelf: '0.18.0',
readelf: 'GNU readelf fixture',
},
},
generatedAt: '2026-07-17T00:00:00.000Z',
});
assert.deepEqual(validateLinuxRuntimeManifest(manifest), []);
assert.equal(
manifest.runtimeTotalBytes,
runtimeFiles.reduce((total, runtimeFile) => total + runtimeFile.size, 0)
);
assert.deepEqual(manifest.runtimeDependencyClosure.externalDependencies, [
'libEGL.so.1',
'libm.so.6',
]);
assert.deepEqual(
manifest.externalSystemLibraries,
EXTERNAL_SYSTEM_LIBRARIES
);
assert.doesNotMatch(manifest.sourceDistribution, /TBD|TODO/i);
assert.match(manifest.sourceDistribution, /source archives/i);
});
test('guards the CLI to Linux x64 and requires exactly one output prefix', () => {
assert.deepEqual(
parseCliInvocation({
platform: 'linux',
arch: 'x64',
argv: ['/tmp/runtime'],
cwd: '/workspace',
}),
{ prefix: '/tmp/runtime' }
);
assert.deepEqual(
parseCliInvocation({
platform: 'linux',
arch: 'x64',
argv: ['--', 'relative/runtime'],
cwd: '/workspace',
}),
{ prefix: '/workspace/relative/runtime' }
);
assert.throws(
() =>
parseCliInvocation({
platform: 'darwin',
arch: 'x64',
argv: ['/tmp/runtime'],
cwd: '/workspace',
}),
/supported on Linux x64 only.*darwin\/x64/
);
assert.throws(
() =>
parseCliInvocation({
platform: 'linux',
arch: 'arm64',
argv: ['/tmp/runtime'],
cwd: '/workspace',
}),
/supported on Linux x64 only.*linux\/arm64/
);
for (const argv of [[], ['/one', '/two']]) {
assert.throws(
() =>
parseCliInvocation({
platform: 'linux',
arch: 'x64',
argv,
cwd: '/workspace',
}),
/Usage: node tools\/embedded-mpv\/build-linux-runtime\.mjs <output-prefix>/
);
}
});
test('does not execute the Linux build when the entrypoint is imported', async () => {
await assert.doesNotReject(() => import(pathToFileURL(builderScript).href));
});
test('patches every materialized ELF runtime to ORIGIN before validating closure', () => {
const builderSource = fs.readFileSync(builderScript, 'utf8');
assert.match(
builderSource,
/run\('patchelf', \['--set-rpath', '\$ORIGIN', libraryPath\]\)/
);
assert.match(
builderSource,
/runCapture\('readelf', \['-d', libraryPath\]\)/
);
assert.match(builderSource, /materializeLibrarySymlinks\(libDir\)/);
assert.match(builderSource, /validateRuntimeDependencyClosure\(\{/);
assert.match(builderSource, /createRuntimeFileRecords\(libDir\)/);
assert.match(builderSource, /runtime-manifest\.json/);
assert.match(builderSource, /validateLinuxRuntimeManifest\(manifest\)/);
});
test('registers the builder script and focused test with package and Nx', () => {
const packageMetadata = JSON.parse(
fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')
);
assert.equal(
packageMetadata.scripts['embedded-mpv:build-runtime:linux'],
'node tools/embedded-mpv/build-linux-runtime.mjs'
);
const packagingProject = JSON.parse(
fs.readFileSync(
path.join(workspaceRoot, 'tools', 'packaging', 'project.json'),
'utf8'
)
);
const inputs = packagingProject.targets.test.inputs;
for (const registeredInput of [
'{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs',
'{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs',
'{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs',
]) {
assert.ok(inputs.includes(registeredInput), registeredInput);
}
assert.match(
packagingProject.targets.test.options.command,
/tools\/embedded-mpv\/build-linux-runtime\.test\.mjs/
);
});
+4 -1
View File
@@ -23,13 +23,16 @@
"{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs",
"{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs",
"{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs",
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs",
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs",
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs",
"{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.test.mjs",
"{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs",
"{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs"
],
"options": {
"command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs",
"command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs",
"cwd": "{workspaceRoot}"
}
},