diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index ee6f9d640..5f7800cc3 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -198,7 +198,9 @@ async function afterPackHook(params) { } ); - await linuxAfterPack(params); + await linuxAfterPack(params, { + targetNames: linuxPackagingContext?.targetNames, + }); if (linuxPackagingContext) { const graphicsMountPath = ensureSnapGraphicsContentMount( params.appOutDir, diff --git a/tools/packaging/linux-after-pack.cjs b/tools/packaging/linux-after-pack.cjs index 93e6b8cb4..3cdb829f1 100644 --- a/tools/packaging/linux-after-pack.cjs +++ b/tools/packaging/linux-after-pack.cjs @@ -1,5 +1,6 @@ const fs = require('fs/promises'); const path = require('path'); +const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); function log(message) { console.log(` - ${message}`); @@ -41,17 +42,33 @@ exec "$SCRIPT_DIR/${executableName}.bin" "\${EXEC_ARGS[@]}" "$@" `; } -async function afterPackHook(params) { +async function afterPackHook(params, { targetNames = params.targets } = {}) { if (params.electronPlatformName !== 'linux') { return; } + const launcherLayout = resolveLinuxLauncherLayout( + targetNames, + params.packager.executableName + ); + if (!launcherLayout.wrapperRequired) { + log('preserving the Electron ELF for isolated Flatpak packaging'); + return; + } + log('applying Linux launcher sandbox fix'); - const executable = path.join(params.appOutDir, params.packager.executableName); + const executable = path.join( + params.appOutDir, + params.packager.executableName + ); + const electronBinary = path.join( + params.appOutDir, + launcherLayout.electronBinaryName + ); try { - await fs.rename(executable, `${executable}.bin`); + await fs.rename(executable, electronBinary); await fs.writeFile( executable, createLoaderScript({ diff --git a/tools/packaging/linux-after-pack.test.mjs b/tools/packaging/linux-after-pack.test.mjs new file mode 100644 index 000000000..3e55e00cb --- /dev/null +++ b/tools/packaging/linux-after-pack.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const linuxAfterPack = require('./linux-after-pack.cjs'); +const { createLoaderScript } = linuxAfterPack; + +const electronElf = Buffer.from([ + 0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00, 0x49, 0x50, 0x54, 0x56, +]); + +async function createAfterPackFixture(targets) { + const appOutDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'iptvnator-linux-after-pack-') + ); + const executablePath = path.join(appOutDir, 'iptvnator'); + await fs.writeFile(executablePath, electronElf, { mode: 0o755 }); + + return { + appOutDir, + executablePath, + params: { + appOutDir, + electronPlatformName: 'linux', + targets, + packager: { + executableName: 'iptvnator', + appInfo: { + productName: 'IPTVnator', + }, + }, + }, + }; +} + +function resolveLinuxLauncherLayout(...args) { + return require('./linux-launcher-layout.cjs').resolveLinuxLauncherLayout( + ...args + ); +} + +async function assertPathMissing(filePath) { + await assert.rejects( + fs.stat(filePath), + (error) => error?.code === 'ENOENT' + ); +} + +test('isolated Flatpak preserves the Electron ELF for Zypak', async (t) => { + const fixture = await createAfterPackFixture([{ name: 'flatpak' }]); + t.after(() => + fs.rm(fixture.appOutDir, { + recursive: true, + force: true, + }) + ); + + await linuxAfterPack(fixture.params); + + assert.deepEqual(await fs.readFile(fixture.executablePath), electronElf); + await assertPathMissing(`${fixture.executablePath}.bin`); +}); + +test('resolves normalized string and Target-like launcher layouts', () => { + assert.deepEqual(resolveLinuxLauncherLayout([' FlatPak ']), { + targetNames: ['flatpak'], + electronBinaryName: 'iptvnator', + wrapperRequired: false, + }); + assert.deepEqual( + resolveLinuxLauncherLayout( + [' AppImage ', { name: ' DEB ' }], + 'iptvnator-player' + ), + { + targetNames: ['appimage', 'deb'], + electronBinaryName: 'iptvnator-player.bin', + wrapperRequired: true, + } + ); +}); + +test('rejects missing, empty, and non-array target lists', () => { + for (const targets of [undefined, null, 'flatpak', new Map(), {}]) { + assert.throws( + () => resolveLinuxLauncherLayout(targets), + /Linux launcher targets must be an array/ + ); + } + assert.throws( + () => resolveLinuxLauncherLayout([]), + /Linux launcher targets must contain at least one target/ + ); +}); + +test('rejects empty target names and normalized duplicates', () => { + for (const target of ['', ' ', {}, { name: '' }, null, 42]) { + assert.throws( + () => resolveLinuxLauncherLayout([target]), + /Linux launcher targets must expose a non-empty name/ + ); + } + assert.throws( + () => resolveLinuxLauncherLayout([{ name: ' DEB ' }, { name: 'deb' }]), + /Linux launcher target "deb" is duplicated/ + ); +}); + +test('rejects Flatpak mixed with another target', () => { + assert.throws( + () => + resolveLinuxLauncherLayout([ + { name: 'flatpak' }, + { name: 'AppImage' }, + ]), + /Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly/ + ); +}); + +for (const targetName of ['appimage', 'deb', 'rpm', 'pacman', 'snap']) { + test(`${targetName} retains the launcher wrapper and Electron ELF binary`, async (t) => { + const fixture = await createAfterPackFixture([{ name: targetName }]); + t.after(() => + fs.rm(fixture.appOutDir, { + recursive: true, + force: true, + }) + ); + + await linuxAfterPack(fixture.params); + + assert.equal( + await fs.readFile(fixture.executablePath, 'utf8'), + createLoaderScript({ + executableName: 'iptvnator', + productName: 'IPTVnator', + }) + ); + assert.deepEqual( + await fs.readFile(`${fixture.executablePath}.bin`), + electronElf + ); + assert.equal( + (await fs.stat(fixture.executablePath)).mode & 0o777, + 0o755 + ); + }); +} + +test('mixed Flatpak targets fail before mutating the Electron ELF', async (t) => { + const fixture = await createAfterPackFixture([ + { name: 'flatpak' }, + { name: 'appimage' }, + ]); + t.after(() => + fs.rm(fixture.appOutDir, { + recursive: true, + force: true, + }) + ); + + await assert.rejects( + linuxAfterPack(fixture.params), + /Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly/ + ); + + assert.deepEqual(await fs.readFile(fixture.executablePath), electronElf); + await assertPathMissing(`${fixture.executablePath}.bin`); +}); + +test('packaging Nx tests register the Linux afterPack regression suite', async () => { + const project = JSON.parse( + await fs.readFile(new URL('./project.json', import.meta.url), 'utf8') + ); + const testFile = + '{workspaceRoot}/tools/packaging/linux-after-pack.test.mjs'; + + assert.ok(project.targets.test.inputs.includes(testFile)); + assert.match( + project.targets.test.options.command, + /node --test .*tools\/packaging\/linux-after-pack\.test\.mjs/ + ); +}); diff --git a/tools/packaging/linux-launcher-layout.cjs b/tools/packaging/linux-launcher-layout.cjs new file mode 100644 index 000000000..2564db529 --- /dev/null +++ b/tools/packaging/linux-launcher-layout.cjs @@ -0,0 +1,56 @@ +'use strict'; + +function normalizedTargetName(target) { + const value = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? target.name + : null; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error('Linux launcher targets must expose a non-empty name.'); + } + return value.trim().toLowerCase(); +} + +function resolveLinuxLauncherLayout(targets, executableName = 'iptvnator') { + if (!Array.isArray(targets)) { + throw new TypeError('Linux launcher targets must be an array.'); + } + if (targets.length === 0) { + throw new Error( + 'Linux launcher targets must contain at least one target.' + ); + } + + const targetNames = []; + for (const target of targets) { + const targetName = normalizedTargetName(target); + if (targetNames.includes(targetName)) { + throw new Error( + `Linux launcher target "${targetName}" is duplicated.` + ); + } + targetNames.push(targetName); + } + + const flatpakSelected = targetNames.includes('flatpak'); + if (flatpakSelected && targetNames.length !== 1) { + throw new Error( + 'Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly.' + ); + } + + const wrapperRequired = !flatpakSelected; + return { + targetNames, + electronBinaryName: wrapperRequired + ? `${executableName}.bin` + : executableName, + wrapperRequired, + }; +} + +module.exports = { + resolveLinuxLauncherLayout, +}; diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 1ac68583b..c8b7b526b 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -24,6 +24,7 @@ "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.mjs", "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", + "{workspaceRoot}/tools/packaging/linux-after-pack.test.mjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", @@ -49,7 +50,7 @@ "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-after-pack.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } },