diff --git a/package.json b/package.json index cc2cfde26..16a8f807c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tools/embedded-mpv/build-linux-runtime.cjs b/tools/embedded-mpv/build-linux-runtime.cjs new file mode 100644 index 000000000..a1c1249db --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.cjs @@ -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(':') : ''; + 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 ', + '', + '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, +}; diff --git a/tools/embedded-mpv/build-linux-runtime.mjs b/tools/embedded-mpv/build-linux-runtime.mjs new file mode 100644 index 000000000..03fe02d39 --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.mjs @@ -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; + } +} diff --git a/tools/embedded-mpv/build-linux-runtime.test.mjs b/tools/embedded-mpv/build-linux-runtime.test.mjs new file mode 100644 index 000000000..6f421fa0f --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.test.mjs @@ -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 / + ); + } +}); + +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/ + ); +}); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 7d28ad67d..5041fe974 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -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}" } },