diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index e7272cd78..bd4936c29 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -1,6 +1,5 @@ const linuxAfterPack = require('./linux-after-pack.cjs'); const { - getEmbeddedMpvAddonArch, isForeignLinuxEmbeddedMpvArch, resolveElectronBuilderArchName, validatePackagedEmbeddedMpv, @@ -118,8 +117,11 @@ function resolveLinuxFrameCopyPackagingContext( ); } - const addonArch = getEmbeddedMpvAddonArch(environment); - const foreignArch = targetArch !== addonArch; + // Official Linux frame-copy artifacts are intentionally x64-only. Do not + // let a caller-provided build-arch environment value promote an ARM + // package to a supported layout: every non-x64 target must remain the + // marker-only native-view fallback. + const foreignArch = targetArch !== 'x64'; const profileValue = environment.IPTVNATOR_LINUX_FRAME_COPY_PROFILE?.trim() ?? ''; if (!profileValue) { diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index 0dee7f872..f40d1671b 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -305,6 +305,9 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( packageLayoutVerifier, /validateLinuxProfileTargets\(\s*linuxFrameCopyProfile,\s*linuxTargetNames\s*\)/s ); + assert.match(packageLayoutVerifier, /dirArch !== 'x64'/); + assert.doesNotMatch(packageLayoutVerifier, /getEmbeddedMpvAddonArch/); + assert.match(electronAfterPackSource, /targetArch !== 'x64'/); }); test('nx-electron packaging does not copy duplicate root package metadata', () => { diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index 2f4074d05..f8a46418d 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -301,6 +301,26 @@ test('resolves the required Linux profile from afterPack targets before mutation ); }); +test('keeps every non-x64 Linux target marker-only even when the configured addon arch matches it', () => { + const context = resolveLinuxFrameCopyPackagingContext( + { + electronPlatformName: 'linux', + arch: 3, + targets: [{ name: 'deb' }], + }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }, + } + ); + + assert.equal(context.targetArch, 'arm64'); + assert.equal(context.foreignArch, true); +}); + test('rejects missing, mixed, and unknown required Linux packaging context', () => { const baseParams = { electronPlatformName: 'linux', @@ -811,6 +831,24 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure }); assert.match(addonLinkErrors.join('\n'), /must not link libmpv/); + const pathBearingAddonLinkErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.addon]: { + needed: ['/tmp/build/libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + pathBearingAddonLinkErrors.join('\n'), + /must not link libmpv.*\/tmp\/build\/libmpv\.so\.2/ + ); + const helperLinkErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, { ...options, elfInspector: validElfInspector(fixture.nativeDir, manifest, { @@ -826,6 +864,29 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure /must directly need libmpv\.so\.2/ ); + const unexpectedHelperLinkErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: [ + manifest.libmpvSoname, + 'libEGL.so.1', + 'libc.so.6', + 'libsurprise.so.1', + ], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + } + ); + assert.match( + unexpectedHelperLinkErrors.join('\n'), + /helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/ + ); + const closureErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, { ...options, elfInspector: validElfInspector(fixture.nativeDir, manifest, { @@ -841,6 +902,69 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure closureErrors.join('\n'), /not bundled or allowlisted.*libsurprise\.so\.1/ ); + + fs.writeFileSync( + join(fixture.appOutDir, 'libelectron-extra.so'), + 'electron library' + ); + const electronLibraryErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libelectron-extra.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + electronLibraryErrors.join('\n'), + /Linux Electron library must not link libmpv.*libelectron-extra\.so/ + ); +}); + +test('rejects undeclared helper dependencies in system-runtime packages', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'system', + targetNames: ['deb'], + } + ); + + const errors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'system', + targetNames: ['deb'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: [ + manifest.libmpvSoname, + 'libEGL.so.1', + 'libc.so.6', + 'libsurprise.so.1', + ], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + }); + + assert.match( + errors.join('\n'), + /helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/ + ); }); test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => { @@ -852,7 +976,7 @@ test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => assert.equal(resolveElectronBuilderArchName(99), null); }); -test('flags Linux packages whose arch differs from the built addon', () => { +test('flags every non-x64 Linux package regardless of configured build arch', () => { assert.equal( isForeignLinuxEmbeddedMpvArch('linux', 3, X64_ADDON_ENV), true @@ -865,6 +989,12 @@ test('flags Linux packages whose arch differs from the built addon', () => { isForeignLinuxEmbeddedMpvArch('linux', 'x64', X64_ADDON_ENV), false ); + assert.equal( + isForeignLinuxEmbeddedMpvArch('linux', 'arm64', { + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }), + true + ); // Only Linux fans out foreign arches from one dist tree. assert.equal( isForeignLinuxEmbeddedMpvArch('darwin', 'arm64', X64_ADDON_ENV), diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index 4f0105c2a..c22849bc3 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -745,26 +745,17 @@ function resolveConfiguredLinuxTargetNames(configuredTargets, targetArch) { return [...targetNames].sort(); } -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. + * 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, - env = process.env -) { +function isForeignLinuxEmbeddedMpvArch(platform, targetArch) { if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') { return false; } const archName = resolveElectronBuilderArchName(targetArch); - return Boolean(archName) && archName !== getEmbeddedMpvAddonArch(env); + return Boolean(archName) && archName !== 'x64'; } // Maps electron-builder Linux output directory names (`linux-unpacked`, @@ -1294,6 +1285,40 @@ function normalizeElfInspection(value, binaryPath) { return result; } +function dependencyFileName(dependencyName) { + return dependencyName.replaceAll('\\', '/').split('/').at(-1) ?? ''; +} + +function listElectronShippedLinuxLibraries(resourceDir) { + const appDir = path.dirname(resourceDir); + const normalizedResourceDir = path.resolve(resourceDir); + 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()) { + visit(entryPath); + continue; + } + if ( + (entry.isFile() || entry.isSymbolicLink()) && + /\.so(?:\.\d+)*$/.test(entry.name) + ) { + libraries.push(entryPath); + } + } + } + + visit(appDir); + return libraries.sort(); +} + function inspectLinuxElfIsolation( resourceDir, nativeDir, @@ -1328,6 +1353,11 @@ function inspectLinuxElfIsolation( reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name), helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name), }; + for (const [index, libraryPath] of listElectronShippedLinuxLibraries( + resourceDir + ).entries()) { + inspectedPaths[`electronLibrary:${index}`] = libraryPath; + } const inspections = {}; for (const [label, binaryPath] of Object.entries(inspectedPaths)) { if ( @@ -1353,19 +1383,35 @@ function inspectLinuxElfIsolation( } } - for (const label of ['electron', 'addon', 'reader']) { + 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(dependencyName) + anyLinuxLibmpvPattern.test(dependencyFileName(dependencyName)) ); if (libmpvDependencies.length > 0) { errors.push( - `Linux ${label} binary must not link libmpv; found ${libmpvDependencies.join( + `Linux ${displayLabel} must not link libmpv; found ${libmpvDependencies.join( ', ' - )}.` + )} in ${inspectedPaths[label]}.` ); } } @@ -1379,8 +1425,9 @@ function inspectLinuxElfIsolation( } const unexpectedLibmpvDependencies = helper.needed.filter( (dependencyName) => - anyLinuxLibmpvPattern.test(dependencyName) && - dependencyName !== manifest.libmpvSoname + anyLinuxLibmpvPattern.test( + dependencyFileName(dependencyName) + ) && dependencyName !== manifest.libmpvSoname ); if (unexpectedLibmpvDependencies.length > 0) { errors.push( @@ -1408,6 +1455,32 @@ function inspectLinuxElfIsolation( }.` ); } + + 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 ( @@ -1822,7 +1895,6 @@ module.exports = { validateNoForbiddenRuntimeLinks, getPackagedRuntimeCandidates, validatePackagedEmbeddedMpv, - getEmbeddedMpvAddonArch, isForeignLinuxEmbeddedMpvArch, linuxUnpackedDirArch, resolveConfiguredLinuxTargetNames, diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 58fcebea6..839fe08be 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -8,7 +8,6 @@ import { inspectPackagedDependencyClosure } from './asar-dependency-closure.mjs' const require = createRequire(import.meta.url); const { extractFile, listPackage } = require('@electron/asar'); const { - getEmbeddedMpvAddonArch, linuxUnpackedDirArch, resolveConfiguredLinuxTargetNames, validatePackagedEmbeddedMpv, @@ -622,9 +621,8 @@ function verifyPackagedDependencyClosure(resourceDir, errors) { ); } -// The embedded MPV addon is built once per CI host (x64), but electron-builder -// emits arm64/armv7l Linux output directories from the same dist tree. Those -// must carry the unavailable marker instead of a foreign-architecture addon. +// Official Linux frame-copy support is x64-only. Every arm64/armv7l output +// must carry the unavailable marker instead of native frame-copy artifacts. function isForeignArchLinuxResourceDir(resourceDir) { if (platform !== 'linux') { return false; @@ -633,7 +631,7 @@ function isForeignArchLinuxResourceDir(resourceDir) { const dirArch = linuxUnpackedDirArch( path.basename(path.dirname(resourceDir)) ); - return Boolean(dirArch) && dirArch !== getEmbeddedMpvAddonArch(); + return Boolean(dirArch) && dirArch !== 'x64'; } function verifyResourceDir(resourceDir) {