mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(embedded-mpv): build LGPL Linux runtime
This commit is contained in:
1 parent
d3a4d4dab8
commit
e51fdc5286
5 files changed
+2227
-1
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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}"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user