feat(player): add embedded-mpv player for macOS as experimental feature

- Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player.
- Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source.
- Created `stage-macos-runtime.mjs` for staging the built runtime artifacts.
- Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`.
- Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process.
- Added README files to document the expected layout and usage for the embedded MPV runtime artifacts.

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-27 00:32:14 +02:00
1 parent 10139955e7
commit 9f873e6bed
53 files changed
+6099 -49

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
# Embedded MPV macOS Runtime
This folder contains tooling for preparing the macOS `libmpv` runtime that is bundled with IPTVnator's experimental embedded MPV player.
## Runtime Policy
Release builds must use an LGPL-compatible runtime:
- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`.
- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`.
- The runtime must be dynamically linked so users can inspect and replace LGPL libraries.
- The exact source URLs, versions, build flags, local patches, and checksums must be published with the release.
Do not ship the Homebrew `mpv` runtime. It is acceptable only for local development when `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` is set, and release packaging rejects it.
## Expected Layout
The native addon build consumes:
```text
vendor/embedded-mpv/
darwin-arm64/
include/mpv/client.h
lib/*.dylib
runtime-manifest.json
darwin-x64/
include/mpv/client.h
lib/*.dylib
runtime-manifest.json
```
The generated `lib/` and `include/` directories are release inputs, not source files. They are ignored by git by default.
## Staging A Built Runtime
After building an LGPL-compatible prefix for one architecture, stage it with:
```bash
node tools/embedded-mpv/stage-macos-runtime.mjs arm64 /path/to/lgpl-prefix
node tools/embedded-mpv/stage-macos-runtime.mjs x64 /path/to/lgpl-prefix
```
The prefix must contain `include/mpv/client.h`, `lib/libmpv.2.dylib` or `lib/libmpv.dylib`, and all non-system dylib dependencies required by `libmpv`.
If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record:
- FFmpeg version, source URL, checksum, configure flags, and patches
- mpv version, source URL, checksum, Meson flags, and patches
- source-distribution URL for the corresponding release
## Building The CI Runtime
Tagged macOS release builds build the runtime from pinned source archives before `electron-backend:build`:
```bash
pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix
```
The builder currently pins:
- FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled
- mpv `0.41.0`, configured with `-Dlibmpv=true -Dgpl=false`
- libplacebo `7.360.1`
- libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz
The build manifest records source URLs, downloaded source SHA-256 values, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging.
## Build Integration
`apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies dylibs into `apps/electron-backend/native/build/Release/lib/`, rewrites Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`.
During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime and has no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV.
Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a macOS release artifact that must include Embedded MPV. PR and non-tag development builds leave that variable unset or `0`, so the same in-tree code can package without a staged runtime while Settings keeps Embedded MPV hidden.
+475
View File
@@ -0,0 +1,475 @@
#!/usr/bin/env node
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
const args = process.argv.slice(2);
const [arch, rawPrefix] = args;
const validArchitectures = new Set(['arm64', 'x64']);
const macosDeploymentTarget =
process.env.MACOSX_DEPLOYMENT_TARGET ?? '11.0';
const workspaceRoot = process.cwd();
const sourcePackages = [
{
id: 'freetype',
version: '2.13.3',
url: 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
license: 'FreeType License or GPL-2.0-or-later',
},
{
id: 'fribidi',
version: '1.0.16',
url: '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',
url: 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
license: 'MIT',
},
{
id: 'libass',
version: '0.17.3',
url: 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
license: 'ISC',
},
{
id: 'ffmpeg',
version: '8.1',
url: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
license: 'LGPL-compatible configuration',
},
{
id: 'libplacebo',
version: '7.360.1',
url: 'https://github.com/haasn/libplacebo/archive/refs/tags/v7.360.1.tar.gz',
license: 'LGPL-2.1-or-later',
},
{
id: 'mpv',
version: '0.41.0',
url: 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
license: 'LGPL-compatible configuration with -Dgpl=false',
},
];
if (process.platform !== 'darwin') {
console.error('Embedded MPV runtime builds are supported on macOS only.');
process.exit(1);
}
if (!validArchitectures.has(arch) || !rawPrefix) {
console.error(
[
'Usage: node tools/embedded-mpv/build-macos-runtime.mjs <arm64|x64> <output-prefix>',
'',
'Builds a pinned LGPL-compatible macOS libmpv runtime from source.',
].join('\n')
);
process.exit(1);
}
const prefix = path.resolve(rawPrefix);
const buildRoot = path.resolve(
process.env.IPTVNATOR_EMBEDDED_MPV_BUILD_ROOT ??
path.join(os.tmpdir(), 'iptvnator-embedded-mpv-runtime', arch)
);
const archiveRoot = path.join(buildRoot, 'archives');
const sourceRoot = path.join(buildRoot, 'sources');
const packageById = new Map(sourcePackages.map((source) => [source.id, source]));
const parallelism =
process.env.MAKEFLAGS?.match(/-j\s*(\d+)/)?.[1] ??
String(os.cpus().length);
const ffmpegConfigureFlags = [
`--prefix=${prefix}`,
'--enable-shared',
'--disable-static',
'--disable-doc',
'--disable-debug',
'--disable-programs',
'--disable-autodetect',
'--disable-gpl',
'--disable-nonfree',
'--enable-pic',
'--enable-securetransport',
'--enable-audiotoolbox',
'--enable-videotoolbox',
];
const mpvMesonFlags = [
'-Dgpl=false',
'-Dlibmpv=true',
'-Dcplayer=false',
'-Dbuild-date=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',
'-Djpeg=disabled',
'-Dlcms2=disabled',
'-Drubberband=disabled',
'-Duchardet=disabled',
'-Dzimg=disabled',
'-Dvulkan=disabled',
'-Dshaderc=disabled',
'-Dspirv-cross=disabled',
'-Dcocoa=disabled',
'-Dgl-cocoa=disabled',
'-Dmacos-cocoa-cb=disabled',
'-Dswift-build=disabled',
'-Dplain-gl=enabled',
];
function log(message) {
process.stdout.write(`[embedded-mpv-runtime] ${message}\n`);
}
function run(command, commandArgs, options = {}) {
log(`${command} ${commandArgs.join(' ')}`);
const result = spawnSync(command, commandArgs, {
cwd: options.cwd ?? workspaceRoot,
env: options.env ?? buildEnv(),
stdio: 'inherit',
...options,
});
if (result.status !== 0) {
throw new Error(
`${command} ${commandArgs.join(' ')} failed with status ${
result.status ?? 1
}.`
);
}
}
function commandExists(command) {
const result = spawnSync('sh', ['-lc', `command -v ${command}`], {
stdio: 'ignore',
});
return result.status === 0;
}
function buildEnv() {
const pkgConfigDirs = [
path.join(prefix, 'lib', 'pkgconfig'),
path.join(prefix, 'share', 'pkgconfig'),
].join(path.delimiter);
return {
...process.env,
PATH: [path.join(prefix, 'bin'), process.env.PATH]
.filter(Boolean)
.join(path.delimiter),
PKG_CONFIG_PATH: pkgConfigDirs,
PKG_CONFIG_LIBDIR: pkgConfigDirs,
CMAKE_PREFIX_PATH: prefix,
DYLD_LIBRARY_PATH: path.join(prefix, 'lib'),
MACOSX_DEPLOYMENT_TARGET: macosDeploymentTarget,
CFLAGS: [`-I${path.join(prefix, 'include')}`, process.env.CFLAGS]
.filter(Boolean)
.join(' '),
LDFLAGS: [`-L${path.join(prefix, 'lib')}`, process.env.LDFLAGS]
.filter(Boolean)
.join(' '),
};
}
function ensureTools() {
const requiredCommands = [
'curl',
'tar',
'make',
'meson',
'ninja',
'pkg-config',
];
const missing = requiredCommands.filter((command) => !commandExists(command));
if (missing.length > 0) {
throw new Error(`Missing required build tools: ${missing.join(', ')}`);
}
}
function archivePathFor(sourcePackage) {
const extension = sourcePackage.url.endsWith('.tar.xz')
? '.tar.xz'
: '.tar.gz';
return path.join(
archiveRoot,
`${sourcePackage.id}-${sourcePackage.version}${extension}`
);
}
function sourcePathFor(packageId) {
return path.join(sourceRoot, packageId);
}
function sha256File(filePath) {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(filePath));
return hash.digest('hex');
}
function downloadSources() {
fs.mkdirSync(archiveRoot, { recursive: true });
fs.mkdirSync(sourceRoot, { recursive: true });
for (const sourcePackage of sourcePackages) {
const archivePath = archivePathFor(sourcePackage);
if (!fs.existsSync(archivePath)) {
run('curl', [
'-fL',
'--retry',
'3',
'--retry-delay',
'5',
'-o',
archivePath,
sourcePackage.url,
]);
}
const packageSourcePath = sourcePathFor(sourcePackage.id);
fs.rmSync(packageSourcePath, { recursive: true, force: true });
fs.mkdirSync(packageSourcePath, { recursive: true });
run('tar', [
'-xf',
archivePath,
'-C',
packageSourcePath,
'--strip-components',
'1',
]);
sourcePackage.sha256 = sha256File(archivePath);
}
}
function configureMakeInstall(packageId, configureArgs) {
const packageSourcePath = sourcePathFor(packageId);
run('./configure', [`--prefix=${prefix}`, ...configureArgs], {
cwd: packageSourcePath,
});
run('make', [`-j${parallelism}`], { cwd: packageSourcePath });
run('make', ['install'], { cwd: packageSourcePath });
}
function mesonInstall(packageId, mesonArgs) {
const packageSourcePath = sourcePathFor(packageId);
const buildDir = path.join(packageSourcePath, 'build-iptvnator');
fs.rmSync(buildDir, { recursive: true, force: true });
run(
'meson',
[
'setup',
buildDir,
`--prefix=${prefix}`,
'--libdir=lib',
'--buildtype=release',
'--default-library=shared',
...mesonArgs,
],
{ cwd: packageSourcePath }
);
run('meson', ['compile', '-C', buildDir], { cwd: packageSourcePath });
run('meson', ['install', '-C', buildDir], { cwd: packageSourcePath });
}
function buildRuntime() {
fs.rmSync(prefix, { recursive: true, force: true });
fs.mkdirSync(prefix, { recursive: true });
configureMakeInstall('freetype', ['--enable-shared', '--disable-static']);
configureMakeInstall('fribidi', ['--enable-shared', '--disable-static']);
mesonInstall('harfbuzz', [
'-Dglib=disabled',
'-Dgobject=disabled',
'-Dcairo=disabled',
'-Dchafa=disabled',
'-Dicu=disabled',
'-Dfreetype=enabled',
'-Dtests=disabled',
'-Dintrospection=disabled',
'-Ddocs=disabled',
'-Dutilities=disabled',
'-Dbenchmark=disabled',
]);
configureMakeInstall('libass', [
'--enable-shared',
'--disable-static',
'--disable-fontconfig',
'--enable-coretext',
'--disable-libunibreak',
]);
const ffmpegSourcePath = sourcePathFor('ffmpeg');
run('./configure', ffmpegConfigureFlags, { cwd: ffmpegSourcePath });
run('make', [`-j${parallelism}`], { cwd: ffmpegSourcePath });
run('make', ['install'], { cwd: ffmpegSourcePath });
mesonInstall('libplacebo', [
'-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',
]);
mesonInstall('mpv', mpvMesonFlags);
}
function listDylibs(directoryPath) {
if (!fs.existsSync(directoryPath)) {
return [];
}
return fs
.readdirSync(directoryPath, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.dylib'))
.map((entry) => path.join(directoryPath, entry.name))
.sort();
}
function validateRuntimeLinks() {
if (!commandExists('otool')) {
log('Skipping otool validation because otool is unavailable.');
return;
}
const libDir = path.join(prefix, 'lib');
const errors = [];
const allowedSystemPrefixes = ['/System/Library/', '/usr/lib/'];
const forbiddenPrefixes = ['/opt/homebrew/', '/usr/local/'];
for (const dylibPath of listDylibs(libDir)) {
const result = spawnSync('otool', ['-L', dylibPath], {
encoding: 'utf8',
stdio: 'pipe',
});
if (result.status !== 0) {
errors.push(`Unable to inspect ${dylibPath}: ${result.stderr}`);
continue;
}
for (const dependencyPath of result.stdout
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim().split(/\s+\(/)[0])
.filter(Boolean)) {
if (
allowedSystemPrefixes.some((prefixValue) =>
dependencyPath.startsWith(prefixValue)
) ||
dependencyPath.startsWith('@loader_path/') ||
dependencyPath.startsWith('@rpath/') ||
dependencyPath.startsWith(prefix)
) {
continue;
}
if (
forbiddenPrefixes.some((prefixValue) =>
dependencyPath.startsWith(prefixValue)
)
) {
errors.push(`${dylibPath} links to forbidden ${dependencyPath}`);
continue;
}
if (path.isAbsolute(dependencyPath)) {
errors.push(`${dylibPath} links to external ${dependencyPath}`);
}
}
}
if (errors.length > 0) {
throw new Error(
['Embedded MPV runtime link validation failed.', ...errors].join('\n')
);
}
}
function sourceMetadata(packageId) {
const sourcePackage = packageById.get(packageId);
return {
version: sourcePackage.version,
sourceUrl: sourcePackage.url,
sourceSha256: sourcePackage.sha256,
license: sourcePackage.license,
};
}
function writeManifest() {
const manifest = {
origin: 'vendored-lgpl-source-build',
arch,
generatedAt: new Date().toISOString(),
macosDeploymentTarget,
buildHost: {
platform: process.platform,
arch: process.arch,
},
packages: Object.fromEntries(
sourcePackages.map((sourcePackage) => [
sourcePackage.id,
{
version: sourcePackage.version,
sourceUrl: sourcePackage.url,
sourceSha256: sourcePackage.sha256,
license: sourcePackage.license,
},
])
),
ffmpeg: {
...sourceMetadata('ffmpeg'),
licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree',
configureFlags: ffmpegConfigureFlags,
},
mpv: {
...sourceMetadata('mpv'),
licensePolicy:
'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false',
mesonFlags: mpvMesonFlags,
},
sourceDistribution:
'Attach the downloaded source archives, this manifest, and any local patches with the macOS binary release.',
};
fs.writeFileSync(
path.join(prefix, 'runtime-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
}
try {
ensureTools();
fs.mkdirSync(buildRoot, { recursive: true });
downloadSources();
buildRuntime();
validateRuntimeLinks();
writeManifest();
log(`Built LGPL-compatible runtime prefix at ${prefix}`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+170
View File
@@ -0,0 +1,170 @@
import fs from 'fs';
import path from 'path';
const args = process.argv.slice(2);
const [arch, sourcePrefix] = args;
const validArchitectures = new Set(['arm64', 'x64']);
const workspaceRoot = process.cwd();
if (!validArchitectures.has(arch) || !sourcePrefix) {
console.error(
[
'Usage: node tools/embedded-mpv/stage-macos-runtime.mjs <arm64|x64> <lgpl-runtime-prefix>',
'',
'The prefix must contain:',
'- include/mpv/client.h',
'- lib/libmpv.2.dylib or lib/libmpv.dylib',
'- all non-system dylib dependencies required by libmpv',
].join('\n')
);
process.exit(1);
}
const normalizedPrefix = path.resolve(sourcePrefix);
const destinationRoot = path.join(
workspaceRoot,
'vendor',
'embedded-mpv',
`darwin-${arch}`
);
const destinationIncludeDir = path.join(destinationRoot, 'include');
const destinationLibDir = path.join(destinationRoot, 'lib');
const sourceIncludeDir = path.join(normalizedPrefix, 'include');
const sourceLibDir = path.join(normalizedPrefix, 'lib');
function assertExists(filePath, message) {
if (!fs.existsSync(filePath)) {
throw new Error(`${message}: ${filePath}`);
}
}
function isFileLike(sourcePath, entry) {
if (entry.isFile()) {
return true;
}
if (!entry.isSymbolicLink()) {
return false;
}
try {
return fs.statSync(sourcePath).isFile();
} catch {
return false;
}
}
function copyDirectory(sourceDir, destinationDir, filter) {
fs.mkdirSync(destinationDir, { recursive: true });
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = path.join(sourceDir, entry.name);
const destinationPath = path.join(destinationDir, entry.name);
if (filter && !filter(sourcePath, entry)) {
continue;
}
if (entry.isDirectory()) {
copyDirectory(sourcePath, destinationPath, filter);
continue;
}
if (isFileLike(sourcePath, entry)) {
fs.copyFileSync(sourcePath, destinationPath);
fs.chmodSync(destinationPath, 0o755);
}
}
}
function findLibMpv(libDir) {
for (const candidate of ['libmpv.2.dylib', 'libmpv.dylib']) {
const candidatePath = path.join(libDir, candidate);
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
return null;
}
function listDylibs(libDir) {
return fs
.readdirSync(libDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.dylib'))
.map((entry) => entry.name)
.sort();
}
function readJsonIfExists(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
try {
assertExists(
path.join(sourceIncludeDir, 'mpv', 'client.h'),
'Missing libmpv header'
);
if (!findLibMpv(sourceLibDir)) {
throw new Error(`Missing libmpv dylib in ${sourceLibDir}`);
}
fs.rmSync(destinationIncludeDir, { recursive: true, force: true });
fs.rmSync(destinationLibDir, { recursive: true, force: true });
fs.mkdirSync(destinationRoot, { recursive: true });
copyDirectory(
path.join(sourceIncludeDir, 'mpv'),
path.join(destinationIncludeDir, 'mpv')
);
copyDirectory(sourceLibDir, destinationLibDir, (_sourcePath, entry) => {
return entry.isDirectory() || entry.name.endsWith('.dylib');
});
const externalManifest =
readJsonIfExists(path.join(normalizedPrefix, 'runtime-manifest.json')) ??
{};
const manifest = {
...externalManifest,
origin: 'vendored-lgpl',
arch,
stagedAt: new Date().toISOString(),
ffmpeg: {
licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree',
...externalManifest.ffmpeg,
configureFlags:
externalManifest.ffmpeg?.configureFlags ??
'Record the exact FFmpeg configure flags used to build this runtime.',
},
mpv: {
licensePolicy: 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false',
...externalManifest.mpv,
mesonFlags:
externalManifest.mpv?.mesonFlags ??
'Record the exact mpv Meson flags used to build this runtime.',
},
dylibs: listDylibs(destinationLibDir),
sourceDistribution:
externalManifest.sourceDistribution ??
'Publish exact source archives and local patches with the macOS binary release.',
};
fs.writeFileSync(
path.join(destinationRoot, 'runtime-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
console.log(
`Staged embedded MPV runtime for darwin-${arch} at ${path.relative(
workspaceRoot,
destinationRoot
)}`
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+65
View File
@@ -0,0 +1,65 @@
const linuxAfterPack = require('./linux-after-pack.cjs');
const {
validatePackagedEmbeddedMpv,
} = require('./embedded-mpv-macos.cjs');
const fs = require('fs');
const path = require('path');
function log(message) {
console.log(` - ${message}`);
}
function isTruthy(value) {
return ['1', 'true', 'yes', 'on'].includes(
String(value ?? '')
.trim()
.toLowerCase()
);
}
async function afterPackHook(params) {
await linuxAfterPack(params);
if (params.electronPlatformName !== 'darwin') {
return;
}
const requireEmbeddedMpv = isTruthy(
process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV
);
log(
requireEmbeddedMpv
? 'validating required embedded MPV macOS runtime links'
: 'validating optional embedded MPV macOS runtime links'
);
const appPath = params.appOutDir.endsWith('.app')
? params.appOutDir
: fs
.readdirSync(params.appOutDir)
.find((entry) => entry.endsWith('.app'));
const resourceDir = appPath
? path.join(
params.appOutDir.endsWith('.app')
? params.appOutDir
: path.join(params.appOutDir, appPath),
'Contents',
'Resources'
)
: params.appOutDir;
const errors = validatePackagedEmbeddedMpv(resourceDir, {
required: requireEmbeddedMpv,
});
if (errors.length > 0) {
throw new Error(
[
'Embedded MPV macOS package validation failed.',
...errors.map((error) => `- ${error}`),
].join('\n')
);
}
log('embedded MPV macOS runtime links validated');
}
module.exports = afterPackHook;
+465
View File
@@ -0,0 +1,465 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: options.stdio ?? 'pipe',
encoding: 'utf8',
...options,
});
if (result.status !== 0) {
const stderr = result.stderr ? `\n${result.stderr}` : '';
throw new Error(
`${command} ${args.join(' ')} failed with status ${result.status ?? 1}.${stderr}`
);
}
return result.stdout ?? '';
}
function commandExists(command) {
const result = spawnSync('sh', ['-lc', `command -v ${command}`], {
stdio: 'ignore',
});
return result.status === 0;
}
function ensureDir(directoryPath) {
fs.mkdirSync(directoryPath, { recursive: true });
}
function removeDir(directoryPath) {
fs.rmSync(directoryPath, { recursive: true, force: true });
}
function copyFile(sourcePath, destinationPath) {
ensureDir(path.dirname(destinationPath));
fs.copyFileSync(sourcePath, destinationPath);
fs.chmodSync(destinationPath, 0o755);
}
function listDylibs(directoryPath) {
if (!fs.existsSync(directoryPath)) {
return [];
}
return fs
.readdirSync(directoryPath, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.dylib'))
.map((entry) => path.join(directoryPath, entry.name))
.sort();
}
function listRuntimeFiles(directoryPath) {
if (!fs.existsSync(directoryPath)) {
return [];
}
return fs
.readdirSync(directoryPath, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => path.join(directoryPath, entry.name))
.sort();
}
function parseOtoolDependencies(binaryPath) {
const output = run('otool', ['-L', binaryPath]);
return output
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.split(/\s+\(/)[0])
.filter(Boolean);
}
function parseOtoolRpaths(binaryPath) {
const output = run('otool', ['-l', binaryPath]);
const lines = output.split(/\r?\n/);
const rpaths = [];
for (let index = 0; index < lines.length; index += 1) {
if (lines[index].trim() !== 'cmd LC_RPATH') {
continue;
}
for (let offset = index + 1; offset < lines.length; offset += 1) {
const match = lines[offset]
.trim()
.match(/^path\s+(.+?)\s+\(offset\s+\d+\)$/);
if (match) {
rpaths.push(match[1]);
break;
}
}
}
return rpaths;
}
function readInstallNameId(binaryPath) {
const result = spawnSync('otool', ['-D', binaryPath], {
stdio: 'pipe',
encoding: 'utf8',
});
if (result.status !== 0) {
return null;
}
return (
result.stdout
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim())
.find(Boolean) ?? null
);
}
function isSystemDependency(dependencyPath) {
return systemRuntimePathPrefixes.some((prefix) =>
dependencyPath.startsWith(prefix)
);
}
function isForbiddenDependency(dependencyPath) {
return forbiddenRuntimePathPrefixes.some((prefix) =>
dependencyPath.startsWith(prefix)
);
}
function resolvePathToken(tokenPath, binaryPath) {
if (tokenPath.startsWith('@loader_path/')) {
return path.resolve(
path.dirname(binaryPath),
tokenPath.replace('@loader_path/', '')
);
}
if (tokenPath.startsWith('@executable_path/')) {
return null;
}
if (tokenPath.startsWith('@rpath/')) {
return null;
}
return tokenPath;
}
function resolveRpathDependency(dependencyPath, binaryPath) {
const dependencyName = dependencyPath.replace('@rpath/', '');
for (const rpath of parseOtoolRpaths(binaryPath)) {
const resolvedRpath = resolvePathToken(rpath, binaryPath);
if (!resolvedRpath) {
continue;
}
const candidatePath = path.resolve(resolvedRpath, dependencyName);
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
return null;
}
function resolveDependencyPath(dependencyPath, binaryPath) {
if (dependencyPath.startsWith('@loader_path/')) {
return resolvePathToken(dependencyPath, binaryPath);
}
if (dependencyPath.startsWith('@rpath/')) {
return resolveRpathDependency(dependencyPath, binaryPath);
}
if (dependencyPath.startsWith('@executable_path/')) {
return null;
}
return dependencyPath;
}
function collectExternalDylibs(entryPaths) {
const visited = new Set();
const queue = [...entryPaths];
const result = new Map();
while (queue.length > 0) {
const currentPath = queue.shift();
if (!currentPath || visited.has(currentPath) || !fs.existsSync(currentPath)) {
continue;
}
visited.add(currentPath);
for (const dependencyPath of parseOtoolDependencies(currentPath)) {
if (isSystemDependency(dependencyPath)) {
continue;
}
const resolvedPath = resolveDependencyPath(dependencyPath, currentPath);
if (!resolvedPath || !fs.existsSync(resolvedPath)) {
continue;
}
if (!result.has(path.basename(resolvedPath))) {
result.set(path.basename(resolvedPath), resolvedPath);
queue.push(resolvedPath);
}
}
}
return [...result.values()];
}
function findLibMpv(runtimeLibDir) {
const candidates = ['libmpv.2.dylib', 'libmpv.dylib'];
for (const candidate of candidates) {
const candidatePath = path.join(runtimeLibDir, candidate);
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
return null;
}
function patchDylibIds(libDir) {
for (const dylibPath of listRuntimeFiles(libDir)) {
if (!readInstallNameId(dylibPath)) {
continue;
}
run('install_name_tool', [
'-id',
`@loader_path/${path.basename(dylibPath)}`,
dylibPath,
]);
}
}
function patchBinaryDependencies(binaryPath, dependencyBaseDir, replacementPrefix) {
const availableRuntimeFiles = new Set(
listRuntimeFiles(dependencyBaseDir).map((runtimePath) =>
path.basename(runtimePath)
)
);
for (const dependencyPath of parseOtoolDependencies(binaryPath)) {
const dependencyName = path.basename(dependencyPath);
if (!availableRuntimeFiles.has(dependencyName)) {
continue;
}
const replacementPath = `${replacementPrefix}/${dependencyName}`;
if (dependencyPath === replacementPath) {
continue;
}
run('install_name_tool', [
'-change',
dependencyPath,
replacementPath,
binaryPath,
]);
}
}
function adHocSignBinary(binaryPath) {
if (process.platform !== 'darwin' || !commandExists('codesign')) {
return;
}
run('codesign', ['--force', '--sign', '-', '--timestamp=none', binaryPath]);
}
function adHocSignBinaries(binaryPaths) {
for (const binaryPath of binaryPaths) {
adHocSignBinary(binaryPath);
}
}
function copyRuntimeToNativeBuild({
runtimeLibDir,
outputLibDir,
runtimeOrigin,
runtimeManifest,
}) {
const libMpvPath = findLibMpv(runtimeLibDir);
if (!libMpvPath) {
throw new Error(`Unable to find libmpv in ${runtimeLibDir}.`);
}
removeDir(outputLibDir);
ensureDir(outputLibDir);
const runtimeFilesByName = new Map([[path.basename(libMpvPath), libMpvPath]]);
for (const dylibPath of collectExternalDylibs([libMpvPath])) {
runtimeFilesByName.set(path.basename(dylibPath), dylibPath);
}
if (runtimeOrigin === 'vendored-lgpl') {
for (const runtimePath of listRuntimeFiles(runtimeLibDir)) {
runtimeFilesByName.set(path.basename(runtimePath), runtimePath);
}
}
const dylibs = [...runtimeFilesByName.values()];
for (const dylibPath of dylibs) {
copyFile(dylibPath, path.join(outputLibDir, path.basename(dylibPath)));
}
if (!fs.existsSync(path.join(outputLibDir, 'libmpv.2.dylib'))) {
const copiedLibMpvPath = path.join(outputLibDir, path.basename(libMpvPath));
if (path.basename(copiedLibMpvPath) !== 'libmpv.2.dylib') {
copyFile(copiedLibMpvPath, path.join(outputLibDir, 'libmpv.2.dylib'));
}
}
patchDylibIds(outputLibDir);
for (const runtimePath of listRuntimeFiles(outputLibDir)) {
patchBinaryDependencies(runtimePath, outputLibDir, '@loader_path');
}
adHocSignBinaries(listRuntimeFiles(outputLibDir));
const manifest = {
origin: runtimeOrigin,
generatedAt: new Date().toISOString(),
libDir: 'lib',
runtimeFiles: listRuntimeFiles(outputLibDir).map((runtimePath) =>
path.basename(runtimePath)
),
dylibs: listDylibs(outputLibDir).map((dylibPath) =>
path.basename(dylibPath)
),
...runtimeManifest,
};
fs.writeFileSync(
path.join(path.dirname(outputLibDir), 'embedded-mpv-runtime.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
return manifest;
}
function patchAddonForBundledRuntime(addonPath, outputLibDir) {
if (!fs.existsSync(addonPath)) {
throw new Error(`Missing embedded MPV native addon: ${addonPath}`);
}
patchBinaryDependencies(addonPath, outputLibDir, '@loader_path/lib');
adHocSignBinary(addonPath);
}
function validateNoForbiddenRuntimeLinks(binaryPaths) {
const errors = [];
for (const binaryPath of binaryPaths) {
if (!fs.existsSync(binaryPath)) {
errors.push(`Missing binary: ${binaryPath}`);
continue;
}
for (const dependencyPath of parseOtoolDependencies(binaryPath)) {
if (isForbiddenDependency(dependencyPath)) {
errors.push(
`${binaryPath} links to forbidden runtime path: ${dependencyPath}`
);
}
if (dependencyPath.startsWith('@loader_path/')) {
const resolvedPath = path.resolve(
path.dirname(binaryPath),
dependencyPath.replace('@loader_path/', '')
);
if (!fs.existsSync(resolvedPath)) {
errors.push(
`${binaryPath} links to missing bundled dependency: ${dependencyPath}`
);
}
}
if (
!isSystemDependency(dependencyPath) &&
!dependencyPath.startsWith('@loader_path/')
) {
errors.push(
`${binaryPath} links to non-bundled runtime path: ${dependencyPath}`
);
}
}
}
return errors;
}
function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
if (process.platform !== 'darwin') {
return [];
}
if (!commandExists('otool')) {
return ['otool is required to validate embedded MPV macOS packaging.'];
}
const unpackedNativeDir = path.join(
resourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
const addonPath = path.join(unpackedNativeDir, 'embedded_mpv.node');
const libDir = path.join(unpackedNativeDir, 'lib');
const manifestPath = path.join(
unpackedNativeDir,
'embedded-mpv-runtime.json'
);
const errors = [];
if (!fs.existsSync(addonPath)) {
if (options.required) {
errors.push(`Missing embedded MPV native addon: ${addonPath}`);
}
return errors;
}
if (!fs.existsSync(manifestPath)) {
errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`);
} else {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.origin !== 'vendored-lgpl') {
errors.push(
`Embedded MPV packaged runtime must be vendored-lgpl, received: ${manifest.origin}`
);
}
}
if (!fs.existsSync(path.join(libDir, 'libmpv.2.dylib'))) {
errors.push(`Missing bundled libmpv.2.dylib in ${libDir}`);
}
const binaries = [addonPath, ...listRuntimeFiles(libDir)];
errors.push(...validateNoForbiddenRuntimeLinks(binaries));
return errors;
}
module.exports = {
collectExternalDylibs,
commandExists,
copyRuntimeToNativeBuild,
findLibMpv,
listRuntimeFiles,
listDylibs,
parseOtoolDependencies,
parseOtoolRpaths,
patchAddonForBundledRuntime,
validateNoForbiddenRuntimeLinks,
validatePackagedEmbeddedMpv,
};
@@ -1,6 +1,9 @@
import fs from 'fs';
import { createRequire } from 'module';
import path from 'path';
const require = createRequire(import.meta.url);
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-macos.cjs');
const args = process.argv.slice(2);
const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
const [platform, arch = ''] = normalizedArgs;
@@ -36,6 +39,7 @@ const electronBuilderConfig = JSON.parse(
);
const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? [];
const snapConfigInspection = loadSnapConfigInspection();
const embeddedMpvRequired = isTruthy(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV);
const workerRelativeDir = path.join(
'dist',
'apps',
@@ -66,6 +70,14 @@ function fileExists(filePath) {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}
function isTruthy(value) {
return ['1', 'true', 'yes', 'on'].includes(
String(value ?? '')
.trim()
.toLowerCase()
);
}
function getMacResourceDirs() {
const candidates = [
{
@@ -477,6 +489,14 @@ function verifyResourceDir(resourceDir) {
verifyLinuxLauncher(resourceDir, errors);
}
if (platform === 'macos') {
errors.push(
...validatePackagedEmbeddedMpv(resourceDir, {
required: embeddedMpvRequired,
})
);
}
return {
resourceDir,
errors,