mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Port the embedded mpv frame-copy pipeline to Windows with WGL rendering and named shared memory. Includes packaging validation, platform gates, tests, and architecture documentation.
914 lines
28 KiB
JavaScript
914 lines
28 KiB
JavaScript
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/'];
|
|
const windowsMpvRuntimeNames = [
|
|
'mpv-2.dll',
|
|
'libmpv-2.dll',
|
|
'mpv.dll',
|
|
'libmpv.dll',
|
|
];
|
|
|
|
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 assertPeRange(image, offset, size, label) {
|
|
if (
|
|
!Number.isSafeInteger(offset) ||
|
|
!Number.isSafeInteger(size) ||
|
|
offset < 0 ||
|
|
size < 0 ||
|
|
offset + size > image.length
|
|
) {
|
|
throw new Error(`Invalid PE image: ${label} is out of range.`);
|
|
}
|
|
}
|
|
|
|
function readPeImportedDllNames(binaryPath) {
|
|
const image = fs.readFileSync(binaryPath);
|
|
assertPeRange(image, 0, 0x40, 'DOS header');
|
|
if (image.toString('ascii', 0, 2) !== 'MZ') {
|
|
throw new Error('Invalid PE image: missing DOS signature.');
|
|
}
|
|
|
|
const peOffset = image.readUInt32LE(0x3c);
|
|
assertPeRange(image, peOffset, 24, 'PE header');
|
|
if (image.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') {
|
|
throw new Error('Invalid PE image: missing PE signature.');
|
|
}
|
|
|
|
const sectionCount = image.readUInt16LE(peOffset + 6);
|
|
const optionalHeaderSize = image.readUInt16LE(peOffset + 20);
|
|
const optionalHeaderOffset = peOffset + 24;
|
|
assertPeRange(
|
|
image,
|
|
optionalHeaderOffset,
|
|
optionalHeaderSize,
|
|
'optional header'
|
|
);
|
|
|
|
const optionalMagic = image.readUInt16LE(optionalHeaderOffset);
|
|
const dataDirectoryOffset =
|
|
optionalMagic === 0x20b
|
|
? optionalHeaderOffset + 112
|
|
: optionalMagic === 0x10b
|
|
? optionalHeaderOffset + 96
|
|
: null;
|
|
const directoryCountOffset =
|
|
optionalMagic === 0x20b
|
|
? optionalHeaderOffset + 108
|
|
: optionalMagic === 0x10b
|
|
? optionalHeaderOffset + 92
|
|
: null;
|
|
if (dataDirectoryOffset === null || directoryCountOffset === null) {
|
|
throw new Error(
|
|
`Invalid PE image: unsupported optional-header magic 0x${optionalMagic.toString(16)}.`
|
|
);
|
|
}
|
|
const optionalHeaderEnd = optionalHeaderOffset + optionalHeaderSize;
|
|
if (
|
|
directoryCountOffset + 4 > optionalHeaderEnd ||
|
|
dataDirectoryOffset + 16 > optionalHeaderEnd
|
|
) {
|
|
throw new Error(
|
|
'Invalid PE image: data directories exceed the optional header.'
|
|
);
|
|
}
|
|
assertPeRange(image, directoryCountOffset, 4, 'data-directory count');
|
|
if (image.readUInt32LE(directoryCountOffset) < 2) {
|
|
return [];
|
|
}
|
|
assertPeRange(image, dataDirectoryOffset + 8, 8, 'import directory');
|
|
|
|
const importRva = image.readUInt32LE(dataDirectoryOffset + 8);
|
|
const importSize = image.readUInt32LE(dataDirectoryOffset + 12);
|
|
if (importRva === 0 && importSize === 0) {
|
|
return [];
|
|
}
|
|
if (importRva === 0 || importSize < 20) {
|
|
throw new Error('Invalid PE image: malformed import directory.');
|
|
}
|
|
|
|
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
|
|
assertPeRange(
|
|
image,
|
|
sectionTableOffset,
|
|
sectionCount * 40,
|
|
'section table'
|
|
);
|
|
const sections = Array.from({ length: sectionCount }, (_, index) => {
|
|
const offset = sectionTableOffset + index * 40;
|
|
return {
|
|
virtualSize: image.readUInt32LE(offset + 8),
|
|
virtualAddress: image.readUInt32LE(offset + 12),
|
|
rawSize: image.readUInt32LE(offset + 16),
|
|
rawOffset: image.readUInt32LE(offset + 20),
|
|
};
|
|
});
|
|
|
|
const rvaToLocation = (rva, label) => {
|
|
const section = sections.find(
|
|
(candidate) =>
|
|
rva >= candidate.virtualAddress &&
|
|
rva <
|
|
candidate.virtualAddress +
|
|
Math.max(candidate.virtualSize, candidate.rawSize)
|
|
);
|
|
if (!section) {
|
|
throw new Error(
|
|
`Invalid PE image: ${label} RVA 0x${rva.toString(16)} is not mapped.`
|
|
);
|
|
}
|
|
|
|
const sectionOffset = rva - section.virtualAddress;
|
|
if (sectionOffset >= section.rawSize) {
|
|
throw new Error(
|
|
`Invalid PE image: ${label} is outside section file data.`
|
|
);
|
|
}
|
|
const offset = section.rawOffset + sectionOffset;
|
|
assertPeRange(image, offset, 1, label);
|
|
return {
|
|
offset,
|
|
sectionEnd: section.rawOffset + section.rawSize,
|
|
};
|
|
};
|
|
|
|
const importedDllNames = [];
|
|
const descriptorCount = Math.floor(importSize / 20);
|
|
let foundTerminator = false;
|
|
for (let index = 0; index < descriptorCount; index += 1) {
|
|
const descriptorLocation = rvaToLocation(
|
|
importRva + index * 20,
|
|
'import descriptor'
|
|
);
|
|
const descriptorOffset = descriptorLocation.offset;
|
|
if (descriptorOffset + 20 > descriptorLocation.sectionEnd) {
|
|
throw new Error(
|
|
'Invalid PE image: import descriptor crosses section file data.'
|
|
);
|
|
}
|
|
assertPeRange(image, descriptorOffset, 20, 'import descriptor');
|
|
const descriptor = image.subarray(
|
|
descriptorOffset,
|
|
descriptorOffset + 20
|
|
);
|
|
if (descriptor.every((value) => value === 0)) {
|
|
foundTerminator = true;
|
|
break;
|
|
}
|
|
|
|
const nameRva = image.readUInt32LE(descriptorOffset + 12);
|
|
const nameLocation = rvaToLocation(nameRva, 'imported DLL name');
|
|
const nameOffset = nameLocation.offset;
|
|
const nameEnd = image.indexOf(0, nameOffset);
|
|
if (nameEnd < 0 || nameEnd >= nameLocation.sectionEnd) {
|
|
throw new Error(
|
|
'Invalid PE image: imported DLL name is not terminated.'
|
|
);
|
|
}
|
|
const name = image.toString('ascii', nameOffset, nameEnd);
|
|
if (!name) {
|
|
throw new Error('Invalid PE image: imported DLL name is empty.');
|
|
}
|
|
importedDllNames.push(name);
|
|
}
|
|
|
|
if (!foundTerminator) {
|
|
throw new Error(
|
|
'Invalid PE image: import descriptor table has no terminator.'
|
|
);
|
|
}
|
|
|
|
return [...new Set(importedDllNames)];
|
|
}
|
|
|
|
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 getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
|
|
switch (platform) {
|
|
case 'darwin':
|
|
return [
|
|
path.join(libDir, 'libmpv.2.dylib'),
|
|
path.join(libDir, 'libmpv.dylib'),
|
|
];
|
|
case 'win32':
|
|
return windowsMpvRuntimeNames.flatMap((name) =>
|
|
[
|
|
nativeDir ? path.join(nativeDir, name) : null,
|
|
path.join(libDir, name),
|
|
].filter(Boolean)
|
|
);
|
|
case 'linux':
|
|
return [];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function normalizeEmbeddedMpvPlatform(value) {
|
|
if (value === 'macos') {
|
|
return 'darwin';
|
|
}
|
|
if (value === 'windows') {
|
|
return 'win32';
|
|
}
|
|
return value ?? process.platform;
|
|
}
|
|
|
|
// electron-builder passes `arch` to afterPack as a builder-util Arch enum
|
|
// value; this table mirrors that enum's declaration order.
|
|
const ELECTRON_BUILDER_ARCH_NAMES = [
|
|
'ia32',
|
|
'x64',
|
|
'armv7l',
|
|
'arm64',
|
|
'universal',
|
|
];
|
|
|
|
function resolveElectronBuilderArchName(arch) {
|
|
if (typeof arch === 'string') {
|
|
return arch;
|
|
}
|
|
return ELECTRON_BUILDER_ARCH_NAMES[arch] ?? null;
|
|
}
|
|
|
|
function getEmbeddedMpvAddonArch(env = process.env) {
|
|
return env.IPTVNATOR_EMBEDDED_MPV_ARCH || process.arch;
|
|
}
|
|
|
|
/**
|
|
* The embedded MPV addon is compiled once per CI host (x64 on Linux), but
|
|
* electron-builder also produces arm64/armv7l Linux packages from the same
|
|
* dist output. Those packages must not ship a foreign-architecture
|
|
* `embedded_mpv.node` — it can never load and produces a cryptic error.
|
|
*/
|
|
function isForeignLinuxEmbeddedMpvArch(
|
|
platform,
|
|
targetArch,
|
|
env = process.env
|
|
) {
|
|
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
|
|
return false;
|
|
}
|
|
const archName = resolveElectronBuilderArchName(targetArch);
|
|
return Boolean(archName) && archName !== getEmbeddedMpvAddonArch(env);
|
|
}
|
|
|
|
// Maps electron-builder Linux output directory names (`linux-unpacked`,
|
|
// `linux-arm64-unpacked`, ...) to the package architecture. `null` when the
|
|
// name is not a Linux unpacked directory.
|
|
function linuxUnpackedDirArch(unpackedDirName) {
|
|
const match = /^linux-(?:([a-z0-9_]+)-)?unpacked$/.exec(unpackedDirName);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return match[1] ?? 'x64';
|
|
}
|
|
|
|
function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
|
const platform = normalizeEmbeddedMpvPlatform(options.platform);
|
|
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 (options.foreignArch) {
|
|
if (fs.existsSync(addonPath)) {
|
|
errors.push(
|
|
`Embedded MPV addon must not ship in foreign-architecture Linux packages: ${addonPath}`
|
|
);
|
|
}
|
|
const markerPath = path.join(
|
|
unpackedNativeDir,
|
|
'embedded-mpv-unavailable.txt'
|
|
);
|
|
if (!fs.existsSync(markerPath)) {
|
|
errors.push(
|
|
`Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}`
|
|
);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
if (!fs.existsSync(addonPath)) {
|
|
if (options.required) {
|
|
errors.push(`Missing embedded MPV native addon: ${addonPath}`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
if (platform === 'darwin' || platform === 'win32') {
|
|
// The frame-copy engine artifacts are built by the same binding.gyp
|
|
// run as the addon; a macOS/Windows package that ships the addon
|
|
// without them would silently lose the engine (support probe hides
|
|
// it). Linux packages intentionally strip the helper until the
|
|
// bundled-libmpv runtime lands (see electron-after-pack.cjs).
|
|
const missingFrameCopyArtifacts = [
|
|
platform === 'win32'
|
|
? 'iptvnator_mpv_helper.exe'
|
|
: 'iptvnator_mpv_helper',
|
|
'embedded_mpv_frame_reader.node',
|
|
]
|
|
.map((name) => path.join(unpackedNativeDir, name))
|
|
.filter((artifactPath) => !fs.existsSync(artifactPath));
|
|
errors.push(
|
|
...missingFrameCopyArtifacts.map(
|
|
(artifactPath) =>
|
|
`Missing embedded MPV frame-copy artifact: ${artifactPath}`
|
|
)
|
|
);
|
|
|
|
if (platform === 'win32') {
|
|
// The helper is a separate executable. Windows resolves its
|
|
// imported libmpv DLL from the executable directory, so a copy
|
|
// under native/lib may satisfy addon bookkeeping but cannot
|
|
// start iptvnator_mpv_helper.exe.
|
|
const helperPath = path.join(
|
|
unpackedNativeDir,
|
|
'iptvnator_mpv_helper.exe'
|
|
);
|
|
const helperRuntimeCandidates = windowsMpvRuntimeNames.map((name) =>
|
|
path.join(unpackedNativeDir, name)
|
|
);
|
|
if (
|
|
!helperRuntimeCandidates.some((candidate) =>
|
|
fs.existsSync(candidate)
|
|
)
|
|
) {
|
|
errors.push(
|
|
[
|
|
`Missing bundled MPV DLL beside the Windows frame-copy helper in ${unpackedNativeDir}.`,
|
|
'Expected one of:',
|
|
...helperRuntimeCandidates.map(
|
|
(candidate) => `- ${candidate}`
|
|
),
|
|
].join('\n')
|
|
);
|
|
}
|
|
|
|
if (fs.existsSync(helperPath)) {
|
|
try {
|
|
const acceptedRuntimeNames = new Set(
|
|
windowsMpvRuntimeNames.map((name) => name.toLowerCase())
|
|
);
|
|
const importedRuntimeNames = readPeImportedDllNames(
|
|
helperPath
|
|
).filter((name) =>
|
|
acceptedRuntimeNames.has(name.toLowerCase())
|
|
);
|
|
if (importedRuntimeNames.length === 0) {
|
|
errors.push(
|
|
`Windows frame-copy helper does not import a supported MPV DLL: ${helperPath}`
|
|
);
|
|
}
|
|
|
|
const packagedRuntimeNames = new Set(
|
|
fs
|
|
.readdirSync(unpackedNativeDir, {
|
|
withFileTypes: true,
|
|
})
|
|
.filter((entry) => entry.isFile())
|
|
.map((entry) => entry.name.toLowerCase())
|
|
);
|
|
for (const importedRuntimeName of importedRuntimeNames) {
|
|
if (
|
|
!packagedRuntimeNames.has(
|
|
importedRuntimeName.toLowerCase()
|
|
)
|
|
) {
|
|
errors.push(
|
|
`Windows frame-copy helper imports ${importedRuntimeName}, but the matching DLL is missing beside it: ${path.join(unpackedNativeDir, importedRuntimeName)}`
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to inspect Windows frame-copy helper imports at ${helperPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!fs.existsSync(manifestPath)) {
|
|
errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`);
|
|
} else {
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
const expectedOrigin =
|
|
platform === 'linux' ? 'external-mpv-process' : 'vendored-lgpl';
|
|
if (manifest.origin !== expectedOrigin) {
|
|
errors.push(
|
|
`Embedded MPV packaged runtime must be ${expectedOrigin}, received: ${manifest.origin}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (platform === 'linux') {
|
|
const packagedFrameCopyHelpers = [
|
|
path.join(unpackedNativeDir, 'iptvnator_mpv_helper'),
|
|
path.join(unpackedNativeDir, 'iptvnator_mpv_helper.exe'),
|
|
].filter((candidate) => fs.existsSync(candidate));
|
|
if (packagedFrameCopyHelpers.length > 0) {
|
|
errors.push(
|
|
[
|
|
'Linux packages must not ship frame-copy helpers linked against the build host system libmpv.',
|
|
'Remove:',
|
|
...packagedFrameCopyHelpers.map(
|
|
(candidate) => `- ${candidate}`
|
|
),
|
|
].join('\n')
|
|
);
|
|
}
|
|
|
|
const bundledLinuxRuntime = [
|
|
path.join(libDir, 'libmpv.so.2'),
|
|
path.join(libDir, 'libmpv.so.1'),
|
|
path.join(libDir, 'libmpv.so'),
|
|
].filter((candidate) => fs.existsSync(candidate));
|
|
|
|
if (bundledLinuxRuntime.length > 0) {
|
|
errors.push(
|
|
[
|
|
'Linux embedded MPV must use the external mpv process backend and must not bundle libmpv.',
|
|
'Remove:',
|
|
...bundledLinuxRuntime.map((candidate) => `- ${candidate}`),
|
|
].join('\n')
|
|
);
|
|
}
|
|
}
|
|
|
|
const runtimeCandidates = getPackagedRuntimeCandidates(
|
|
libDir,
|
|
platform,
|
|
unpackedNativeDir
|
|
);
|
|
if (
|
|
runtimeCandidates.length > 0 &&
|
|
!runtimeCandidates.some((candidate) => fs.existsSync(candidate))
|
|
) {
|
|
errors.push(
|
|
[
|
|
`Missing bundled embedded MPV runtime for ${platform} in ${libDir}.`,
|
|
'Expected one of:',
|
|
...runtimeCandidates.map((candidate) => `- ${candidate}`),
|
|
].join('\n')
|
|
);
|
|
}
|
|
|
|
if (platform === 'darwin') {
|
|
if (process.platform !== 'darwin') {
|
|
errors.push(
|
|
'macOS embedded MPV link validation must run on a macOS host.'
|
|
);
|
|
} else if (!commandExists('otool')) {
|
|
errors.push(
|
|
'otool is required to validate embedded MPV macOS packaging.'
|
|
);
|
|
} else {
|
|
const binaries = [addonPath, ...listRuntimeFiles(libDir)];
|
|
errors.push(...validateNoForbiddenRuntimeLinks(binaries));
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
module.exports = {
|
|
collectExternalDylibs,
|
|
commandExists,
|
|
copyRuntimeToNativeBuild,
|
|
findLibMpv,
|
|
listRuntimeFiles,
|
|
listDylibs,
|
|
parseOtoolDependencies,
|
|
parseOtoolRpaths,
|
|
patchAddonForBundledRuntime,
|
|
validateNoForbiddenRuntimeLinks,
|
|
getPackagedRuntimeCandidates,
|
|
validatePackagedEmbeddedMpv,
|
|
getEmbeddedMpvAddonArch,
|
|
isForeignLinuxEmbeddedMpvArch,
|
|
linuxUnpackedDirArch,
|
|
resolveElectronBuilderArchName,
|
|
};
|