mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
Fixes the launcher/Zypak regression reported in #1203. The additional GPU/video.js behavior remains tracked separately in that issue.
1989 lines
62 KiB
JavaScript
1989 lines
62 KiB
JavaScript
const crypto = require('node:crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const { isDeepStrictEqual } = require('node:util');
|
|
const {
|
|
EXTERNAL_SYSTEM_LIBRARIES,
|
|
GLIBC_TOOLCHAIN_ALLOWLIST,
|
|
parseReadelfDynamic,
|
|
validateRuntimeDependencyClosure,
|
|
} = require('../embedded-mpv/build-linux-runtime.cjs');
|
|
const {
|
|
validateLinuxRuntimeManifest,
|
|
} = require('../embedded-mpv/linux-runtime-manifest.cjs');
|
|
const {
|
|
validateLinuxSourceArchiveBinding,
|
|
} = require('../embedded-mpv/linux-source-archive-contract.cjs');
|
|
const {
|
|
NOTICE_MANIFEST,
|
|
THIRD_PARTY_NOTICES,
|
|
validateLinuxRuntimeNotices,
|
|
} = require('../embedded-mpv/generate-linux-runtime-notices.cjs');
|
|
const {
|
|
LINUX_SYSTEM_PACKAGE_DEPENDENCIES,
|
|
resolveLinuxFrameCopyProfile,
|
|
validateLinuxProfileTargets,
|
|
} = require('./linux-frame-copy-profile.cjs');
|
|
const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs');
|
|
|
|
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
|
|
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
|
|
const windowsMpvRuntimeNames = [
|
|
'mpv-2.dll',
|
|
'libmpv-2.dll',
|
|
'mpv.dll',
|
|
'libmpv.dll',
|
|
];
|
|
const linuxFrameCopyArtifacts = Object.freeze({
|
|
addon: Object.freeze({
|
|
name: 'embedded_mpv.node',
|
|
regularFile: true,
|
|
readable: true,
|
|
}),
|
|
frameReader: Object.freeze({
|
|
name: 'embedded_mpv_frame_reader.node',
|
|
regularFile: true,
|
|
readable: true,
|
|
}),
|
|
helper: Object.freeze({
|
|
name: 'iptvnator_mpv_helper',
|
|
regularFile: true,
|
|
readable: true,
|
|
executable: true,
|
|
}),
|
|
});
|
|
const linuxFrameCopyProcessIsolation = Object.freeze({
|
|
addonLoadsLibmpv: false,
|
|
readerLoadsLibmpv: false,
|
|
electronLoadsLibmpv: false,
|
|
helperLinksLibmpv: true,
|
|
helperRunpath: Object.freeze(['$ORIGIN/lib']),
|
|
});
|
|
const linuxNativeViewFallback = 'process-isolated mpv --wid';
|
|
const versionedLinuxLibmpvPattern = /^libmpv\.so\.\d+(?:\.\d+)*$/;
|
|
const anyLinuxLibmpvPattern = /^libmpv\.so(?:\.|$)/;
|
|
const linuxRuntimeLegalPaths = Object.freeze([
|
|
NOTICE_MANIFEST,
|
|
THIRD_PARTY_NOTICES,
|
|
'licenses',
|
|
]);
|
|
|
|
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 ELECTRON_BUILDER_ARCH_NAMES.includes(arch) ? arch : null;
|
|
}
|
|
return ELECTRON_BUILDER_ARCH_NAMES[arch] ?? null;
|
|
}
|
|
|
|
function resolveConfiguredLinuxTargetNames(configuredTargets, targetArch) {
|
|
if (!Array.isArray(configuredTargets)) {
|
|
throw new TypeError('Electron Builder linux.target must be an array.');
|
|
}
|
|
const archName = resolveElectronBuilderArchName(targetArch);
|
|
if (!archName) {
|
|
throw new Error(
|
|
`Unknown Electron Builder architecture: ${String(targetArch)}.`
|
|
);
|
|
}
|
|
|
|
const targetNames = new Set();
|
|
for (const target of configuredTargets) {
|
|
const targetName =
|
|
typeof target === 'string'
|
|
? target
|
|
: target && typeof target === 'object'
|
|
? target.target
|
|
: null;
|
|
if (typeof targetName !== 'string' || targetName.trim() === '') {
|
|
throw new Error(
|
|
'Electron Builder Linux targets must have a non-empty target name.'
|
|
);
|
|
}
|
|
|
|
if (target && typeof target === 'object' && target.arch !== undefined) {
|
|
const configuredArches = Array.isArray(target.arch)
|
|
? target.arch
|
|
: [target.arch];
|
|
const configuredArchNames = configuredArches.map(
|
|
(configuredArch) => {
|
|
const configuredArchName =
|
|
resolveElectronBuilderArchName(configuredArch);
|
|
if (!configuredArchName) {
|
|
throw new Error(
|
|
`Unknown Electron Builder architecture: ${String(
|
|
configuredArch
|
|
)}.`
|
|
);
|
|
}
|
|
return configuredArchName;
|
|
}
|
|
);
|
|
if (!configuredArchNames.includes(archName)) {
|
|
continue;
|
|
}
|
|
}
|
|
targetNames.add(targetName.trim().toLowerCase());
|
|
}
|
|
|
|
if (targetNames.size === 0) {
|
|
throw new Error(
|
|
`Electron Builder has no Linux targets configured for ${archName}.`
|
|
);
|
|
}
|
|
return [...targetNames].sort();
|
|
}
|
|
|
|
/**
|
|
* Official Linux frame-copy support is x64-only. electron-builder also
|
|
* produces arm64/armv7l packages, which must always carry only the
|
|
* unavailable marker and native-view fallback.
|
|
*/
|
|
function isForeignLinuxEmbeddedMpvArch(platform, targetArch) {
|
|
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
|
|
return false;
|
|
}
|
|
const archName = resolveElectronBuilderArchName(targetArch);
|
|
return Boolean(archName) && archName !== 'x64';
|
|
}
|
|
|
|
// 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 pathExistsByLstat(filePath) {
|
|
try {
|
|
fs.lstatSync(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function inspectRegularReadableFile(
|
|
filePath,
|
|
label,
|
|
errors,
|
|
expectedMode = null
|
|
) {
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(filePath);
|
|
} catch {
|
|
errors.push(`Missing ${label}: ${filePath}`);
|
|
return null;
|
|
}
|
|
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
errors.push(`${label} must be a regular file: ${filePath}`);
|
|
return null;
|
|
}
|
|
try {
|
|
fs.accessSync(filePath, fs.constants.R_OK);
|
|
} catch {
|
|
errors.push(`${label} must be readable: ${filePath}`);
|
|
}
|
|
|
|
if (expectedMode !== null && (stat.mode & 0o7777) !== expectedMode) {
|
|
errors.push(
|
|
`${label} must have mode ${expectedMode
|
|
.toString(8)
|
|
.padStart(4, '0')}: ${filePath}`
|
|
);
|
|
}
|
|
return stat;
|
|
}
|
|
|
|
function readPackagedJson(filePath, label, errors) {
|
|
let contents;
|
|
try {
|
|
contents = fs.readFileSync(filePath, 'utf8');
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to read ${label} at ${filePath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(contents);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Invalid JSON in ${label} at ${filePath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function validateForeignLinuxNativeDir(nativeDir) {
|
|
const errors = [];
|
|
const markerName = 'embedded-mpv-unavailable.txt';
|
|
const markerPath = path.join(nativeDir, markerName);
|
|
let nativeStat;
|
|
try {
|
|
nativeStat = fs.lstatSync(nativeDir);
|
|
} catch {
|
|
return [
|
|
`Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}`,
|
|
];
|
|
}
|
|
if (!nativeStat.isDirectory() || nativeStat.isSymbolicLink()) {
|
|
return [
|
|
`Foreign-architecture embedded MPV native path must be a regular directory: ${nativeDir}`,
|
|
];
|
|
}
|
|
|
|
const entries = fs.readdirSync(nativeDir).sort();
|
|
const unexpectedEntries = entries.filter((name) => name !== markerName);
|
|
if (unexpectedEntries.length > 0) {
|
|
errors.push(
|
|
[
|
|
'Embedded MPV artifacts must not ship in foreign-architecture Linux packages; only the unavailable marker is allowed.',
|
|
...unexpectedEntries.map(
|
|
(name) => `- ${path.join(nativeDir, name)}`
|
|
),
|
|
].join('\n')
|
|
);
|
|
}
|
|
if (!entries.includes(markerName)) {
|
|
errors.push(
|
|
`Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}`
|
|
);
|
|
} else {
|
|
inspectRegularReadableFile(
|
|
markerPath,
|
|
'embedded MPV unavailable marker',
|
|
errors
|
|
);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function validateNativeViewOnlyLinuxPackage(
|
|
nativeDir,
|
|
addonPath,
|
|
manifestPath,
|
|
errors
|
|
) {
|
|
for (const legalPath of linuxRuntimeLegalPaths) {
|
|
const packagedLegalPath = path.join(nativeDir, legalPath);
|
|
if (pathExistsByLstat(packagedLegalPath)) {
|
|
errors.push(
|
|
`Linux native-view-only packages must not ship bundled runtime legal files: ${packagedLegalPath}`
|
|
);
|
|
}
|
|
}
|
|
for (const artifactName of [
|
|
'iptvnator_mpv_helper',
|
|
'iptvnator_mpv_helper.exe',
|
|
'embedded_mpv_frame_reader.node',
|
|
]) {
|
|
const artifactPath = path.join(nativeDir, artifactName);
|
|
if (pathExistsByLstat(artifactPath)) {
|
|
errors.push(
|
|
`Linux native-view-only packages must not ship frame-copy helpers or readers: ${artifactPath}`
|
|
);
|
|
}
|
|
}
|
|
|
|
const markerPath = path.join(nativeDir, 'embedded-mpv-unavailable.txt');
|
|
if (pathExistsByLstat(markerPath)) {
|
|
errors.push(
|
|
`Same-architecture Linux packages must not retain the unavailable marker: ${markerPath}`
|
|
);
|
|
}
|
|
|
|
const libDir = path.join(nativeDir, 'lib');
|
|
if (pathExistsByLstat(libDir)) {
|
|
errors.push(
|
|
`Linux native-view-only packages must not bundle libmpv or retain a private runtime directory: ${libDir}`
|
|
);
|
|
}
|
|
|
|
inspectRegularReadableFile(addonPath, 'embedded MPV native addon', errors);
|
|
if (
|
|
!inspectRegularReadableFile(
|
|
manifestPath,
|
|
'embedded MPV runtime manifest',
|
|
errors
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const manifest = readPackagedJson(
|
|
manifestPath,
|
|
'embedded MPV runtime manifest',
|
|
errors
|
|
);
|
|
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
if (manifest !== null) {
|
|
errors.push('Embedded MPV runtime manifest must be an object.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const expectedFields = {
|
|
schemaVersion: 1,
|
|
origin: 'external-mpv-process',
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
runtimeMode: 'native-view-only',
|
|
frameCopyAvailable: false,
|
|
artifacts: { addon: 'embedded_mpv.node' },
|
|
nativeViewFallback: linuxNativeViewFallback,
|
|
};
|
|
for (const [field, expected] of Object.entries(expectedFields)) {
|
|
if (!isDeepStrictEqual(manifest[field], expected)) {
|
|
errors.push(
|
|
`Linux native-view-only manifest ${field} must equal ${JSON.stringify(
|
|
expected
|
|
)}; received ${JSON.stringify(manifest[field])}.`
|
|
);
|
|
}
|
|
}
|
|
if (Object.hasOwn(manifest, 'profile')) {
|
|
errors.push(
|
|
'Linux native-view-only manifest must not include a frame-copy profile.'
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeLinuxTargetNames(targetNames, errors) {
|
|
if (!Array.isArray(targetNames) || targetNames.length === 0) {
|
|
errors.push(
|
|
'Linux frame-copy package validation requires at least one target name.'
|
|
);
|
|
return [];
|
|
}
|
|
const normalized = [];
|
|
for (const targetName of targetNames) {
|
|
const name = String(targetName ?? '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!name) {
|
|
errors.push(
|
|
'Linux frame-copy target names must be non-empty strings.'
|
|
);
|
|
continue;
|
|
}
|
|
if (normalized.includes(name)) {
|
|
errors.push(`Linux frame-copy target "${name}" is duplicated.`);
|
|
continue;
|
|
}
|
|
normalized.push(name);
|
|
}
|
|
return normalized.sort();
|
|
}
|
|
|
|
function validatePackagedManifestContract(
|
|
manifest,
|
|
profile,
|
|
targetNames,
|
|
errors
|
|
) {
|
|
const expectedFields = {
|
|
schemaVersion: 1,
|
|
origin: profile.manifestOrigin,
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
profile: profile.name,
|
|
runtimeMode: profile.runtimeMode,
|
|
targets: targetNames,
|
|
artifacts: linuxFrameCopyArtifacts,
|
|
processIsolation: linuxFrameCopyProcessIsolation,
|
|
nativeViewFallback: linuxNativeViewFallback,
|
|
};
|
|
for (const [field, expected] of Object.entries(expectedFields)) {
|
|
if (!isDeepStrictEqual(manifest[field], expected)) {
|
|
errors.push(
|
|
`Linux frame-copy manifest ${field} for profile "${profile.name}" must equal ${JSON.stringify(
|
|
expected
|
|
)}; received ${JSON.stringify(manifest[field])}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
typeof manifest.generatedAt !== 'string' ||
|
|
manifest.generatedAt.trim() === '' ||
|
|
Number.isNaN(Date.parse(manifest.generatedAt))
|
|
) {
|
|
errors.push(
|
|
'Linux frame-copy manifest generatedAt must be a valid timestamp.'
|
|
);
|
|
}
|
|
if (
|
|
typeof manifest.libmpvSoname !== 'string' ||
|
|
!versionedLinuxLibmpvPattern.test(manifest.libmpvSoname)
|
|
) {
|
|
errors.push(
|
|
'Linux frame-copy manifest libmpvSoname must be a versioned libmpv SONAME.'
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateSystemLinuxRuntime(nativeDir, manifest, errors) {
|
|
for (const legalPath of linuxRuntimeLegalPaths) {
|
|
const packagedLegalPath = path.join(nativeDir, legalPath);
|
|
if (pathExistsByLstat(packagedLegalPath)) {
|
|
errors.push(
|
|
`Linux system frame-copy packages must not ship bundled runtime legal files: ${packagedLegalPath}`
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
!isDeepStrictEqual(
|
|
manifest.packageDependencies,
|
|
LINUX_SYSTEM_PACKAGE_DEPENDENCIES
|
|
)
|
|
) {
|
|
errors.push(
|
|
`Linux system frame-copy manifest packageDependencies must equal ${JSON.stringify(
|
|
LINUX_SYSTEM_PACKAGE_DEPENDENCIES
|
|
)}.`
|
|
);
|
|
}
|
|
if (!isDeepStrictEqual(manifest.runtimeFiles, [])) {
|
|
errors.push(
|
|
'Linux system frame-copy manifest runtimeFiles must be empty.'
|
|
);
|
|
}
|
|
if (manifest.runtimeTotalBytes !== 0) {
|
|
errors.push(
|
|
'Linux system frame-copy manifest runtimeTotalBytes must equal 0.'
|
|
);
|
|
}
|
|
for (const forbiddenField of [
|
|
'runtimeDependencyClosure',
|
|
'externalSystemLibraries',
|
|
'sourceArchive',
|
|
'sourceRuntime',
|
|
]) {
|
|
if (Object.hasOwn(manifest, forbiddenField)) {
|
|
errors.push(
|
|
`Linux system frame-copy manifest must not include ${forbiddenField}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
const libDir = path.join(nativeDir, 'lib');
|
|
if (pathExistsByLstat(libDir)) {
|
|
errors.push(
|
|
`Linux system frame-copy packages must not retain a private runtime directory: ${libDir}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function sha256File(filePath) {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(fs.readFileSync(filePath))
|
|
.digest('hex');
|
|
}
|
|
|
|
function validateBundledLinuxRuntime(nativeDir, manifest, errors) {
|
|
if (!isDeepStrictEqual(manifest.packageDependencies, {})) {
|
|
errors.push(
|
|
'Linux bundled frame-copy manifest packageDependencies must be empty.'
|
|
);
|
|
}
|
|
|
|
const sourceRuntimeErrors = validateLinuxRuntimeManifest(
|
|
manifest.sourceRuntime
|
|
);
|
|
errors.push(
|
|
...sourceRuntimeErrors.map(
|
|
(error) => `Invalid packaged Linux source runtime: ${error}`
|
|
)
|
|
);
|
|
errors.push(
|
|
...validateLinuxSourceArchiveBinding(manifest.sourceArchive).map(
|
|
(error) => `Invalid packaged Linux source archive binding: ${error}`
|
|
)
|
|
);
|
|
errors.push(
|
|
...validateLinuxRuntimeNotices(nativeDir, manifest.sourceRuntime, {
|
|
allowUnrelatedFiles: true,
|
|
}).map((error) => `Invalid packaged Linux runtime notices: ${error}`)
|
|
);
|
|
if (
|
|
!isDeepStrictEqual(
|
|
manifest.runtimeFiles,
|
|
manifest.sourceRuntime?.runtimeFiles
|
|
)
|
|
) {
|
|
errors.push(
|
|
'Linux bundled frame-copy manifest runtimeFiles must exactly match sourceRuntime.runtimeFiles.'
|
|
);
|
|
}
|
|
if (
|
|
!isDeepStrictEqual(
|
|
manifest.runtimeDependencyClosure,
|
|
manifest.sourceRuntime?.runtimeDependencyClosure
|
|
)
|
|
) {
|
|
errors.push(
|
|
'Linux bundled frame-copy manifest runtimeDependencyClosure must exactly match sourceRuntime.runtimeDependencyClosure.'
|
|
);
|
|
}
|
|
if (
|
|
!isDeepStrictEqual(
|
|
manifest.externalSystemLibraries,
|
|
manifest.sourceRuntime?.externalSystemLibraries
|
|
)
|
|
) {
|
|
errors.push(
|
|
'Linux bundled frame-copy manifest externalSystemLibraries must exactly match sourceRuntime.externalSystemLibraries.'
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(manifest.runtimeFiles)) {
|
|
errors.push(
|
|
'Linux bundled frame-copy manifest runtimeFiles must be an array.'
|
|
);
|
|
return;
|
|
}
|
|
const expectedTotal = manifest.runtimeFiles.reduce(
|
|
(total, runtimeFile) =>
|
|
total +
|
|
(runtimeFile &&
|
|
Number.isSafeInteger(runtimeFile.size) &&
|
|
runtimeFile.size > 0
|
|
? runtimeFile.size
|
|
: 0),
|
|
0
|
|
);
|
|
if (manifest.runtimeTotalBytes !== expectedTotal) {
|
|
errors.push(
|
|
`Linux bundled frame-copy manifest runtimeTotalBytes must equal ${expectedTotal}.`
|
|
);
|
|
}
|
|
|
|
const libDir = path.join(nativeDir, 'lib');
|
|
let libStat;
|
|
try {
|
|
libStat = fs.lstatSync(libDir);
|
|
} catch {
|
|
errors.push(`Missing bundled Linux runtime directory: ${libDir}`);
|
|
return;
|
|
}
|
|
if (!libStat.isDirectory() || libStat.isSymbolicLink()) {
|
|
errors.push(
|
|
`Bundled Linux runtime path must be a regular directory: ${libDir}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const declaredNames = new Set();
|
|
for (const runtimeFile of manifest.runtimeFiles) {
|
|
if (
|
|
!runtimeFile ||
|
|
typeof runtimeFile.name !== 'string' ||
|
|
path.basename(runtimeFile.name) !== runtimeFile.name ||
|
|
runtimeFile.name === '.' ||
|
|
runtimeFile.name === '..'
|
|
) {
|
|
errors.push(
|
|
`Bundled Linux runtime manifest contains an unsafe file name: ${JSON.stringify(
|
|
runtimeFile?.name
|
|
)}.`
|
|
);
|
|
continue;
|
|
}
|
|
if (declaredNames.has(runtimeFile.name)) {
|
|
errors.push(
|
|
`Bundled Linux runtime manifest contains duplicate file ${runtimeFile.name}.`
|
|
);
|
|
continue;
|
|
}
|
|
declaredNames.add(runtimeFile.name);
|
|
}
|
|
|
|
let packagedEntries;
|
|
try {
|
|
packagedEntries = fs.readdirSync(libDir).sort();
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to enumerate bundled Linux runtime at ${libDir}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
return;
|
|
}
|
|
for (const entryName of packagedEntries) {
|
|
if (!declaredNames.has(entryName)) {
|
|
errors.push(
|
|
`Found undeclared bundled Linux runtime artifact: ${path.join(
|
|
libDir,
|
|
entryName
|
|
)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const runtimeFile of manifest.runtimeFiles) {
|
|
if (!runtimeFile || !declaredNames.has(runtimeFile.name)) {
|
|
continue;
|
|
}
|
|
const runtimePath = path.join(libDir, runtimeFile.name);
|
|
const stat = inspectRegularReadableFile(
|
|
runtimePath,
|
|
`bundled Linux runtime file ${runtimeFile.name}`,
|
|
errors
|
|
);
|
|
if (!stat) {
|
|
continue;
|
|
}
|
|
if (stat.size !== runtimeFile.size) {
|
|
errors.push(
|
|
`Bundled Linux runtime size mismatch for ${runtimeFile.name}: expected ${runtimeFile.size}, received ${stat.size}.`
|
|
);
|
|
}
|
|
let actualSha256;
|
|
try {
|
|
actualSha256 = sha256File(runtimePath);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to hash bundled Linux runtime file ${runtimePath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
continue;
|
|
}
|
|
if (actualSha256 !== runtimeFile.sha256) {
|
|
errors.push(
|
|
`Bundled Linux runtime SHA-256 mismatch for ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeElfInspection(value, binaryPath) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error(
|
|
`ELF inspection for ${binaryPath} must return an object.`
|
|
);
|
|
}
|
|
const result = {
|
|
soname: value.soname ?? null,
|
|
};
|
|
if (
|
|
result.soname !== null &&
|
|
(typeof result.soname !== 'string' ||
|
|
path.basename(result.soname) !== result.soname)
|
|
) {
|
|
throw new Error(
|
|
`ELF inspection for ${binaryPath} must return a safe SONAME or null.`
|
|
);
|
|
}
|
|
for (const field of ['needed', 'rpath', 'runpath']) {
|
|
if (
|
|
!Array.isArray(value[field]) ||
|
|
value[field].some((entry) => typeof entry !== 'string')
|
|
) {
|
|
throw new Error(
|
|
`ELF inspection for ${binaryPath} must return string array ${field}.`
|
|
);
|
|
}
|
|
result[field] = [...new Set(value[field])].sort();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function dependencyFileName(dependencyName) {
|
|
return dependencyName.replaceAll('\\', '/').split('/').at(-1) ?? '';
|
|
}
|
|
|
|
function listElectronShippedLinuxLibraries(resourceDir, options = {}) {
|
|
const appDir = path.dirname(resourceDir);
|
|
const normalizedResourceDir = path.resolve(resourceDir);
|
|
const excludedSnapLibraryRoots =
|
|
options.artifactFormat === 'snap'
|
|
? new Set(
|
|
[
|
|
path.join(appDir, 'lib'),
|
|
path.join(appDir, 'usr', 'lib'),
|
|
].map((directoryPath) => path.resolve(directoryPath))
|
|
)
|
|
: new Set();
|
|
const libraries = [];
|
|
|
|
function visit(directoryPath) {
|
|
for (const entry of fs.readdirSync(directoryPath, {
|
|
withFileTypes: true,
|
|
})) {
|
|
const entryPath = path.join(directoryPath, entry.name);
|
|
if (path.resolve(entryPath) === normalizedResourceDir) {
|
|
continue;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
if (excludedSnapLibraryRoots.has(path.resolve(entryPath))) {
|
|
continue;
|
|
}
|
|
visit(entryPath);
|
|
continue;
|
|
}
|
|
if (
|
|
(entry.isFile() || entry.isSymbolicLink()) &&
|
|
/\.so(?:\.\d+)*$/.test(entry.name)
|
|
) {
|
|
libraries.push(entryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// afterPack and unpacked-layout checks see the pristine Electron tree, so
|
|
// recurse to catch future nested Electron libraries. An extracted Snap
|
|
// overlays package-manager lib/ and usr/lib/ trees onto that same root;
|
|
// exclude exactly those target-provided roots while scanning everything
|
|
// else recursively.
|
|
visit(appDir);
|
|
return libraries.sort();
|
|
}
|
|
|
|
function inspectLinuxElfIsolation(
|
|
resourceDir,
|
|
nativeDir,
|
|
manifest,
|
|
targetNames,
|
|
options,
|
|
errors
|
|
) {
|
|
const executableName = options.executableName ?? 'iptvnator';
|
|
let launcherLayout;
|
|
try {
|
|
launcherLayout = resolveLinuxLauncherLayout(
|
|
targetNames,
|
|
executableName
|
|
);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to resolve Linux launcher layout: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const hostPlatform = options.hostPlatform ?? process.platform;
|
|
let inspectElf = options.elfInspector;
|
|
if (!inspectElf) {
|
|
if (hostPlatform !== 'linux') {
|
|
return;
|
|
}
|
|
if (!commandExists('readelf')) {
|
|
errors.push(
|
|
'readelf is required to validate Linux embedded MPV packaging.'
|
|
);
|
|
return;
|
|
}
|
|
inspectElf = (binaryPath) =>
|
|
parseReadelfDynamic(run('readelf', ['-d', binaryPath]));
|
|
}
|
|
if (typeof inspectElf !== 'function') {
|
|
errors.push('Linux ELF inspector must be a function.');
|
|
return;
|
|
}
|
|
|
|
const inspectedPaths = {
|
|
electron: path.join(
|
|
path.dirname(resourceDir),
|
|
launcherLayout.electronBinaryName
|
|
),
|
|
addon: path.join(nativeDir, linuxFrameCopyArtifacts.addon.name),
|
|
reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name),
|
|
helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name),
|
|
};
|
|
for (const [index, libraryPath] of listElectronShippedLinuxLibraries(
|
|
resourceDir,
|
|
{ artifactFormat: options.artifactFormat }
|
|
).entries()) {
|
|
inspectedPaths[`electronLibrary:${index}`] = libraryPath;
|
|
}
|
|
const inspections = {};
|
|
for (const [label, binaryPath] of Object.entries(inspectedPaths)) {
|
|
if (
|
|
!inspectRegularReadableFile(
|
|
binaryPath,
|
|
`Linux ${label} ELF binary`,
|
|
errors
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
try {
|
|
inspections[label] = normalizeElfInspection(
|
|
inspectElf(binaryPath),
|
|
binaryPath
|
|
);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to inspect Linux ${label} ELF binary at ${binaryPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const label of Object.keys(inspections).filter(
|
|
(name) =>
|
|
name === 'electron' ||
|
|
name === 'addon' ||
|
|
name === 'reader' ||
|
|
name.startsWith('electronLibrary:')
|
|
)) {
|
|
const dynamic = inspections[label];
|
|
if (!dynamic) {
|
|
continue;
|
|
}
|
|
const displayLabel = label.startsWith('electronLibrary:')
|
|
? 'Electron library'
|
|
: label;
|
|
for (const dependencyName of dynamic.needed) {
|
|
if (dependencyFileName(dependencyName) !== dependencyName) {
|
|
errors.push(
|
|
`Linux ${displayLabel} DT_NEEDED entry must not contain a path: ${dependencyName} in ${inspectedPaths[label]}.`
|
|
);
|
|
}
|
|
}
|
|
const libmpvDependencies = dynamic.needed.filter((dependencyName) =>
|
|
anyLinuxLibmpvPattern.test(dependencyFileName(dependencyName))
|
|
);
|
|
if (libmpvDependencies.length > 0) {
|
|
errors.push(
|
|
`Linux ${displayLabel} must not link libmpv; found ${libmpvDependencies.join(
|
|
', '
|
|
)} in ${inspectedPaths[label]}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
const helper = inspections.helper;
|
|
if (helper) {
|
|
if (!helper.needed.includes(manifest.libmpvSoname)) {
|
|
errors.push(
|
|
`Linux frame-copy helper must directly need ${manifest.libmpvSoname}.`
|
|
);
|
|
}
|
|
const unexpectedLibmpvDependencies = helper.needed.filter(
|
|
(dependencyName) =>
|
|
anyLinuxLibmpvPattern.test(
|
|
dependencyFileName(dependencyName)
|
|
) && dependencyName !== manifest.libmpvSoname
|
|
);
|
|
if (unexpectedLibmpvDependencies.length > 0) {
|
|
errors.push(
|
|
`Linux frame-copy helper must not need a different libmpv SONAME: ${unexpectedLibmpvDependencies.join(
|
|
', '
|
|
)}.`
|
|
);
|
|
}
|
|
if (helper.rpath.length > 0) {
|
|
errors.push(
|
|
`Linux frame-copy helper must not contain RPATH; found ${helper.rpath.join(
|
|
':'
|
|
)}.`
|
|
);
|
|
}
|
|
if (
|
|
helper.runpath.length !== 1 ||
|
|
helper.runpath[0] !== '$ORIGIN/lib'
|
|
) {
|
|
errors.push(
|
|
`Linux frame-copy helper RUNPATH must be exactly $ORIGIN/lib; received ${
|
|
helper.runpath.length > 0
|
|
? helper.runpath.join(':')
|
|
: '<empty>'
|
|
}.`
|
|
);
|
|
}
|
|
|
|
const allowedHelperDependencies = new Set([
|
|
manifest.libmpvSoname,
|
|
...GLIBC_TOOLCHAIN_ALLOWLIST,
|
|
...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
|
|
...(manifest.runtimeMode === 'bundled' &&
|
|
Array.isArray(manifest.runtimeFiles)
|
|
? manifest.runtimeFiles.map(({ name }) => name)
|
|
: []),
|
|
...(manifest.runtimeMode === 'bundled' &&
|
|
Array.isArray(
|
|
manifest.runtimeDependencyClosure?.externalDependencies
|
|
)
|
|
? manifest.runtimeDependencyClosure.externalDependencies
|
|
: []),
|
|
]);
|
|
for (const dependencyName of helper.needed) {
|
|
if (
|
|
dependencyFileName(dependencyName) !== dependencyName ||
|
|
!allowedHelperDependencies.has(dependencyName)
|
|
) {
|
|
errors.push(
|
|
`Linux frame-copy helper dependency is not bundled or allowlisted: ${dependencyName}.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
manifest.runtimeMode !== 'bundled' ||
|
|
!Array.isArray(manifest.runtimeFiles)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const runtimeFileNames = manifest.runtimeFiles
|
|
.map((runtimeFile) => runtimeFile?.name)
|
|
.filter((name) => typeof name === 'string');
|
|
const runtimeNameSet = new Set(runtimeFileNames);
|
|
const allowedExternalNames = new Set([
|
|
...GLIBC_TOOLCHAIN_ALLOWLIST,
|
|
...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
|
|
]);
|
|
const closureEntries = [];
|
|
let closureHasErrors = false;
|
|
for (const runtimeFileName of runtimeFileNames) {
|
|
const runtimePath = path.join(nativeDir, 'lib', runtimeFileName);
|
|
let dynamic;
|
|
try {
|
|
dynamic = normalizeElfInspection(
|
|
inspectElf(runtimePath),
|
|
runtimePath
|
|
);
|
|
} catch (error) {
|
|
closureHasErrors = true;
|
|
errors.push(
|
|
`Unable to inspect bundled Linux runtime ELF at ${runtimePath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
continue;
|
|
}
|
|
closureEntries.push({
|
|
name: runtimeFileName,
|
|
soname: dynamic.soname,
|
|
needed: dynamic.needed,
|
|
rpath: dynamic.rpath,
|
|
runpath: dynamic.runpath,
|
|
});
|
|
if (dynamic.rpath.length > 0) {
|
|
closureHasErrors = true;
|
|
errors.push(
|
|
`${runtimeFileName} has forbidden RPATH ${dynamic.rpath.join(
|
|
':'
|
|
)}.`
|
|
);
|
|
}
|
|
if (dynamic.runpath.length !== 1 || dynamic.runpath[0] !== '$ORIGIN') {
|
|
closureHasErrors = true;
|
|
errors.push(
|
|
`${runtimeFileName} RUNPATH must be exactly $ORIGIN; got ${
|
|
dynamic.runpath.length > 0
|
|
? dynamic.runpath.join(':')
|
|
: '<empty>'
|
|
}.`
|
|
);
|
|
}
|
|
for (const dependencyName of dynamic.needed) {
|
|
if (
|
|
!runtimeNameSet.has(dependencyName) &&
|
|
!allowedExternalNames.has(dependencyName)
|
|
) {
|
|
closureHasErrors = true;
|
|
errors.push(
|
|
`Runtime dependency is not bundled or allowlisted: ${runtimeFileName} -> ${dependencyName}.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (closureHasErrors) {
|
|
return;
|
|
}
|
|
try {
|
|
const actualClosure = validateRuntimeDependencyClosure({
|
|
entries: closureEntries,
|
|
runtimeFileNames,
|
|
buildPrefix: '',
|
|
});
|
|
if (
|
|
!isDeepStrictEqual(actualClosure, manifest.runtimeDependencyClosure)
|
|
) {
|
|
errors.push(
|
|
'Actual bundled Linux ELF dependency closure does not match the packaged manifest.'
|
|
);
|
|
}
|
|
} catch (error) {
|
|
errors.push(
|
|
`Invalid bundled Linux ELF dependency closure: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function validateLinuxPackagedEmbeddedMpv(resourceDir, options) {
|
|
const nativeDir = path.join(
|
|
resourceDir,
|
|
'app.asar.unpacked',
|
|
'electron-backend',
|
|
'native'
|
|
);
|
|
if (options.foreignArch) {
|
|
return validateForeignLinuxNativeDir(nativeDir);
|
|
}
|
|
|
|
const errors = [];
|
|
const addonPath = path.join(nativeDir, 'embedded_mpv.node');
|
|
const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json');
|
|
if (!pathExistsByLstat(addonPath)) {
|
|
if (options.required) {
|
|
errors.push(`Missing embedded MPV native addon: ${addonPath}`);
|
|
}
|
|
if (pathExistsByLstat(nativeDir)) {
|
|
for (const staleName of [
|
|
'embedded_mpv_frame_reader.node',
|
|
'iptvnator_mpv_helper',
|
|
'iptvnator_mpv_helper.exe',
|
|
'embedded-mpv-runtime.json',
|
|
'embedded-mpv-unavailable.txt',
|
|
'lib',
|
|
]) {
|
|
const stalePath = path.join(nativeDir, staleName);
|
|
if (pathExistsByLstat(stalePath)) {
|
|
errors.push(
|
|
`Embedded MPV addon is missing but stale packaged artifact remains: ${stalePath}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
if (!options.profile) {
|
|
if (options.required) {
|
|
errors.push(
|
|
'Linux frame-copy profile is required for a required same-architecture package.'
|
|
);
|
|
}
|
|
validateNativeViewOnlyLinuxPackage(
|
|
nativeDir,
|
|
addonPath,
|
|
manifestPath,
|
|
errors
|
|
);
|
|
return errors;
|
|
}
|
|
|
|
let profile;
|
|
try {
|
|
profile = resolveLinuxFrameCopyProfile(options.profile);
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
return errors;
|
|
}
|
|
const targetNames = normalizeLinuxTargetNames(options.targetNames, errors);
|
|
if (targetNames.length > 0) {
|
|
try {
|
|
errors.push(
|
|
...validateLinuxProfileTargets(profile.name, targetNames)
|
|
);
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
const readerPath = path.join(
|
|
nativeDir,
|
|
linuxFrameCopyArtifacts.frameReader.name
|
|
);
|
|
const helperPath = path.join(
|
|
nativeDir,
|
|
linuxFrameCopyArtifacts.helper.name
|
|
);
|
|
inspectRegularReadableFile(addonPath, 'embedded MPV native addon', errors);
|
|
inspectRegularReadableFile(
|
|
readerPath,
|
|
'embedded MPV frame reader',
|
|
errors,
|
|
0o644
|
|
);
|
|
inspectRegularReadableFile(
|
|
helperPath,
|
|
'embedded MPV frame-copy helper',
|
|
errors,
|
|
0o755
|
|
);
|
|
const staleWindowsHelper = path.join(nativeDir, 'iptvnator_mpv_helper.exe');
|
|
if (pathExistsByLstat(staleWindowsHelper)) {
|
|
errors.push(
|
|
`Linux frame-copy package must not retain the Windows helper: ${staleWindowsHelper}`
|
|
);
|
|
}
|
|
const staleMarker = path.join(nativeDir, 'embedded-mpv-unavailable.txt');
|
|
if (pathExistsByLstat(staleMarker)) {
|
|
errors.push(
|
|
`Same-architecture Linux package must not retain the unavailable marker: ${staleMarker}`
|
|
);
|
|
}
|
|
|
|
if (
|
|
!inspectRegularReadableFile(
|
|
manifestPath,
|
|
'embedded MPV runtime manifest',
|
|
errors
|
|
)
|
|
) {
|
|
return errors;
|
|
}
|
|
const manifest = readPackagedJson(
|
|
manifestPath,
|
|
'embedded MPV runtime manifest',
|
|
errors
|
|
);
|
|
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
if (manifest !== null) {
|
|
errors.push('Embedded MPV runtime manifest must be an object.');
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
validatePackagedManifestContract(manifest, profile, targetNames, errors);
|
|
if (profile.runtimeMode === 'system') {
|
|
validateSystemLinuxRuntime(nativeDir, manifest, errors);
|
|
} else {
|
|
validateBundledLinuxRuntime(nativeDir, manifest, errors);
|
|
}
|
|
inspectLinuxElfIsolation(
|
|
resourceDir,
|
|
nativeDir,
|
|
manifest,
|
|
targetNames,
|
|
options,
|
|
errors
|
|
);
|
|
return errors;
|
|
}
|
|
|
|
function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
|
const platform = normalizeEmbeddedMpvPlatform(options.platform);
|
|
if (platform === 'linux') {
|
|
return validateLinuxPackagedEmbeddedMpv(resourceDir, options);
|
|
}
|
|
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 (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).
|
|
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 = 'vendored-lgpl';
|
|
if (manifest.origin !== expectedOrigin) {
|
|
errors.push(
|
|
`Embedded MPV packaged runtime must be ${expectedOrigin}, received: ${manifest.origin}`
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
listElectronShippedLinuxLibraries,
|
|
listRuntimeFiles,
|
|
listDylibs,
|
|
parseOtoolDependencies,
|
|
parseOtoolRpaths,
|
|
patchAddonForBundledRuntime,
|
|
validateNoForbiddenRuntimeLinks,
|
|
getPackagedRuntimeCandidates,
|
|
validatePackagedEmbeddedMpv,
|
|
isForeignLinuxEmbeddedMpvArch,
|
|
linuxUnpackedDirArch,
|
|
resolveConfiguredLinuxTargetNames,
|
|
resolveElectronBuilderArchName,
|
|
};
|