diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 96395ba6e..8df8b6e81 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1104,14 +1104,38 @@ jobs: LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)" test -f "${LAUNCHER_PATH}" - test -f "${LAUNCHER_PATH}.bin" - grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}" - grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}" + test -x "${LAUNCHER_PATH}" + if [ -e "${LAUNCHER_PATH}.bin" ] || [ -L "${LAUNCHER_PATH}.bin" ]; then + echo "::error::Flatpak must not contain ${LAUNCHER_PATH}.bin" + exit 1 + fi + ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")" + test "${ELF_MAGIC}" = "7f454c46" ' - xvfb-run -a dbus-run-session -- flatpak run \ - --env=LIBGL_ALWAYS_SOFTWARE=1 \ - com.fourgray.iptvnator \ - --embedded-mpv-runtime-probe + set +e + PROBE_OUTPUT="$( + xvfb-run -a dbus-run-session -- flatpak run \ + --env=LIBGL_ALWAYS_SOFTWARE=1 \ + com.fourgray.iptvnator \ + --embedded-mpv-runtime-probe 2>&1 + )" + PROBE_STATUS=$? + set -e + + PROBE_OUTPUT_LIMIT=16384 + printf '%s\n' "${PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT}" + if [ "${#PROBE_OUTPUT}" -gt "${PROBE_OUTPUT_LIMIT}" ]; then + echo "::warning::Flatpak runtime probe output was truncated to ${PROBE_OUTPUT_LIMIT} characters." + fi + if [[ "${PROBE_OUTPUT}" == *"not an ELF file"* ]] || + [[ "${PROBE_OUTPUT}" == *"Zypak needs to be called directly"* ]]; then + echo "::error::Flatpak launched a wrapper instead of the Electron ELF." + exit 1 + fi + if [ "${PROBE_STATUS}" -ne 0 ]; then + echo "::error::Flatpak application runtime probe failed with status ${PROBE_STATUS}." + exit "${PROBE_STATUS}" + fi - name: Upload artifacts (macOS) if: matrix.os == 'macos' diff --git a/AGENTS.md b/AGENTS.md index a81b7cba1..7371074f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,10 @@ Key files: Pacman=`mpv,libglvnd,mesa` - `portable`: AppImage/Snap with the pinned LGPL-compatible closure - `flatpak`: Flatpak with the same pinned closure +- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real + Electron ELF so Electron Builder's `electron-wrapper` passes it directly to + Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and + `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. - The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04 provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the package dependency or advertising frame-copy without a compatible runtime. diff --git a/CLAUDE.md b/CLAUDE.md index 7b404d099..42cd4b7fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -639,8 +639,12 @@ engine` (restart required) or Official x64 packages use three separate profiles: DEB/RPM/Pacman depend on system libmpv plus the helper's direct EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and - Flatpak bundles the same closure. Exact system dependencies are - DEB=`libmpv2,libegl1,libgl1,libgbm1`, + Flatpak bundles the same closure. Flatpak is an isolated packaging pass and + keeps `iptvnator` as the real Electron ELF so Electron Builder's + `electron-wrapper` passes it directly to Zypak. Other Linux targets retain the + conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed + Flatpak/non-Flatpak target sets fail before mutation. Exact system + dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`, RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`. diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index b0833d40b..19feed60d 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -695,11 +695,13 @@ Linux release profiles: source-built closure and manifest origin. Its app-level probe reconstructs only the exact Freedesktop 24.08 EGL external-platform search path inside the trusted `/app` payload. -- The three profiles are separate packaging passes; mixing target sets fails - closed. Linux packages for other architectures (arm64, armv7l) must not ship - x64 native artifacts. `afterPack` replaces the native directory with - `embedded-mpv-unavailable.txt`, and package verification requires that - marker. +- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real + Electron ELF so Electron Builder's `electron-wrapper` passes it directly to + Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and + `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. +- Linux packages for other architectures (arm64, armv7l) must not ship x64 + native artifacts. `afterPack` replaces the native directory with + `embedded-mpv-unavailable.txt`, and package verification requires that marker. - Every packaged manifest names its exact artifacts, profile/targets, libmpv SONAME, loader closure, byte sizes, SHA-256 hashes, package dependencies, and native-view fallback. Artifact modes and ELF dependency isolation are diff --git a/docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md b/docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md new file mode 100644 index 000000000..bfb47f0af --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md @@ -0,0 +1,738 @@ +# Flatpak Zypak Launcher Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the Flatpak `iptvnator` entry as the real Electron ELF so +Electron Builder passes it directly to Zypak, while preserving the existing +Linux sandbox wrapper for every other package target. + +**Architecture:** A small CommonJS launcher-layout contract resolves the +Electron binary name from normalized target names and rejects mixed Flatpak +passes. The afterPack hook, unpacked-layout validators, final-artifact +validator, and CI all consume the same target-dependent contract. + +**Tech Stack:** Node.js CommonJS/ESM, `node:test`, Nx packaging targets, +Electron Builder 26, Flatpak/Zypak, GitHub Actions. + +--- + +### Task 1: Add the shared launcher contract and fix afterPack + +**Files:** + +- Create: `tools/packaging/linux-launcher-layout.cjs` +- Create: `tools/packaging/linux-after-pack.test.mjs` +- Modify: `tools/packaging/linux-after-pack.cjs` +- Modify: `tools/packaging/electron-after-pack.cjs` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write the failing isolated-Flatpak hook test** + +Create a temporary executable with ELF magic, call the real hook, and assert +that Flatpak retains the exact original file: + +```js +test('preserves the Electron ELF for an isolated Flatpak target', async (t) => { + const fixture = createLauncherFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + + await linuxAfterPack(createAfterPackParams(fixture.appOutDir, ['flatpak'])); + + assert.deepEqual( + fs.readFileSync(fixture.executablePath), + fixture.executableBytes + ); + assert.equal(fs.existsSync(`${fixture.executablePath}.bin`), false); +}); +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +node --test tools/packaging/linux-after-pack.test.mjs +``` + +Expected: FAIL because the current hook replaces `iptvnator` with a Bash +script and creates `iptvnator.bin`. + +- [ ] **Step 3: Add the pure launcher-layout resolver** + +Implement a strict resolver with this public contract: + +```js +function resolveLinuxLauncherLayout(targets, executableName = 'iptvnator') { + if (!Array.isArray(targets) || targets.length === 0) { + throw new Error( + 'Linux launcher layout requires at least one Electron Builder target.' + ); + } + + const targetNames = targets.map((target) => { + const value = typeof target === 'string' ? target : target?.name; + const name = String(value ?? '') + .trim() + .toLowerCase(); + if (!name) { + throw new Error( + 'Linux launcher targets must expose a non-empty name.' + ); + } + return name; + }); + + if (new Set(targetNames).size !== targetNames.length) { + throw new Error('Linux launcher targets must be unique.'); + } + + const flatpak = targetNames.includes('flatpak'); + if (flatpak && targetNames.length !== 1) { + throw new Error( + 'Flatpak must be packaged in an isolated Electron Builder pass so Zypak receives the Electron ELF directly.' + ); + } + + return { + targetNames, + electronBinaryName: flatpak ? executableName : `${executableName}.bin`, + wrapperRequired: !flatpak, + }; +} +``` + +Export `resolveLinuxLauncherLayout`. + +- [ ] **Step 4: Make the Linux hook preserve isolated Flatpak** + +Resolve the layout before any filesystem mutation. For Flatpak, log that the +ELF is preserved and return. For other targets, rename to the resolved +`electronBinaryName` and create the unchanged sandbox wrapper: + +```js +async function afterPackHook(params, { targetNames = params.targets } = {}) { + if (params.electronPlatformName !== 'linux') { + return; + } + + const layout = resolveLinuxLauncherLayout( + targetNames, + params.packager.executableName + ); + if (!layout.wrapperRequired) { + log('preserving Flatpak Electron ELF for direct Zypak launch'); + return; + } + + const executable = path.join( + params.appOutDir, + params.packager.executableName + ); + const electronBinary = path.join( + params.appOutDir, + layout.electronBinaryName + ); + + try { + await fs.rename(executable, electronBinary); + await fs.writeFile( + executable, + createLoaderScript({ + executableName: params.packager.executableName, + productName: params.packager.appInfo.productName, + }) + ); + await fs.chmod(executable, 0o755); + } catch (error) { + log(`failed to create launcher wrapper: ${error.message}`); + throw new Error('Failed to create launcher wrapper'); + } + + log('Linux launcher sandbox fix applied'); +} +``` + +Pass `linuxPackagingContext?.targetNames` from `electron-after-pack.cjs` so +the hook consumes the already validated afterPack target names: + +```js +await linuxAfterPack(params, { + targetNames: linuxPackagingContext?.targetNames, +}); +``` + +- [ ] **Step 5: Add non-Flatpak and mixed-target regression cases** + +Use the real hook to prove: + +```js +test('keeps the sandbox wrapper for non-Flatpak targets', async (t) => { + for (const targetName of ['appimage', 'deb', 'rpm', 'pacman', 'snap']) { + const fixture = createLauncherFixture(); + t.after(() => + fs.rmSync(fixture.root, { recursive: true, force: true }) + ); + await linuxAfterPack( + createAfterPackParams(fixture.appOutDir, [targetName]) + ); + assert.deepEqual( + fs.readFileSync(`${fixture.executablePath}.bin`), + fixture.executableBytes + ); + assert.match( + fs.readFileSync(fixture.executablePath, 'utf8'), + /exec "\$SCRIPT_DIR\/iptvnator\.bin"/ + ); + } +}); + +test('rejects a mixed Flatpak pass before mutating the executable', async (t) => { + const fixture = createLauncherFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + await assert.rejects( + linuxAfterPack( + createAfterPackParams(fixture.appOutDir, ['flatpak', 'appimage']) + ), + /Flatpak must be packaged in an isolated Electron Builder pass/ + ); + assert.deepEqual( + fs.readFileSync(fixture.executablePath), + fixture.executableBytes + ); + assert.equal(fs.existsSync(`${fixture.executablePath}.bin`), false); +}); +``` + +- [ ] **Step 6: Register and run the focused test** + +Add `linux-after-pack.test.mjs` to the explicit `packaging:test` command and +inputs in `tools/packaging/project.json`. + +Run: + +```bash +node --test tools/packaging/linux-after-pack.test.mjs +``` + +Expected: all launcher tests PASS. + +- [ ] **Step 7: Commit Task 1** + +```bash +git add tools/packaging/linux-launcher-layout.cjs \ + tools/packaging/linux-after-pack.cjs \ + tools/packaging/linux-after-pack.test.mjs \ + tools/packaging/electron-after-pack.cjs \ + tools/packaging/project.json +git commit -m "fix(packaging): preserve Flatpak Electron ELF" +``` + +### Task 2: Make unpacked-layout validation target-aware + +**Files:** + +- Modify: `tools/packaging/embedded-mpv-packaging.cjs` +- Modify: `tools/packaging/embedded-mpv-arch.test.mjs` +- Modify: `tools/packaging/verify-electron-package-layout.mjs` +- Modify: `tools/packaging/electron-package-identity.test.mjs` + +- [ ] **Step 1: Change the Flatpak fixture and verify RED** + +In `prepares portable and Flatpak manifests with the exact bundled closure`, +rename only the Flatpak fixture's Electron binary: + +```js +fs.renameSync( + join(flatpak.appOutDir, 'iptvnator.bin'), + join(flatpak.appOutDir, 'iptvnator') +); +``` + +Teach the test ELF inspector about both legitimate basenames: + +```js +['iptvnator', { needed: ['libc.so.6'], rpath: [], runpath: [] }], +['iptvnator.bin', { needed: ['libc.so.6'], rpath: [], runpath: [] }], +``` + +Run: + +```bash +node --test --test-name-pattern='prepares portable and Flatpak manifests' \ + tools/packaging/embedded-mpv-arch.test.mjs +``` + +Expected: FAIL with a missing `iptvnator.bin` validation error. + +- [ ] **Step 2: Resolve the pristine Electron ELF through the shared contract** + +Require `resolveLinuxLauncherLayout` in +`embedded-mpv-packaging.cjs`. In `inspectLinuxElfIsolation`, resolve from +`options.targetNames` and use its `electronBinaryName`: + +```js +let launcherLayout; +try { + launcherLayout = resolveLinuxLauncherLayout( + options.targetNames, + options.executableName ?? 'iptvnator' + ); +} catch (error) { + errors.push( + `Unable to resolve Linux launcher layout: ${ + error instanceof Error ? error.message : String(error) + }` + ); + 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; +} +``` + +Pass the normalized `targetNames` already calculated by +`validateLinuxPackagedEmbeddedMpv` into the inspection options. + +- [ ] **Step 3: Make the general package-layout verifier profile-aware** + +Require the same resolver in `verify-electron-package-layout.mjs`, change +`verifyLinuxLauncher` to accept `targetNames`, and call it with +`linuxTargetNames`. + +For Flatpak: + +```js +if (!layout.wrapperRequired) { + if (fileExists(`${launcherPath}.bin`)) { + errors.push( + `Flatpak must not contain the Linux sandbox wrapper binary: ${launcherPath}.bin` + ); + } + if (!fileHasElfMagic(launcherPath)) { + errors.push( + `Flatpak launcher target must be an ELF executable: ${launcherPath}` + ); + } + return; +} + +const launcherBinaryPath = path.join(appDir, layout.electronBinaryName); +if (!fileExists(launcherBinaryPath)) { + errors.push( + `Missing Linux launcher binary in ${appDir}: ${path.basename(launcherBinaryPath)}` + ); + return; +} +if (!fileExists(launcherPath)) { + errors.push( + `Missing Linux launcher wrapper in ${appDir}: ${path.basename(launcherPath)}` + ); + return; +} + +const launcherScript = fs.readFileSync(launcherPath, 'utf8'); +const requiredMarkers = [ + 'SCRIPT_PATH="${BASH_SOURCE[0]}"', + 'readlink -f "$SCRIPT_PATH"', + `exec "$SCRIPT_DIR/${linuxExecutableName}.bin"`, +]; +const missingMarkers = requiredMarkers.filter( + (marker) => !launcherScript.includes(marker) +); +if (missingMarkers.length > 0) { + errors.push( + [ + `Linux launcher wrapper is missing symlink-safe logic in ${launcherPath}.`, + 'Missing markers:', + ...missingMarkers.map((marker) => `- ${marker}`), + ].join('\n') + ); +} +``` + +Implement `fileHasElfMagic` with one four-byte `fs.readSync` call and always +close the descriptor: + +```js +function fileHasElfMagic(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + try { + const magic = Buffer.alloc(4); + return ( + fs.readSync(descriptor, magic, 0, magic.length, 0) === + magic.length && + magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ); + } finally { + fs.closeSync(descriptor); + } +} +``` + +- [ ] **Step 4: Extend the package-identity source contract test** + +Assert that the general verifier imports the shared resolver, calls +`verifyLinuxLauncher(resourceDir, linuxTargetNames, errors)`, checks ELF magic, +and does not unconditionally set `launcherBinaryPath` before resolving target +layout. + +- [ ] **Step 5: Run targeted validation** + +```bash +node --test --test-name-pattern='prepares portable and Flatpak manifests' \ + tools/packaging/embedded-mpv-arch.test.mjs +node --test --test-name-pattern='package layout verifier uses' \ + tools/packaging/electron-package-identity.test.mjs +``` + +Expected: both commands PASS. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add tools/packaging/embedded-mpv-packaging.cjs \ + tools/packaging/embedded-mpv-arch.test.mjs \ + tools/packaging/verify-electron-package-layout.mjs \ + tools/packaging/electron-package-identity.test.mjs +git commit -m "fix(packaging): validate Flatpak launcher ELF" +``` + +### Task 3: Update final-artifact verification, CI, and documentation + +**Files:** + +- Modify: `tools/packaging/verify-linux-frame-copy-runtime.mjs` +- Modify: `tools/packaging/verify-linux-frame-copy-runtime.test.mjs` +- Modify: `.github/workflows/build-and-make.yaml` +- Modify: `tools/packaging/configure-linux-frame-copy-build.test.mjs` +- Modify: `docs/architecture/embedded-mpv-native.md` +- Modify: `tools/embedded-mpv/README.md` +- Modify: `docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Add a failing extracted-Flatpak regression** + +Allow the fixture helper to select the Electron filename: + +```js +function createSystemPayload({ + architecture = 'x64', + electronBinaryName = 'iptvnator.bin', +} = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-layout-') + ); + const appDir = path.join(root, 'opt', 'IPTVnator'); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, electronBinaryName), + elfHeader(architecture) + ); + + if (architecture === 'x64') { + fs.writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', { + mode: 0o644, + }); + fs.writeFileSync( + path.join(nativeDir, 'embedded_mpv_frame_reader.node'), + 'reader', + { mode: 0o644 } + ); + fs.writeFileSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 'helper', + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(SYSTEM_MANIFEST, null, 2)}\n`, + { mode: 0o644 } + ); + } else { + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + `Unavailable for ${architecture}\n` + ); + } + + return { root, appDir, resourceDir, nativeDir }; +} +``` + +Use a foreign-architecture marker fixture so this test isolates launcher +selection without needing a bundled x64 manifest: + +```js +test('validates a marker-only Flatpak with an unwrapped Electron ELF', () => { + const fixture = createSystemPayload({ + architecture: 'arm64', + electronBinaryName: 'iptvnator', + }); + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'flatpak', + profileName: 'flatpak', + packageDependencies: [], + elfInspector: validElfInspector, + probeRunner() { + assert.fail( + 'foreign Flatpak must not run the helper probe' + ); + }, + }), + [] + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); +``` + +Run: + +```bash +node --test --test-name-pattern='marker-only Flatpak with an unwrapped' \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs +``` + +Expected: FAIL because the verifier reads `iptvnator.bin`. + +- [ ] **Step 2: Resolve every final-artifact Electron path consistently** + +Require `resolveLinuxLauncherLayout` and add: + +```js +function resolveElectronBinaryPath(resourceDir, artifactFormat) { + const layout = resolveLinuxLauncherLayout([artifactFormat]); + return path.join(path.dirname(resourceDir), layout.electronBinaryName); +} +``` + +Use it in: + +- `validateElectronIsolation`; +- `verifyExtractedLinuxFrameCopyRuntime` architecture detection; +- the architecture returned from `verifyLinuxFrameCopyArtifact`. + +Keep Snap/AppImage/DEB/RPM/Pacman expectations on `iptvnator.bin`. + +- [ ] **Step 3: Add an outer artifact-verifier Flatpak regression** + +Create a temporary `.flatpak` file, inject an extractor that writes +`iptvnator` ELF and the marker-only native directory under its supplied +destination, and assert: + +```js +assert.deepEqual( + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'flatpak', + extractArtifact({ destination }) { + const appDir = path.join( + destination, + 'files', + 'lib', + 'com.fourgray.iptvnator' + ); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, 'iptvnator'), + elfHeader('arm64') + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + 'Unavailable for arm64\n' + ); + return destination; + }, + metadataReader: () => ({ + declaredArch: 'arm64', + dependencies: [], + }), + elfInspector: validElfInspector, + probeRunner() { + assert.fail('foreign Flatpak must not probe'); + }, + }), + { + artifactPath: path.resolve(artifactPath), + format: 'flatpak', + profileName: 'flatpak', + architecture: 'arm64', + } +); +``` + +- [ ] **Step 4: Invert the installed-Flatpak CI layout assertion** + +Inside the existing sandbox shell check: + +```bash +LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)" +test -f "${LAUNCHER_PATH}" +test ! -e "${LAUNCHER_PATH}.bin" +ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")" +test "${ELF_MAGIC}" = "7f454c46" +``` + +Capture the application-level probe output and fail on the historical Zypak +diagnostics: + +```bash +PROBE_OUTPUT="$( + xvfb-run -a dbus-run-session -- flatpak run \ + --env=LIBGL_ALWAYS_SOFTWARE=1 \ + com.fourgray.iptvnator \ + --embedded-mpv-runtime-probe 2>&1 +)" +printf '%s\n' "${PROBE_OUTPUT}" +if printf '%s\n' "${PROBE_OUTPUT}" | + grep -Eq 'not an ELF file|Zypak needs to be called directly'; then + echo "::error::Flatpak launched a wrapper instead of the Electron ELF." + exit 1 +fi +``` + +Update `configure-linux-frame-copy-build.test.mjs` to require the ELF magic +check, `.bin` rejection, warning guard, and absence of the old wrapper-marker +greps. + +- [ ] **Step 5: Update canonical launcher documentation** + +Document the exact invariant in all listed documentation: + +```text +Flatpak is an isolated packaging pass and keeps `iptvnator` as the real +Electron ELF so Electron Builder's `electron-wrapper` passes it directly to +Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and +`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. +``` + +In the earlier frame-copy design, replace the unconditional +`Electron executable (iptvnator.bin)` wording with +`iptvnator for Flatpak; iptvnator.bin for other Linux targets`. + +- [ ] **Step 6: Run targeted tests and formatting** + +```bash +node --test --test-name-pattern='Flatpak|Linux CI verifies' \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + tools/packaging/configure-linux-frame-copy-build.test.mjs +pnpm prettier --check \ + tools/packaging/verify-linux-frame-copy-runtime.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + tools/packaging/configure-linux-frame-copy-build.test.mjs \ + .github/workflows/build-and-make.yaml \ + docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md \ + docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \ + AGENTS.md CLAUDE.md +``` + +Expected: tests and formatting PASS. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add tools/packaging/verify-linux-frame-copy-runtime.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + .github/workflows/build-and-make.yaml \ + tools/packaging/configure-linux-frame-copy-build.test.mjs \ + docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md \ + docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \ + AGENTS.md CLAUDE.md +git commit -m "test(packaging): enforce direct Flatpak Zypak launch" +``` + +### Task 4: Verify the integrated fix + +**Files:** + +- Verify only; do not add unrelated changes. + +- [ ] **Step 1: Run the complete packaging tests** + +```bash +pnpm nx test packaging --skip-nx-cache +``` + +Expected: all tests PASS, including the new launcher tests. + +- [ ] **Step 2: Run packaging lint** + +```bash +pnpm nx lint packaging --skip-nx-cache +``` + +Expected: zero ESLint errors. + +- [ ] **Step 3: Run repository formatting checks for changed files** + +```bash +pnpm prettier --check \ + tools/packaging/linux-launcher-layout.cjs \ + tools/packaging/linux-after-pack.cjs \ + tools/packaging/linux-after-pack.test.mjs \ + tools/packaging/electron-after-pack.cjs \ + tools/packaging/embedded-mpv-packaging.cjs \ + tools/packaging/embedded-mpv-arch.test.mjs \ + tools/packaging/verify-electron-package-layout.mjs \ + tools/packaging/electron-package-identity.test.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.mjs \ + tools/packaging/verify-linux-frame-copy-runtime.test.mjs \ + tools/packaging/configure-linux-frame-copy-build.test.mjs \ + tools/packaging/project.json \ + .github/workflows/build-and-make.yaml \ + docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md \ + docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md \ + docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md \ + docs/superpowers/plans/2026-07-18-flatpak-zypak-launcher.md \ + AGENTS.md CLAUDE.md +``` + +Expected: all changed files use repository formatting. + +- [ ] **Step 4: Inspect the final diff** + +```bash +git diff 8fdac824..HEAD --check +git status --short +``` + +Expected: no whitespace errors and only scoped launcher, validator, CI, test, +plan, and documentation changes. diff --git a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md index 94745ccaf..096e50531 100644 --- a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md +++ b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md @@ -55,6 +55,11 @@ that the selected target set matches the runtime mode. It must fail closed if an official x64 package is requested with an absent, incomplete, or ambiguous profile. +Flatpak is an isolated packaging pass and keeps `iptvnator` as the real +Electron ELF so Electron Builder's `electron-wrapper` passes it directly to +Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and +`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. + System package dependencies are: - DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` @@ -131,8 +136,9 @@ the complete non-system dependency closure. ELF dependencies inside that closure and the helper use only SONAMEs plus `$ORIGIN`-relative RPATH/RUNPATH; they may not retain build-prefix paths. -`embedded_mpv.node`, the Electron executable (`iptvnator.bin`), and Electron's -shipped libraries must not have a direct `DT_NEEDED` entry for libmpv. +`embedded_mpv.node`, the Electron executable (`iptvnator` for Flatpak; +`iptvnator.bin` for other Linux targets), and Electron's shipped libraries must +not have a direct `DT_NEEDED` entry for libmpv. `iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a profile-specific choice. The source `electron-backend/native{,/**/*}` tree is excluded from `app.asar`; `afterPack` is the sole owner of the normalized diff --git a/docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md b/docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md new file mode 100644 index 000000000..96b996a1d --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-flatpak-zypak-launcher-design.md @@ -0,0 +1,69 @@ +# Flatpak Zypak Launcher Design + +## Goal + +Package Flatpak so Electron Builder's generated `electron-wrapper` passes the +real IPTVnator ELF executable directly to Zypak. Preserve the existing +conditional Linux sandbox wrapper for AppImage, DEB, RPM, Pacman, and Snap. + +## Root Cause + +The common Linux `afterPack` hook currently renames the Electron executable +from `iptvnator` to `iptvnator.bin` and writes a shell script at `iptvnator`. +Electron Builder's Flatpak launcher calls `zypak-wrapper iptvnator`, so Zypak +receives the shell script instead of an ELF executable. Zypak rejects that +layout; the shell script can then mask the failure by adding `--no-sandbox` on +hosts with restricted user namespaces. + +## Launcher Contract + +Introduce one packaging-owned launcher-layout helper: + +- an isolated Flatpak target uses `iptvnator` as the Electron ELF and does not + apply the custom Linux sandbox wrapper; +- every other supported Linux target keeps the existing `iptvnator` shell + wrapper and `iptvnator.bin` Electron ELF; +- a target set containing Flatpak and any other target fails before filesystem + mutation because Electron Builder shares one unpacked application tree + across those targets; +- target matching is case-insensitive and uses Electron Builder's documented + `AfterPackContext.targets[].name` values, not output paths or environment + heuristics. + +The launcher hook, pristine-layout validation, and extracted-artifact +validation must all resolve the Electron ELF path through this contract. + +## Validation + +Regression coverage will prove the following: + +1. The Linux launcher hook preserves the original executable bytes and creates + no `.bin` file for an isolated Flatpak target. +2. The hook retains the existing wrapper layout for a non-Flatpak Linux target. +3. A mixed Flatpak/non-Flatpak target set fails before renaming the executable. +4. Pristine Flatpak validation inspects `iptvnator`, while other profiles + inspect `iptvnator.bin`. +5. Extracted Flatpak verification reads architecture and checks process + isolation from `iptvnator`. +6. CI asserts that the installed Flatpak target is an ELF, rejects a sibling + `.bin` layout, and fails on the known Zypak wrapper warnings. + +The existing application-level `--embedded-mpv-runtime-probe` remains the +sandboxed launch check. Once the custom wrapper is absent, it can no longer +silently add `--no-sandbox`; reaching the Electron main-process probe therefore +also verifies the corrected Zypak entry path. + +## Documentation + +Update the canonical Linux Embedded MPV packaging documentation and the living +`AGENTS.md`/`CLAUDE.md` summaries to state the launcher split explicitly. +Correct the earlier Linux frame-copy design document's claim that every Linux +Electron executable is named `iptvnator.bin`. + +## Non-Goals + +- Changing the Chromium GPU/Video.js behavior reported in issue #1203. +- Removing the conditional sandbox wrapper from non-Flatpak Linux packages. +- Adding `--no-sandbox`, changing `chrome-sandbox` permissions, or bypassing + Zypak. +- Changing the bundled Embedded MPV runtime or Flatpak permissions. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 677122365..ccbd1b39b 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -190,9 +190,12 @@ controlled status `1`, then reconnects the provider and requires a successful diagnostic. This keeps the canonical layouts and missing-provider fallback in the same regression contract. -Profiles cannot share one Electron Builder pass because its targets reuse the -same unpacked application directory. A missing or unsupported profile, or a -target from another profile, fails packaging. +Flatpak is an isolated packaging pass and keeps `iptvnator` as the real +Electron ELF so Electron Builder's `electron-wrapper` passes it directly to +Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and +`iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation. A +missing or unsupported profile, or a target from another profile, fails +packaging. Linux frame-copy release artifacts are x64-only. Non-x64 packages are always marker-only even if environment variables point at the x64 staged runtime. diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 4f3ec6b81..943b046ed 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -555,6 +555,52 @@ test('Flatpak application runtime probe runs under an isolated D-Bus session', ( ); }); +test('Flatpak CI verifies the direct Zypak ELF and preserves probe status', () => { + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + + assert.match( + flatpakVerificationStep, + /LAUNCHER_PATH="\$\(readlink -f \/app\/bin\/iptvnator\)"/ + ); + assert.match( + flatpakVerificationStep, + /test -f "\$\{LAUNCHER_PATH\}"[\s\S]*test -x "\$\{LAUNCHER_PATH\}"/ + ); + assert.match( + flatpakVerificationStep, + /if \[ -e "\$\{LAUNCHER_PATH\}\.bin" \] \|\| \[ -L "\$\{LAUNCHER_PATH\}\.bin" \]; then/ + ); + assert.match( + flatpakVerificationStep, + /ELF_MAGIC="\$\(od -An -tx1 -N4 "\$\{LAUNCHER_PATH\}" \| tr -d "\[:space:\]"\)"[\s\S]*test "\$\{ELF_MAGIC\}" = "7f454c46"/ + ); + assert.doesNotMatch( + flatpakVerificationStep, + /grep -q .*readlink -f "\$SCRIPT_PATH"/ + ); + assert.doesNotMatch( + flatpakVerificationStep, + /grep -q .*exec "\$SCRIPT_DIR\/iptvnator\.bin"/ + ); + + assert.match( + flatpakVerificationStep, + /set \+e[\s\S]*PROBE_OUTPUT="\$\([\s\S]*xvfb-run -a dbus-run-session -- flatpak run[\s\S]*--embedded-mpv-runtime-probe 2>&1[\s\S]*\)"[\s\S]*PROBE_STATUS=\$\?[\s\S]*set -e/ + ); + assert.match( + flatpakVerificationStep, + /PROBE_OUTPUT_LIMIT=16384[\s\S]*"\$\{PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT\}"/ + ); + assert.match(flatpakVerificationStep, /not an ELF file/); + assert.match(flatpakVerificationStep, /Zypak needs to be called directly/); + assert.match( + flatpakVerificationStep, + /if \[ "\$\{PROBE_STATUS\}" -ne 0 \]; then[\s\S]*exit "\$\{PROBE_STATUS\}"/ + ); +}); + test('foreign DEB CI explicitly selects both marker-only ARM architectures', () => { const foreignDebStep = workflowStep( 'Make marker-only foreign-architecture DEB packages' 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/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index 64d934b8d..cbdc9c0cc 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -56,6 +56,10 @@ const packageLayoutVerifier = fs.readFileSync( join(currentDir, 'verify-electron-package-layout.mjs'), 'utf8' ); +const flatpakLauncherValidationSource = fs.readFileSync( + join(currentDir, 'flatpak-launcher-validation.cjs'), + 'utf8' +); const electronAfterPackSource = fs.readFileSync( join(currentDir, 'electron-after-pack.cjs'), 'utf8' @@ -308,6 +312,94 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( assert.match(packageLayoutVerifier, /dirArch !== 'x64'/); assert.doesNotMatch(packageLayoutVerifier, /getEmbeddedMpvAddonArch/); assert.match(electronAfterPackSource, /targetArch !== 'x64'/); + assert.match( + packageLayoutVerifier, + /const\s*{\s*resolveLinuxLauncherLayout\s*}\s*=\s*require\(['"]\.\/linux-launcher-layout\.cjs['"]\)/ + ); + assert.match( + packageLayoutVerifier, + /const\s*{\s*validateFlatpakLauncher\s*,?\s*}\s*=\s*require\(['"]\.\/flatpak-launcher-validation\.cjs['"]\)/ + ); + assert.match( + packageLayoutVerifier, + /function verifyLinuxLauncher\(\s*resourceDir,\s*targetNames,\s*errors\s*\)/ + ); + assert.match( + packageLayoutVerifier, + /resolveLinuxLauncherLayout\(\s*targetNames,\s*linuxExecutableName\s*\)/ + ); + assert.match( + packageLayoutVerifier, + /verifyLinuxLauncher\(\s*resourceDir,\s*linuxTargetNames,\s*errors\s*\)/ + ); + + const launcherVerifier = packageLayoutVerifier.match( + /function verifyLinuxLauncher\([\s\S]*?\n}\n\nfunction verifyFlatpakPermissions/ + )?.[0]; + assert.ok(launcherVerifier); + assert.ok( + launcherVerifier.indexOf('resolveLinuxLauncherLayout(') < + launcherVerifier.indexOf('const launcherBinaryPath') + ); + assert.match( + launcherVerifier, + /if \(!launcherLayout\.wrapperRequired\) \{\s*errors\.push\(\s*\.\.\.validateFlatpakLauncher\(\s*appDir,\s*linuxExecutableName\s*\)\s*\);\s*return;\s*\}\s*const launcherBinaryPath[\s\S]*?fs\.readFileSync\(launcherPath,\s*['"]utf8['"]\)/ + ); +}); + +test('Flatpak launcher validation locks descriptor-based ELF inspection', () => { + assert.match( + flatpakLauncherValidationSource, + /const expectedElfMagic = Buffer\.from\(\[\s*0x7f,\s*0x45,\s*0x4c,\s*0x46,?\s*\]\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /descriptor = fs\.openSync\(\s*launcherPath,\s*fs\.constants\.O_RDONLY\s*\|\s*fs\.constants\.O_NOFOLLOW\s*\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /launcherStat = fs\.fstatSync\(descriptor\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /const elfMagic = Buffer\.alloc\(expectedElfMagic\.length\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /bytesRead = fs\.readSync\(\s*descriptor,\s*elfMagic,\s*0,\s*elfMagic\.length,\s*0\s*\)/ + ); + assert.match( + flatpakLauncherValidationSource, + /bytesRead !== expectedElfMagic\.length/ + ); + assert.match( + flatpakLauncherValidationSource, + /finally\s*{\s*try\s*{\s*fs\.closeSync\(descriptor\)/ + ); + + const openOffset = flatpakLauncherValidationSource.indexOf( + 'descriptor = fs.openSync(' + ); + const statOffset = flatpakLauncherValidationSource.indexOf( + 'launcherStat = fs.fstatSync(descriptor)' + ); + const readOffset = flatpakLauncherValidationSource.indexOf( + 'bytesRead = fs.readSync(' + ); + const finallyOffset = flatpakLauncherValidationSource.indexOf( + '} finally {', + readOffset + ); + const closeOffset = flatpakLauncherValidationSource.indexOf( + 'fs.closeSync(descriptor)', + finallyOffset + ); + assert.ok( + openOffset < statOffset && + statOffset < readOffset && + readOffset < finallyOffset && + finallyOffset < closeOffset + ); }); 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 ca37b0d02..1b4f56c17 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -279,6 +279,7 @@ function pureValidationOptions(options = {}) { function validElfInspector(nativeDir, manifest, overrides = {}) { const libDir = join(nativeDir, 'lib'); const records = new Map([ + ['iptvnator', { needed: ['libc.so.6'], rpath: [], runpath: [] }], ['iptvnator.bin', { needed: ['libc.so.6'], rpath: [], runpath: [] }], [ FRAME_COPY_ARTIFACTS.addon, @@ -598,6 +599,10 @@ test('prepares a normalized system profile with no private runtime', (t) => { test('prepares portable and Flatpak manifests with the exact bundled closure', (t) => { const portable = createNativeFixture(); const flatpak = createNativeFixture(); + fs.renameSync( + join(flatpak.appOutDir, 'iptvnator.bin'), + join(flatpak.appOutDir, 'iptvnator') + ); t.after(() => { for (const fixture of [portable, flatpak]) { fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); @@ -699,6 +704,11 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( pureValidationOptions({ profile: 'flatpak', targetNames: ['flatpak'], + hostPlatform: 'linux', + elfInspector: validElfInspector( + flatpak.nativeDir, + flatpakManifest + ), }) ), [] diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index 02e2a0711..173c89b09 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -25,6 +25,7 @@ const { 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/']; @@ -1380,9 +1381,26 @@ 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) { @@ -1403,9 +1421,11 @@ function inspectLinuxElfIsolation( return; } - const executableName = options.executableName ?? 'iptvnator'; const inspectedPaths = { - electron: path.join(path.dirname(resourceDir), `${executableName}.bin`), + 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), @@ -1770,7 +1790,14 @@ function validateLinuxPackagedEmbeddedMpv(resourceDir, options) { } else { validateBundledLinuxRuntime(nativeDir, manifest, errors); } - inspectLinuxElfIsolation(resourceDir, nativeDir, manifest, options, errors); + inspectLinuxElfIsolation( + resourceDir, + nativeDir, + manifest, + targetNames, + options, + errors + ); return errors; } diff --git a/tools/packaging/flatpak-launcher-validation.cjs b/tools/packaging/flatpak-launcher-validation.cjs new file mode 100644 index 000000000..933bf6098 --- /dev/null +++ b/tools/packaging/flatpak-launcher-validation.cjs @@ -0,0 +1,125 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const expectedElfMagic = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); +const executableModeBits = 0o111; + +function fsErrorCode(error) { + return error && typeof error === 'object' && typeof error.code === 'string' + ? error.code + : 'UNKNOWN'; +} + +function validateBinarySibling(siblingPath, errors) { + try { + fs.lstatSync(siblingPath); + } catch (error) { + if (fsErrorCode(error) === 'ENOENT') { + return; + } + errors.push( + `Unable to inspect Flatpak Electron launcher binary sibling at ${siblingPath} (${fsErrorCode(error)}).` + ); + return; + } + + errors.push( + `Flatpak Electron layout must not include a launcher binary sibling: ${siblingPath}` + ); +} + +function validateFlatpakLauncher(appDir, executableName) { + const errors = []; + const launcherPath = path.join(appDir, executableName); + const flatpakBinarySiblingPath = `${launcherPath}.bin`; + validateBinarySibling(flatpakBinarySiblingPath, errors); + + let descriptor; + try { + descriptor = fs.openSync( + launcherPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + ); + } catch (error) { + const errorCode = fsErrorCode(error); + if (errorCode === 'ENOENT') { + errors.push( + `Missing Flatpak Electron ELF in ${appDir}: ${path.basename(launcherPath)}` + ); + } else if (errorCode === 'ELOOP') { + errors.push( + `Flatpak Electron launcher must be a regular file: ${launcherPath}` + ); + } else { + errors.push( + `Unable to open Flatpak Electron launcher at ${launcherPath} (${errorCode}).` + ); + } + return errors; + } + + try { + let launcherStat; + try { + launcherStat = fs.fstatSync(descriptor); + } catch (error) { + errors.push( + `Unable to stat Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + return errors; + } + + if (!launcherStat.isFile()) { + errors.push( + `Flatpak Electron launcher must be a regular file: ${launcherPath}` + ); + return errors; + } + if ((launcherStat.mode & executableModeBits) === 0) { + errors.push( + `Flatpak Electron launcher must be executable: ${launcherPath}` + ); + } + + const elfMagic = Buffer.alloc(expectedElfMagic.length); + let bytesRead; + try { + bytesRead = fs.readSync( + descriptor, + elfMagic, + 0, + elfMagic.length, + 0 + ); + } catch (error) { + errors.push( + `Unable to read Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + return errors; + } + + if ( + bytesRead !== expectedElfMagic.length || + !elfMagic.equals(expectedElfMagic) + ) { + errors.push( + `Flatpak Electron launcher must be an ELF binary: ${launcherPath}` + ); + } + } finally { + try { + fs.closeSync(descriptor); + } catch (error) { + errors.push( + `Unable to close Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + } + } + return errors; +} + +module.exports = { + validateFlatpakLauncher, +}; diff --git a/tools/packaging/flatpak-launcher-validation.test.mjs b/tools/packaging/flatpak-launcher-validation.test.mjs new file mode 100644 index 000000000..0b8ff95b2 --- /dev/null +++ b/tools/packaging/flatpak-launcher-validation.test.mjs @@ -0,0 +1,108 @@ +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 { + validateFlatpakLauncher, +} = require('./flatpak-launcher-validation.cjs'); + +const executableName = 'iptvnator'; +const electronElf = Buffer.from([ + 0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00, +]); + +async function createFixture(t) { + const appDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'iptvnator-flatpak-launcher-') + ); + t.after(() => fs.rm(appDir, { recursive: true, force: true })); + + return { + appDir, + launcherPath: path.join(appDir, executableName), + siblingPath: path.join(appDir, `${executableName}.bin`), + }; +} + +async function writeLauncher(launcherPath, contents, mode) { + await fs.writeFile(launcherPath, contents); + await fs.chmod(launcherPath, mode); +} + +test('accepts a regular executable Flatpak Electron ELF', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o755); + + assert.deepEqual( + validateFlatpakLauncher(fixture.appDir, executableName), + [] + ); +}); + +test('rejects a symlinked Flatpak Electron launcher', async (t) => { + const fixture = await createFixture(t); + const electronTargetPath = path.join(fixture.appDir, 'electron-target'); + await writeLauncher(electronTargetPath, electronElf, 0o755); + await fs.symlink(electronTargetPath, fixture.launcherPath); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron launcher must be a regular file: ${fixture.launcherPath}`, + ]); +}); + +test('rejects a dangling Flatpak launcher binary sibling', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o755); + await fs.symlink('missing-electron', fixture.siblingPath); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron layout must not include a launcher binary sibling: ${fixture.siblingPath}`, + ]); +}); + +test('rejects a non-executable regular Flatpak Electron ELF', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o644); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron launcher must be executable: ${fixture.launcherPath}`, + ]); +}); + +for (const [description, contents] of [ + ['short ELF magic', Buffer.from([0x7f, 0x45, 0x4c])], + ['incorrect ELF magic', Buffer.from([0x00, 0x45, 0x4c, 0x46])], +]) { + test(`rejects ${description} in the Flatpak Electron launcher`, async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, contents, 0o755); + + assert.deepEqual( + validateFlatpakLauncher(fixture.appDir, executableName), + [ + `Flatpak Electron launcher must be an ELF binary: ${fixture.launcherPath}`, + ] + ); + }); +} + +test('packaging Nx tests register the Flatpak launcher validation suite', async () => { + const project = JSON.parse( + await fs.readFile(new URL('./project.json', import.meta.url), 'utf8') + ); + const moduleFile = + '{workspaceRoot}/tools/packaging/flatpak-launcher-validation.cjs'; + const testFile = + '{workspaceRoot}/tools/packaging/flatpak-launcher-validation.test.mjs'; + + assert.ok(project.targets.test.inputs.includes(moduleFile)); + assert.ok(project.targets.test.inputs.includes(testFile)); + assert.match( + project.targets.test.options.command, + /node --test .*tools\/packaging\/flatpak-launcher-validation\.test\.mjs/ + ); +}); 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..2c3097c63 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -22,8 +22,11 @@ "{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs", "{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs", "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", + "{workspaceRoot}/tools/packaging/flatpak-launcher-validation.cjs", + "{workspaceRoot}/tools/packaging/flatpak-launcher-validation.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 +52,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/flatpak-launcher-validation.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}" } }, diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index ab94e048c..40a709c6f 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -18,6 +18,10 @@ const { const { validateLinuxProfileTargets, } = require('./linux-frame-copy-profile.cjs'); +const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); +const { + validateFlatpakLauncher, +} = require('./flatpak-launcher-validation.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; const [platform, arch = ''] = normalizedArgs; @@ -468,10 +472,34 @@ function verifyPackagedPackageMetadata(resourceDir, errors) { } } -function verifyLinuxLauncher(resourceDir, errors) { +function verifyLinuxLauncher(resourceDir, targetNames, errors) { + let launcherLayout; + try { + launcherLayout = resolveLinuxLauncherLayout( + targetNames, + linuxExecutableName + ); + } catch (error) { + errors.push( + `Unable to resolve Linux launcher layout: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + const appDir = path.dirname(resourceDir); const launcherPath = path.join(appDir, linuxExecutableName); - const launcherBinaryPath = `${launcherPath}.bin`; + + if (!launcherLayout.wrapperRequired) { + errors.push(...validateFlatpakLauncher(appDir, linuxExecutableName)); + return; + } + + const launcherBinaryPath = path.join( + appDir, + launcherLayout.electronBinaryName + ); if (!fileExists(launcherBinaryPath)) { errors.push( @@ -733,7 +761,7 @@ function verifyResourceDir(resourceDir) { verifyLinuxExecutableArgs(errors); verifyFlatpakPermissions(errors); verifySnapPackagingConfig(errors); - verifyLinuxLauncher(resourceDir, errors); + verifyLinuxLauncher(resourceDir, linuxTargetNames, errors); } errors.push( diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index e7bfb7a3b..68121639d 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -24,6 +24,7 @@ const { LINUX_SYSTEM_PACKAGE_DEPENDENCIES, resolveLinuxFrameCopyProfile, } = require('./linux-frame-copy-profile.cjs'); +const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); const { RUNTIME_PROBE_MAX_BUFFER_BYTES, RUNTIME_PROBE_TIMEOUT_MS, @@ -1350,9 +1351,14 @@ function dependencyFileName(dependencyName) { return String(dependencyName).replaceAll('\\', '/').split('/').at(-1) ?? ''; } +function resolveElectronBinaryPath(resourceDir, artifactFormat) { + const layout = resolveLinuxLauncherLayout([artifactFormat]); + return path.join(path.dirname(resourceDir), layout.electronBinaryName); +} + function validateElectronIsolation(resourceDir, artifactFormat, elfInspector) { const errors = []; - const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + const electronPath = resolveElectronBinaryPath(resourceDir, artifactFormat); const binaries = [ { label: 'Electron binary', binaryPath: electronPath }, ...listElectronShippedLinuxLibraries(resourceDir, { @@ -1557,7 +1563,7 @@ export function verifyExtractedLinuxFrameCopyRuntime({ ) ); - const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + const electronPath = resolveElectronBinaryPath(resourceDir, artifactFormat); let packageArch; try { packageArch = readElfArchitecture(electronPath); @@ -1709,10 +1715,7 @@ export function verifyLinuxFrameCopyArtifact({ ].join('\n') ); } - const electronPath = path.join( - path.dirname(resourceDir), - 'iptvnator.bin' - ); + const electronPath = resolveElectronBinaryPath(resourceDir, format); return { artifactPath: resolvedArtifactPath, format, diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index ad2b7154e..0a92857ea 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -219,7 +219,10 @@ function elfHeader(architecture) { return image; } -function createSystemPayload({ architecture = 'x64' } = {}) { +function createSystemPayload({ + architecture = 'x64', + electronBinaryName = 'iptvnator.bin', +} = {}) { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-verifier-layout-') ); @@ -233,7 +236,7 @@ function createSystemPayload({ architecture = 'x64' } = {}) { ); fs.mkdirSync(nativeDir, { recursive: true }); fs.writeFileSync( - path.join(appDir, 'iptvnator.bin'), + path.join(appDir, electronBinaryName), elfHeader(architecture) ); @@ -1574,6 +1577,64 @@ test('artifact verification enforces Snap metadata for x64 and ARM payloads', () } }); +test('verifies an outer Flatpak artifact with an unwrapped Electron ELF', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-flatpak-artifact-') + ); + const artifactPath = path.join(root, 'IPTVnator.flatpak'); + fs.writeFileSync(artifactPath, 'flatpak fixture'); + + try { + assert.deepEqual( + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'flatpak', + extractArtifact({ destination }) { + const appDir = path.join( + destination, + 'files', + 'lib', + 'com.fourgray.iptvnator' + ); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, 'iptvnator'), + elfHeader('arm64') + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + 'Unavailable for arm64\n' + ); + return destination; + }, + metadataReader: () => ({ + declaredArch: 'arm64', + dependencies: [], + }), + elfInspector: validElfInspector, + probeRunner() { + assert.fail('foreign Flatpak must not probe'); + }, + }), + { + artifactPath: path.resolve(artifactPath), + format: 'flatpak', + profileName: 'flatpak', + architecture: 'arm64', + } + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('bundled probes remove ambient loader paths and use only packaged libraries', () => { assert.deepEqual( createRuntimeProbeEnvironment({ @@ -1824,6 +1885,32 @@ test('requires marker-only foreign packages, scans Electron, and never probes', } }); +test('validates a marker-only Flatpak with an unwrapped Electron ELF', () => { + const fixture = createSystemPayload({ + architecture: 'arm64', + electronBinaryName: 'iptvnator', + }); + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'flatpak', + profileName: 'flatpak', + packageDependencies: [], + elfInspector: validElfInspector, + probeRunner() { + assert.fail( + 'foreign Flatpak must not run the helper probe' + ); + }, + }), + [] + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + test('always removes its temporary extraction root after a verifier failure', () => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-verifier-cleanup-parent-')