From 0962765793794441f46bc1067bced0e7759357ca Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2026 20:18:20 +0200 Subject: [PATCH] fix(packaging): enforce direct Flatpak Zypak launch --- .github/workflows/build-and-make.yaml | 38 ++++++-- AGENTS.md | 4 + CLAUDE.md | 8 +- docs/architecture/embedded-mpv-native.md | 12 ++- ...mbedded-mpv-frame-copy-packaging-design.md | 10 +- tools/embedded-mpv/README.md | 9 +- .../configure-linux-frame-copy-build.test.mjs | 46 ++++++++++ .../verify-linux-frame-copy-runtime.mjs | 15 +-- .../verify-linux-frame-copy-runtime.test.mjs | 91 ++++++++++++++++++- 9 files changed, 206 insertions(+), 27 deletions(-) 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/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/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/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-')