mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
8fdac824fd
commit
c266eaa680
22 files changed
+1679
-38
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
),
|
||||
})
|
||||
),
|
||||
[]
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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-')
|
||||
|
||||
Reference in new issue
Block a user