fix(packaging): preserve Flatpak Electron ELF for Zypak (#1205)

Fixes the launcher/Zypak regression reported in #1203. The additional GPU/video.js behavior remains tracked separately in that issue.
This commit is contained in:
4gray authored and GitHub committed 2026-07-18 22:42:45 +02:00
1 parent 8fdac824fd
commit c266eaa680
22 files changed
+1679 -38

No files matched your search

+31 -7
View File
@@ -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'
+4
View File
@@ -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.
+6 -2
View File
@@ -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`.
+7 -5
View File
@@ -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
@@ -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.
@@ -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
@@ -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.
+6 -3
View File
@@ -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.
@@ -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'
+3 -1
View File
@@ -198,7 +198,9 @@ async function afterPackHook(params) {
}
);
await linuxAfterPack(params);
await linuxAfterPack(params, {
targetNames: linuxPackagingContext?.targetNames,
});
if (linuxPackagingContext) {
const graphicsMountPath = ensureSnapGraphicsContentMount(
params.appOutDir,
@@ -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', () => {
@@ -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
),
})
),
[]
+30 -3
View File
@@ -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;
}
@@ -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,
};
@@ -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/
);
});
+20 -3
View File
@@ -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({
+187
View File
@@ -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/
);
});
+56
View File
@@ -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,
};
+4 -1
View File
@@ -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}"
}
},
@@ -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(
@@ -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,
@@ -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-')