mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
10139955e7
commit
9f873e6bed
53 files changed
+6099
-49
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user